From 8fd86d20e13b96728e0143071c8584111835e145 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 28 Sep 2026 06:18:23 +0000 Subject: [PATCH] Support time-based -r ranges (relative and ISO) --- docs/database.md | 1 + kanta/__main__.py | 38 ++++++-- kanta/replaylog.py | 118 +++++++++++++++++++++++-- tests/test_range.py | 205 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 345 insertions(+), 17 deletions(-) diff --git a/docs/database.md b/docs/database.md index 24a6bfe..9d26a64 100644 --- a/docs/database.md +++ b/docs/database.md @@ -79,6 +79,7 @@ A revision number identifies a *state position between diffs*, not a diff: - **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. Negative values count from the end of the filtered output. +- **Time specs** — a relative `N` + unit (`s`, `min`, `h`, `d`, `w`, e.g. `15min` or `2w`) selects the last N units ending now; a UTC ISO point (`2026-09-28`, `2026-09-28T10:30`, or compact `20260928T1030`) selects the whole natural period its precision denotes (that day, hour, minute, or second); an ISO range `start/end` is inclusive of the end's whole period, and either end may be left open. All times are UTC. Time specs match records individually by their timestamp rather than resolving to a line window, so non-monotonic timestamps simply match wherever they occur, and multiple time specs intersect like the others. 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. diff --git a/kanta/__main__.py b/kanta/__main__.py index 03568eb..40a8d1a 100644 --- a/kanta/__main__.py +++ b/kanta/__main__.py @@ -10,6 +10,7 @@ import importlib.metadata import importlib.util import sys import tempfile +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -33,6 +34,7 @@ from kanta.replaylog import ( SnapshotEvent, _snapshot_lines, end_of_file, + parse_time_spec, record_change_event, record_label, replay_events, @@ -250,7 +252,12 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: " 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" + " filtered output. Time specs (UTC) filter records by" + " timestamp: relative '15min', '1h', '7d' (N plus one of s, min," + " h, d, w) cover the last N units ending now; an ISO point" + " ('2026-09-28', '2026-09-28T10:30', '20260928T1030') covers its" + " natural period by precision; an ISO range 'a/b' is inclusive" + " and either side may be open ('2026-09-24/'). Use ':' for" " half-open ranges and '..' for inclusive end ranges. Examples:" " '2:5', '2..5', 'l10:l20', 's1:s3', 'v0:v2', 'r10:r20', 's-1:'," " ':-1', '-1', '-r r1:r5 -r 0'." @@ -425,6 +432,16 @@ def _print_snapshot_indicator( _print(f"{label} {ts} {line}") +def _in_time_windows(ts: datetime, windows: list) -> bool: + """Return whether a record timestamp falls within every time window.""" + if ts.tzinfo is None: + ts = ts.replace(tzinfo=UTC) + return all( + (start is None or ts >= start) and (end is None or ts < end) + for start, end in windows + ) + + def _print_snapshot_matches( state: dict[str, Any], kanta: Kanta[Any], highlight: Any ) -> None: @@ -551,13 +568,16 @@ async def _run(args: argparse.Namespace) -> int: } # Resolve -r specs into a line range (unit specs) or a single - # snapshot selection, plus plain specs applied to the output items. + # snapshot selection, plus plain specs applied to the output items + # and time specs matched per record. try: - selection, plain_specs = ( + selection, plain_specs, time_specs = ( select_all(args.range, events, change_count) if args.range - else (Selection(0, end_of_file(events)), []) + else (Selection(0, end_of_file(events)), [], []) ) + now = datetime.now(UTC) + time_windows = [parse_time_spec(spec, now) for spec in time_specs] except RangeNotFoundError as exc: raise _CliError(str(exc), EXIT_RANGE_ERROR) from exc except ValueError as exc: @@ -565,7 +585,7 @@ async def _run(args: argparse.Namespace) -> int: grep_patterns = [GrepPattern.parse(p) for p in args.grep or ()] - if selection.snapshot is not None and not args.context: + if selection.snapshot is not None and not args.context and not time_windows: snap_event = selection.snapshot state = snap_event.snap.state version = snap_event.snap.v @@ -617,8 +637,8 @@ async def _run(args: argparse.Namespace) -> int: continue highlight = None if isinstance(event, SnapshotEvent): - is_match = True - if grep_patterns: + is_match = _in_time_windows(event.snap.ts, time_windows) + if is_match and grep_patterns: logfmt = kanta._impl.callback_registry.build_logfmt( InjectionContext( kanta=kanta, @@ -645,7 +665,9 @@ async def _run(args: argparse.Namespace) -> int: highlight = evaluate( event.record, previous, grep_patterns, logfmt=logfmt ) - is_match = highlight is not None or not grep_patterns + is_match = ( + highlight is not None or not grep_patterns + ) and _in_time_windows(event.record.ts, time_windows) pool.append((event, previous, current, highlight if is_match else None)) if is_match: matched.append(len(pool) - 1) diff --git a/kanta/replaylog.py b/kanta/replaylog.py index dca8d1a..57fdc89 100644 --- a/kanta/replaylog.py +++ b/kanta/replaylog.py @@ -10,7 +10,9 @@ from __future__ import annotations import copy import dataclasses +import re from collections.abc import Iterator +from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING, Any, Union import msgspec @@ -433,6 +435,97 @@ def select(spec: str, events: list[Event], total: int) -> Selection: return Selection(lines[idx], end_line) +_REL_RE = re.compile(r"(\d+)(s|min|h|d|w)") +_REL_UNITS = {"s": 1, "min": 60, "h": 3600, "d": 86400, "w": 604800} + +# ISO 8601, UTC: hyphenated date with optional T-time (colons optional), or a +# compact date requiring T. Precision (day/hour/minute/second) selects the +# period the point denotes. +_ISO_NORM = re.compile( + r"(\d{4})-(\d{2})-(\d{2})(?:T(\d{2})(?::?(\d{2}))?(?::?(\d{2}))?)?" +) +_ISO_COMPACT = re.compile(r"(\d{4})(\d{2})(\d{2})T(\d{2})(?:(\d{2}))?(?:(\d{2}))?") + + +def _parse_iso_point(text: str) -> tuple[datetime, timedelta]: + """Parse an ISO point into its UTC start and the period it denotes.""" + match = _ISO_NORM.fullmatch(text) or _ISO_COMPACT.fullmatch(text) + if match is None: + raise ValueError( + f"invalid time {text!r} (UTC ISO like 2026-09-28, 2026-09-28T10:30" + " or 20260928T1030)" + ) + year, month, day, hour, minute, second = ( + int(group) if group else None for group in match.groups() + ) + assert year is not None and month is not None and day is not None + try: + base = datetime( + year, month, day, hour or 0, minute or 0, second or 0, tzinfo=UTC + ) + except ValueError: + raise ValueError( + f"invalid time {text!r} (UTC ISO like 2026-09-28, 2026-09-28T10:30" + " or 20260928T1030)" + ) from None + if second is not None: + period = timedelta(seconds=1) + elif minute is not None: + period = timedelta(minutes=1) + elif hour is not None: + period = timedelta(hours=1) + else: + period = timedelta(days=1) + return base, period + + +def is_time_spec(spec: str) -> bool: + """Return True if the spec is a time spec rather than a position spec. + + Time specs are relative (``15min``, ``1h``, ``7d`` — a trailing unit + letter), ISO points or ranges (a hyphenated date, a ``T`` time, or a + ``/`` separator). A leading hyphen stays a negative plain index. + """ + if _REL_RE.fullmatch(spec): + return True + if "/" in spec: + return True + return bool( + _ISO_NORM.fullmatch(spec) or _ISO_COMPACT.fullmatch(spec) + ) or _looks_like_iso(spec) + + +def _looks_like_iso(spec: str) -> bool: + """Heuristic for malformed ISO specs, so they error as times, not indexes.""" + return bool(re.match(r"\d{4}-", spec) or re.match(r"\d{8}T", spec)) + + +def parse_time_spec( + spec: str, now: datetime +) -> tuple[datetime | None, datetime | None]: + """Resolve a time spec into a UTC [start, end) window; None = unbounded. + + A relative spec covers the last N units ending at ``now``. An ISO point + covers its natural period (day/hour/minute/second by precision). An ISO + range ``a/b`` runs from the start of a's period through the end of b's + (inclusive); either side may be left open. + """ + match = _REL_RE.fullmatch(spec) + if match: + delta = timedelta(seconds=int(match.group(1)) * _REL_UNITS[match.group(2)]) + return now - delta, now + if "/" in spec: + start_str, end_str = spec.split("/", 1) + start = _parse_iso_point(start_str)[0] if start_str else None + end = None + if end_str: + end_base, end_period = _parse_iso_point(end_str) + end = end_base + end_period + return start, end + base, period = _parse_iso_point(spec) + return base, base + period + + def is_plain_spec(spec: str) -> bool: """Return True if every bound of the spec is a plain number (no unit letter). @@ -449,24 +542,33 @@ def is_plain_spec(spec: str) -> bool: def select_all( specs: list[str], events: list[Event], total: int -) -> tuple[Selection, list[str]]: +) -> tuple[Selection, list[str], list[str]]: """Resolve repeated ``-r`` specs into a database window plus plain specs. Unit specs (``l``, ``s``, ``v``, ``r``, and mixed specs) resolve to line windows via :func:`select` and intersect restrictively. The snapshot - shortcut applies only to a single unit spec. Plain-number specs are - returned unresolved for the caller to apply against the filtered output. + shortcut applies only to a single unit spec. Plain-number specs and + time specs are returned unresolved for the caller to apply against the + filtered output. """ - plain_specs = [spec for spec in specs if is_plain_spec(spec)] - unit_specs = [spec for spec in specs if not is_plain_spec(spec)] + time_specs: list[str] = [] + plain_specs: list[str] = [] + unit_specs: list[str] = [] + for spec in specs: + if is_time_spec(spec): + time_specs.append(spec) + elif is_plain_spec(spec): + plain_specs.append(spec) + else: + unit_specs.append(spec) if not unit_specs: - return Selection(0, end_of_file(events)), plain_specs + return Selection(0, end_of_file(events)), plain_specs, time_specs selections = [select(spec, events, total) for spec in unit_specs] if len(selections) == 1: - return selections[0], plain_specs + return selections[0], plain_specs, time_specs start = max(sel.start_line for sel in selections) end = min(sel.end_line for sel in selections) - return Selection(min(start, end), end), plain_specs + return Selection(min(start, end), end), plain_specs, time_specs def _clamp_index(value: int, count: int) -> int: diff --git a/tests/test_range.py b/tests/test_range.py index 31acbea..2b8cc88 100644 --- a/tests/test_range.py +++ b/tests/test_range.py @@ -2,8 +2,11 @@ import asyncio import json -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta +import pytest + +from kanta.replaylog import is_time_spec, parse_time_spec from kanta.serialization import JsonSerializer from kanta.structs import META_FORMAT, ChangeRecord, Snapshot from tests.support import Data, make_kanta @@ -12,6 +15,7 @@ from tests.support import Data, make_kanta JSON = ("json", JsonSerializer) T0 = datetime(2026, 1, 1, tzinfo=UTC) +NOW = datetime(2026, 9, 28, 12, 0, 0, tzinfo=UTC) def _run_cli(path, *args): @@ -276,3 +280,202 @@ def test_grep_and_context_shift_dump_to_last_printed(tmp_path, capsys): assert _run_cli(path, "--grep", "zzz", "-o", "-") == 0 out = capsys.readouterr() assert json.loads(out.out) == {"users": {}, "counter": 3} + + +def _write_timed(path, entries): + """Write one change record per (timestamp, action, counter) entry.""" + serializer = JsonSerializer() + framer = serializer.framer_cls() + out = bytearray() + for ts, action, counter in entries: + out += framer.frame_change( + serializer.encode( + ChangeRecord(ts=ts, a=action, v=0, diff={"counter": counter}) + ), + record_offset=len(out), + ) + path.write_bytes(bytes(out)) + + +def test_is_time_spec_classification(): + for spec in [ + "30s", + "15min", + "1h", + "7d", + "2w", + "2026-09-28", + "2026-09-28T10", + "2026-09-28T10:30", + "20260928T103045", + "2026-09-24/2026-09-28", + "2026-09-24/", + "/2026-09-28", + "2026-13-45", # malformed ISO still classifies as time, then errors + ]: + assert is_time_spec(spec), spec + for spec in ["5", "-1", "2:5", "2..5", ":-1", "r3", "s-1", "l10", "v0", "r1:r4"]: + assert not is_time_spec(spec), spec + + +def test_parse_time_spec_relative(): + assert parse_time_spec("1h", NOW) == (NOW - timedelta(hours=1), NOW) + assert parse_time_spec("15min", NOW) == (NOW - timedelta(minutes=15), NOW) + assert parse_time_spec("30s", NOW) == (NOW - timedelta(seconds=30), NOW) + assert parse_time_spec("7d", NOW) == (NOW - timedelta(days=7), NOW) + assert parse_time_spec("2w", NOW) == (NOW - timedelta(weeks=2), NOW) + + +def test_parse_time_spec_iso_periods(): + assert parse_time_spec("2026-09-28", NOW) == ( + datetime(2026, 9, 28, tzinfo=UTC), + datetime(2026, 9, 29, tzinfo=UTC), + ) + assert parse_time_spec("2026-09-28T10", NOW) == ( + datetime(2026, 9, 28, 10, tzinfo=UTC), + datetime(2026, 9, 28, 11, tzinfo=UTC), + ) + assert parse_time_spec("2026-09-28T10:30", NOW) == ( + datetime(2026, 9, 28, 10, 30, tzinfo=UTC), + datetime(2026, 9, 28, 10, 31, tzinfo=UTC), + ) + assert parse_time_spec("20260928T103045", NOW) == ( + datetime(2026, 9, 28, 10, 30, 45, tzinfo=UTC), + datetime(2026, 9, 28, 10, 30, 46, tzinfo=UTC), + ) + + +def test_parse_time_spec_iso_ranges(): + # Inclusive of the end's whole period. + assert parse_time_spec("2026-09-24/2026-09-28", NOW) == ( + datetime(2026, 9, 24, tzinfo=UTC), + datetime(2026, 9, 29, tzinfo=UTC), + ) + # Open ends are unbounded. + assert parse_time_spec("2026-09-24/", NOW) == ( + datetime(2026, 9, 24, tzinfo=UTC), + None, + ) + assert parse_time_spec("/2026-09-28", NOW) == ( + None, + datetime(2026, 9, 29, tzinfo=UTC), + ) + with pytest.raises(ValueError, match="invalid time"): + parse_time_spec("2026-13-45", NOW) + + +def test_cli_time_iso_filters_items(tmp_path, capsys): + path = tmp_path / "data.kantadb" + _write_timed( + path, + [ + (datetime(2026, 9, 24, 10, tzinfo=UTC), "inc0", 1), + (datetime(2026, 9, 28, 8, tzinfo=UTC), "inc1", 2), + (datetime(2026, 9, 28, 23, tzinfo=UTC), "inc2", 3), + ], + ) + capsys.readouterr() + + # A date denotes the full UTC day; the dump follows the last printed item. + assert _run_cli(path, "-r", "2026-09-28", "-o", "-") == 0 + out = capsys.readouterr() + assert "inc0" not in out.err + assert "inc1" in out.err + assert "inc2" in out.err + assert json.loads(out.out) == {"counter": 3} + + # Hour precision. + assert _run_cli(path, "-r", "2026-09-28T08") == 0 + err = capsys.readouterr().err + assert "inc1" in err + assert "inc2" not in err + + # Compact minute precision. + assert _run_cli(path, "-r", "20260924T1000") == 0 + err = capsys.readouterr().err + assert "inc0" in err + assert "inc1" not in err + + # An inclusive ISO range covers the end's whole period. + assert _run_cli(path, "-r", "2026-09-24/2026-09-28") == 0 + err = capsys.readouterr().err + assert "inc0" in err and "inc2" in err + + # Open-ended range. + assert _run_cli(path, "-r", "/2026-09-25") == 0 + err = capsys.readouterr().err + assert "inc0" in err + assert "inc1" not in err + + +def test_cli_time_combines_with_plain_and_grep(tmp_path, capsys): + path = tmp_path / "data.kantadb" + _write_timed( + path, + [ + (datetime(2026, 9, 24, 10, tzinfo=UTC), "old", 1), + (datetime(2026, 9, 28, 8, tzinfo=UTC), "new_a", 2), + (datetime(2026, 9, 28, 9, tzinfo=UTC), "new_b", 3), + ], + ) + capsys.readouterr() + + # Time filter first, then the plain slice over what remains. + assert _run_cli(path, "-r", "2026-09-28", "-r", "0", "-o", "-") == 0 + out = capsys.readouterr() + assert "new_a" in out.err + assert "new_b" not in out.err + assert json.loads(out.out) == {"counter": 2} + + # Time and grep are both restrictive. + assert _run_cli(path, "-r", "2026-09-28", "--grep", "new_b") == 0 + err = capsys.readouterr().err + assert "new_a" not in err + assert "new_b" in err + + +def test_cli_time_matches_items_not_windows(tmp_path, capsys): + """Non-monotonic timestamps: items match individually, not by position.""" + path = tmp_path / "data.kantadb" + _write_timed( + path, + [ + (datetime(2026, 9, 28, 12, tzinfo=UTC), "noon", 1), + (datetime(2026, 9, 28, 8, tzinfo=UTC), "morning", 2), + (datetime(2026, 9, 28, 10, tzinfo=UTC), "ten", 3), + ], + ) + capsys.readouterr() + + assert _run_cli(path, "-r", "2026-09-28T10") == 0 + err = capsys.readouterr().err + assert "ten" in err + assert "noon" not in err + assert "morning" not in err + + +def test_cli_time_relative(tmp_path, capsys): + path = tmp_path / "data.kantadb" + now = datetime.now(UTC) + _write_timed( + path, + [ + (now - timedelta(hours=2), "old", 1), + (now - timedelta(minutes=30), "recent", 2), + ], + ) + capsys.readouterr() + + assert _run_cli(path, "-r", "1h") == 0 + err = capsys.readouterr().err + assert "recent" in err + assert "old" not in err + + +def test_cli_time_invalid_is_error(tmp_path, capsys): + path = tmp_path / "data.kantadb" + _write_timed(path, [(T0, "inc0", 1)]) + capsys.readouterr() + + assert _run_cli(path, "-r", "2026-13-45") == 1 + assert "Invalid --range value" in capsys.readouterr().err