The previous post ended with a puzzle. NumPy 2.5.4 declares no runtime dependencies, yet building it from source made pip install seven wheels first. Requires-Dist is the dependency list in the wheel metadata we opened in the first post. It names none of them. The build tools arrived inside an isolated environment and left no record behind.
This post measures that gap with Fromager[1], the source build tool this series is about. Fromager builds every package from source, including its own build tools, and writes down what it did. So let’s bootstrap a small tree and read the record.
The input is three unpinned packages: requests for a pure Python case, NumPy to continue the earlier example, and cryptography for a Rust case.
# toplevel-requirements.txt
requests
numpy
cryptography
$ fromager --verbose bootstrap -r toplevel-requirements.txt
The bootstrap resolved requests 2.34.2, NumPy 2.5.4, and cryptography 50.0.2, and produced 35 packages in total. It ran on Fedora Linux, with CPython 3.12.15 inside the build environments. Three of the 35 packages are the ones we asked for. Six more would appear in pip install requests numpy cryptography. The remaining 26 are needed only at build time.
For NumPy, the record gives three different answers to the question "what does NumPy need?"
- To run: zero packages.
- To build in an isolated environment: seven packages. They are the same seven, at the same versions, that pip installed in the previous post.
- To build from source, with no prebuilt inputs: twenty-two packages.
Pip shows only the smallest of three dependency trees. The other two are invisible.
Four places build requirements hide
Build requirements can come from four places. Each one is easy to miss.
1. The pyproject.toml build table. PEP 518 lets a package declare its first build requirements in [build-system].requires. NumPy’s table asks for meson-python and Cython before its build backend can load. This is the only list a reader can find before the build starts.
2. The wheel-build hook. Once the build environment is ready, the build tool calls get_requires_for_build_wheel to ask the backend if it needs more. NumPy’s backend adds ninja, which runs the compile steps, and patchelf, which fixes up the compiled files. The answer can also depend on the environment. charset-normalizer’s backend adds Cython only when CHARSET_NORMALIZER_USE_CYTHON=1 is set. It was not set in this run, so it added nothing.
3. The sdist hook. PEP 517 also defines get_requires_for_build_sdist, for tools needed only to build an sdist. Fromager keeps a separate type for these, build-sdist. But at the commit this post checked, Fromager gets them by calling the wheel hook again. A later post explains why that matters.
4. The legacy fallback. Cython 3.3.0 and semantic-version 2.10.0 ship sdists with no pyproject.toml. Nothing in either sdist declares build requirements, so Fromager adds setuptools>=40.8.0 by default, the same default pypa/build uses. Two of the 35 packages took that path.
Of the four places, only the build table is visible before the build starts. The rest appear during the build, or not at all.
The table below adds two dependencies to the three we asked for: cffi, needed by cryptography, and Cython, needed by NumPy. The "To run" column comes from each wheel’s Requires-Dist.
| Package | To run | To build, from the table | To build, added by the hook |
|---|---|---|---|
| requests 2.34.2 | charset-normalizer, idna, urllib3, certifi | setuptools>=61.0 | nothing |
| numpy 2.5.4 | nothing | meson-python>=0.18.0, Cython>=3.1.0 | ninja>=1.8.2, patchelf>=0.11.0 |
| cryptography 50.0.2 | cffi | maturin, cffi, setuptools | nothing |
| cffi 2.1.1 | pycparser | setuptools>=77.0.3 | nothing |
| cython 3.3.0 | nothing | no pyproject.toml in the sdist | nothing |
NumPy shows the gap clearly. ninja and patchelf are not in any file or in the metadata. They exist only after the backend’s code runs, and the answer can change with the environment.
Two records Fromager writes
Fromager writes what it finds into two files.
graph.json is the complete map, with one node per package and one edge per requirement. In this run, it has 35 nodes and 110 edges. Every edge has a type. The type says why that dependency exists:
toplevel: a package you asked for.install: needed at run time.build-system,build-backend,build-sdist: needed at build time, from places 1, 2, and 3 above.
build-order.json is the build sequence. It lists the packages bottom-up, so each package is built before anything that needs it.
Here is NumPy’s node in graph.json, trimmed:
{
"version": "2.5.4",
"edges": [
{"key": "meson-python==0.22.1", "req_type": "build-system", "req": "meson-python>=0.18.0"},
{"key": "cython==3.3.0", "req_type": "build-system", "req": "Cython>=3.1.0"},
{"key": "patchelf==0.19.1.0", "req_type": "build-sdist", "req": "patchelf>=0.11.0"},
{"key": "ninja==1.13.2", "req_type": "build-sdist", "req": "ninja>=1.8.2"}
]
}
The graph says more than "NumPy needs Cython." It says Cython is a build requirement, which version was used, and the original requirement string. pip keeps no such record.
How big the gap gets
Start with the three packages we asked for. Follow only the install edges. You reach nine packages. That is exactly what pip install requests numpy cryptography installs.
The other 26 are reachable only through build edges. That is 74 percent of the tree:
calver, cmake, cython, flit-core, hatch-fancy-pypi-readme, hatch-vcs,
hatchling, maturin, meson, meson-python, ninja, packaging, patchelf,
pathspec, pluggy, poetry-core, pyproject-metadata, scikit-build-core,
semantic-version, setuptools-rust, setuptools-scm, setuptools 84.0.0,
setuptools 84.2.1, tomlkit, trove-classifiers, vcs-versioning
The list has two setuptools versions. I will explain it in a future post.
semantic-version is the easy one to miss. It sits behind an install edge, so it looks like a runtime dependency. But its parent, setuptools-rust, is needed only to build. So semantic-version is build-only too, and pip never sees it.
Now NumPy’s three numbers from the start of this post.
To run: zero. The wheel’s metadata has no Requires-Dist lines at all.
To build in an isolated environment: seven. They are cython 3.3.0, meson 1.12.1, meson-python 0.22.1, ninja 1.13.2, packaging 26.3, patchelf 0.19.1.0, and pyproject-metadata 0.12.1. pip and Fromager agree on this set. They differ on where it comes from. pip downloaded wheels from PyPI. Fromager built each one from source.
To build from source, with no prebuilt inputs: twenty-two. The extra fifteen exist only to build those seven: calver, cmake, flit-core, hatch-fancy-pypi-readme, hatch-vcs, hatchling, pathspec, pluggy, poetry-core, scikit-build-core, setuptools-scm, setuptools 84.2.1, tomlkit, trove-classifiers, and vcs-versioning.
Here are the counts for all three packages:
| Package | To run | Direct build deps | Build environment | Full build tree |
|---|---|---|---|---|
| numpy 2.5.4 | 0 | 4 | 7 | 22 |
| requests 2.34.2 | 4 | 1 | 1 | 18 |
| cryptography 50.0.2 | 2 | 3 | 4 | 10 |
"Build environment" is the direct build deps plus what those need at run time. "To run" and "Full build tree" count indirect dependencies too. cryptography’s 2 is cffi and pycparser, and cffi needs pycparser.
In every row, the build tree is larger than the run tree. pip’s metadata shows only the run tree.
What this means for you
Before building from source, read [build-system]. It is the only build dependency list a file will give you.
Expect more. Hooks add requirements while the build runs, and the answer can change with the environment. NumPy’s two table names became seven packages, then twenty-two.
If you build at scale, or under a source policy, keep a record of build inputs, split by type. pip and uv keep none. conda-forge lists them by hand. Fromager records them by type. The opener of this series makes the full case.
The build tree is separate from the runtime tree, usually larger, and invisible to pip’s metadata.
The next post looks at what else Fromager records: the build log, the copy inside each wheel, and what the graph says when one package shows up twice.
Further reading
- Fromager: dependencies in the build graph
- Fromager: glossary
- PEP 517: A build-system independent format for source trees
- PEP 518: Specifying Minimum Build System Requirements for Python Projects
- charset-normalizer on PyPI
- This post uses Fromager built from commit
45241aadd284fd9514fc5c250e9ec0ceccfd91a6, eight commits after the 0.101.0 release, reporting version0.101.1.dev8+g45241aadd; later revisions may change behavior. Checked 2026-11-01: 0.102.0 is the latest stable release, and upstreammainis at7014fd9438dd63903a5a988050d330cc7a4a6c4a. ↩︎
Leave a Reply