A source build fails halfway through a tree of 35 packages. Or a package you never asked for shows up in your wheel index. You end up asking the same two questions:
Why is this package here? What did it need?
The previous post counted the build tree for requests, NumPy, and cryptography. Of 35 packages, 26 were needed only to build something. It also showed Fromager[1]‘s graph.json. Every edge in that file has a type. That is enough to see why one package shows up twice.
Why one package appears twice
setuptools shows up twice in graph.json, as 84.0.0 and 84.2.1. Here are the two nodes that pull it in, trimmed:
{
"charset-normalizer==3.5.2": {
"version": "3.5.2",
"edges": [
{"key": "setuptools==84.0.0", "req_type": "build-system", "req": "setuptools<84.1,>=68"}
]
},
"requests==2.34.2": {
"version": "2.34.2",
"edges": [
{"key": "setuptools==84.2.1", "req_type": "build-system", "req": "setuptools>=61.0"}
]
}
}
Each edge keeps the requirement text.
setuptools<84.1,>=68 has a maximum. The newest release, 84.2.1, was published on 2026-10-20. That is too new, so Fromager picked 84.0.0, published on 2026-08-08, the newest release under the maximum. Only charset-normalizer asks this way.
setuptools>=61.0 has a minimum and no maximum. Fifteen packages ask this way. All of them got 84.2.1, the newest release.
The maximum is there on purpose. charset-normalizer’s changelog raises it in release after release, up to the current <84.1. The changelog does not say why the maximum stays, so this post does not guess.
Fromager answered each requirement on its own. Both requirements were met, and the tree still has two versions of setuptools. If you want one version everywhere, that is a separate choice, and you have to write it down. Later posts cover how Fromager picks versions, and how its graph commands explain duplicates.
The record travels with the wheel
A build log can say why a package was built. Logs get deleted, and so do work directories. Fromager copies that package’s requirement files into the wheel’s .dist-info directory, with a fromager- prefix.
In this run, requests-2.34.2-0-py3-none-any.whl contains fromager-build-system-requirements.txt, fromager-build-backend-requirements.txt, fromager-build-sdist-requirements.txt, and a fromager-build-settings file. The plain requirements.txt is left out. The wheel’s Requires-Dist already lists those.
A later run that finds this wheel reads these files from the wheel. It does not ask the sdist again. The build inputs stay with the wheel after the work directory is gone. A later post covers how that reuse works.
What the edge type tells you
The edge type tells you what can break, and when. Fromager’s dependency documentation says this plainly. If a build dependency fails, the package that needs it cannot build. If an install dependency fails, that package may still build. The failure shows up when you run it.
That changes where you look:
- NumPy’s build fails while its build environment is being installed. The problem is one of the packages in that environment, not NumPy’s code.
- The requests wheel builds, then importing it fails later. Follow its install edges: charset-normalizer, idna, urllib3, and certifi.
The record’s blind spots
The record has four limits.
It stores versions and download URLs, not hashes. It names the source file. It does not show that the bytes match what was published. An open proposal would record a SHA-256 digest for every file, in a provenance index next to the graph.
It covers Python packages only. Compilers, system libraries, and Python itself come from the build machine, as an earlier post showed.
It describes one environment. Fromager drops a requirement when its environment marker does not match the build interpreter, here CPython 3.12.15 on Linux. The same tree on another Python version or platform can need different packages.
Its build-sdist file may not hold what the sdist hook would say. Fromager’s glossary says it should. At the commit this post checked, the code calls the wheel hook instead. The build-backend and build-sdist files in a wheel can match. If a backend gave the two hooks different answers, the sdist answer would be missed. This is what the code does at that commit, not a standing rule.
When a build surprises you
Start with the record:
- To learn why a package is in the tree, follow its edges in
graph.json. - To learn what a wheel needed to build, look in its
.dist-infofor thefromager-files. - To understand two versions of one package, compare the requirement text on its edges.
- To judge what a failure can break, check the edge type. Build edges break the build. Install edges break at run time.
The next post follows the build order. The bootstrap loop builds the build tools first, including setuptools.
Further reading
- Fromager: dependencies in the build graph
- How Python Packages Build Themselves
- 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-04: 0.102.0 is the latest stable release, and upstreammainis atd04584f9bfdaf6ab7b91a38bc9b8ae8fb4f0f12b. ↩︎
Leave a Reply