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. - **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. - **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. 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 importlib.util
import sys import sys
import tempfile import tempfile
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -33,6 +34,7 @@ from kanta.replaylog import (
SnapshotEvent, SnapshotEvent,
_snapshot_lines, _snapshot_lines,
end_of_file, end_of_file,
parse_time_spec,
record_change_event, record_change_event,
record_label, record_label,
replay_events, replay_events,
@@ -250,7 +252,12 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
" subset is printed. Negative" " subset is printed. Negative"
" snapshot values count from the end (s-1 is the last" " snapshot values count from the end (s-1 is the last"
" snapshot); negative plain numbers count from the end of the" " 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:" " half-open ranges and '..' for inclusive end ranges. Examples:"
" '2:5', '2..5', 'l10:l20', 's1:s3', 'v0:v2', 'r10:r20', 's-1:'," " '2:5', '2..5', 'l10:l20', 's1:s3', 'v0:v2', 'r10:r20', 's-1:',"
" ':-1', '-1', '-r r1:r5 -r 0'." " ':-1', '-1', '-r r1:r5 -r 0'."
@@ -425,6 +432,16 @@ def _print_snapshot_indicator(
_print(f"{label} {ts} {line}") _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( def _print_snapshot_matches(
state: dict[str, Any], kanta: Kanta[Any], highlight: Any state: dict[str, Any], kanta: Kanta[Any], highlight: Any
) -> None: ) -> None:
@@ -551,13 +568,16 @@ async def _run(args: argparse.Namespace) -> int:
} }
# Resolve -r specs into a line range (unit specs) or a single # 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: try:
selection, plain_specs = ( selection, plain_specs, time_specs = (
select_all(args.range, events, change_count) select_all(args.range, events, change_count)
if args.range 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: except RangeNotFoundError as exc:
raise _CliError(str(exc), EXIT_RANGE_ERROR) from exc raise _CliError(str(exc), EXIT_RANGE_ERROR) from exc
except ValueError as 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 ()] 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 snap_event = selection.snapshot
state = snap_event.snap.state state = snap_event.snap.state
version = snap_event.snap.v version = snap_event.snap.v
@@ -617,8 +637,8 @@ async def _run(args: argparse.Namespace) -> int:
continue continue
highlight = None highlight = None
if isinstance(event, SnapshotEvent): if isinstance(event, SnapshotEvent):
is_match = True is_match = _in_time_windows(event.snap.ts, time_windows)
if grep_patterns: if is_match and grep_patterns:
logfmt = kanta._impl.callback_registry.build_logfmt( logfmt = kanta._impl.callback_registry.build_logfmt(
InjectionContext( InjectionContext(
kanta=kanta, kanta=kanta,
@@ -645,7 +665,9 @@ async def _run(args: argparse.Namespace) -> int:
highlight = evaluate( highlight = evaluate(
event.record, previous, grep_patterns, logfmt=logfmt 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)) pool.append((event, previous, current, highlight if is_match else None))
if is_match: if is_match:
matched.append(len(pool) - 1) matched.append(len(pool) - 1)
+110 -8
View File
@@ -10,7 +10,9 @@ from __future__ import annotations
import copy import copy
import dataclasses import dataclasses
import re
from collections.abc import Iterator from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any, Union from typing import TYPE_CHECKING, Any, Union
import msgspec import msgspec
@@ -433,6 +435,97 @@ def select(spec: str, events: list[Event], total: int) -> Selection:
return Selection(lines[idx], end_line) 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: def is_plain_spec(spec: str) -> bool:
"""Return True if every bound of the spec is a plain number (no unit letter). """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( def select_all(
specs: list[str], events: list[Event], total: int 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. """Resolve repeated ``-r`` specs into a database window plus plain specs.
Unit specs (``l``, ``s``, ``v``, ``r``, and mixed specs) resolve to line Unit specs (``l``, ``s``, ``v``, ``r``, and mixed specs) resolve to line
windows via :func:`select` and intersect restrictively. The snapshot windows via :func:`select` and intersect restrictively. The snapshot
shortcut applies only to a single unit spec. Plain-number specs are shortcut applies only to a single unit spec. Plain-number specs and
returned unresolved for the caller to apply against the filtered output. 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)] time_specs: list[str] = []
unit_specs = [spec for spec in specs if not is_plain_spec(spec)] 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: 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] selections = [select(spec, events, total) for spec in unit_specs]
if len(selections) == 1: 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) start = max(sel.start_line for sel in selections)
end = min(sel.end_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: def _clamp_index(value: int, count: int) -> int:
+204 -1
View File
@@ -2,8 +2,11 @@
import asyncio import asyncio
import json 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.serialization import JsonSerializer
from kanta.structs import META_FORMAT, ChangeRecord, Snapshot from kanta.structs import META_FORMAT, ChangeRecord, Snapshot
from tests.support import Data, make_kanta from tests.support import Data, make_kanta
@@ -12,6 +15,7 @@ from tests.support import Data, make_kanta
JSON = ("json", JsonSerializer) JSON = ("json", JsonSerializer)
T0 = datetime(2026, 1, 1, tzinfo=UTC) T0 = datetime(2026, 1, 1, tzinfo=UTC)
NOW = datetime(2026, 9, 28, 12, 0, 0, tzinfo=UTC)
def _run_cli(path, *args): 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 assert _run_cli(path, "--grep", "zzz", "-o", "-") == 0
out = capsys.readouterr() out = capsys.readouterr()
assert json.loads(out.out) == {"users": {}, "counter": 3} 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