Metadata-Version: 2.4
Name: hatch-maturin-build
Version: 0.1.1
Summary: Build hook for hatchling that enables maturin builds for rust extensions. 
Project-URL: Documentation, https://cjameshawkins.com/hatch-maturin-build/
Project-URL: Issues, https://github.com/cjames23/hatch-maturin-build/issues
Project-URL: Source, https://github.com/cjames23/hatch-maturin-build
Author-email: Cary Hawkins <hawkinscary23@gmail.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 5 - Production/Stable
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Requires-Python: >=3.8
Requires-Dist: hatchling
Requires-Dist: maturin
Description-Content-Type: text/markdown

# hatch-maturin-build

[![test](https://github.com/cjames23/hatch-maturin-build/actions/workflows/test.yml/badge.svg)](https://github.com/cjames23/hatch-maturin-build/actions/workflows/test.yml)
[![docs](https://github.com/cjames23/hatch-maturin-build/actions/workflows/docs.yml/badge.svg)](https://cjameshawkins.com/hatch-maturin-build/)
[![PyPI](https://img.shields.io/pypi/v/hatch-maturin-build.svg)](https://pypi.org/project/hatch-maturin-build/)

A [hatchling](https://hatch.pypa.io/latest/) build hook that compiles Rust extension modules with
[maturin](https://github.com/PyO3/maturin), so a project can use hatch for environments, versioning,
and packaging while maturin handles only the Rust.

-----

## Why

maturin is an excellent PEP 517 backend, but it is *the* backend — you cannot compose it with the
rest of hatchling's build pipeline. Anything that wants a Rust extension *and* generated stubs, a
rendered README, a VCS-derived version, or any other build hook currently has to choose.

This plugin inverts the relationship. hatchling stays the backend and owns metadata, file selection,
editable installs, and the wheel itself; maturin is invoked for the one thing it is uniquely good at.

## Installation

```toml
[build-system]
requires = ["hatchling", "hatch-maturin-build"]
build-backend = "hatchling.build"
```

`cargo` must be on `PATH`. maturin is pulled in as a dependency of this plugin.

## Usage

```toml
[build-system]
requires = ["hatchling", "hatch-maturin-build"]
build-backend = "hatchling.build"

[project]
name = "mypkg"
dynamic = ["version"]

# Read by maturin, exactly as it always was. This plugin does not reparse it.
[tool.maturin]
python-source = "python"
module-name = "mypkg.mypkg"

[tool.hatch.version]
path = "python/mypkg/__init__.py"

[tool.hatch.build.targets.wheel]
packages = ["python/mypkg"]

[tool.hatch.build.targets.wheel.hooks.maturin]
features = ["pyo3/abi3-py39"]
```

Then build with anything: `hatch build`, `python -m build`, `pip install .`, `uv build`.

Configuration lives in three layers, in increasing precedence:

1. `[tool.maturin]` — what the crate *is*. Owned by maturin, untouched by this plugin, and still
   valid if you switch back to `build-backend = "maturin"`.
2. `[tool.hatch.build.targets.<target>.hooks.maturin]` — how *this build target* compiles it.
3. Environment variables — the per-invocation escape hatch (see below).

### Options

| Option | Type | Default | maturin flag |
| --- | --- | --- | --- |
| `manifest-path` | str | `Cargo.toml` | `--manifest-path` |
| `profile` | str | `release` | `--profile` |
| `target` | str | host | `--target` |
| `target-dir` | str | cargo default | `--target-dir` |
| `bindings` | str | auto | `--bindings` |
| `auditwheel` | str | maturin default | `--auditwheel` |
| `jobs` | int | cargo default | `--jobs` |
| `features` | list[str] | `[]` | `--features` (repeated) |
| `compatibility` | list[str] | auto | `--compatibility` (repeated) |
| `config` | list[str] | `[]` | `--config` (repeated) |
| `all-features` | bool | `false` | `--all-features` |
| `no-default-features` | bool | `false` | `--no-default-features` |
| `generate-stubs` | bool | `false` | `--generate-stubs` |
| `include-debuginfo` | bool | `false` | `--include-debuginfo` |
| `strip` | bool | `false` | `--strip` |
| `zig` | bool | `false` | `--zig` |
| `locked` / `frozen` / `offline` | bool | `false` | same |
| `args` | list[str] | `[]` | appended verbatim |
| `executable` | str | auto | path to the maturin binary |
| `interpreter` | str | `sys.executable` | `--interpreter` |
| `sdist-include` | list[str] | `[]` | extra files for the sdist |

Unknown options are an error rather than a silent no-op. Reach for `args` when you need a flag this
table does not cover.

Note that `profile` defaults to `release` even though bare `maturin build` defaults to debug. A
PEP 517 build should be optimized, which is what maturin's own backend does.

### Environment variables

hatchling discards PEP 517 `config_settings` entirely — every hook in `hatchling/build.py` marks the
parameter unused — so `pip install --config-settings=...` cannot reach a build hook. Environment
variables are the only channel that works through pip, `build`, and uv alike:

| Variable | Effect |
| --- | --- |
| `HATCH_MATURIN_PROFILE` | overrides `profile`, e.g. `dev` for a debug build |
| `HATCH_MATURIN_TARGET` | overrides `target` |
| `HATCH_MATURIN_BINDINGS` | overrides `bindings` |
| `HATCH_MATURIN_FEATURES` | overrides `features` (comma- or space-separated) |
| `HATCH_MATURIN_ARGS` | extra flags, shell-split |
| `HATCH_MATURIN_EXECUTABLE` | path to the maturin binary |

```bash
HATCH_MATURIN_PROFILE=dev pip install --no-build-isolation -e .
```

Inside hatch, put them in `[tool.hatch.envs.<env>.env-vars]` instead.

## How it works

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 through `build_data`.

That round trip is deliberate rather than lazy. On Linux maturin defaults to `AuditWheelMode::Repair`,
which derives the real manylinux/musllinux platform tag from what the extension actually links
against, bundles external shared libraries beside it, and rewrites RPATH. Shelling out to a bare
`cargo build` and copying the `.so` would skip all of that and produce a wheel that is tagged wrong
and missing its vendored libraries.

Three consequences worth knowing:

- **The tag comes from maturin.** The hook sets `build_data["tag"]` explicitly rather than using
  hatchling's `infer_tag`, which only knows the host interpreter and is therefore wrong for both
  abi3 and cross builds.
- **hatchling owns the Python files.** Any member of maturin's wheel that hatchling already ships is
  dropped, so `force_include` never shadows a file another build hook may have rewritten. Files
  maturin *generates* (cffi glue, bin shims, stubs) have no source counterpart and are kept.
- **`*.data/` is re-homed.** Scripts and data go through `build_data["shared_scripts"]` and
  `["shared_data"]` so hatchling names the `.data` directory. maturin falls back to the `Cargo.toml`
  version whenever hatch owns the version dynamically, so the directory name it picked cannot be
  trusted.

### Editable installs

hatchling's editable wheel points an import hook at your *source* directories and forces a
`py3-none-any` tag. An extension force-included into such a wheel lands in site-packages, where
nothing will ever look for it. So for editable builds the hook copies the compiled artifacts into
the source tree, the way `maturin develop` does, and records what it wrote in
`<cargo target dir>/.hatch-maturin-editable.json` so `hatch clean` can remove them again.

The hook refuses to overwrite any file in the source tree that it did not write itself.

```bash
pip install --no-build-isolation -e .
```

`--no-build-isolation` is what recovers most of the speed difference against `maturin develop`; the
rest is the wheel zip round trip, which is small next to a cargo build.

### sdists

hatchling's sdist is VCS-based, so a git-tracked crate is already covered. The hook force-includes
`Cargo.toml` and `Cargo.lock` as a safety net, plus anything in `sdist-include`.

**Known gap:** path dependencies outside the project root are not enumerated. There is no way to do
that without `cargo package --list`, which demands a far stricter workspace state than a build hook
should impose.

## Limitations

These are limits of building through maturin's wheel output rather than of the hook itself, and are
tracked upstream in [PyO3/maturin#1419](https://github.com/PyO3/maturin/issues/1419):

- **One cargo build per wheel.** hatchling invokes the hook once per wheel, so a non-abi3 build for N
  interpreters is N compiles. Prefer abi3.
- **Stubs cost a second compile.** `maturin generate-stubs` runs its own cargo build.
- **Output discovery is a convention, not a contract.** "Everything that is not `.dist-info` is
  mine" is not a promise maturin makes, so a maturin release could change what the hook sees.
- **Metadata is computed twice.** maturin needs a `[project]` table valid enough not to error, even
  though its metadata output is discarded.

All four dissolve if maturin grows a `build-extension` subcommand that emits artifacts plus a JSON
manifest. The manifest this hook assembles internally is deliberately shaped like that future
output, so adopting it would be a change to a single method.

## Development

Run the unit tests on the current interpreter:

```bash
hatch test
```

Run them across every supported interpreter:

```bash
hatch test --all
```

Run the integration test, which compiles a real pyo3 crate and therefore needs cargo and a network.
It is deselected by default:

```bash
hatch test -- -m integration
```

Lint, format, and type check:

```bash
hatch check
```

Serve the documentation locally:

```bash
hatch run docs:serve
```

## Releasing

Publishing runs on [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) — there are
no API tokens in the repository. Bump the version in `src/hatch_maturin_build/__about__.py`, then
push a matching tag:

```bash
git tag v0.1.0 && git push origin v0.1.0
```

The workflow refuses to publish if the tag and the package version disagree.

## License

`hatch-maturin-build` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html)
license.
