Where Does the First Wheel Come From?

Say your organization adopts a strict rule: no prebuilt Python wheel enters the build environment. The rule sounds straightforward until you try to build NumPy. In an earlier post, --no-binary numpy stopped NumPy’s wheel, but pip still installed seven wheels for the tools that build NumPy. The tools you use to avoid wheels arrive as wheels.

Why make such a rule? Not because source is automatically safe. A source build still runs code chosen by the package. The reason is control. Building from source lets an organization choose its sources, versions, build inputs, and artifacts.

That control does not remove trust. Python, compilers, system libraries, source repositories, and build hooks still have to be trusted. The difference is that these trust decisions move into a process the organization can inspect and record.

That makes the first obstacle immediate. If no prebuilt Python wheels are allowed, the build tools have to be built from source too. Those tools are Python packages, and they may need other Python packages to build.

So the question in the title is not rhetorical. Where does the first wheel come from?

Fromager was built, in part, to answer this kind of question. Its [1] bootstrap loop can build a tree without starting from a prebuilt Python package. The input stays the same, pinned to the versions from the earlier posts:

$ fromager --verbose bootstrap -r toplevel-requirements.txt

The run produced the same tree shape as before: 35 packages, 110 edges. A few transitive versions had drifted, but the top levels stayed pinned. It finished in just under 14 minutes. Four seconds in, it was already building the first build tool.

That first build tool was not an arbitrary implementation detail. It was the package most likely to look like a circular argument.

The bottom of the tree

Cryptography’s build table is one of the first things the loop reads. It asks for three build tools: maturin, cffi, and setuptools. In the whole tree, 16 of the 35 packages ask for setuptools. Two more get the default fallback, setuptools>=40.8.0, because their sdists ship no build table. So the walk reaches setuptools almost at once. If any package looks like the start of an endless circle, it is this one.

Here is the answer, from the setuptools sdist this run downloaded:

[build-system]
requires = [
    # "setuptools>=77",
    # "setuptools_scm[toml]>=3.4.1",
    # jaraco/skeleton#174
    # "coherent.licensed",
]
build-backend = "setuptools.build_meta"
backend-path = ["."]

The requires list is empty. Every line in it is commented out. The backend-path setting says the build backend ships inside the sdist itself. setuptools builds itself, from its own sdist, with no Python build requirements installed.

The folk version of the story, "to build setuptools you need setuptools", is not what the file says. setuptools breaks the circle a different way: it declares no build requirements and includes its build backend in the sdist.

That shortcut only solves one circle. Most build tools still declare dependencies, so the loop has more work to do. maturin asks for setuptools>=77.0.0. meson-python asks for meson, and its hooks add ninja and patchelf. patchelf is a compiled C++ program, built with scikit-build-core. That adds hatchling, then trove-classifiers, then calver. Tools need tools until a package declares nothing.

In this tree, three packages declare nothing: setuptools, flit-core, and poetry-core. Each ships its backend in the sdist, so these are the bottom of the build graph. In graph.json, their nodes have no outgoing build edges.

When the walk reaches setuptools, it builds the wheel in an empty build environment. Fromager publishes that wheel to its local wheel server. Later build environments install it from there, not from PyPI.

That solves the first wheel, but only if the walk can keep going until it reaches a bottom.

How the walk works

Fromager used to walk the tree with recursive function calls. Deep trees hit Python’s recursion limit, so we replaced that design with an explicit work stack:

while stack:
    item = stack.pop()
    new_items = item.run(self)

    for each new_item in new_items:
        stack.push(new_item)

Each package version becomes a work item. The item moves through seven phases:

Phase What it does
RESOLVE pick the version, one item per version
START add the edge to the graph, drop the item if already seen
PREPARE_SOURCE download and unpack the source, read the build table
PREPARE_BUILD install build-system wheels, run hooks for more requirements
BUILD build the sdist and wheel, publish the wheel
PROCESS_INSTALL_DEPS read install requirements, record the build order
COMPLETE clean up

At the end of each phase, item.run() returns the next phase and any newly discovered requirements. Both go back onto the stack.

The stack is last in, first out. A dependency is pushed after its parent’s next phase, so the dependency finishes its subtree before the parent continues. The build order comes from that rule; nobody writes it by hand.

Nix, Gentoo, and Spack use hand-written build recipes for every package. Fromager uses the recipe each package already carries: its build table and its build hooks. The opener covered why that matters for a Python tree that changes every week.

What the walk remembers

The order is fixed. Fromager builds the tools needed to build a package first, because those tools are installed into its build environment before the package is built. Install requirements are found later, after the package wheel is built and its metadata is read. So each package moves through its build dependencies, its own build, and then its install dependencies.

The loop also remembers each package version it has already started. Its record includes the package name, extras, version, and artifact type. If the walk reaches the same record again, it drops the duplicate item. That is why 16 packages can ask for setuptools, but it is built only once. The same rule also stops the walk from chasing a circle. graph.json still records every request, so the repeated edges are not lost.

Each work item also carries the reason it was created. For example, NumPy needed meson-python; its hooks added patchelf; patchelf needed scikit-build-core, which brought hatchling, trove-classifiers, and calver. The log prints that chain, so it is clear why a small package is being built.

What the loop does not do

The boundary from the earlier posts still holds. The loop builds Python packages. Python itself, the compilers, Rust, autotools, and the system libraries all come from the build host.

The seen key includes the version. One encounter builds one version of one package. When two packages need different versions, both get built. That is how the two setuptools versions from the previous post ended up in one tree. If you want every matching version built on purpose, that is a separate mode. A later post covers it.

The stack removed the recursion limit. It did not make deep trees small. A chain of six reasons is still a long chain, and a wide AI stack produces many of them at once. What changed is where the depth costs memory. It now uses heap memory instead of Python call frames. The pending work also sits in a file you can read, instead of a call stack you cannot.

Key takeaways

  • A source-only policy is about control and auditability. It is not a claim that source is automatically safe.
  • With pip, building everything from source still starts from prebuilt build tools. Fromager builds the build tools first, from source. The loop makes that possible without per-package recipes.
  • The build order comes out of the walk. Nobody writes it by hand, unlike the recipes in Nix or Gentoo.
  • The bottom of the tree is a package that declares no build needs. setuptools qualifies on purpose. It comments out its own requirements and ships its backend in the sdist.
  • An explicit stack and a seen set let deep trees and repeats finish. The why stack records the chain of reasons for every build.
  • The boundary: Python, compilers, and system libraries still come from the build environment.

The first wheel comes from a package that deliberately asks for nothing.

The next post moves from order to choice. What happens when fifteen packages ask for setuptools with twelve different rules?

Further reading


  1. This post uses Fromager built from commit 70aa4594b9e3c5198e56d1e520698f19881bc87d, nine commits after the 0.102.0 release, reporting version 0.102.1.dev9+g70aa4594b; later revisions may change behavior. Checked 2026-11-07: 0.103.0 is the latest stable release, and upstream main is at d86e4968dbf2c59a3d97d26eb369c8f2a9f512c2. ↩︎

Leave a Reply