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.pyfile. - 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 Hide 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.