21. Projects and packaging: pyproject.toml, uv, lockfiles
21.1 The modern layout
uv init --package netkit # src/ layout, installable package, entry point
# pyproject.toml: one file for metadata, dependencies, and tool config
[project]
name = "netkit"
version = "0.1.0"
description = "Small network tools"
readme = "README.md"
requires-python = ">=3.13"
dependencies = ["httpx>=0.28"] # what users need: ranges, not pins
[project.scripts]
netkit = "netkit.cli:main" # installs a `netkit` command
[dependency-groups]
dev = ["pytest>=8.4", "ruff>=0.13", "mypy>=1.18"]
[build-system]
requires = ["uv_build>=0.8"] # or hatchling / setuptools
build-backend = "uv_build"
Why src/: with the package under src/, tests import the installed
package, not the files that happen to sit in the current directory, so a
missing file in your wheel fails your tests instead of your users.
21.2 Version ranges in pyproject.toml, exact versions in the lockfile
pyproject.tomlsays what versions work (httpx>=0.28). Libraries should only ever do this; pinning exact versions in a library makes it impossible to install next to other libraries.uv.lockrecords the exact version and hash of every package, including transitive ones, for every platform. Commit it for applications.uv sync --lockedin CI and Docker installs exactly that set, or fails.
uv add "pydantic>=2.12" # updates pyproject.toml and uv.lock
uv lock --upgrade-package httpx
uv sync --locked # reproducible install; fails if the lock is stale
uv tree # who pulled in what
uv build # sdist + wheel in dist/
uv publish # to PyPI (use trusted publishing from CI, Ch 50)
21.3 Wheels, sdists, and native code
A wheel (.whl) is a zip of ready-to-import files, tagged by Python
version, OS, and CPU (numpy-2.3.5-cp313-cp313-macosx_14_0_arm64.whl). A
source distribution (sdist) must be built on install, which for packages
with C or Rust code needs a compiler. If pip install starts compiling for
minutes, it found no wheel for your platform; this happens with brand-new
Python versions and unusual platforms (Alpine's musl libc is the classic,
which is why Python Docker images are usually Debian-based, Ch 70).
21.4 War story
A data team's nightly job started failing on a Tuesday with
AttributeError deep inside a plotting library. Nothing in their code had
changed. Their requirements.txt listed pandas with no version, and a new
major release had come out on Monday with a removed API. Their laptops still
had the old version cached, so "it works on my machine" was literally true.
Lesson: an application without a lockfile is a different program every
time it is installed. Commit uv.lock, install with --locked, and upgrade
dependencies on purpose, in a pull request that runs the tests.