Support time-based -r ranges (relative and ISO)

This commit is contained in:
LeoVasanko committed 2026-09-28 06:18:23 +00:00
1 parent e296859383
commit 8fd86d20e1
4 files changed
+345 -17

No files matched your search

+1
View File
@@ -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.
+30 -8
View File
@@ -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)
+110 -8
View File
@@ -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:
+204 -1
View File
@@ -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