Support time-based -r ranges (relative and ISO)
This commit is contained in:
1 parent
e296859383
commit
8fd86d20e1
4 files changed
+345
-17
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user