Python Advanced – Packaging & Publishing

August 11, 20264 min readUpdated 8/20/2026

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.