Every packaging tutorial that stops at “and now you have a pyproject.toml“ has
skipped the part where the bugs live. The bugs live in the artefact — the
built wheel, installed into an environment that is not your source tree. Two of
the most common bugs in typed Python libraries are invisible until you do that,
because both of them are bugs about what got copied in, and your development
environment has the original.
A note on this item. The full exercise is: build a wheel from the configuration module you wrote in “Parse, don’t validate” and “Typed settings”, install it into a clean virtual environment, and run a consumer module against it. That needs a multi-file submission and a build step, which this grader does not have. So this one is a page rather than a problem — but the exercise is genuinely worth doing on your own machine, and the checklist at the end is the acceptance criteria.
First, the thing everyone gets wrong about setup.py
setup.py the file is not deprecated. Invoking it as a command-line
program is.
python setup.py install → pip install .
python setup.py develop → pip install -e .
python setup.py sdist → python -m build --sdist
python setup.py bdist_wheel → python -m build --wheel
python setup.py test → pytest
The distinction matters because “setup.py is deprecated” leads people to rewrite
working build logic they did not need to touch. A setup.py that setuptools
imports — to compile a C extension, to compute a version — is completely
supported. What is deprecated is treating it as the build interface, because
that interface predates PEP 517 and cannot express “here is what my build needs
in order to run”.
The minimum modernisation is therefore not deleting the file. It is adding a build-system table:
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
This does more than declare a backend. It switches on build isolation: the
build runs in a fresh environment containing only what requires lists. Your
build environment stops being your development environment, which means a build
that works on your laptop because you happen to have cython installed now
fails honestly instead of shipping a broken sdist.
The py.typed bug
You annotate a library exhaustively. mypy --strict is clean. You publish. A
consumer installs it, runs their own checker, and every value crossing your API
is Any.
Nothing is wrong with your annotations. PEP 561 says a package shipping inline
annotations must contain a py.typed marker file, and the consumer’s
checker must ignore your types if it is absent. The file is zero bytes. The
failure mode is total and silent.
And the marker existing in your source tree is not the same as it existing in the wheel. A zero-byte file with no extension is exactly the kind of thing a build backend’s default file-matching skips. With setuptools:
[tool.setuptools.package-data]
yourpkg = ["py.typed"]
Then verify it, because “I added the config” is not evidence:
python -m build
unzip -l dist/yourpkg-*.whl | grep py.typed
This is the most common packaging bug in typed libraries, and it is invisible from inside the repository — your own type checker reads the source directory, where the file is.
💡python -m build succeeds, unzip -l shows py.typed, pip install dist/*.whl works, and a consumer still sees Any for everything. What is left?
click to reveal
Most likely one of three things, in decreasing order of frequency:
The marker is in the wrong directory. py.typed must sit inside the
package — yourpkg/py.typed — not next to it at the distribution root. A
namespace package needs one in each portion that ships types.
A stub package is shadowing you. PEP 561 resolution order puts stubs
before inline types: a yourpkg-stubs distribution, or an entry in typeshed’s
third-party stubs that the consumer installed via types-yourpkg, wins over
your annotations. If typeshed carries stubs for your library from before you
annotated it, consumers with types-yourpkg installed will keep seeing the old,
possibly wrong, types until those stubs are removed. Check for that the moment
you first ship py.typed.
They are importing something you did not export. With
--no-implicit-reexport, a name your __init__.py imported but did not
re-export deliberately is private, and a consumer reaching for it gets an
error or a fallback rather than your type. That is the system working, but from
the consumer’s seat it looks identical to a missing marker.
The reason all three are hard to spot is the same: you cannot reproduce any of
them from inside your own repository. The consumer smoke test — a separate
directory, a clean venv, pip install the built wheel, one module that imports
only the public API, mypy --strict on that module — is the only check that
exercises the real path.
src-layout, and the bug it exists to prevent
flat layout src layout
. .
├── yourpkg/ ├── src/
│ └── __init__.py │ └── yourpkg/
└── tests/ │ └── __init__.py
└── tests/
With a flat layout, the current working directory is on sys.path when you run
pytest, so import yourpkg finds your source directory — not the installed
package. Your tests therefore never test the artefact. A missing data file, a
module you forgot to list, a subpackage without an __init__.py: all pass
locally and fail for the first user.
With src layout that shortcut does not exist. import yourpkg can only succeed
if the package is installed, which means your tests run against the same thing
your users get. This is the entire argument, and it is a good one.
The cost is that you must pip install -e . before you can run anything, which
is a real friction and the reason people resist. Pay it.
What goes in pyproject.toml
One file, and it absorbs most of the config sprawl (setup.cfg, MANIFEST.in,
.flake8, mypy.ini, pytest.ini, tox.ini) that made it impossible to know
where a setting lived:
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "yourpkg"
version = "1.2.0"
description = "One line."
readme = "README.md"
requires-python = ">=3.12"
license = "MIT" # SPDX expression (PEP 639)
license-files = ["LICENSE"]
dependencies = ["httpx>=0.27"]
[project.optional-dependencies]
cli = ["typer>=0.12"]
[tool.setuptools.package-data]
yourpkg = ["py.typed"]
[tool.mypy]
strict = true
python_version = "3.12" # your LOWEST supported version, not your dev one
Two fields worth dwelling on:
license is an SPDX expression since PEP 639 — "MIT", or
"Apache-2.0 OR MIT" — not a table and not a classifier. The old
License :: OSI Approved :: MIT License classifiers are deprecated.
dynamic declares which keys the backend fills in (a version read from
__init__.py, a description read from the README). A backend must error
rather than guess for anything not listed there, which is what stops a
half-configured build from silently publishing version 0.0.0.
And [tool.mypy] python_version deserves its own line in review: it should be
your lowest supported version, not the interpreter you happen to develop on.
Set to 3.13 while requires-python = ">=3.12", your checker will happily accept
syntax and stdlib APIs that do not exist for a third of your users. This is one
of the most commonly misconfigured settings in real projects.
The acceptance criteria
Do this on your own machine with any package you have written:
-
python -m buildproduces an sdist and a wheel. -
unzip -l dist/*.whllistsyourpkg/py.typed. -
In a fresh venv, in a different directory:
pip install dist/*.whl. -
A consumer module importing only names from your
__all__runs. -
Importing an internal name —
yourpkg.internal.helpers— is anImportErroror at least clearly not part of the contract. -
mypy --stricton that consumer module passes, and reports noAnycoming from your package. -
tar tf dist/*.tar.gzshows the sdist can actually rebuild the wheel — it contains the build config and any non-Python sources.
Steps 3 to 6 are the whole point. Everything before them is configuration you believed in; those four are the first time anything checks.