How Python Packages Build Themselves

In July 2025, a phishing site proxied the real PyPI pages at pypj.org: a lowercase i swapped for a j. Four PyPI accounts were phished. An attacker used one compromised account to upload two malicious num2words releases, which the account owner removed (incident report, GHSA-jxr6-qrxx-2ph2).

In December 2024, the ultralytics project had a different problem. The files it published to PyPI contained cryptomining code that was not in its public source repository (PYSEC-2024-154).

These incidents make building from reviewed source appealing as we want what we install to match the source code project delivers.

The previous post ended with a catch: when you build a package from source, the package itself decides what code runs the build. This post explains what happens between pip downloading an sdist (a source distribution) and producing a wheel (a prebuilt package). During a build, the sdist can download more code and run it. Most users never see this happen. We follow that process step by step.

Two jobs: the frontend and the backend

A source build uses two programs. They share a small interface, not their internals.

The frontend is the tool you run: pip, python -m build, or uv. It takes a source tree, sets up a place to build, and drives the process.

The backend turns the source tree into a wheel. It can be a separate package from PyPI, such as meson-python, or a module inside the sdist. pip does not know how to build every package. It asks the package which backend to use.

Two PEPs define the interface. PEP 518 introduced pyproject.toml and [build-system].requires to declare build requirements.

PEP 517 added build-backend and defines the hooks the frontend calls. Any tool that follows the protocol can drive the build.

Let’s follow NumPy, the array library whose wheel we inspected in the previous post. Here is its [build-system] table for version 2.5.4, with formatting simplified:

[build-system]
requires = ["meson-python>=0.18.0", "Cython>=3.1.0"]
build-backend = "mesonpy"

The frontend installs meson-python and Cython before calling the backend. The import name mesonpy belongs to the meson-python package. It connects the Python build protocol to Meson, which drives NumPy’s native compilation. Cython translates parts of the source into C. The C and C++ compilers are additional tools supplied by the build environment.

A package with no [build-system] table still follows the model. The default is legacy setuptools, so there is still a backend to call.

The hook protocol

PEP 517 defines a small set of hooks. The frontend calls a hook, gets a result, and moves on. Every backend must provide build_wheel and build_sdist. Two optional hooks matter here: get_requires_for_build_wheel can add build requirements at run time, and prepare_metadata_for_build_wheel can produce metadata without a full wheel. If the metadata hook is absent, pyproject_hooks can build a wheel and extract its metadata. Editable installs have their own hooks (PEP 660).

How does the frontend call code it has never imported? Through a subprocess. pyproject_hooks, the library pip and python -m build use, writes the hook arguments to input.json, then runs a helper in the package’s source directory. The helper imports the backend, runs the hook, and writes the result to output.json. The backend never runs inside the frontend’s process.

This matters later: the frontend chooses which Python interpreter runs the backend.

backend-path goes further. A package can point build-backend at a module in its own source tree. The backend does not have to be a known package. It can be a file in the sdist.

Here is the flow for the pip example below, using the default PyPI index:

        pip  (frontend)
          |
          |  1. download and unpack the sdist          (network: PyPI)
          |  2. create an isolated build environment
          |  3. install [build-system].requires  ----> wheels from PyPI  (*)
          |  4. run get_requires_for_build_wheel hook
          |        subprocess: python executes the selected backend
          |  5. install anything the hook returned --> wheels from PyPI  (*)
          |  6. request metadata
          |        use prepare_metadata_for_build_wheel if available;
          |        otherwise build a wheel and extract its metadata
          |  7. build the wheel, or reuse the wheel from step 6
          |        backend hooks run in subprocesses
          |  8. receive the wheel filename
          v

Two arrows point to PyPI. Steps 3 and 5 install build requirements, using prebuilt wheels when available. The requirements, metadata, and wheel stages can run the selected backend. If the backend is part of the source tree, that code comes from the sdist. The exact hook sequence depends on which hooks the backend provides.

Watching a build happen

Let’s build NumPy 2.5.4 and follow the steps. This run uses CPython 3.12.15, pip 26.2.1, build 1.6.1, and GCC 11.5.0 in an x86-64 Red Hat Universal Base Image (UBI) 9 container. The container supplies the C and C++ compilers and Python headers; pip supplies the Python build requirements. NumPy’s source-build guide explains the system prerequisites.

This test environment has no external BLAS or LAPACK numerical library, so NumPy uses its internal fallback routines. Those are slower than an optimized library such as OpenBLAS. The goal here is to understand the packaging process, not performance. So the wheel we build will differ from the optimized wheel on PyPI.

NumPy publishes wheels, so --no-binary numpy forces pip to use its sdist. The flag applies only to NumPy; build tools can still arrive as wheels. --no-deps skips runtime package dependencies, while build requirements are still installed. The -Ccompile-args=-j2 setting tells the backend to limit compilation to two jobs.

$ PIP_NO_CACHE_DIR=1 python3.12 -m pip wheel -v --no-deps --no-binary numpy --no-cache-dir \
    -Ccompile-args=-j2 numpy==2.5.4 -w wheels/
Collecting numpy==2.5.4
  Downloading numpy-2.5.4.tar.gz (20.9 MB)
  Installing build dependencies: started
  Running command installing build dependencies for numpy
  ... (dependency resolution and progress bars trimmed)
  Downloading meson_python-0.22.1-py3-none-any.whl (31 kB)
  Downloading cython-3.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl (3.4 MB)
  Downloading meson-1.12.1-py3-none-any.whl (1.1 MB)
  Downloading packaging-26.3-py3-none-any.whl (129 kB)
  Downloading pyproject_metadata-0.12.1-py3-none-any.whl (23 kB)
  ... (installation detail trimmed)
  Successfully installed Cython-3.3.0 meson-1.12.1 meson-python-0.22.1 packaging-26.3 pyproject-metadata-0.12.1
  Installing build dependencies: finished with status 'done'
  Getting requirements to build wheel: started
  Running command Getting requirements to build wheel
  Getting requirements to build wheel: finished with status 'done'
  Installing backend dependencies: started
  ... (dependency resolution and progress bars trimmed)
  Successfully installed ninja-1.13.2 patchelf-0.19.1.0
  Installing backend dependencies: finished with status 'done'
  Preparing metadata (pyproject.toml): started
  Running command Preparing metadata (pyproject.toml)
  ... (Meson setup command and compiler checks trimmed)
  Project name: NumPy
  Project version: 2.5.4
  ... (native compilation and wheel assembly trimmed)
  Preparing metadata (pyproject.toml): finished with status 'done'
Building wheels for collected packages: numpy
  Building wheel for numpy (pyproject.toml): started
  Running command Building wheel for numpy (pyproject.toml)
  Building wheel for numpy (pyproject.toml): finished with status 'done'
  ... (wheel hash and cache path trimmed)
Successfully built numpy

Step 3 installed five wheels: meson-python and Cython from the table, plus their dependencies meson, packaging, and pyproject-metadata. The backend hook then requested two more tools, ninja and patchelf, and step 5 installed them. Two names in the table led to seven build wheels.

Step 6 is the surprise. meson-python 0.22.1 does not implement prepare_metadata_for_build_wheel, so pip built a complete wheel just to read its metadata. That is why the log shows compilation under "Preparing metadata." The later wheel stage reuses that wheel. A metadata request can do the full build.

The result is numpy-2.5.4-cp312-cp312-linux_x86_64.whl: a wheel for CPython 3.12 on this Linux machine, with a different platform tag from the published manylinux wheel.

Here is the same source through a different frontend, python -m build. Download numpy-2.5.4.tar.gz from the PyPI release files into the current directory, then unpack it:

$ tar -xzf numpy-2.5.4.tar.gz
$ cd numpy-2.5.4
$ PIP_NO_CACHE_DIR=1 python3.12 -m build -v --wheel -Ccompile-args=-j2 --outdir ../wheels-build/ .
* Creating isolated environment: venv+pip...
* Installing packages in isolated environment:
  - Cython>=3.1.0
  - meson-python>=0.18.0
  ... (initial dependency installation trimmed)
* Getting build dependencies for wheel...
* Installing packages in isolated environment:
  - ninja >= 1.8.2
  - patchelf >= 0.11.0
  ... (additional dependency installation and version report trimmed)
* Building wheel...
... (Meson setup, compilation, and wheel assembly trimmed)
Successfully built numpy-2.5.4-cp312-cp312-linux_x86_64.whl
$ cd ..

build creates a virtual environment (venv), installs the declared requirements, asks the backend for more, and calls build_wheel. It skips the metadata request because it only needs a wheel. Both frontends use the same hooks; each picks the ones it needs.

Now compare run-time needs with build-time needs. Search the wheel’s METADATA for Python package dependencies:

$ unzip -p wheels/numpy-2.5.4-cp312-cp312-linux_x86_64.whl 'numpy-2.5.4.dist-info/METADATA' | grep '^Requires-Dist:'

No matching lines. NumPy has no runtime dependencies, yet this build installed seven wheels. Requires-Dist says nothing about the tools that built the wheel, or about native libraries like OpenBLAS. The next post looks at how big that gap can get.

The build is not just configuration

The first point is simple: a source build runs build code chosen by the package. That is more than the metadata in pyproject.toml. The code can be an external backend like meson-python, or a module the author shipped. Either way, the package controls what runs.

Some readers remember setup.py, the old build script: plainly a program, run by the build. Modern pyproject.toml looks safer, because it looks like plain configuration. But the table still starts a program. NumPy’s short table launches a backend that runs Meson, generates code, checks the compiler, and compiles native extensions, all under your user account.

Here is a made-up package. It is not on PyPI. It has no runtime dependencies, no static build requirements, and no compiled code. Its backend is in the source tree:

[build-system]
requires = []
build-backend = "backend"
backend-path = ["."]

[project]
name = "helloworld"
version = "1.0.0"
# backend.py
def get_requires_for_build_wheel(config_settings=None):
    print("backend.py: get_requires_for_build_wheel is running")
    print("backend.py: this sdist's own code decides what gets installed next")
    return ["six"]

def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
    print("backend.py: build_wheel is running")
    ...  # assembles a minimal wheel by hand

The wheel assembly is omitted. This backend is minimal and supports only --wheel; it does not implement build_sdist.

Building it with python -m build (output trimmed):

$ cd helloworld
$ PIP_NO_CACHE_DIR=1 python3.12 -m build -v --wheel --outdir ../wheels-toy/ .
* Creating isolated environment: venv+pip...
* Getting build dependencies for wheel...
backend.py: get_requires_for_build_wheel is running
backend.py: this sdist's own code decides what gets installed next
* Installing packages in isolated environment:
  - six
< Downloading six-1.17.0-py2.py3-none-any.whl (11 kB)
< Successfully installed six-1.17.0
* Building wheel...
backend.py: build_wheel is running
Successfully built helloworld-1.0.0-py3-none-any.whl

Two things happened. The backend’s prints appeared in the log, so code from the source tree ran in a subprocess. The frontend also installed six, because the hook returned it. The build requirements are not fixed in the file. The sdist’s own code decides them at build time.

So building from source is not automatically safer than installing a wheel. A build runs code. The real question is what code runs, where, with what inputs, and who built its tools.

Build isolation still uses PyPI

The second point is about the two (*) arrows.

The pip run above installed seven wheels from PyPI before compilation started: five from the table, two from the backend hook. Even the careful --no-binary numpy command added seven PyPI wheels to the build.

pip’s build isolation keeps build requirements away from your installed packages, which avoids version conflicts. But it is not a sandbox. Build code can still use the network and write files your user can write.

And isolation did not remove the PyPI dependency. It moved it where most users do not look. With --no-binary=:all:, the build tools are built from source too, and they have their own build requirements. The problem repeats: source builds need build tools, and build tools are Python packages.

Together, the two points leave a gap: the code that runs during a build, and the wheels it needs, are missing from the metadata pip shows you.

Common backends

The [build-system] table lets a package choose any backend. These are common:

Backend What runs at build time Typical use
setuptools Python, including old setup.py the default; large legacy base
hatchling Python configuration-driven pure Python
flit_core Python small pure-Python packages
maturin Python and Rust via cargo Rust extension modules

Each backend has an import name in build-backend: setuptools.build_meta, hatchling.build, flit_core.buildapi, or maturin. Our NumPy example uses mesonpy, supplied by meson-python, for its C and C++ extensions. scikit-build-core connects the same protocol to CMake.

The maturin row adds a Rust toolchain. It can use an existing installation or request puccinialin and bootstrap Rust when cargo is missing. Backend code can fetch native tools too; pip’s dependency isolation does not control all build inputs.

Backends are also a place where trust matters. flit_core is deliberately small, so there is less build code to trust. Full-featured backends trade that simplicity for flexibility.

Fromager: same hooks, local wheels

Fromager is a PEP 517 frontend built on the same pyproject_hooks library as pip and python -m build.[1] Same hooks as pip, but a different source for the wheels.

Like any frontend, Fromager chooses the interpreter. get_build_backend_hook_caller() in src/fromager/dependencies.py runs each hook subprocess with the Python from a build environment it creates. The hook calls match the interface above: get_requires_for_build_wheel for extra requirements, pep517_metadata_of_sdist() for metadata with a fallback to building a wheel, and build_wheel for the wheel.

Steps 3 and 5 are where it differs. BuildEnvironment makes a venv with uv and installs build requirements with uv pip install --only-binary :all: from a configured wheel index, normally its local server. Fromager’s goal is to build everything from source, from the bottom up: earlier source builds supply the wheels for later builds, so the stack is built in dependency order.

Prebuilt wheels are the escape hatch. A variant can mark a package as pre-built, and Fromager pulls a binary wheel instead of building it. That is for the rare case where there is no source to build, or the source cannot be built for some reason. The wheel server can also be external. So whether a build is truly source-only depends on the settings and the index contents.

Preparation runs in three rounds: [build-system].requires, then get_requires_for_build_wheel, then sdist build dependencies. Each round is cached in a requirements file next to the source.

Two more details. The default sdist rebuild does not call build_sdist; it re-packs the source tree, because many packages expect a git checkout and fail the hook. And Fromager can patch [build-system].requires before building; the hooks still run through the standard interface.

The post after next covers how Fromager bootstraps its build tools.

What this means for you

Before you build from source, read [build-system]. It names the backend and the packages needed before the build starts, and hooks can add more later.

pip install --no-build-isolation is sometimes useful. It drops the isolated environment, so the backend runs against what you already have. It does not sandbox the backend or block network access.

If you build at scale, offline, or under a source policy, you need to know where the build tools come from. Fromager exists to answer that question. The opener of this series covers the full argument.

The next post looks at build-only dependencies: declared in one table, added by hooks at run time, and ordered by Fromager.

Further reading


  1. The Fromager source discussion uses commit 45241aadd284fd9514fc5c250e9ec0ceccfd91a6, eight commits after the 0.101.0 release. Checked 2026-10-21: 0.101.0 is the latest stable release, and upstream main is at f9cf0319dd63ffd37eb2b0b9291518a095d01d50. The worked examples build NumPy with pip and build; later revisions may change behavior. ↩︎

Leave a Reply