Skip to content
← Back to explainers

Python explainer

Choose a Build Backend and Test the Wheel

About 5 min to read. Allow extra time to work through examples.

See how pip, uv or python -m build call a backend such as setuptools or hatchling, how an editable install points back at your folder, and how to test the wheel you ship.

This article uses Python. You can make it your practice mode to browse related content across the site.

Your tests pass after pip install -e .. You publish a wheel, and the first user who renders a report gets FileNotFoundError for templates/report.txt. The editable install read the template from your project folder. The wheel never contained it.

The frontend calls the backend

Two programs take part in a build. The build frontend is the command you run, such as pip, uv or python -m build. The build backend is the library that turns your project folder into a wheel or an sdist, such as setuptools, hatchling or uv_build. pyproject.toml names the backend and the packages it needs:

[build-system]
requires = ["hatchling>=1.27,<2"]
build-backend = "hatchling.build"

The frontend creates a temporary environment, installs the packages in requires into it, and calls the backend there. This is build isolation. It keeps your project's packages out of the build, and the build's packages out of your project. pip documents the whole build process.

Isolation separates packages. It does not fix their versions, so next month the same range can install a newer hatchling. A build also runs the backend's code, and it can run your project's own build code, so build only projects you trust.

Choose a backend for what your package needs

If your current backend works, keep it. Switch when you need something it lacks, such as a rule for including data files or a way to compile C code.

  • Hatchling lets you choose the files in each build target and run build hooks.
  • Setuptools is the oldest backend. It builds C extensions and still supports a setup.py file.
  • uv_build builds pure-Python packages and expects a src/ layout by default. It cannot build compiled extensions.

The frontend and the backend are separate choices. uv can call setuptools, and pip can call uv_build. The next article introduces uv.

How an editable install points at your folder

pip install -e . asks the backend for an editable wheel. That is a small wheel that points back at your folder instead of holding a copy of your code. Most backends put a .pth file in site-packages. Python reads every .pth file at startup and adds each folder it lists to sys.path, the list of folders Python searches for imports. Some backends install an import hook instead, which maps your package's name to your folder. Either way, import report_demo loads the files in your project.

That tells you which changes need a reinstall:

Change Reinstall?
Edit an existing .py file No. Restart any running Python process to load the change.
Add a module or a package It depends on the backend's mechanism. Reinstall if the import fails.
Change dependencies Yes. pip installs dependencies only during an install.
Add or change a command in [project.scripts] Yes. The installer writes the commands.
Change compiled C or Rust code Rebuild.

The editable wheel is not the wheel you publish. Through it, your code can read every file in your folder, including the files your real wheel leaves out. That is the bug in the opening story.

Test the wheel outside your folder

Tests that run from the project folder can import your source instead of the installed package. A src layout makes that less likely, but only installing the real wheel shows what it contains.

For an example, put the [build-system] table above in a pyproject.toml with this project configuration:

[project]
name = "report-demo"
version = "0.1.0"

[tool.hatch.build.targets.wheel]
packages = ["src/report_demo"]

Create an empty src/report_demo/__init__.py and a file src/report_demo/templates/report.txt that contains hello. Install the build package in your current Python environment, then run this script from the project folder:

import os
from pathlib import Path
import subprocess
import sys
import tempfile
import venv

with tempfile.TemporaryDirectory() as directory:
    work = Path(directory)
    dist = work / "dist"
    subprocess.run(
        [sys.executable, "-m", "build", "--outdir", str(dist)], check=True
    )
    wheels = list(dist.glob("*.whl"))
    assert len(wheels) == 1, wheels
    env = work / "env"
    venv.EnvBuilder(with_pip=True).create(env)
    python = env / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
    subprocess.run(
        [str(python), "-m", "pip", "install", str(wheels[0])], check=True
    )
    subprocess.run(
        [str(python), "-I", "-c",
         "from importlib.resources import files; "
         "text = files('report_demo').joinpath('templates/report.txt').read_text(); "
         "assert text.strip() == 'hello'"],
        cwd=work, check=True,
    )

The script builds into an empty dist folder, so it cannot pick up an old wheel. python -m build makes the sdist first and the wheel from the sdist, so the script also tests the build from the sdist. The last step reads the template from the installed wheel. It runs with -I and from the temporary folder, so neither your project folder nor PYTHONPATH can supply the package. In your own project, read your own resources and call your own commands.

Check your understanding A JSON file is in the sdist but missing from the wheel. What should you check? Show answer

Check the backend's rules for which files go into each target. The backend applies separate rules to the sdist and the wheel, so the two can contain different files. With setuptools, look at package-data, include-package-data and MANIFEST.in, as its data-file guide describes. Other backends have their own settings.

Fix the rule, rebuild into an empty folder, and test the installed wheel by reading the file. A test that only imports the package passes even when the file is missing.

step 25 of 41

Modules, Packaging and Tooling

Up next Manage a Project with uv