Packaging your code
pyproject.toml, the src layout, versions, entry points and wheels.
Create a free account Try part of it here
Why it matters
Code that only runs from your own folder is hard to share. A package can be installed with one pip command, brings its dependencies along, puts a command on the user's path and says which version it is. It's how every library you've used reached you, and how your own tools reach teammates, servers and CI. The project-structure lesson introduced the layout; this lesson goes inside the package itself, down to the zip file that pip actually installs.
What you'll be able to do
- Lay out a project in the src layout, and describe it in pyproject.toml's [project] table
- Declare dependencies and version ranges, and choose version numbers that tell users what changed
- Turn a function into a command with an entry point
- Explain what a wheel is by building and installing one by hand, and what publishing involves
What it covers
- The src layout
- pyproject.toml
- Versions that mean something
- What a wheel really is
- A RECORD line
- Publishing
- Worked example: installing a wheel and reading its metadata
The src layout
A packaged project usually looks like this:
trail-notes/
├── pyproject.toml the project's description: name, version, dependencies, commands
├── README.md
├── src/
│ └── trail_notes/ the importable package (underscores, not hyphens)
│ ├── __init__.py
│ └── cli.py
└── tests/
└── test_cli.py
The code lives under src/, not at the top level. That small choice means your tests can't accidentally import the code straight from the folder: they import the installed package, exactly as users will, so a file you forgot to include shows up as a failing test instead of a surprise for a user.
The project's distribution name (trail-notes, what you pip install) and its import name (trail_notes, what you import) are often spelled differently, because hyphens aren't allowed in Python names. During development, pip install -e . installs the project in editable mode: the package points at your source folder, so changes take effect without reinstalling.
pyproject.toml
One file describes the project for every tool that builds or installs it:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "trail-notes"
version = "1.2.0"
description = "Keep notes for the trail, from the command line."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
dependencies = [
"requests>=2.31,<3",
"rich>=13",
]
[project.optional-dependencies]
dev = ["pytest>=8", "black", "mypy"]
[project.scripts]
notes = "trail_notes.cli:main"
[build-system]names the tool that turns the project into installable files: hatchling here; setuptools, flit and poetry's backend are common alternatives.[project]is the standard metadata every build tool reads: name, version, the Python versions it supports, and its dependencies, which pip installs along with it.- Optional dependencies are installed only on request:
pip install -e ".[dev]"adds the test and tool packages for people working on the project. [project.scripts]creates commands.notes = "trail_notes.cli:main"means "make a command callednotesthat callsmain()in thetrail_notes.climodule". That's howpytest,blackandmypybecome commands when you install them.
Python's standard library can read this file too, with tomllib:
import tomllib PYPROJECT = ''' [project] name = "trail-notes" version = "1.2.0" requires-python = ">=3.11" dependencies = ["requests>=2.31,<3", "rich>=13"] [project.scripts] notes = "trail_notes.cli:main" ''' config = tomllib.loads(PYPROJECT) project = config["project"] print(project["name"], project["version"]) print("needs:", project["dependencies"]) module, function = project["scripts"]["notes"].split(":") print(f"the notes command runs {function}() from {module}")
TOML is a simple settings format: [section] headers, key = value lines, strings in quotes and lists in square brackets. tomllib.loads turns it into ordinary dictionaries. Tools such as black and mypy read their own [tool.black] and [tool.mypy] sections of this same file, which is why one pyproject.toml can configure a whole project.
import tomllib data = tomllib.loads('[project]\nname = "kit"\nversion = "0.3.1"\n[project.scripts]\nkit = "kit.app:run"\n') print(data["project"]["version"], list(data["project"]["scripts"]))
Show the answer
0.3.1 ['kit']
[project.scripts] is a table nested inside [project], so its keys (the command names) appear under data["project"]["scripts"].
Versions that mean something
Most Python projects use semantic versioning: MAJOR.MINOR.PATCH, like 1.2.0.
- Bump PATCH (1.2.0 → 1.2.1) for bug fixes that change nothing else.
- Bump MINOR (1.2.1 → 1.3.0) when you add features in a backward-compatible way.
- Bump MAJOR (1.3.0 → 2.0.0) when you break something: remove a function, change what one returns.
Users then write version specifiers for their dependencies with confidence. requests>=2.31,<3 means "2.31 or newer, but not 3.0", which takes fixes and features but not breaking changes. ~=2.31 is shorthand for much the same. Pin exact versions (==2.32.3) in an application's lock file, so every deployment is identical, but keep a library's ranges loose, so it can share an environment with other libraries.
Before 1.0.0, all bets are off: many projects treat 0.x as "still settling", where any release may break things. Releasing a 1.0 is a promise about stability.
The version number is a message to your users: it tells them how careful they need to be when they upgrade.
What a wheel really is
Running python -m build turns the project into two files in dist/: a source archive and a wheel, trail_notes-1.2.0-py3-none-any.whl. pip installs wheels. And a wheel is simply a zip file with a set layout:
- the package's own files, like
trail_notes/__init__.py, exactly where they'll go insite-packages; - a
trail_notes-1.2.0.dist-info/folder of metadata:METADATA(name, version, dependencies),WHEEL(which wheel format and which Pythons),entry_points.txt(the commands) andRECORD(every file with a hash of its contents, so pip can check nothing was damaged).
The file name says who can use it: py3-none-any means "any Python 3, no compiled code, any platform". Packages with C extensions, like NumPy, publish separate wheels for each operating system and Python version.
Installing a pure-Python wheel is mostly unzipping it into site-packages and creating the commands. That's literally how the academy installs Flask and black for your code: it unzips their wheels into the browser's Python. The exercise below builds and installs one by hand, so none of this stays magic.
A RECORD line
Each line of a wheel's RECORD file is path,sha256=<hash>,<size>, where the hash is the file's SHA-256 digest in URL-safe base64 without the = padding. Write record_line(path, data) for a file's path and bytes.
import base64 import hashlib def record_line(path, data): pass print(record_line("trail_notes/__init__.py", b'__version__ = "1.2.0"\n'))
Publishing
The Python Package Index, PyPI, is where pip install looks by default. Publishing a release takes a few steps:
- Choose a name nobody has taken, and bump the version (PyPI never lets you upload the same version twice).
- Build:
python -m buildcreates the wheel and the source archive indist/. - Try it on TestPyPI first, a separate practice index, and install from there in a fresh virtual environment.
- Upload with
twine upload dist/*oruv publish. Better still, let CI publish when you push a version tag, using PyPI's trusted publishing, which needs no stored password at all.
Most code never needs to be public. Teams share private packages through a private index, or simply with pip install git+https://... straight from a repository. Either way, the same pyproject.toml and the same wheel format do the work. And remember the responsibilities that come with publishing: a clear license, a README that explains how to use it, and version numbers users can trust.
Worked example: installing a wheel and reading its metadata
Install a tiny wheel by unzipping it, then ask Python's standard library what's installed, the way pip and other tools do.
import importlib import importlib.metadata import io import sys import zipfile from pathlib import Path files = { "hello_kit/__init__.py": '__version__ = "0.1.0"\n\ndef main():\n print("hello from hello-kit")\n return 0\n', "hello_kit-0.1.0.dist-info/METADATA": "Metadata-Version: 2.1\nName: hello-kit\nVersion: 0.1.0\n", "hello_kit-0.1.0.dist-info/WHEEL": "Wheel-Version: 1.0\nGenerator: by hand\nRoot-Is-Purelib: true\nTag: py3-none-any\n", "hello_kit-0.1.0.dist-info/entry_points.txt": "[console_scripts]\nhello = hello_kit:main\n", } buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as wheel: for name, text in files.items(): wheel.writestr(name, text) Path("hello_kit-0.1.0-py3-none-any.whl").write_bytes(buffer.getvalue()) target = Path("site_demo") zipfile.ZipFile("hello_kit-0.1.0-py3-none-any.whl").extractall(target) sys.path.insert(0, str(target.resolve())) importlib.invalidate_caches() print("installed version:", importlib.metadata.version("hello-kit")) command = importlib.metadata.entry_points(group="console_scripts", name="hello") for ep in command: print("the hello command runs", ep.value) ep.load()()
The wheel is built in memory with zipfile and saved under its standard file name. "Installing" it is unzipping into a folder that's on sys.path. importlib.metadata then finds the .dist-info folder, just as it finds real packages: version("hello-kit") reads the METADATA file, and entry_points reads entry_points.txt. Loading the entry point imports hello_kit and returns its main function, which is all an installed command does when you type its name. (This wheel skips RECORD for brevity; the exercise adds it.)
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why put the package under src/ instead of at the top of the project?