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.
import findsPython'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.
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.
mypy --strict actually catchesAnnotations 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:
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.
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.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.