pyproject.toml, Wheels & the Console Entry Point
In short: pip install quarry then quarry run ./data working on a
stranger's machine needs three things. pyproject.toml — read by every tool
with the standard tomllib since Python 3.11 — describes the project: which
backend builds it, its name and version, its dependencies as loose ranges,
and [project.scripts], one line mapping a command name to a dotted
module:attribute reference. A wheel (.whl) is a zip file laid out
exactly as it will sit in site-packages; installing one is close to
unzipping it. And the entry point is a short walk — import the module on
the left of the colon, getattr the name on the right — that pip turns
into a tiny executable on the user's PATH.

Check the metadata before a release
Every modern project has one pyproject.toml at its root:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "quarry"
version = "0.5.0"
requires-python = ">=3.11"
dependencies = ["chardet>=5,<6", "rich~=13.7"]
[project.scripts]
quarry = "quarry.cli:main"Three tables, three jobs: [build-system] says which tool builds the
project — pip never builds anything itself, it installs the named backend
into a throwaway environment and asks it for a wheel. [project] is
metadata every tool agrees on, dependencies included — loose ranges, as a
library should have; lesson 2's lock file pins them for Quarry's own
deployment, but the metadata stays honest ranges. [project.scripts]
declares commands. The file is TOML, and it's typed:
That typing bites people who write versions the way they say them out loud:
import tomllib
print(tomllib.loads("version = 1.10")["version"])Build quarry-0.5.0-py3-none-any.whl
A wheel is a zip archive: your package folders exactly as they'll sit in
site-packages, plus one <name>-<version>.dist-info folder of metadata.
You build one with the standard front-end — python -m build — which hands
your source tree to the backend and writes two files into dist/: the
sdist (.tar.gz, source plus metadata) and the wheel, ready to
install:
Nothing about a wheel is hidden — it's a zip file, and zipfile can make
one:
A real wheel adds WHEEL (format version and platform tag) and RECORD (a
hash and size per file, so the installer can verify and later remove exactly
what it unpacked). py3-none-any means pure Python that runs anywhere —
Quarry needs one wheel total; a project with compiled code needs one per
Python version per platform.
In a src layout, the package lives at src/quarry/, which isn't on
sys.path by accident — the only way to import it is to install it, which
during development means an editable install (pip install -e .): the
installed copy points back at src/, so edits show up without reinstalling,
and tests import Quarry the way users will.
Wire quarry.cli:main to the quarry command
[project.scripts] maps a command name to an object reference: a
module imported by its dotted name, then an attribute looked up on it. On
install, pip writes a tiny executable whose entire job is from quarry.cli import main; sys.exit(main()). Two consequences follow from lesson 1:
quarry.cli is imported, so its relative imports work and its own main
guard does not run — the wrapper calls main() itself — and main takes no
arguments and communicates through its return value, which goes straight
to sys.exit:
def main():
rows = ["a", "b"]
total = len(rows)
try:
raise SystemExit(main())
except SystemExit as exc:
print(exc.code)Resolving the reference is a short walk — import, then follow each dotted
attribute with getattr:
The common mistake is writing the reference like a path, with a dot where the colon belongs:
ValueErrorThis entry point would install a quarry command that crashes the moment it starts. Fix the TOML, not the loop.
The same machinery serves plugins: any [project.entry-points."quarry.stages"]
table becomes a group in the wheel's entry_points.txt, and at run time
importlib.metadata.entry_points(group="quarry.stages") lists what every
installed package declared — so a stage from someone else's package can join
Quarry's registry without Quarry importing it by name.
Traps worth knowing
A version number publishes once. You can yank a broken release so
resolvers skip it, but you cannot replace 0.5.0 with different files — so
the release routine is bump, build, install the wheel into a fresh
environment, test it, then upload, and most teams let CI do all of it on a
tag. Pure Python is cheap to ship. Six platforms times four supported
Pythons is 24 wheels per release for compiled code, each built and tested on
its own machine; a py3-none-any wheel is one file for all of them — a real
reason to stay pure Python until measurements say otherwise.
Quick practice
try:
raise SystemExit("disk full")
except SystemExit as exc:
print(exc.code, isinstance(exc.code, str))import tomllib
print(tomllib.loads('deps = ["a", "b"]')["deps"], type(tomllib.loads("n = 3")["n"]).__name__)ValueErrorRun it, read the ValueError, then fix ref so it has a module and an attribute separated by a colon.
🎯 Your turn
Build check_pyproject(text) — the last piece of Quarry's shipment, run
before every release. Parse the text with tomllib and return the problems
in this order (an empty list means ready to build):
"invalid TOML"on its own, if it won't parse;"missing build-system.build-backend";"missing [project]"— and stop there;"missing project.name"or"invalid project.name"(letters, digits,.,_,-, starting and ending with a letter or digit);"project.version must be a string", or"missing project.version"unless"version"is listed inproject.dynamic;"bad entry point: <command>"for each script, in sorted order, whose value isn'tdotted.module:dotted.attr.
check_pyproject('''
[build-system]
build-backend = "hatchling.build"
[project]
name = "quarry"
version = 0.5
[project.scripts]
quarry = "quarry/cli.py:main"
''')
# → ["project.version must be a string", "bad entry point: quarry"]Press ▶ Run and hit ✓ Check. ✅
