ADR-070: maturin Build Backend and Distribution Metadata as the Version Source of Truth¶
Status¶
Accepted Date: 2026-09-04 | Supersedes ADR-008
Context¶
ADR-008 recorded hatchling as the build backend and setuptools-scm as the version manager, writing a generated src/cosalette/_version.py from git tags. Both halves of that record have since stopped matching the repository.
The build backend changed and was never re-recorded. When the Rust signal-filter extension landed (ADR-022), the project moved to maturin so that cosalette-filters-rs could be compiled into the wheel as cosalette._filters_rs. pyproject.toml has read requires = ["maturin>=1.12,<2"] / build-backend = "maturin" ever since, with a [tool.maturin] block declaring python-source = "packages/src", the abi3-py314 pyo3 features, and the crate manifest path. No ADR covered that migration — ADR-022 mentions maturin only in passing, as a note that development requires a Rust toolchain for maturin develop. setuptools-scm was consequently never part of the build at all: maturin reads the static [project] version field and ignores [tool.setuptools_scm] entirely.
The version mechanism drifted into two disagreeing APIs. The [tool.setuptools_scm] block survived as dead configuration, and _version.py was kept alive only by a devcontainer post-create.sh step that regenerated it via scripts/update_version.py. cosalette.__version__ preferred that generated, gitignored module and fell back to importlib.metadata only when it was missing. In a source checkout the two runtime version APIs therefore returned different strings: cosalette.__version__ reported 0.7.2.dev8+g3c640a46e (derived by setuptools-scm from the last tag plus commit distance) while importlib.metadata.version("cosalette") reported 0.8.0 (the installed distribution's metadata). Anything reporting the framework version — the health heartbeat's version field, --version output, log records — could disagree with what a dependency resolver or an SBOM saw for the same process.
Release-time versioning is meanwhile already consolidated and consistent: release-please bumps [project] version in pyproject.toml (marked # x-release-please-version), records the same value in .release-please-manifest.json ({".": "0.8.0"}), and tags the commit (v0.8.0). All three agree. Only the runtime read path was ambiguous.
Decision¶
Use the installed distribution's metadata as the single runtime source of cosalette.__version__, and record maturin as the build backend, superseding ADR-008's hatchling + setuptools-scm design.
cosalette/__init__.py reads importlib.metadata.version("cosalette") directly, with a single fallback for the not-installed case. No generated _version.py module exists anywhere in the package any more, [tool.setuptools_scm] and the setuptools-scm[toml] dev dependency are removed, scripts/update_version.py is deleted, the devcontainer step that regenerated the module is gone, and the **/_version.py gitignore entry is dropped.
The release-time inputs are unchanged and remain the authority for what that metadata says: release-please writes [project] version in pyproject.toml, mirrors it in .release-please-manifest.json, and tags the commit; maturin bakes that static version into the wheel's METADATA, which is exactly what importlib.metadata reads back at runtime. Everything else about ADR-008 that is still true — PyPI as the distribution channel, the package name cosalette, src layout, the PEP 561 py.typed marker, independent semver with cosalette>=0.5,<1.0-style consumer pins — carries forward unchanged.
# packages/src/cosalette/__init__.py
from importlib.metadata import PackageNotFoundError, version
try:
# Single source of truth: installed distribution metadata, so that
# cosalette.__version__ and importlib.metadata.version("cosalette")
# can never disagree.
__version__: str = version("cosalette")
except PackageNotFoundError:
# Not installed at all (e.g. running straight from a source tree).
__version__ = "0.0.0+unknown"
# pyproject.toml — the release-time inputs
# [project]
# version = "0.8.0" # x-release-please-version
#
# [build-system]
# requires = ["maturin>=1.12,<2"]
# build-backend = "maturin"
Decision Drivers¶
- The two runtime version APIs must never disagree —
cosalette.__version__andimportlib.metadata.version("cosalette")reported0.7.2.dev8+g3c640a46eand0.8.0for the same process - The recorded build backend must match
pyproject.toml: maturin has been the backend since the Rust filter extension landed (ADR-022), and maturin reads the static[project] version, so setuptools-scm was dead configuration that never participated in a build - The version a running app reports (health heartbeat
versionfield, log records,--version) must equal the version a dependency resolver, an SBOM, or a downstream pin sees for that same installation - One mechanism, not two: a generated, gitignored module kept alive by a devcontainer post-create step is invisible in the source tree and silently absent for anyone who did not run that step
- Release-time versioning is already consolidated on release-please (
[project] version,.release-please-manifest.json, git tag) and needs no second, parallel version pipeline - Standard-library-only read path —
importlib.metadatais stdlib, so the runtime version lookup adds no dependency and no build-time code generation
Considered Options¶
Option 1: hatchling + setuptools-scm generated _version.py¶
The ADR-008 design: hatchling as the build backend with setuptools-scm deriving the version from git tags and writing a generated src/cosalette/_version.py, which __init__.py imports in preference to distribution metadata.
- Advantages: Git tags drive version numbers with no manual bump step; A source checkout reports a meaningful development version (
0.7.2.dev8+g3c640a46e) that encodes distance from the last tag - Disadvantages: Incompatible with the actual build backend — maturin is required for the pyo3 extension and reads the static
[project] version, so setuptools-scm never ran during a build; Produces two disagreeing runtime version APIs, the concrete defect this ADR exists to remove; The generated module is gitignored and therefore absent unless a devcontainer post-create step regenerated it, making behaviour depend on developer environment setup; Duplicates release-please, which already owns version bumping acrosspyproject.toml, the manifest, and the tag
Option 2: maturin + static [project] version, version from importlib.metadata (chosen)¶
Keep maturin as the build backend with a static [project] version maintained by release-please, and have cosalette.__version__ read the installed distribution's metadata through importlib.metadata, with a 0.0.0+unknown fallback when the package is not installed.
- Advantages: The two runtime version APIs are the same call, so they cannot drift apart by construction; Matches the build backend actually in use — maturin bakes
[project] versioninto the wheelMETADATAthatimportlib.metadatareads back; No generated files, no build-time code generation, no gitignored module, no devcontainer bootstrap step; Stdlib-only read path; drops thesetuptools-scm[toml]dev dependency andscripts/update_version.pyentirely; The version a process reports is by definition the version of the distribution that was installed, which is what SBOMs, resolvers, and downstream pins see - Disadvantages: An uninstalled source tree reports
0.0.0+unknowninstead of a tag-derived development version; Requires an editable or real install before the runtime version is meaningful; Version bumps depend on release-please keeping[project] version,.release-please-manifest.json, and the git tag in step
Option 3: maturin with a dynamic git-tag-derived version¶
Keep maturin but declare version dynamic and derive it from git tags at build time via a maturin-compatible SCM plugin, so wheel metadata and the runtime version both come from the tag.
- Advantages: Retains tag-driven versioning with no manual bump step; Wheel metadata and runtime version stay a single value, so the disagreement defect would also be fixed
- Disadvantages: Adds a build-time dependency and a maturin plugin integration for a problem release-please already solves; Fights release-please, which is configured to write the static
[project] versionand the manifest — either the tag or release-please must become subordinate; Builds from a source archive or a shallow CI clone with no tags silently produce a wrong version; Larger change surface than the defect warrants, for no gain over reading the metadata that is already correct
Decision Matrix¶
| Criterion | hatchling + setuptools-scm generated _version.py | maturin + static [project] version, version from importlib.metadata | maturin with a dynamic git-tag-derived version |
|---|---|---|---|
| Runtime API consistency | 1 | 5 | 4 |
| Matches the actual build backend | 1 | 5 | 4 |
| Mechanism count / maintenance burden | 2 | 5 | 3 |
| Fits the existing release-please pipeline | 2 | 5 | 2 |
| Source-tree developer experience | 4 | 3 | 4 |
Scale: 1 (poor) to 5 (excellent)
Consequences¶
Positive¶
cosalette.__version__andimportlib.metadata.version("cosalette")are the same call and can never disagree; a regression test asserts the equality- The recorded build backend finally matches
pyproject.toml— maturin, with[tool.maturin]compilingcosalette-filters-rsintocosalette._filters_rs(ADR-022) - No generated
_version.pyexists in the package; nothing is gitignored, nothing needs regenerating, and the module inventory in the source tree is complete as checked in scripts/update_version.py, the.devcontainer/post-create.shregeneration step, the[tool.setuptools_scm]block, thesetuptools-scm[toml]dev dependency, and the**/_version.pygitignore entry are all removed- The version reported by a running app is exactly the version of the installed distribution, so heartbeat payloads, logs, SBOMs, and dependency resolvers agree
- Release-time versioning has one owner: release-please writes
[project] versionand.release-please-manifest.jsonand tags the commit
Negative¶
- An uninstalled source tree reports
0.0.0+unknown— running straight frompackages/srcwithout an editable install no longer yields a meaningful version string - Development versions no longer encode distance from the last tag; every build between releases reports the released version already written in
[project] version - Correctness of the reported version now depends on release-please keeping
pyproject.toml,.release-please-manifest.json, and the git tag in agreement — a manual edit to one of the three would go unnoticed at runtime - The version is only observable after an install step, so tooling that imports the package from a bare checkout must tolerate the
0.0.0+unknownsentinel
2026-09-04