Get Started covers creating a virtual environment and
Modules & Packages covers pip install.
This post is what comes after: turning a folder of scripts into something other people — including
future you, on another machine — can install and rely on.
pyproject.toml is the whole configuration
One file at the root of your project replaces setup.py, setup.cfg and
most of what used to live beside them:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "bank-console"
version = "0.2.0"
description = "A console bank over CSV files."
requires-python = ">=3.12"
dependencies = [
"rich>=13,<14",
]
[project.optional-dependencies]
dev = ["pytest>=8", "ruff", "mypy"]
[project.scripts]
bank = "bank.__main__:main"
Three things there matter more than the rest. requires-python stops pip installing your
package into an interpreter that cannot run it — a clear error instead of a mysterious
SyntaxError. optional-dependencies keeps test and lint tools out of what your
users install. And project.scripts generates a real bank command on their
PATH, pointing at a function.
The build backend is pluggable; hatchling is a good default, and
setuptools is what you will find in older projects.
Version ranges, not pins
This is the distinction that causes the most trouble, so it is worth stating plainly.
A library declares ranges. rich>=13,<14 says "any 13.x". Pinning
rich==13.7.1 in a library is antisocial: it makes your package uninstallable alongside
anything wanting 13.8, and the conflict lands on a user who did nothing wrong.
An application pins exactly. A deployed service should install byte-identical
dependencies every time, and that is what a lockfile is for — not pyproject.toml.
pip install pip-tools
pip-compile pyproject.toml -o requirements.lock # resolve once, exactly
pip-sync requirements.lock # make the venv match, exactly
pip freeze > requirements.txt is not the same thing. Freeze records whatever happens
to be installed — including packages you installed by hand and forgot, and no record of which were
your actual dependencies. A lockfile is generated from your declared dependencies, so it is
reproducible and regenerable.
pip-sync also removes packages not in the lockfile, which pip install -r
does not. That is the difference between "these are installed" and "only these are installed".
Editable installs
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]" # your package, editable, plus the dev extras
-e installs a link rather than a copy, so edits take effect immediately with no
reinstall. This is how you work on a package locally, and it fixes the most common structural
complaint in Python projects — that tests cannot import the code.
Once installed editable, from bank.money import format_money works from anywhere: your
tests, a notebook, another project. No sys.path manipulation and no
PYTHONPATH in a shell profile. If a project has a conftest.py whose only job
is inserting the parent directory into sys.path, an editable install is the fix.
The src layout
bank-console/
pyproject.toml
src/
bank/
__init__.py
money.py
tests/
test_money.py
README.md
Putting the package under src/ looks like pointless nesting and is not. Without it the
project root is on sys.path when you run tests, so import bank finds the
source directory whether or not the package installs correctly. With src/ it cannot, so
your tests exercise the installed package.
The failure this prevents is the classic one: everything passes locally, and the published wheel is missing a module nobody noticed because the tests never used the wheel.
Building
pip install build twine
python3 -m build # writes dist/*.whl and dist/*.tar.gz
twine check dist/* # validate the metadata before uploading
Two artefacts. The wheel (.whl) is the built distribution pip prefers —
installing it is unzipping. The sdist (.tar.gz) is the source, used when
no matching wheel exists. Publish both.
Inspect the wheel before you trust it — it is a zip, and this catches a missing module long before a user does:
import zipfile, io
# Stand-in for `unzip -l dist/bank_console-0.2.0-whl`
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as z:
z.writestr("bank/__init__.py", "")
z.writestr("bank/money.py", "def format_money(v): return f'${v:,.2f}'")
with zipfile.ZipFile(buffer) as z:
print(sorted(z.namelist()))
# Output: ['bank/__init__.py', 'bank/money.py']
Publishing
twine upload --repository testpypi dist/* # rehearse here first
twine upload dist/* # the real thing
Rehearse on TestPyPI, because a released version can never be replaced. PyPI lets you delete a file but will not accept the same version number again — a broken 1.0.2 means shipping 1.0.3, and anyone who installed the broken one keeps it.
Use a scoped API token rather than your password, stored in ~/.pypirc or a CI secret,
and prefer trusted publishing from GitHub Actions so no long-lived token exists at all.
Versioning, and what it promises
MAJOR.MINOR.PATCH, and the contract is about what breaks:
- PATCH — a bug fix. Nobody's code stops working.
- MINOR — new functionality, backwards compatible. Old code still works.
- MAJOR — something removed or changed. Someone's code will break.
Renaming a public function is a major bump even though it feels small — that is exactly the change that breaks a caller. Deprecate first: keep the old name working, warn when it is used, and remove it in the next major.
uv, briefly
uv venv # create a virtual environment
uv pip install -e ".[dev]"
uv lock # a lockfile, built in
uv run pytest # run in the project environment, no activation
uv replaces venv, pip and pip-tools with one
tool, and is dramatically faster. It reads the same pyproject.toml, so adopting it is not
a migration and abandoning it costs nothing.
Everything above still describes what is happening underneath, which is why it is worth knowing in that order. Next: NumPy Arrays.