Skip to content
← All articles

Packaging the artefact: the bugs that only exist in the wheel

setup.py the file is not deprecated; invoking it as a CLI is. The missing py.typed marker, the src-layout bug, and why a packaging exercise that never installs the artefact leaves both invisible — plus the seven-step acceptance check that finds them.

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 packageyourpkg/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:

  1. python -m build produces an sdist and a wheel.
  2. unzip -l dist/*.whl lists yourpkg/py.typed.
  3. In a fresh venv, in a different directory: pip install dist/*.whl.
  4. A consumer module importing only names from your __all__ runs.
  5. Importing an internal name — yourpkg.internal.helpers — is an ImportError or at least clearly not part of the contract.
  6. mypy --strict on that consumer module passes, and reports no Any coming from your package.
  7. tar tf dist/*.tar.gz shows 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.