Index Ai Academy
Phase 7 · Software engineering · Python in depth

Packaging your code

pyproject.toml, the src layout, versions, entry points and wheels.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. The src layout
  2. pyproject.toml
  3. Versions that mean something
  4. What a wheel really is
  5. A RECORD line
  6. Publishing
  7. 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 called notes that calls main() in the trail_notes.cli module". That's how pytest, black and mypy become 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}")
It runs right here in your browser, with nothing to install.

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.

Quick check
What does this print?
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"]))
  1. 0.3.1 ['kit.app:run']
  2. kit ['run']
  3. An error
  4. 0.3.1 ['kit']
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.

Try it now

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 in site-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) and RECORD (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.

Drill

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'))
In the lesson, your code is checked as you write it.

Publishing

The Python Package Index, PyPI, is where pip install looks by default. Publishing a release takes a few steps:

  1. Choose a name nobody has taken, and bump the version (PyPI never lets you upload the same version twice).
  2. Build: python -m build creates the wheel and the source archive in dist/.
  3. Try it on TestPyPI first, a separate practice index, and install from there in a fresh virtual environment.
  4. Upload with twine upload dist/* or uv 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

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()()
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

Why put the package under src/ instead of at the top of the project?

  1. So tests import the installed package, catching files missing from the build
  2. Because Python can't import from the top level
  3. To make the package smaller
  4. It's required by PyPI

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson