Skip to content

Design notes

Why a build hook and not a builder

hatchling's wheel builder already handles metadata, RECORD, WHEEL, tags, .data/{scripts,data}, license files, and editable installs. None of that is interesting to maturin. A Rust project only needs to add two things:

  1. produce compiled artifacts, and
  2. tell the builder where they go in the wheel and what tag the wheel gets.

A BuildHookInterface does exactly that through build_data, which is why the integration is about forty lines of real logic.

Why the wheel round trip

maturin has no "just build the extension" output mode. Every file-producing command emits a wheel, emits an sdist, or installs into a virtualenv. So the hook runs maturin build into a scratch directory, unpacks the resulting wheel, discards the .dist-info, and hands the remainder to hatchling.

That is deliberate rather than lazy. The tempting shortcut — run cargo build, copy the .so — is wrong on Linux, where maturin defaults to AuditWheelMode::Repair. The repair pass:

  • inspects the linked ELF and decides which manylinux/musllinux policy the extension actually satisfies,
  • bundles external shared libraries alongside it, and
  • rewrites RPATH and SONAME.

Two consequences follow. The platform tag is not a function of the compiler, it is a function of what the extension links against. And repair emits files beyond the .so. Copying just the extension produces a wheel that is tagged manylinux and isn't.

Where the tag comes from

The hook sets build_data["tag"] explicitly, taken verbatim from the filename of the wheel maturin produced.

The conventional alternative — build_data["infer_tag"] = True, which is what other compiled-extension hooks use — calls hatchling's get_best_matching_tag(), derived from the running interpreter's sysconfig. That is wrong for abi3 builds and wrong again for cross builds: compiling for aarch64 on an x86_64 host would produce an x86_64 tag on an aarch64 wheel.

WheelBuilder.build_standard guards its tag assignment with if "tag" not in build_data:, so a hook that sets the tag first wins.

Who owns which files

Any member of maturin's wheel whose path hatchling already produces is dropped.

For a standard wheel that matters because force_include takes precedence over normal inclusion, so maturin's copy of a source file would silently replace the one hatchling selected — discarding whatever an earlier build hook did to it.

Files maturin generates have no source counterpart and are kept: cffi glue, the shims emitted for bindings = "bin" under repair, and .pyi stubs.

*.data/scripts and *.data/data are routed through build_data["shared_scripts"] and build_data["shared_data"] rather than force-included at their literal paths. The .data directory name embeds a project version, and maturin falls back to the Cargo.toml version whenever hatch owns the version dynamically, so the name maturin chose cannot be trusted.

Editable installs

hatchling's editable wheel is built by build_editable_detection, which walks .py files, points an editables import hook at their source directories, and then overwrites build_data["tag"] unconditionally with the pure-Python default.

So a force-included extension module lands in site-packages/mypkg/, while mypkg.__path__ resolves to the source tree — nothing will ever look for it. The hook copies compiled artifacts into the source tree instead, the way maturin develop does, mapping wheel paths back to source directories by inverting hatchling's own distribution_path mapping.

What it wrote is recorded in <cargo target dir>/.hatch-maturin-editable.json so hatch clean can remove it again. The target directory is gitignored in every Rust project, so nothing is left in the repo. The hook refuses to overwrite any file it did not write itself.

On speed

maturin develop is faster than pip install -e . mostly because it skips build isolation — no fresh environment, no dependency re-resolution — and skips the wheel zip round trip. Passing --no-build-isolation recovers most of that. The remaining gap is the zip round trip, which is small next to a cargo build.

Limitations, and what would remove them

These are limits of building through maturin's wheel output rather than of the hook, and are tracked upstream in PyO3/maturin#1419.

Limitation Why
One cargo build per wheel hatchling invokes the hook once per wheel, and non-abi3 pyo3 genuinely recompiles per interpreter. Prefer abi3.
Stubs cost a second compile maturin generate-stubs runs its own cargo build.
Output discovery is a convention "Everything that is not .dist-info is mine" is not a promise maturin makes.
Metadata is computed twice maturin needs a [project] table valid enough not to error, though its metadata output is discarded.
Out-of-root path dependencies are missing from sdists Enumerating them needs cargo package --list, which demands a stricter workspace state than a build hook should impose.

All of these dissolve if maturin grows a subcommand that emits artifacts plus a JSON manifest — something like:

maturin pep517 build-extension --request request.json --manifest-out manifest.json

reusing the existing build and repair pipeline but writing through PathWriter instead of WheelWriter, and reporting the resolved tag, the artifact list with install paths, and any bundled libraries.

The manifest this hook assembles internally is deliberately shaped like that hypothetical output:

{
  "tag": "cp39-abi3-macosx_11_0_arm64",
  "artifacts": [{"kind": "extension", "path": "...", "install_path": "mypkg/_lib.abi3.so"}],
  "shared": {"scripts": {}, "data": {}}
}

Adopting a native build-extension would therefore be a change to a single method, MaturinBuildHook._maturin_build_extension.