Chapter 49, Advanced
Packaging and project structure
Transform code into something installable, versionable, and publishable. Even if you never publish on PyPI, the package structure solves import and testing issues.
Code for this chapter: avancado/cap49_empacotamento.py
Why use the src layout
Putting the package inside a folder src/ avoids a subtle error: with the code in the root, tests accidentally import the local folder and pass even if the installed package is broken. With src/, the only way to import is installing the package, and then you test what the user will receive.
exemplos/pacote/
pyproject.toml
README.md
src/
calc_notes/
__init__.py
operacoes.py
cli.py
tests/
test_operacoes.py
The pyproject.toml
Everything lives in one file. The section [build-system] says how to build. The section [project] describes the package. And [project.scripts] creates a terminal command linked to a function:
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "calc-notes"
version = "0.1.0"
description = "Calculadora de exemplo do livro Python na Prática"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
[project.scripts]
calc-notes = "calc_notes.cli:main"
[dependency-groups]
dev = ["pytest>=8"]
The package code
def somar(a: float, b: float) -> float:
return a + b
def dividir(a: float, b: float) -> float:
if b == 0:
raise ZeroDivisionError("divisor não pode ser zero")
return a / b
"""Pacote de exemplo do livro Python na Prática."""
from importlib.metadata import PackageNotFoundError, version
from calc_notes.operacoes import dividir, somar
try:
__version__ = version("calc-notes")
except PackageNotFoundError:
__version__ = "0+local"
__all__ = ["__version__", "dividir", "somar"]
I read the version from the metadata of the installed package instead of repeating it in the code. Thus there is one source of truth, the pyproject.toml.
import argparse
from calc_notes.operacoes import somar
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="calc-notes")
parser.add_argument("a", type=float)
parser.add_argument("b", type=float)
args = parser.parse_args(argv)
print(somar(args.a, args.b))
return 0
import pytest
from calc_notes import dividir, somar
def test_somar():
assert somar(2, 3) == 5
def test_dividir_por_zero():
with pytest.raises(ZeroDivisionError):
dividir(1, 0)
calc-notes
Calculadora de exemplo do livro Python na Prática.
Install in editable mode and use
The editable mode installs the package pointing to your source code, so every change takes effect immediately, without reinstalling:
cd exemplos/pacote
uv pip install -e .
calc-notes 2 3
5.0
Without uv, inside a virtual environment: python -m pip install -e .. To test, run pytest inside the package folder.
The module importlib.metadata queries the metadata of any installed package, and raises PackageNotFoundError if it does not exist:
from importlib.metadata import PackageNotFoundError, version
try:
print(version("pacote-que-nao-existe"))
except PackageNotFoundError as erro:
print("não instalado:", erro)
não instalado: No package metadata was found for pacote-que-nao-existe
Build and publish
A package is distributed in two formats: the sdist (source code, .tar.gz) and the wheel (already ready to install, .whl). Any of the commands below generates both in the folder dist/:
uv build
Building source distribution...
Building wheel from source distribution...
Successfully built dist/calc_notes-0.1.0.tar.gz
Successfully built dist/calc_notes-0.1.0-py3-none-any.whl
Equivalent alternatives: python -m build (with the package build) and poetry build. To publish, first train on TestPyPI, a test index:
uv publish --publish-url https://test.pypi.org/legacy/ --token "$TOKEN"
It also works with twine upload --repository testpypi dist/*. In real projects, I prefer the trusted (trusted publishing): GitHub Actions proves your identity to PyPI with a temporary token, and you never store a long-term password.
Choose the build backend
| Backend | When to use |
|---|---|
hatchling | Default choice for pure Python packages, simple and configurable |
uv_build | Same scenario, very fast, integrated with uv |
setuptools | Old projects, or those with C extensions |
poetry-core | Projects managed by Poetry |
maturin, scikit-build-core | Extensions in Rust or C++ |
Libraries and applications think differently about versions
A application is installed in an environment that you control. Commit the lockfile and pin the versions. A library is installed in other people's environments, along with other libraries. Declare broad (requests>=2.32,<3ranges), because exact versions cause conflicts for those using yours. The library's lockfile is only for your development and testing.
Exercise 1
Run with python -m
Make the package run with python -m calc_notes 2 3, adding a file to the package.