Skip to content

    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.

    Estrutura do pacote de exemplo
    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:

    exemplos/pacote/pyproject.toml
    [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

    exemplos/pacote/src/calc_notes/operacoes.py
    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
    
    exemplos/pacote/src/calc_notes/__init__.py
    """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.

    exemplos/pacote/src/calc_notes/cli.py
    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
    
    exemplos/pacote/tests/test_operacoes.py
    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)
    
    exemplos/pacote/README.md
    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:

    Terminal
    cd exemplos/pacote
    uv pip install -e .
    
    Terminal
    calc-notes 2 3
    
    Output
    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:

    avancado/cap49_empacotamento.pylinhas 10 a 15
    from importlib.metadata import PackageNotFoundError, version
    
    try:
        print(version("pacote-que-nao-existe"))
    except PackageNotFoundError as erro:
        print("não instalado:", erro)
    
    Output
    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/:

    Terminal
    uv build
    
    Output
    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:

    Terminal
    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

    BackendWhen to use
    hatchlingDefault choice for pure Python packages, simple and configurable
    uv_buildSame scenario, very fast, integrated with uv
    setuptoolsOld projects, or those with C extensions
    poetry-coreProjects managed by Poetry
    maturin, scikit-build-coreExtensions 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.