Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec8695ba0c |
+11
-1
@@ -18,7 +18,7 @@ import msgspec
|
|||||||
from kanta import Kanta
|
from kanta import Kanta
|
||||||
from kanta.callbacks import InjectionContext, callback_error_reporter
|
from kanta.callbacks import InjectionContext, callback_error_reporter
|
||||||
from kanta.exceptions import DatabaseError, DataIntegrityError, ReplayError
|
from kanta.exceptions import DatabaseError, DataIntegrityError, ReplayError
|
||||||
from kanta.grep import GrepPattern, evaluate
|
from kanta.grep import GrepPattern, evaluate, matches_snapshot
|
||||||
from kanta.logging import (
|
from kanta.logging import (
|
||||||
LogEvent,
|
LogEvent,
|
||||||
emit_event,
|
emit_event,
|
||||||
@@ -538,6 +538,16 @@ async def _run(args: argparse.Namespace) -> int:
|
|||||||
continue
|
continue
|
||||||
label = record_label(event.line_number, event.record_index)
|
label = record_label(event.line_number, event.record_index)
|
||||||
if isinstance(event, SnapshotEvent):
|
if isinstance(event, SnapshotEvent):
|
||||||
|
if grep_patterns:
|
||||||
|
logfmt = kanta._impl.callback_registry.build_logfmt(
|
||||||
|
InjectionContext(
|
||||||
|
kanta=kanta,
|
||||||
|
previous_state=current,
|
||||||
|
current_state=current,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not matches_snapshot(current, grep_patterns, logfmt=logfmt):
|
||||||
|
continue
|
||||||
_print_snapshot_indicator(
|
_print_snapshot_indicator(
|
||||||
label,
|
label,
|
||||||
event.snap,
|
event.snap,
|
||||||
|
|||||||
+46
-16
@@ -376,6 +376,48 @@ class GrepHighlighter:
|
|||||||
return self._apply_marks(text, self._meta_marks.get(field, []))
|
return self._apply_marks(text, self._meta_marks.get(field, []))
|
||||||
|
|
||||||
|
|
||||||
|
def _match_entry(
|
||||||
|
pattern: GrepPattern, entry: _Entry, logfmt: Any
|
||||||
|
) -> tuple[frozenset[int] | None, _ValueMark | None] | None:
|
||||||
|
"""Match one pattern against one entry, returning its match marks.
|
||||||
|
|
||||||
|
Returns ``None`` when the entry does not match. Otherwise returns the
|
||||||
|
matched path-element indices and/or the value mark, ready for
|
||||||
|
:meth:`GrepHighlighter.add`.
|
||||||
|
"""
|
||||||
|
pretty = None
|
||||||
|
if logfmt is not None:
|
||||||
|
pretty = logfmt(entry.raw, ".".join(entry.path))
|
||||||
|
if pattern.term is not None:
|
||||||
|
elements = _match_path(pattern.term, entry.path)
|
||||||
|
vmark = _dual_mark(pattern.term, entry.raw, entry.text, pretty)
|
||||||
|
if elements is None and vmark is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
elements = _match_path(pattern.path_term or "", entry.path)
|
||||||
|
vmark = _dual_mark(pattern.value_term or "", entry.raw, entry.text, pretty)
|
||||||
|
if elements is None or vmark is None:
|
||||||
|
return None
|
||||||
|
return elements, vmark
|
||||||
|
|
||||||
|
|
||||||
|
def matches_snapshot(
|
||||||
|
state: dict, patterns: list[GrepPattern], logfmt: Any = None
|
||||||
|
) -> bool:
|
||||||
|
"""Match *patterns* against a snapshot's full state.
|
||||||
|
|
||||||
|
The state is flattened into the same ``(path, value)`` entries change
|
||||||
|
records are matched against, so path and value matching semantics are
|
||||||
|
identical; a snapshot simply has no action or user to match. Returns
|
||||||
|
whether every pattern matched somewhere in the state.
|
||||||
|
"""
|
||||||
|
entries = list(_leaf_entries([], unmarshal(state), None))
|
||||||
|
for pattern in patterns:
|
||||||
|
if not any(_match_entry(pattern, entry, logfmt) for entry in entries):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def evaluate(
|
def evaluate(
|
||||||
record: ChangeRecord,
|
record: ChangeRecord,
|
||||||
previous: dict | None,
|
previous: dict | None,
|
||||||
@@ -411,23 +453,11 @@ def evaluate(
|
|||||||
highlighter.add_meta("user", mark)
|
highlighter.add_meta("user", mark)
|
||||||
matched = True
|
matched = True
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
pretty = None
|
match = _match_entry(pattern, entry, logfmt)
|
||||||
if logfmt is not None:
|
if match is None:
|
||||||
pretty = logfmt(entry.raw, ".".join(entry.path))
|
continue
|
||||||
if pattern.term is not None:
|
|
||||||
elements = _match_path(pattern.term, entry.path)
|
|
||||||
vmark = _dual_mark(pattern.term, entry.raw, entry.text, pretty)
|
|
||||||
if elements is None and vmark is None:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
elements = _match_path(pattern.path_term or "", entry.path)
|
|
||||||
vmark = _dual_mark(
|
|
||||||
pattern.value_term or "", entry.raw, entry.text, pretty
|
|
||||||
)
|
|
||||||
if elements is None or vmark is None:
|
|
||||||
continue
|
|
||||||
matched = True
|
matched = True
|
||||||
highlighter.add(entry, elements, vmark)
|
highlighter.add(entry, *match)
|
||||||
if not matched:
|
if not matched:
|
||||||
return None
|
return None
|
||||||
return highlighter
|
return highlighter
|
||||||
|
|||||||
+83
-4
@@ -11,6 +11,7 @@ from kanta.grep import (
|
|||||||
_match_value,
|
_match_value,
|
||||||
evaluate,
|
evaluate,
|
||||||
mark_spans,
|
mark_spans,
|
||||||
|
matches_snapshot,
|
||||||
)
|
)
|
||||||
from kanta.logging import _USER_PATH
|
from kanta.logging import _USER_PATH
|
||||||
from kanta.serialization import JsonSerializer
|
from kanta.serialization import JsonSerializer
|
||||||
@@ -227,6 +228,29 @@ def test_highlighter_meta_marks_action_and_user():
|
|||||||
assert hl.meta("extra", "other") == "extra"
|
assert hl.meta("extra", "other") == "extra"
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_snapshot_uses_change_record_matching():
|
||||||
|
state = {"users": {"alice": {"email": "alice@example.com", "age": 30}}}
|
||||||
|
assert matches_snapshot(state, _patterns("users.alice"))
|
||||||
|
assert matches_snapshot(state, _patterns("alice@example"))
|
||||||
|
assert matches_snapshot(state, _patterns("age=30"))
|
||||||
|
assert matches_snapshot(state, _patterns("users.alice", "=30"))
|
||||||
|
assert not matches_snapshot(state, _patterns("bob"))
|
||||||
|
assert not matches_snapshot(state, _patterns("3")) # not a full number match
|
||||||
|
assert not matches_snapshot(state, _patterns("alice", "missing"))
|
||||||
|
assert not matches_snapshot({}, _patterns("alice"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_matches_snapshot_supports_logfmt_prettified_values():
|
||||||
|
state = {"when": 1767225600}
|
||||||
|
|
||||||
|
def logfmt(value, path):
|
||||||
|
return "2026-01-01" if value == 1767225600 else None
|
||||||
|
|
||||||
|
assert matches_snapshot(state, _patterns("2026"), logfmt=logfmt)
|
||||||
|
assert matches_snapshot(state, _patterns("1767225600"), logfmt=logfmt)
|
||||||
|
assert not matches_snapshot(state, _patterns("2026"))
|
||||||
|
|
||||||
|
|
||||||
def test_entry_dataclass_holds_anchor_for_deletes():
|
def test_entry_dataclass_holds_anchor_for_deletes():
|
||||||
entry = _Entry(["a", "b"], 1, "1", anchor=["a"])
|
entry = _Entry(["a", "b"], 1, "1", anchor=["a"])
|
||||||
assert entry.anchor == ["a"]
|
assert entry.anchor == ["a"]
|
||||||
@@ -269,7 +293,7 @@ def _sample_changes():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _run_cli(tmp_path, capsys, monkeypatch, changes, *args, color=False):
|
def _run_cli(tmp_path, capsys, monkeypatch, changes, *args, color=False, state=None):
|
||||||
if color:
|
if color:
|
||||||
monkeypatch.setenv("FORCE_COLOR", "1")
|
monkeypatch.setenv("FORCE_COLOR", "1")
|
||||||
monkeypatch.delenv("NO_COLOR", raising=False)
|
monkeypatch.delenv("NO_COLOR", raising=False)
|
||||||
@@ -277,7 +301,7 @@ def _run_cli(tmp_path, capsys, monkeypatch, changes, *args, color=False):
|
|||||||
monkeypatch.setenv("NO_COLOR", "1")
|
monkeypatch.setenv("NO_COLOR", "1")
|
||||||
monkeypatch.delenv("FORCE_COLOR", raising=False)
|
monkeypatch.delenv("FORCE_COLOR", raising=False)
|
||||||
path = tmp_path / "test.kantadb"
|
path = tmp_path / "test.kantadb"
|
||||||
_write_db(path, changes)
|
_write_db(path, changes, state=state)
|
||||||
code = main([str(path), *args])
|
code = main([str(path), *args])
|
||||||
assert code == 0
|
assert code == 0
|
||||||
return capsys.readouterr().err
|
return capsys.readouterr().err
|
||||||
@@ -335,13 +359,68 @@ def test_cli_grep_repeated_patterns_must_all_match(tmp_path, capsys, monkeypatch
|
|||||||
|
|
||||||
|
|
||||||
def test_cli_grep_no_match_exits_zero(tmp_path, capsys, monkeypatch):
|
def test_cli_grep_no_match_exits_zero(tmp_path, capsys, monkeypatch):
|
||||||
"""No matching records is not an error; snapshot lines still print."""
|
"""No matching records is not an error; non-matching snapshots are hidden."""
|
||||||
err = _run_cli(tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "nobody")
|
err = _run_cli(tmp_path, capsys, monkeypatch, _sample_changes(), "--grep", "nobody")
|
||||||
assert "snapshot s0" in err
|
assert "snapshot s0" not in err
|
||||||
assert "create_alice" not in err
|
assert "create_alice" not in err
|
||||||
assert "create_bob" not in err
|
assert "create_bob" not in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_grep_snapshot_prints_only_when_state_matches(
|
||||||
|
tmp_path, capsys, monkeypatch
|
||||||
|
):
|
||||||
|
"""Snapshots are matched against their full state like change records."""
|
||||||
|
state = {"users": {"alice": {"email": "alice@example.com", "age": 30}}}
|
||||||
|
|
||||||
|
err = _run_cli(
|
||||||
|
tmp_path,
|
||||||
|
capsys,
|
||||||
|
monkeypatch,
|
||||||
|
_sample_changes(),
|
||||||
|
"--grep",
|
||||||
|
"alice",
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
assert "snapshot s0" in err
|
||||||
|
|
||||||
|
err = _run_cli(
|
||||||
|
tmp_path,
|
||||||
|
capsys,
|
||||||
|
monkeypatch,
|
||||||
|
_sample_changes(),
|
||||||
|
"--grep",
|
||||||
|
"users.alice.age=30",
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
assert "snapshot s0" in err
|
||||||
|
|
||||||
|
# A pattern matching nothing in the state suppresses the snapshot.
|
||||||
|
err = _run_cli(
|
||||||
|
tmp_path,
|
||||||
|
capsys,
|
||||||
|
monkeypatch,
|
||||||
|
_sample_changes(),
|
||||||
|
"--grep",
|
||||||
|
"bob",
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
assert "snapshot s0" not in err
|
||||||
|
|
||||||
|
# Repeated patterns are ANDed within the snapshot state too.
|
||||||
|
err = _run_cli(
|
||||||
|
tmp_path,
|
||||||
|
capsys,
|
||||||
|
monkeypatch,
|
||||||
|
_sample_changes(),
|
||||||
|
"--grep",
|
||||||
|
"alice",
|
||||||
|
"--grep",
|
||||||
|
"missing",
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
assert "snapshot s0" not in err
|
||||||
|
|
||||||
|
|
||||||
def test_cli_grep_path_value_forms(tmp_path, capsys, monkeypatch):
|
def test_cli_grep_path_value_forms(tmp_path, capsys, monkeypatch):
|
||||||
"""The 'path=value', 'path=' and '=value' forms restrict the match side."""
|
"""The 'path=value', 'path=' and '=value' forms restrict the match side."""
|
||||||
err = _run_cli(
|
err = _run_cli(
|
||||||
|
|||||||
Reference in New Issue
Block a user