Packaging & mypy — the artifact, not the folder

venv & pip was about the environment you install into. This page is about the thing you install: a pyproject.toml, a layout, and a wheel that has to work on a machine which has never seen your repository. In Maven you already know the shape — source lives under src/main/java, tests under src/test/java, and the jar you ship is assembled from the first, never from your working directory. Python lets you skip that separation, and the price is a category of bug that only ever appears on someone else's machine. The second half is mypy --strict, which is the closest thing Python has to javac — with the important difference that it is opt-in, per file, and silently gives up wherever you let it.

pyproject.tomlsrc layout wheelsentry points py.typedmypy --strict

Where the code sits, and what import finds

Python's import system starts from the current directory. That one rule decides everything below: if your package directory sits at the repo root, then every test you run from the repo root imports the folder you are editing, and the wheel you eventually publish is never exercised by anything. Move the same package one level down into src/ and the repo root has nothing importable in it, so nothing is imported by accident: the tests see only what you deliberately put on the path, and a test that installs the built wheel into a clean venv exercises exactly the artifact you ship.

Same package, four arrangements

every path and message below came from running it

The pyproject.toml that produces the third and fourth arrangements is short. Everything in it is a decision the flat layout let you avoid making:

[build-system]
requires = ["setuptools>=68,<81", "wheel"]     # who builds the wheel — pinned, like a plugin version
build-backend = "setuptools.build_meta"

[project]
name = "llmutils"                            # the DISTRIBUTION name — what you pip install
version = "0.1.0"
requires-python = ">=3.12"

[project.scripts]
llmutils = "llmutils.cli:main"               # installs a launcher into the venv's bin/

[tool.setuptools.packages.find]
where = ["src"]                              # the import name lives here; tests/ cannot be swept in

[tool.setuptools.package-data]
llmutils = ["py.typed"]                      # a wheel ships .py files and NOTHING else unless told

Three things in there are worth saying out loud. The distribution name (llmutils in [project]) and the import name (the directory under src/) are separate strings that happen to match here — pip install scikit-learn, import sklearn is the famous case. An entry point is not a script you copy into bin/; it is a line of metadata that tells the installer to generate a launcher for llmutils.cli:main in whatever environment receives the wheel, which is why it works after pip install on a machine that has never seen your repo. And a wheel is a zip of exactly what you listed: build one and read it with unzip -l dist/*.whl before you believe anything about it. Exercise py-08's flat-layout trap builds a wheel whose entire contents are five dist-info entries and no package at all — while every test still passes, because the tests were importing the working copy.

What mypy --strict actually catches

Annotations are not checked at runtime; nothing enforces them until you run a checker. In plain mode mypy is polite — it skips unannotated functions entirely. --strict is the switch that turns that politeness off: unannotated definitions become errors, and returning Any from a function that promised a type becomes an error too. Four snippets, and the verbatim output of mypy 1.20.2 --strict on each:

Four snippets, four error codes

output copied verbatim from a real mypy 1.20.2 run

The error code in the brackets is the part to learn: it is what you put in a # type: ignore[union-attr] when you genuinely know better, and what you grep for when a codebase has 400 of them. [no-untyped-def] is the load-bearing one — inside an unannotated function every expression is Any, so mypy stops checking not just that function but everything downstream of what it returns. A codebase loses its types one helper at a time.

One of the four has no equivalent in Java at all, and one is Java's own rule. [no-any-return] exists because json.load genuinely returns Any — the checker is telling you that your -> dict[str, Any] is a promise nobody verified, and the fix is to narrow it yourself (if not isinstance(data, dict): raise ValueError(...)) rather than to widen the annotation. And list being invariant is the same rule as Java's List<Integer> not being a List<Number>, for the same reason: the callee could append a float to your list[int]. mypy even suggests the cure in its own note — accept the read-only Sequence[float], which is covariant, exactly as Java's List<? extends Number> is.

None of this reaches whoever installs your wheel unless you ship one empty file. py.typed is PEP 561's marker: without it, a checker must treat an installed package as untyped no matter how well annotated its source is. It is the fourth layout above, and it is the reason a wheel is worth asserting on in a test.

⚠️ Traps & honesty: the resolved paths and every mypy line here come from real runs on CPython 3.12.3 with mypy 1.20.2 and setuptools 68 — a different mypy version may word a message differently, and the error codes are the stable part · pip install -e . on a src layout still puts your working copy on the path (that is the point of an editable install), so it is the wheel, not the editable install, that proves what ships · hatchling, flit and poetry-core are all perfectly good backends instead of setuptools; the layout argument is identical for all of them · flat layouts are not forbidden and plenty of good projects use one — the claim here is narrower: with a flat layout nothing you run locally exercises the artifact · --strict is a bundle of about a dozen flags, and real codebases usually enable it per-module in [[tool.mypy.overrides]] rather than all at once · mypy checks nothing about runtime values: an API that returns a different shape than its annotation says will still pass.
Takeaways: import starts at the current directory, so a package at the repo root is imported from your working copy and the wheel is never exercised; under src/ the repo root has nothing importable, so tests run against what you deliberately put on the path, and only a wheel installed into a clean venv proves what ships · the distribution name and the import name are different strings · an entry point is metadata that makes the installer generate a launcher, not a file you ship · a wheel contains exactly what pyproject.toml listed — unzip -l dist/*.whl is the only honest check · --strict turns unannotated functions and leaked Any into errors, and the bracketed error code is the thing to learn · py.typed is what makes your annotations visible to everyone downstream. Next: exercise py-08, where you build all of it and eight checks read the wheel.

Second opinion (taught here — these corroborate): Python Packaging User Guide · setuptools on src layout · mypy error codes (strict) · PEP 561.