Add @kanta.clock hook replacing the UTC clock for all record and snapshot timestamps
This commit is contained in:
@@ -253,6 +253,25 @@ class Kanta(Generic[T]):
|
|||||||
return _register
|
return _register
|
||||||
return _register(fn)
|
return _register(fn)
|
||||||
|
|
||||||
|
def clock(self, fn=None):
|
||||||
|
"""Register a clock callback replacing the default UTC clock.
|
||||||
|
|
||||||
|
Can be used as ``@kanta.clock``. The callback takes no arguments and
|
||||||
|
must return a :class:`~datetime.datetime`; its value is used for all
|
||||||
|
record timestamps (``ts``, and ``m`` when ``mtime`` is ``True``) and
|
||||||
|
snapshot timestamps. Register before :meth:`open` so that bootstrap
|
||||||
|
and migration records use the custom clock as well. This is mainly
|
||||||
|
useful for tests and reproducible demos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _register(callback):
|
||||||
|
self._impl.add_clock(callback)
|
||||||
|
return callback
|
||||||
|
|
||||||
|
if fn is None:
|
||||||
|
return _register
|
||||||
|
return _register(fn)
|
||||||
|
|
||||||
def logmigr(self, fn=None):
|
def logmigr(self, fn=None):
|
||||||
"""Register a migration logging callback.
|
"""Register a migration logging callback.
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -296,7 +296,11 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
|||||||
self.snapshot.request_force()
|
self.snapshot.request_force()
|
||||||
await self.flush()
|
await self.flush()
|
||||||
self.snapshot.maybe_write(
|
self.snapshot.maybe_write(
|
||||||
self.file, self.version, self.statedict, m=self.mtime
|
self.file,
|
||||||
|
self.version,
|
||||||
|
self.statedict,
|
||||||
|
m=self.mtime,
|
||||||
|
now=self.now(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if migrations_ran and migration_result is not None:
|
if migrations_ran and migration_result is not None:
|
||||||
|
|||||||
+32
-2
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from collections.abc import Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -41,6 +43,7 @@ class PersistenceMixin:
|
|||||||
opened: bool
|
opened: bool
|
||||||
readonly: bool
|
readonly: bool
|
||||||
mtime: datetime | None
|
mtime: datetime | None
|
||||||
|
clock: Callable[[], datetime] | None
|
||||||
|
|
||||||
def __init__(self, **kwargs: Any) -> None:
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
"""Initialize persistence-owned state used by mixin methods."""
|
"""Initialize persistence-owned state used by mixin methods."""
|
||||||
@@ -62,6 +65,31 @@ class PersistenceMixin:
|
|||||||
self.flush_interval = flush_interval
|
self.flush_interval = flush_interval
|
||||||
self.version = 0
|
self.version = 0
|
||||||
self.mtime: datetime | None = None
|
self.mtime: datetime | None = None
|
||||||
|
self.clock: Callable[[], datetime] | None = None
|
||||||
|
|
||||||
|
def add_clock(self, callback) -> None:
|
||||||
|
"""Register a clock callback ``() -> datetime`` replacing the UTC clock."""
|
||||||
|
if not callable(callback):
|
||||||
|
raise TypeError("clock callback must be callable")
|
||||||
|
for param in inspect.signature(callback).parameters.values():
|
||||||
|
if param.default is inspect.Parameter.empty and param.kind in (
|
||||||
|
param.POSITIONAL_ONLY,
|
||||||
|
param.POSITIONAL_OR_KEYWORD,
|
||||||
|
param.KEYWORD_ONLY,
|
||||||
|
):
|
||||||
|
raise TypeError("clock callback must not require arguments")
|
||||||
|
self.clock = callback
|
||||||
|
|
||||||
|
def now(self) -> datetime:
|
||||||
|
"""Current time from the registered clock (default: UTC now)."""
|
||||||
|
if self.clock is None:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
ts = self.clock()
|
||||||
|
if not isinstance(ts, datetime):
|
||||||
|
raise TypeError(
|
||||||
|
f"clock callback must return a datetime, got {type(ts).__name__}"
|
||||||
|
)
|
||||||
|
return ts
|
||||||
|
|
||||||
def add_fatal_error(self, callback) -> None:
|
def add_fatal_error(self, callback) -> None:
|
||||||
"""Register one fatal error callback in call order."""
|
"""Register one fatal error callback in call order."""
|
||||||
@@ -100,7 +128,9 @@ class PersistenceMixin:
|
|||||||
|
|
||||||
def maybe_snapshot(self) -> None:
|
def maybe_snapshot(self) -> None:
|
||||||
"""Evaluate and possibly write a snapshot from current state."""
|
"""Evaluate and possibly write a snapshot from current state."""
|
||||||
self.snapshot.maybe_write(self.file, self.version, self.statedict, m=self.mtime)
|
self.snapshot.maybe_write(
|
||||||
|
self.file, self.version, self.statedict, m=self.mtime, now=self.now()
|
||||||
|
)
|
||||||
|
|
||||||
def queue_change(
|
def queue_change(
|
||||||
self,
|
self,
|
||||||
@@ -128,7 +158,7 @@ class PersistenceMixin:
|
|||||||
The queued :class:`ChangeRecord`, or ``None`` if the diff was empty
|
The queued :class:`ChangeRecord`, or ``None`` if the diff was empty
|
||||||
and *force* is ``False``.
|
and *force* is ``False``.
|
||||||
"""
|
"""
|
||||||
now = datetime.now(UTC)
|
now = self.now()
|
||||||
|
|
||||||
if mtime is True:
|
if mtime is True:
|
||||||
m = now
|
m = now
|
||||||
|
|||||||
+7
-2
@@ -38,11 +38,16 @@ class SnapshotState:
|
|||||||
self.changes += count
|
self.changes += count
|
||||||
|
|
||||||
def maybe_write(
|
def maybe_write(
|
||||||
self, file, version: int, state: dict, m: datetime | None = None
|
self,
|
||||||
|
file,
|
||||||
|
version: int,
|
||||||
|
state: dict,
|
||||||
|
m: datetime | None = None,
|
||||||
|
now: datetime | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Write snapshot when thresholds/time policy allows it."""
|
"""Write snapshot when thresholds/time policy allows it."""
|
||||||
force = self._force_pending
|
force = self._force_pending
|
||||||
now = datetime.now(UTC)
|
now = now if now is not None else datetime.now(UTC)
|
||||||
if not force:
|
if not force:
|
||||||
if self.changes < self._min_diffs:
|
if self.changes < self._min_diffs:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from .support import (
|
||||||
|
Data,
|
||||||
|
make_kanta,
|
||||||
|
make_migrations_module,
|
||||||
|
read_changes,
|
||||||
|
read_last_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
T0 = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clock_rejects_non_callable(tmp_path, format_config):
|
||||||
|
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match="must be callable"):
|
||||||
|
kanta.clock(42)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clock_rejects_required_argument(tmp_path, format_config):
|
||||||
|
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match="must not require arguments"):
|
||||||
|
|
||||||
|
@kanta.clock
|
||||||
|
def fake_now(tz) -> datetime:
|
||||||
|
return T0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clock_rejects_non_datetime_result(tmp_path, format_config):
|
||||||
|
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||||
|
|
||||||
|
@kanta.clock
|
||||||
|
def fake_now() -> datetime:
|
||||||
|
return "noon"
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match="must return a datetime"):
|
||||||
|
await kanta.open(log=False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clock_controls_record_timestamps(tmp_path, format_config):
|
||||||
|
path = tmp_path / "test.db"
|
||||||
|
kanta = make_kanta(path, Data, format_config)
|
||||||
|
current = T0
|
||||||
|
|
||||||
|
@kanta.clock
|
||||||
|
def fake_now() -> datetime:
|
||||||
|
return current
|
||||||
|
|
||||||
|
await kanta.open(log=False)
|
||||||
|
current = T0 + timedelta(hours=1)
|
||||||
|
with kanta.transaction(action="update") as data:
|
||||||
|
data.counter = 1
|
||||||
|
current = T0 + timedelta(hours=2)
|
||||||
|
with kanta.transaction(action="repair", mtime=False) as data:
|
||||||
|
data.counter = 2
|
||||||
|
await kanta.close()
|
||||||
|
|
||||||
|
bootstrap, update, repair = read_changes(path, format_config)
|
||||||
|
assert bootstrap.ts == T0
|
||||||
|
assert bootstrap.m == T0
|
||||||
|
assert update.ts == T0 + timedelta(hours=1)
|
||||||
|
assert update.m == T0 + timedelta(hours=1)
|
||||||
|
# System operation: stamped by the clock, but m is not updated.
|
||||||
|
assert repair.ts == T0 + timedelta(hours=2)
|
||||||
|
assert repair.m is None
|
||||||
|
assert kanta.mtime == T0 + timedelta(hours=1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clock_controls_migration_and_snapshot_timestamps(
|
||||||
|
tmp_path, format_config
|
||||||
|
):
|
||||||
|
path = tmp_path / "test.db"
|
||||||
|
kanta = make_kanta(path, Data, format_config)
|
||||||
|
|
||||||
|
@kanta.clock
|
||||||
|
def fake_now() -> datetime:
|
||||||
|
return T0
|
||||||
|
|
||||||
|
await kanta.open(log=False)
|
||||||
|
await kanta.close()
|
||||||
|
|
||||||
|
def migrate_v1(d):
|
||||||
|
"""Bump counter"""
|
||||||
|
d["counter"] = 1
|
||||||
|
|
||||||
|
migrations = make_migrations_module("clock_migrations", "migrate_v1", migrate_v1)
|
||||||
|
t1 = T0 + timedelta(days=1)
|
||||||
|
kanta2 = make_kanta(path, Data, format_config, migrations=migrations)
|
||||||
|
|
||||||
|
@kanta2.clock
|
||||||
|
def fake_now2() -> datetime:
|
||||||
|
return t1
|
||||||
|
|
||||||
|
await kanta2.open(log=False)
|
||||||
|
await kanta2.close()
|
||||||
|
|
||||||
|
migrate_records = [
|
||||||
|
r for r in read_changes(path, format_config) if r.a.startswith("migrate:")
|
||||||
|
]
|
||||||
|
assert migrate_records
|
||||||
|
assert all(r.ts == t1 for r in migrate_records)
|
||||||
|
|
||||||
|
snapshot = read_last_snapshot(path, format_config)
|
||||||
|
assert snapshot is not None
|
||||||
|
assert snapshot.ts == t1
|
||||||
|
# mtime is carried forward from the last real modification.
|
||||||
|
assert snapshot.m == T0
|
||||||
Reference in New Issue
Block a user