From c2821dedb50068dde9e6df8821116a18793e3dc5 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 28 Sep 2026 04:36:41 +0000 Subject: [PATCH] Dump the state after the last printed record for all -r/--grep/--context filters --- docs/database.md | 4 +++- kanta/__main__.py | 15 +++++++++++---- tests/test_range.py | 37 +++++++++++++++++++++++++++++++------ 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/docs/database.md b/docs/database.md index e4e73a3..476954d 100644 --- a/docs/database.md +++ b/docs/database.md @@ -78,12 +78,14 @@ A revision number identifies a *state position between diffs*, not a diff: `-r` is repeatable and its specs fall into two classes: - **Unit specs** — any spec with a letter-prefixed bound (`l`, `s`, `v`, `r`, including mixed forms like `2:s3`) — index into the database file and resolve to a line window. Multiple unit specs intersect restrictively: only the common line range is processed. -- **Plain-number specs** — both bounds (or the single index) are plain numbers — index into the *filtered output*: the records left after all unit specs and all `--grep` patterns have been applied. They are a pure display filter and do not affect replay, migrations, or the `-o` output state. Negative values count from the end of the filtered output. +- **Plain-number specs** — both bounds (or the single index) are plain numbers — index into the *filtered output*: the records left after all unit specs and all `--grep` patterns have been applied. Negative values count from the end of the filtered output. Like repeated `--grep` patterns (which must all match), repeated ranges are restrictive: `-r r1:r5 -r 0:2` prints only the first two records of those in the revision window, regardless of flag order. Plain ranges truncate Python-style and print nothing when the intersection is empty; a single plain index must exist and fails with exit code 2 otherwise. `--context N` (default 0) adds GNU grep-style context as the final step: each printed item is expanded by N surrounding records drawn from the records in the processed range, free of the `--grep` and plain-number filters, with overlapping expansions merged so nearby matches do not duplicate context records. +The JSON dump (`-o`) and migrations operate on the state after the **last printed record**, whatever filtering produced the printout — so a plain `-r 2:5` dump ends at the 5th filtered record, and `--grep` or `--context` shift the dump to their last printed record as well. When nothing is printed (an empty filter result or `-q`), the dump falls back to the state at the end of the unit-spec window (or the end of the file). + ## Meta Format Migrations The snapshot `k` field carries an integer meta format version (`kanta.structs.META_FORMAT`, currently 1), bumped manually whenever the record-level format around the payloads changes. k1 introduced revision numbers. diff --git a/kanta/__main__.py b/kanta/__main__.py index f7ff874..b686b3f 100644 --- a/kanta/__main__.py +++ b/kanta/__main__.py @@ -244,9 +244,10 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: " vN = version, rN = revision (rN is the record that produced" " state N, with its state). Plain numbers instead index into" " the filtered output: they select among the records left after" - " all unit ranges and --grep patterns, like a display filter," - " without affecting the output state. Repeated ranges are" - " restrictive: only the common subset is printed. Negative" + " all unit ranges and --grep patterns. The -o dump and" + " migrations operate on the state after the last printed" + " record. Repeated ranges are restrictive: only the common" + " subset is printed. Negative" " snapshot values count from the end (s-1 is the last" " snapshot); negative plain numbers count from the end of the" " filtered output. Use ':' for" @@ -622,6 +623,12 @@ async def _run(args: argparse.Namespace) -> int: expanded.update(range(lo, hi)) matched = sorted(expanded) items = [pool[idx] for idx in matched] + # The dump and migrations operate on the state after the last + # printed item; an empty display keeps the window-end state. + if items: + last_event, _, last_current, _ = items[-1] + state = last_current + version = last_event.version for event, previous, current, highlight in items: label = record_label(event.line_number, event.record_index) if isinstance(event, SnapshotEvent): @@ -645,7 +652,7 @@ async def _run(args: argparse.Namespace) -> int: if items: _print() - # Apply optional migrations to the range-end state. + # Apply optional migrations to the selected end state. if kanta._impl.migrations is not None: try: previous_version = version diff --git a/tests/test_range.py b/tests/test_range.py index 37ec54b..f7245be 100644 --- a/tests/test_range.py +++ b/tests/test_range.py @@ -28,8 +28,8 @@ def _build(path, increments=3): asyncio.run(build()) -def test_plain_range_filters_output_not_state(tmp_path, capsys): - """Plain numbers slice the printed items; -o state is unaffected.""" +def test_plain_range_restricts_dump_to_last_printed(tmp_path, capsys): + """The JSON dump holds the state after the last printed item.""" path = tmp_path / "data.kantadb" _build(path) capsys.readouterr() @@ -41,8 +41,8 @@ def test_plain_range_filters_output_not_state(tmp_path, capsys): assert "inc0" in out.err assert "inc1" in out.err assert "inc2" not in out.err - # Display filter only: the output state is the full replayed state. - assert json.loads(out.out) == {"users": {}, "counter": 3} + # The dump is the state after inc1, the last printed record. + assert json.loads(out.out) == {"users": {}, "counter": 2} # Inclusive end. assert _run_cli(path, "-r", "0..1") == 0 @@ -143,8 +143,8 @@ def test_unit_and_plain_combine(tmp_path, capsys): out = capsys.readouterr() assert "inc0" in out.err assert "inc1" not in out.err - # The plain spec does not narrow the output state. - assert json.loads(out.out) == {"users": {}, "counter": 2} + # The dump is the state after the last printed item (inc0). + assert json.loads(out.out) == {"users": {}, "counter": 1} def test_context_expands_after_grep(tmp_path, capsys): @@ -191,3 +191,28 @@ def test_context_merges_overlaps(tmp_path, capsys): assert err.count(action) == 1 assert "bootstrap" not in err assert "inc4" not in err + + +def test_grep_and_context_shift_dump_to_last_printed(tmp_path, capsys): + """Grep and --context are display filters, but the dump follows the + last printed record, so they move it too.""" + path = tmp_path / "data.kantadb" + _build(path) + capsys.readouterr() + + # Only inc1 prints; the dump is the state after inc1. + assert _run_cli(path, "--grep", "inc1", "-o", "-") == 0 + out = capsys.readouterr() + assert "inc0" not in out.err + assert json.loads(out.out) == {"users": {}, "counter": 2} + + # Context extends the printout past the match; the dump follows it. + assert _run_cli(path, "--grep", "inc1", "--context", "1", "-o", "-") == 0 + out = capsys.readouterr() + assert "inc2" in out.err + assert json.loads(out.out) == {"users": {}, "counter": 3} + + # Nothing printed: the dump falls back to the range-end state. + assert _run_cli(path, "--grep", "zzz", "-o", "-") == 0 + out = capsys.readouterr() + assert json.loads(out.out) == {"users": {}, "counter": 3}