Skip to content

Commit 3c412c5

Browse files
fix(build): leech_core's version tracks leech's, and a mismatch is visible (#204)
leech_core sat at 0.3.0 from v0.3.1 to v0.6.4 -- ten releases, spanning #176, #185, #187, #188, #192, #195, #200 and #202 -- while the Rust changed underneath it. The string is not decoration: uv keys its archive cache on it, so `uv sync` could restore a compiled extension built from any earlier revision that shared the version, over a current build. Caught it doing exactly that: 43 tests failing with pre-#188 behaviour (chunk_signal_kmer_inputs no longer snapping map[0] = 0) against an up-to-date working tree. rust/Cargo.toml is now the single source and tracks leech's version; rust/pyproject.toml takes it through `dynamic = ["version"]` rather than carrying a third copy to keep in sync. leech_core also exports __version__ now, via env!(CARGO_PKG_VERSION), and check_rust() compares it against leech.__version__. The two are separate distributions built from one repository, so a cross-revision pairing does not raise -- it produces different numbers, which is how #176 stayed hidden. check_rust() printed a bare "leech_core" with no version; it now names it and says which half to rebuild, including the case where the stale half is leech's own editable metadata. The release skill bumps both and re-verifies check_rust() afterwards, so this cannot drift again by omission.
1 parent 7b51e4f commit 3c412c5

9 files changed

Lines changed: 202 additions & 8 deletions

File tree

.claude/commands/release.md

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ Parse the user's version input:
2323

2424
**Important**: If current version has a pre-release suffix (e.g., `-alpha`, `-beta`, `-rc.1`), preserve the suffix when bumping.
2525

26-
Current version location: `pyproject.toml` line 3 (`version = "x.y.z"`)
26+
Version locations (**both must move together**):
27+
- `pyproject.toml` line 3 (`version = "x.y.z"`) — the `leech` package
28+
- `rust/Cargo.toml` (`version = "x.y.z"`) — the `leech-core` extension.
29+
`rust/pyproject.toml` takes it from there via `dynamic = ["version"]`, so
30+
Cargo.toml is the only place to edit.
2731

2832
## Phase 2: Planning File Cleanup
2933

@@ -92,7 +96,21 @@ If found, ask user what to do:
9296
## Phase 5: Version Update
9397

9498
1. **Update pyproject.toml**: Change version on line 3 from old to new version
95-
2. **Verify no other version strings**: Search for hardcoded version strings in:
99+
2. **Update rust/Cargo.toml to the SAME version**: `leech-core` tracks
100+
`leech` exactly. This is not optional and not cosmetic:
101+
- `uv` keys its archive cache on this string, so a version that does not move
102+
lets `uv sync` restore a compiled extension built from *any* earlier
103+
revision that shared it — silently, over a current build.
104+
- `check_rust()` compares `leech_core.__version__` against
105+
`leech.__version__` and warns on a mismatch. That warning is only useful if
106+
the versions actually move together.
107+
108+
It sat at `0.3.0` from v0.3.1 to v0.6.4 — ten releases — and did exactly the
109+
above.
110+
3. **Rebuild and re-verify after the bump**: `bash rust/build.sh`, then confirm
111+
`python -c "from leech._rust_accel import check_rust; check_rust()"` prints
112+
the new version with no mismatch warning.
113+
4. **Verify no other version strings**: Search for hardcoded version strings in:
96114
- `src/leech/__init__.py` (if it exists)
97115
- `docs/*.md` files (for version references in docs)
98116
- Update any found version references
@@ -121,7 +139,7 @@ Wait for user confirmation before proceeding.
121139

122140
## Phase 7: Release Finalization
123141

124-
1. **Stage all changes**: `git add pyproject.toml CHANGELOG.md [any other updated files]`
142+
1. **Stage all changes**: `git add pyproject.toml rust/Cargo.toml rust/Cargo.lock CHANGELOG.md uv.lock [any other updated files]`
125143
2. **Create commit**: `git commit -m "chore: release vX.Y.Z"`
126144
3. **Create annotated tag**: `git tag -a vX.Y.Z -m "Release vX.Y.Z"`
127145
4. **Display next steps**:

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Fixed
11+
12+
- **`leech_core`'s version now tracks `leech`'s.** It sat at `0.3.0` from v0.3.1
13+
to v0.6.4 — ten releases, spanning #176, #185, #187, #188, #192, #195, #200
14+
and #202 — while the Rust changed underneath it. That is not cosmetic: `uv`
15+
keys its archive cache on the version string, so `uv sync` could restore a
16+
compiled extension built from *any* earlier revision that shared it, over a
17+
current build. Observed doing exactly that: 43 tests failing with pre-#188
18+
behaviour (`chunk_signal_kmer_inputs` no longer snapping `map[0] = 0`) against
19+
an up-to-date working tree.
20+
21+
`rust/Cargo.toml` is the single source; `rust/pyproject.toml` takes it via
22+
`dynamic = ["version"]` rather than carrying a third copy to keep in sync.
23+
24+
- **`leech_core` exports `__version__`, and `check_rust()` reports a mismatch.**
25+
The two are separate distributions built from one repository, so an extension
26+
compiled at one revision can sit alongside a `leech` from another. That
27+
pairing does not raise — it produces different numbers, which is how #176
28+
stayed hidden (new Rust, old serial driver). `check_rust()` printed a bare
29+
`leech_core` with no version at all; it now names it and says which half to
30+
rebuild.
31+
32+
### Added
33+
34+
- `tests/test_rust_version_pairing.py`: asserts the two declared versions agree
35+
in the source tree, that `rust/pyproject.toml` defers rather than pinning a
36+
third copy, and that the *installed* extension matches the tree — the last of
37+
which is the stale-build hazard itself.
38+
39+
### Changed
40+
41+
- The release process (`.claude/commands/release.md`) bumps both versions and
42+
re-verifies `check_rust()` afterwards, so this cannot drift again by omission.
43+
844
## [0.6.4] - 2026-08-24
945

1046
Dependency and internal-consolidation release. No user-facing behaviour change;

rust/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/Cargo.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
[package]
22
name = "leech_core"
3-
version = "0.3.0"
3+
# Tracks `leech`'s version in the root pyproject.toml and must be bumped with
4+
# it -- see `.claude/commands/release.md`. This is the single source: the wheel
5+
# takes it via `dynamic = ["version"]` in rust/pyproject.toml, and the module
6+
# exports it as `leech_core.__version__` via `env!("CARGO_PKG_VERSION")`.
7+
#
8+
# Not semver for this crate; nothing consumes leech_core independently. The
9+
# string's real job is to be a cache key and a staleness check. uv keys its
10+
# archive cache on it, so a version that does not move lets `uv sync` restore a
11+
# compiled extension built from any earlier revision that shared it. It sat at
12+
# 0.3.0 from leech v0.3.1 to v0.6.4 -- ten releases spanning #176, #185, #187,
13+
# #188, #192, #195, #200 and #202 -- and did exactly that, silently reinstating
14+
# pre-#188 chunk behaviour over a current build.
15+
version = "0.6.4"
416
edition = "2024"
517

618
[lib]

rust/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ build-backend = "maturin"
44

55
[project]
66
name = "leech-core"
7-
version = "0.3.0"
7+
# Single source is rust/Cargo.toml; maturin reads it through this.
8+
dynamic = ["version"]
89
requires-python = ">=3.12"
910

1011
[tool.maturin]

rust/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ mod signal_stats;
99

1010
#[pymodule]
1111
fn leech_core(m: &Bound<'_, PyModule>) -> PyResult<()> {
12+
// Exported so `leech._rust_accel.check_rust()` can compare it against
13+
// `leech.__version__`. leech_core is a separate distribution from leech, so
14+
// an extension built from one revision can sit alongside a leech from
15+
// another; without a version to compare, that pairing is invisible until it
16+
// produces wrong numbers.
17+
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
1218
m.add_function(wrap_pyfunction!(signal_refine::seq_banded_dp, m)?)?;
1319
m.add_function(wrap_pyfunction!(signal_refine::extract_levels, m)?)?;
1420
m.add_function(wrap_pyfunction!(signal_refine::rough_rescale_quantile, m)?)?;

src/leech/_rust_accel.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,54 @@ def rust_supports_softclip_recovery(recover_softclip_signal: bool) -> bool:
118118
return not recover_softclip_signal or RUST_SUPPORTS_SOFTCLIP_RECOVERY
119119

120120

121+
def rust_version_mismatch() -> tuple[str, str] | None:
122+
"""``(leech_version, leech_core_version)`` when the two disagree.
123+
124+
``leech_core`` is a separate distribution from ``leech``, built from the
125+
same repository but installed independently, so an extension compiled at one
126+
revision can sit alongside a ``leech`` from another. That pairing produces
127+
wrong numbers rather than an error -- it is how issue #176 stayed hidden
128+
(new Rust, old serial driver), and how a stale ``uv`` cache entry silently
129+
reinstated pre-#188 chunk behaviour over a current build.
130+
131+
Both versions move together on release, so a difference means one half of
132+
the install is stale. Returns ``None`` when they agree, or when either
133+
version cannot be determined (an old extension exports no ``__version__``,
134+
and there is nothing useful to say about that).
135+
"""
136+
if not HAS_RUST:
137+
return None
138+
import leech_core
139+
140+
import leech
141+
142+
core_version = getattr(leech_core, "__version__", None)
143+
leech_version = getattr(leech, "__version__", None)
144+
if not core_version or not leech_version or core_version == leech_version:
145+
return None
146+
return (leech_version, core_version)
147+
148+
121149
def check_rust() -> None:
122150
"""Print Rust acceleration status."""
123151
if HAS_RUST:
124152
import leech_core
125153

126154
version = getattr(leech_core, "__version__", None)
127-
label = f"leech_core {version}" if version else "leech_core"
155+
label = f"leech_core {version}" if version else "leech_core (version unknown)"
128156
print(f"Rust acceleration: enabled ({label})")
157+
mismatch = rust_version_mismatch()
158+
if mismatch is not None:
159+
leech_version, core_version = mismatch
160+
print(
161+
f" WARNING: leech {leech_version} paired with leech_core "
162+
f"{core_version}. They are built from one repository and "
163+
f"released together, so a mismatch means half the install is "
164+
f"stale. Rebuild the extension with `bash rust/build.sh`; if "
165+
f"that does not clear it, the stale half is leech's own "
166+
f"metadata -- reinstall it (`uv pip install -e .`), which an "
167+
f"editable install needs after a version bump."
168+
)
129169
funcs = [
130170
"compute_signal_stats",
131171
"encode_signal_kmer",

tests/test_rust_version_pairing.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""`leech` and `leech_core` are released together and must report the same version.
2+
3+
They are separate distributions built from one repository, so an extension
4+
compiled at one revision can sit alongside a `leech` from another. That pairing
5+
does not raise — it produces different numbers. It is how #176 stayed hidden
6+
(new Rust, old serial driver), and how a stale `uv` archive-cache entry
7+
silently reinstated pre-#188 chunk behaviour over a current build: uv keys its
8+
cache on the version string, and `leech_core` sat at `0.3.0` from leech v0.3.1
9+
to v0.6.4 while the Rust changed underneath it.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import tomllib
15+
from pathlib import Path
16+
17+
import pytest
18+
19+
from leech._rust_accel import HAS_RUST, rust_version_mismatch
20+
21+
REPO_ROOT = Path(__file__).resolve().parent.parent
22+
23+
24+
def _version(path: Path, *keys: str) -> str:
25+
data = tomllib.loads(path.read_text())
26+
for key in keys:
27+
data = data[key]
28+
return data
29+
30+
31+
class TestDeclaredVersionsAgree:
32+
"""Checked from the source tree, so it holds without an install."""
33+
34+
def test_leech_core_tracks_leech(self):
35+
leech_version = _version(REPO_ROOT / "pyproject.toml", "project", "version")
36+
core_version = _version(REPO_ROOT / "rust" / "Cargo.toml", "package", "version")
37+
assert core_version == leech_version, (
38+
f"rust/Cargo.toml is {core_version} but pyproject.toml is "
39+
f"{leech_version}. They are released together and uv keys its "
40+
f"extension cache on the leech_core version -- if it does not move, "
41+
f"`uv sync` can restore a stale compiled extension over a current "
42+
f"build. Bump both; see .claude/commands/release.md."
43+
)
44+
45+
def test_wheel_version_is_not_pinned_separately(self):
46+
"""`rust/pyproject.toml` must defer to Cargo.toml, not carry a third copy."""
47+
rust_pyproject = tomllib.loads((REPO_ROOT / "rust" / "pyproject.toml").read_text())
48+
project = rust_pyproject["project"]
49+
assert "version" not in project, (
50+
"rust/pyproject.toml pins its own version; it should declare "
51+
'dynamic = ["version"] so rust/Cargo.toml stays the single source.'
52+
)
53+
assert "version" in project.get("dynamic", [])
54+
55+
56+
@pytest.mark.skipif(not HAS_RUST, reason="leech_core not installed")
57+
class TestInstalledVersionsAgree:
58+
def test_extension_exports_its_version(self):
59+
import leech_core
60+
61+
assert getattr(leech_core, "__version__", None), (
62+
"leech_core exports no __version__, so a stale pairing cannot be "
63+
"detected. It is added in rust/src/lib.rs via env!(CARGO_PKG_VERSION)."
64+
)
65+
66+
def test_no_mismatch_in_this_environment(self):
67+
mismatch = rust_version_mismatch()
68+
assert mismatch is None, (
69+
f"leech {mismatch[0]} is paired with leech_core {mismatch[1]}. "
70+
f"Rebuild with `bash rust/build.sh`; if that does not clear it, "
71+
f"reinstall leech (`uv pip install -e .`)."
72+
)
73+
74+
def test_installed_extension_matches_the_source_tree(self):
75+
import leech_core
76+
77+
declared = _version(REPO_ROOT / "rust" / "Cargo.toml", "package", "version")
78+
assert leech_core.__version__ == declared, (
79+
f"installed leech_core is {leech_core.__version__} but the source "
80+
f"tree declares {declared} -- the build is stale. This is the exact "
81+
f"shape of the uv-cache hazard: run `bash rust/build.sh`."
82+
)

uv.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)