Skip to content

rust stage 3 (a+b): diff_compressor port + retire python via pyo3 + dotenv test hygiene#278

Closed
chopratejas wants to merge 2 commits intorust-stage-3a-diff-compressorfrom
rust-stage-3b-diff-bridge
Closed

rust stage 3 (a+b): diff_compressor port + retire python via pyo3 + dotenv test hygiene#278
chopratejas wants to merge 2 commits intorust-stage-3a-diff-compressorfrom
rust-stage-3b-diff-bridge

Conversation

@chopratejas
Copy link
Copy Markdown
Owner

@chopratejas chopratejas commented Apr 26, 2026

Summary

Combines stages 3a + 3b into one PR going to main. The original stack (#276 + #278) is collapsed because the Python deletion in 3b makes 3a's intermediate state (Python-and-Rust coexisting) less interesting to land on its own.

What's in here (6 commits)

Stage 3a — port DiffCompressor to Rust:

  • feat(rust): diff_compressor port — byte-equal parity + sidecar stats — full port to crates/headroom-core/src/diff_compressor.rs. 27 fixtures, 27 byte-equal matches in cargo run -p headroom-parity run.
  • refactor(rust): diff_compressor — surface hidden cutoffs + lossy-emit stats — cutoff signals (per-file hunk drops, context lines trimmed, file_mode normalizations) exposed as a DiffCompressorStats sidecar struct so observability isn't dropped on the floor.
  • fix(diff_compressor): four silent information-loss paths in Python AND Rust — bugs in BOTH impls: rename/dissimilarity markers discarded, combined-diff hunks (merge commits) parsed-then-dropped, no-newline markers lost across distance, pre-diff content order mangled. All four fixed in both the Python and Rust impls.
  • fix(diff): close ContentRouter routing gaps for merge diffs and long preamblesContentRouter was failing to route real merge-commit diffs (diff --combined / diff --cc) and long-preamble diffs to DiffCompressor. Detection scan widened; combined-diff headers added to the regex tier.

Stage 3b — retire the Python implementation:

  • feat(rust): retire python diff_compressor, ship rust-only via pyo3 — the Python DiffCompressor is replaced by a thin pyo3-backed shim that delegates to headroom._core.DiffCompressor (built from crates/headroom-py). No env-var fallback. No Python implementation. The wheel is a hard import. Deletes ~700 lines of Python parser/scorer/formatter code.
  • fix(tests): stop module-level dotenv loaders from polluting os.environ during pytest collection — pre-existing latent bug surfaced by the new [dev] extras provisioning. litellm (and dotenv.load_dotenv()) at module-level loaded .env into os.environ during pytest collection, breaking @pytest.mark.skipif(not os.environ.get("...")) guards in unrelated integration tests. Local full-suite went from 46 fails / 387s to 2 fails / 134s.

Surface preserved

  • headroom.transforms.diff_compressor.DiffCompressor — same class name, same __init__, same compress(content, context) shape. Returns Python DiffCompressionResult dataclass instances.
  • DiffCompressorConfig and DiffCompressionResult dataclasses kept.
  • Sidecar compress_with_stats(...) exposes the rust-only DiffCompressorStats.

What's removed

  • Python parser / scorer / formatter (~700 lines).
  • Internal DiffHunk / DiffFile parser dataclasses (rust crate has parallel coverage).
  • 12 of 41 tests in test_diff_compressor.py that probed _parse_diff / _score_hunks or the deleted parser dataclasses. The remaining 29 public-API tests now run against the rust backend through the same import path — kept unchanged.
  • HEADROOM_DIFF_COMPRESSOR_BACKEND env var.

Build

`scripts/build_rust_extension.sh` runs `maturin develop` and symlinks the built `.so` into `headroom/` so `import headroom._core` resolves past the in-tree package shadowing the maturin overlay.

CI note

CI does not yet build the wheel. The 25 failing tests in `test_diff_compressor.py` on this PR are because `headroom._core` isn't importable in CI yet. Adding a CI step for `maturin develop` (or shipping a prebuilt wheel) is required before this can merge — that work is out of scope for this PR but should land alongside.

Test plan

  • 544 transforms tests pass locally with the wheel built.
  • 33 rust-vs-fixture parity tests guard the pyo3 bridge against regressions.
  • `cargo run -p headroom-parity run` shows 27/27 diff_compressor matches.
  • Full local suite: 4672 passed, 260 skipped, 2 failed (unrelated environment-dependent: missing PIL / Docker).
  • CI: add a `maturin develop` step before pytest.

🤖 Generated with Claude Code

@chopratejas chopratejas force-pushed the rust-stage-3a-diff-compressor branch from dba733d to 48c1324 Compare April 26, 2026 16:15
The python `DiffCompressor` is replaced by a thin pyo3-backed shim that
delegates to `headroom._core.DiffCompressor`. There is no python
implementation and no env-var fallback — the wheel is a hard import.

Why now: opt-in defaults don't drive python retirement. Byte-equal
parity was already proven across 27 fixtures (stage 3a); keeping a
shadow python impl behind a flag is a permanent maintenance cost with
no operational benefit. Stage 3b deletes ~700 lines of python parser /
scorer / formatter code; the rust crate has its own coverage.

Surface preserved:
- `headroom.transforms.diff_compressor.DiffCompressor` — same class
  name, same `__init__`, same `compress(content, context)` shape.
  Returns python `DiffCompressionResult` dataclasses so call sites that
  destructure with `asdict()` work unchanged.
- `DiffCompressorConfig` and `DiffCompressionResult` dataclasses kept.
- Sidecar `compress_with_stats(...)` exposes the rust-only
  `DiffCompressorStats` (per-file hunk drops, context lines trimmed,
  file_mode normalizations) for observability.

Removed:
- Python parser / scorer / formatter (~700 lines).
- Internal `DiffHunk` / `DiffFile` parser dataclasses (rust crate has
  parallel coverage).
- 12 tests that probed `_parse_diff` / `_score_hunks` or the deleted
  parser dataclasses. The 29 public-API tests in `test_diff_compressor.py`
  remain and now exercise the rust backend through the same import path.
- `HEADROOM_DIFF_COMPRESSOR_BACKEND` env var — no longer meaningful.

Build:
- `scripts/build_rust_extension.sh` runs `maturin develop` and symlinks
  the built `.so` into `headroom/` so `import headroom._core` resolves
  past the in-tree package shadowing the maturin overlay.
- `.gitignore` excludes the symlinks and allowlists the build script.

Tests:
- 544 transforms pass; 33 rust-vs-fixture parity tests guard the pyo3
  bridge against regressions (`tests/test_transforms/test_diff_compressor_rust_parity.py`).
- Mypy clean.
…n during pytest collection

# The bug

Several test modules and two production modules loaded the project `.env`
at *import time*. During pytest collection (where every test module is
imported once), this populated `os.environ` with API keys from `.env`.

The skipif guards in `test_proxy_passthrough_integration.py` (and
others) evaluate at collection time:

    @pytest.mark.skipif(not os.environ.get("OPENAI_API_KEY"), reason="...")

If the polluter module was collected *before* the guard, the guard saw
the leaked key, decided not to skip, and the integration tests ran
live against a fake key and failed. In a fresh local-dev venv with
`.env` + full `[dev]` extras, this manifested as ~16 spurious test
failures plus a misleading test runtime of 6+ minutes (live HTTP).

# Why now

CI does not see this (no `.env`). It only manifests when:
1. `litellm` (and friends) are installed — they run `dotenv.load_dotenv()`
   on import, populating `os.environ` from `.env`.
2. A `.env` file with real API keys exists locally.

Until the venv was provisioned with the full `[dev]` extras during
recent test work, `pytest.importorskip("litellm")` and
`from headroom.pricing import litellm_pricing` both silently no-op'd
(via try/except ImportError → `LITELLM_AVAILABLE=False`), so the leak
never triggered. With litellm now installed, the latent bug surfaced.

# The fix — three patterns

1. **Production modules** (`headroom/pricing/litellm_pricing.py`,
   `headroom/backends/litellm.py`): wrap the eager `import litellm` with
   a snapshot/restore of `os.environ`. Any keys litellm's bundled
   `python-dotenv` adds during import are deleted immediately. The
   module is fully imported and cached in `sys.modules` so subsequent
   imports hit the cache without re-running the side effect.

2. **Test modules using `pytest.importorskip("litellm")`**
   (`test_backend_bugs.py`, `test_bedrock_region.py`,
   `test_cost_tracker_counterfactual.py`): replace with
   `tests._dotenv.importorskip_no_env_leak("litellm")`, which does the
   same snapshot/restore around `importlib.import_module`.

3. **Test modules that intentionally need `.env` values for skipif
   guards** (`test_compression_summary_*.py`, `test_query_echo.py`,
   `test_cost_tracker_counterfactual.py`, `test_memory_usage_integration.py`,
   `test_bundled_tools_savings.py`): replace module-level
   `os.environ.setdefault(...)` / `dotenv.load_dotenv()` with
   `tests._dotenv.load_env_overrides()` (returns a local dict — does
   NOT mutate `os.environ`) plus `autouse_apply_env(...)` (function-
   scoped fixture that applies via `monkeypatch.setenv`, auto-cleaned
   at teardown). The skipif still works because
   `ANTHROPIC_KEY = os.environ.get(...) or _env_overrides.get(...)`
   reads from the local dict as fallback.

# Helper module

New `tests/_dotenv.py` exposes:
- `load_env_overrides() -> dict[str, str]` — read `.env` into a dict.
- `autouse_apply_env(overrides) -> fixture` — function-scoped autouse
  fixture that applies via `monkeypatch.setenv`.
- `importorskip_no_env_leak(module) -> module` — drop-in
  `pytest.importorskip` substitute that quarantines env mutations.

# Results

Local full-suite (excluding live-LLM and live-feed tests):
- Before: 46 failed, 4830 passed, 387s
- After:   2 failed, 4672 passed, 134s

The remaining 2 failures are unrelated environment-dependent tests
(missing `PIL` / Docker daemon).
@chopratejas chopratejas force-pushed the rust-stage-3b-diff-bridge branch from d2b5b4b to d5ca50c Compare April 26, 2026 16:17
@chopratejas chopratejas changed the title rust stage 3b: retire python diff_compressor, ship rust-only via pyo3 rust stage 3 (a+b): diff_compressor port + retire python via pyo3 + dotenv test hygiene Apr 26, 2026
@chopratejas
Copy link
Copy Markdown
Owner Author

Superseded by #277 (3a+3b combined into one PR going to main).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant