Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions .claude/commands/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@ Parse the user's version input:

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

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

## Phase 2: Planning File Cleanup

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

1. **Update pyproject.toml**: Change version on line 3 from old to new version
2. **Verify no other version strings**: Search for hardcoded version strings in:
2. **Update rust/Cargo.toml to the SAME version**: `leech-core` tracks
`leech` exactly. This is not optional and not cosmetic:
- `uv` keys its archive cache on this string, so a version that does not move
lets `uv sync` restore a compiled extension built from *any* earlier
revision that shared it — silently, over a current build.
- `check_rust()` compares `leech_core.__version__` against
`leech.__version__` and warns on a mismatch. That warning is only useful if
the versions actually move together.

It sat at `0.3.0` from v0.3.1 to v0.6.4 — ten releases — and did exactly the
above.
3. **Rebuild and re-verify after the bump**: `bash rust/build.sh`, then confirm
`python -c "from leech._rust_accel import check_rust; check_rust()"` prints
the new version with no mismatch warning.
4. **Verify no other version strings**: Search for hardcoded version strings in:
- `src/leech/__init__.py` (if it exists)
- `docs/*.md` files (for version references in docs)
- Update any found version references
Expand Down Expand Up @@ -121,7 +139,7 @@ Wait for user confirmation before proceeding.

## Phase 7: Release Finalization

1. **Stage all changes**: `git add pyproject.toml CHANGELOG.md [any other updated files]`
1. **Stage all changes**: `git add pyproject.toml rust/Cargo.toml rust/Cargo.lock CHANGELOG.md uv.lock [any other updated files]`
2. **Create commit**: `git commit -m "chore: release vX.Y.Z"`
3. **Create annotated tag**: `git tag -a vX.Y.Z -m "Release vX.Y.Z"`
4. **Display next steps**:
Expand Down
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- **`leech_core`'s version now tracks `leech`'s.** It 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. That is not cosmetic: `uv`
keys its archive cache on the version string, so `uv sync` could restore a
compiled extension built from *any* earlier revision that shared it, over a
current build. Observed 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 the single source; `rust/pyproject.toml` takes it via
`dynamic = ["version"]` rather than carrying a third copy to keep in sync.

- **`leech_core` exports `__version__`, and `check_rust()` reports a mismatch.**
The two are separate distributions built from one repository, so an extension
compiled at one revision can sit alongside a `leech` from another. That
pairing does not raise — it produces different numbers, which is how #176
stayed hidden (new Rust, old serial driver). `check_rust()` printed a bare
`leech_core` with no version at all; it now names it and says which half to
rebuild.

### Added

- `tests/test_rust_version_pairing.py`: asserts the two declared versions agree
in the source tree, that `rust/pyproject.toml` defers rather than pinning a
third copy, and that the *installed* extension matches the tree — the last of
which is the stale-build hazard itself.

### Changed

- The release process (`.claude/commands/release.md`) bumps both versions and
re-verifies `check_rust()` afterwards, so this cannot drift again by omission.

## [0.6.4] - 2026-08-24

Dependency and internal-consolidation release. No user-facing behaviour change;
Expand Down
2 changes: 1 addition & 1 deletion rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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

[lib]
Expand Down
3 changes: 2 additions & 1 deletion rust/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ build-backend = "maturin"

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

[tool.maturin]
Expand Down
6 changes: 6 additions & 0 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ mod signal_stats;

