open atlas
↑ Back to track
Python for JS/TS developers PY · 10 · 02

pyproject.toml and entry points: build backends, src layout, and console-script shims

pyproject.toml [project] declares metadata; the build backend turns source into a wheel. src layout forces tests onto the installed package; editable installs are a .pth redirect; [project.scripts] generates PATH shims; plugins discover via entry-point groups.

PY Senior ◷ 17 min
Level
FoundationsJuniorMiddleSenior

An internal tool shipped for months with a flat layout — mytool/ and tests/ side by side at the repo root. Then a platform team filed a ticket that read like a riddle: installing the tool broke their test suite. Investigation: setuptools auto-discovery had been packaging the top-level tests/ directory into the wheel all along, so every pip install mytool quietly dropped a package named tests into site-packages. The platform team ran their suite with from tests.helpers import factory — and after installing the tool, import tests resolved to the tool’s shipped test stubs instead of their local directory, in exactly the environments where the local path lost the sys.path race. The tool’s own CI never noticed: its tests imported the local tree, never the wheel. Two fixes shipped: src/ layout, so nothing importable sits at the repo root, and a CI step that lists wheel contents. The wheel had been wrong for 23 releases. Nothing had ever tested the artifact — only the source.

pyproject.toml: one file, two contracts

Before you touch a build backend or wonder why pip install does what it does, ask yourself: which of the two fundamentally different jobs is this file doing right now? The answer changes which table you edit and which tool you blame when something is wrong.

pyproject.toml holds two separable contracts. The [project] table is metadata: name, version, dependencies (version ranges — this is a library declaration, not a lockfile), and optional-dependencies (extras like mytool[postgres] that consumers opt into). The [build-system] table answers a different question: who turns this source tree into an installable artifact. The named build backend — hatchling, setuptools, flit-core — is a library exposing standardized hooks (build_wheel, build_sdist); frontends like pip, uv, and python -m build call those hooks in an isolated environment with requires installed. That separation is the whole modern packaging story: pip does not know how to build your project, it only knows how to ask the backend. Backends differ in configuration and defaults, not in the artifact’s nature — hatchling has strict, predictable file selection; setuptools carries two decades of compatibility (and the auto-discovery that packaged tests/ in the Hook); flit-core is minimal for pure-Python. One genuinely senior knob: dynamic = ["version"] plus a VCS plugin (hatch-vcs, setuptools-scm) derives the version from the git tag at build time — one source of truth, no release commit that bumps a string in two files and gets one wrong.

src layout: test the thing you ship

Flat layout puts the importable package at the repo root. The trap is sys.path arithmetic from the imports lesson: run tests from the root and the local mytool/ directory wins every import — your tests exercise the source tree, never the built artifact. A wheel missing a subpackage, a data file dropped by file-selection config, a stale entry point: all invisible, because the artifact is never on trial. src/ layout moves the package to src/mytool/, which is not on sys.path — now import mytool can only resolve to an installed package, so the test run validates whatever pip install actually produced. This is the “tests pass locally, broken wheel shipped” prevention, and it is the layout uv scaffolds by default. The honest cost: you must install before testing (editable install makes that a one-time step), and quick python -c "import mytool" experiments from the repo root stop working — which is precisely the point.

Quiz

Your wheel shipped without mytool/templates/ and prod died with ModuleNotFoundError — but the full test suite was green. Flat layout, tests run from the repo root. What made the suite blind?

Editable installs: a .pth redirect, and its limits

pip install -e . (or the install uv sync performs for the workspace project) does not copy your code. The backend writes a redirect into site-packages — classically a .pth file appending your src/ to sys.path, or a small import-hook shim for finer control. Result: the environment imports your working tree live; edit, rerun, no reinstall. The limits follow directly from what was not redirected: metadata is a snapshot. Dependencies, entry points, console scripts — all were written at install time. Add a new [project.scripts] entry or a dependency and your editable install will not know until you reinstall. The symptom is always the same shape: “I added the command but it is not on PATH” — the code is live, the metadata is frozen.

Entry points: console scripts and plugin discovery

[project.scripts] mycli = "mytool.cli:main" is metadata, and the installer acts on it: at install time it generates a small executable shim in the environment’s bin/ directory — on PATH when the venv is active — whose entire job is from mytool.cli import main; sys.exit(main()). Your source file never lands on PATH and needs no chmod +x, no shebang; the shim also explains why the previous lesson’s -m discipline matters — the shim imports your module with full package context, never executes the file. main’s return value becomes the exit code.

The same metadata mechanism generalizes into plugin systems. Any distribution can declare entry points under an arbitrary group name; any program can query importlib.metadata.entry_points(group="pytest11") and get every installed distribution’s registrations — read from installed metadata, no file scanning, no registry, no configuration. That is literally how pytest finds plugins: install pytest-cov, it appears under group pytest11, pytest loads it at startup; uninstall it and it is gone. Two design notes from production: discovery cost scales with the number of installed distributions (cheap, but not free in fat environments), and loading an entry point imports the plugin module — a plugin with import-time side effects inherits every failure mode from the previous lesson, now triggered by somebody else’s startup.

Quiz

After pip install, the command mycli works in the venv. What is actually sitting on PATH?

Extras versus dependency groups

When you see pytest in a library’s install_requires — or a dev extra that pulls linters into every consumer’s environment — that is the confusion this section exists to prevent.

Two mechanisms, two audiences, routinely confused. Extras (optional-dependencies) are published metadata: mytool[postgres] is a feature flag your consumers install; the extra’s dependencies ship in the wheel’s metadata for the world to resolve. Dependency groups ([dependency-groups], PEP 735) are development-side: test runners, linters, type checkers — resolved by your tooling (uv installs the dev group into the workspace by default), and never published in the wheel. The discipline that follows: pytest does not belong in an extra — a mytool[test] extra ships your test stack to every curious consumer and pollutes their resolutions; features belong in extras, workflows belong in groups. Legacy projects fake groups with a dev extra because old tooling predates PEP 735 — recognize it as a workaround, not a pattern to copy.

Recall before you leave
  1. 01
    What does a build backend actually do, and what is the src-layout argument — including the failure it prevents and the cost it charges?
  2. 02
    Trace mycli from pyproject line to shell command, then explain how the same mechanism lets pytest find plugins — and the two production caveats.
Recap

pyproject.toml carries two contracts: [project] is published metadata — name, range dependencies, extras consumers can opt into — and [build-system] names the backend whose standardized hooks turn the source tree into sdist and wheel when a frontend asks; pip and uv build nothing themselves, and dynamic version from VCS tags removes the bump-two-files release bug. Layout decides what your tests actually test: flat layout leaves the package at the repo root where sys.path resolves it first, so the suite exercises source while the wheel rots unseen — the Hook tool shipped a stray tests package for 23 releases and broke a downstream team before anyone listed the artifact’s contents; src layout takes the local tree out of the race, forcing imports through the installed package at the price of an install step. Editable installs make that price one-time via a .pth redirect — code live, metadata frozen, so new entry points need a reinstall. [project.scripts] turns a metadata line into a generated bin/ shim that imports your function and exits with its return value; the same entry-point machinery under group names is the entire pytest plugin system — declared in metadata, discovered through importlib.metadata, imported at load with all the import-time-side-effect consequences. Extras publish features; dependency groups keep dev tooling local and out of your consumers’ resolvers. Now when you see mycli missing after adding a script, a plugin that pytest can’t find, or a dependency that showed up in your library’s consumers’ lock files, you know exactly which layer to inspect and which of these mechanisms to reach for.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.