#[pymodule]
fn leech_core(m: &Bound<'_, PyModule>) -> PyResult<()> {
// Exported so `leech._rust_accel.check_rust()` can compare it against
// `leech.__version__`. leech_core is a separate distribution from leech, so
// an extension built from one revision can sit alongside a leech from
// another; without a version to compare, that pairing is invisible until it
// produces wrong numbers.
m.add("__version__", env!("CARGO_PKG_VERSION"))?;
m.add_function(wrap_pyfunction!(signal_refine::seq_banded_dp, m)?)?;
m.add_function(wrap_pyfunction!(signal_refine::extract_levels, m)?)?;
m.add_function(wrap_pyfunction!(signal_refine::rough_rescale_quantile, m)?)?;
Expand Down
42 changes: 41 additions & 1 deletion src/leech/_rust_accel.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,54 @@ def rust_supports_softclip_recovery(recover_softclip_signal: bool) -> bool:
return not recover_softclip_signal or RUST_SUPPORTS_SOFTCLIP_RECOVERY


def rust_version_mismatch() -> tuple[str, str] | None:
"""``(leech_version, leech_core_version)`` when the two disagree.

``leech_core`` is a separate distribution from ``leech``, built from the
same repository but installed independently, so an extension compiled at one
revision can sit alongside a ``leech`` from another. That pairing produces
wrong numbers rather than an error -- it is how issue #176 stayed hidden
(new Rust, old serial driver), and how a stale ``uv`` cache entry silently
reinstated pre-#188 chunk behaviour over a current build.

Both versions move together on release, so a difference means one half of
the install is stale. Returns ``None`` when they agree, or when either
version cannot be determined (an old extension exports no ``__version__``,
and there is nothing useful to say about that).
"""
if not HAS_RUST:
return None
import leech_core

import leech

core_version = getattr(leech_core, "__version__", None)
leech_version = getattr(leech, "__version__", None)
if not core_version or not leech_version or core_version == leech_version:
return None
return (leech_version, core_version)


def check_rust() -> None:
"""Print Rust acceleration status."""
if HAS_RUST:
import leech_core

version = getattr(leech_core, "__version__", None)
label = f"leech_core {version}" if version else "leech_core"
label = f"leech_core {version}" if version else "leech_core (version unknown)"
print(f"Rust acceleration: enabled ({label})")
mismatch = rust_version_mismatch()
if mismatch is not None:
leech_version, core_version = mismatch
print(
f" WARNING: leech {leech_version} paired with leech_core "
f"{core_version}. They are built from one repository and "
f"released together, so a mismatch means half the install is "
f"stale. Rebuild the extension with `bash rust/build.sh`; if "
f"that does not clear it, the stale half is leech's own "
f"metadata -- reinstall it (`uv pip install -e .`), which an "
f"editable install needs after a version bump."
)
funcs = [
"compute_signal_stats",
"encode_signal_kmer",
Expand Down
82 changes: 82 additions & 0 deletions tests/test_rust_version_pairing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""`leech` and `leech_core` are released together and must report the same version.

They are separate distributions built from one repository, so an extension
compiled at one revision can sit alongside a `leech` from another. That pairing
does not raise — it produces different numbers. It is how #176 stayed hidden
(new Rust, old serial driver), and how a stale `uv` archive-cache entry
silently reinstated pre-#188 chunk behaviour over a current build: uv keys its
cache on the version string, and `leech_core` sat at `0.3.0` from leech v0.3.1
to v0.6.4 while the Rust changed underneath it.
"""

from __future__ import annotations

import tomllib
from pathlib import Path

import pytest

from leech._rust_accel import HAS_RUST, rust_version_mismatch

REPO_ROOT = Path(__file__).resolve().parent.parent


def _version(path: Path, *keys: str) -> str:
data = tomllib.loads(path.read_text())
for key in keys:
data = data[key]
return data


class TestDeclaredVersionsAgree:
"""Checked from the source tree, so it holds without an install."""

def test_leech_core_tracks_leech(self):
leech_version = _version(REPO_ROOT / "pyproject.toml", "project", "version")
core_version = _version(REPO_ROOT / "rust" / "Cargo.toml", "package", "version")
assert core_version == leech_version, (
f"rust/Cargo.toml is {core_version} but pyproject.toml is "
f"{leech_version}. They are released together and uv keys its "
f"extension cache on the leech_core version -- if it does not move, "
f"`uv sync` can restore a stale compiled extension over a current "
f"build. Bump both; see .claude/commands/release.md."
)

def test_wheel_version_is_not_pinned_separately(self):
"""`rust/pyproject.toml` must defer to Cargo.toml, not carry a third copy."""
rust_pyproject = tomllib.loads((REPO_ROOT / "rust" / "pyproject.toml").read_text())
project = rust_pyproject["project"]
assert "version" not in project, (
"rust/pyproject.toml pins its own version; it should declare "
'dynamic = ["version"] so rust/Cargo.toml stays the single source.'
)
assert "version" in project.get("dynamic", [])


@pytest.mark.skipif(not HAS_RUST, reason="leech_core not installed")
class TestInstalledVersionsAgree:
def test_extension_exports_its_version(self):
import leech_core

assert getattr(leech_core, "__version__", None), (
"leech_core exports no __version__, so a stale pairing cannot be "
"detected. It is added in rust/src/lib.rs via env!(CARGO_PKG_VERSION)."
)

def test_no_mismatch_in_this_environment(self):
mismatch = rust_version_mismatch()
assert mismatch is None, (
f"leech {mismatch[0]} is paired with leech_core {mismatch[1]}. "
f"Rebuild with `bash rust/build.sh`; if that does not clear it, "
f"reinstall leech (`uv pip install -e .`)."
)

def test_installed_extension_matches_the_source_tree(self):
import leech_core

declared = _version(REPO_ROOT / "rust" / "Cargo.toml", "package", "version")
assert leech_core.__version__ == declared, (
f"installed leech_core is {leech_core.__version__} but the source "
f"tree declares {declared} -- the build is stale. This is the exact "
f"shape of the uv-cache hazard: run `bash rust/build.sh`."
)
1 change: 0 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading