Initial commit

This commit is contained in:
Leo Vasanko
2026-06-12 19:18:15 +00:00
commit afebad72df
34 changed files with 3143 additions and 0 deletions
View File
+14
View File
@@ -0,0 +1,14 @@
import pytest
from kanta import JsonSerializer, MsgPackSerializer
@pytest.fixture(
params=[
("json", JsonSerializer),
("msgpack", MsgPackSerializer),
],
ids=["json", "msgpack"],
)
def format_config(request):
return request.param
+82
View File
@@ -0,0 +1,82 @@
import sys
from datetime import UTC, datetime
from pathlib import Path
from types import ModuleType
from uuid import UUID
import msgspec
from kanta import ChangeRecord, Kanta
class User(msgspec.Struct):
name: str = ""
age: int = 0
class Data(msgspec.Struct):
users: dict[str, User] = {}
counter: int = 0
class ExoticData(msgspec.Struct, omit_defaults=False):
uuid_values: dict[str, UUID] = {}
uuid_keys: dict[UUID, int] = {}
datetime_values: dict[str, datetime] = {}
datetime_keys: dict[datetime, int] = {}
bytes_values: dict[str, bytes] = {}
bytes_keys: dict[bytes, int] = {}
class EvolvableDataV1(msgspec.Struct, omit_defaults=False):
counter: int = 0
class EvolvableDataV2(msgspec.Struct, omit_defaults=False):
counter: int = 0
enabled: bool = True
def make_kanta(path: Path, data_or_type, format_config, **kwargs):
_, serializer_cls = format_config
root = data_or_type() if isinstance(data_or_type, type) else data_or_type
return Kanta(
str(path),
root,
serializer=serializer_cls(),
**kwargs,
)
def seed_single_change(path: Path, change: ChangeRecord, format_config) -> None:
_, serializer_cls = format_config
serializer = serializer_cls()
framer = serializer.framer_cls()
payload = serializer.encode(change)
path.write_bytes(framer.frame_change(payload, record_offset=0))
def change_actions(path: Path, format_config) -> list[str]:
_, serializer_cls = format_config
serializer = serializer_cls()
framer = serializer.framer_cls()
actions: list[str] = []
for is_snapshot, payload, _, _ in framer.iter_records(path.read_bytes(), 0):
if is_snapshot:
continue
rec = serializer.decode(payload, type=ChangeRecord)
actions.append(rec.a)
return actions
def make_migrations_module(name: str, fn_name: str, fn):
mod = ModuleType(name)
mod.__dict__[fn_name] = fn
sys.modules[name] = mod
return mod
def fixed_change(action: str, diff: dict, *, version: int = 0) -> ChangeRecord:
return ChangeRecord(
ts=datetime(2026, 1, 1, tzinfo=UTC), a=action, v=version, diff=diff
)
+16
View File
@@ -0,0 +1,16 @@
from kanta import compute_diff
def test_no_diff():
assert compute_diff({"a": 1}, {"a": 1}) is None
def test_simple_diff():
diff = compute_diff({"a": 1}, {"a": 2})
assert diff is not None
assert diff == {"a": 2}
def test_nested_diff():
diff = compute_diff({"x": {"y": 1}}, {"x": {"y": 2}})
assert diff == {"x": {"y": 2}}
+25
View File
@@ -0,0 +1,25 @@
from kanta import format_diff
def test_add():
lines = format_diff({"name": "Alice"}, previous={})
assert any("name" in line for line in lines)
def test_update():
lines = format_diff({"name": "Bob"}, previous={"name": "Alice"})
assert any("Bob" in line for line in lines)
def test_delete():
lines = format_diff({"$delete": ["old_key"]}, previous={"old_key": 1})
assert any("old_key" in line for line in lines)
def test_resolver():
lines = format_diff(
{"users": {"uuid-1": {"name": "Alice"}}},
previous={},
resolver=lambda x: "Alice" if x == "uuid-1" else x,
)
assert any("Alice" in line for line in lines)
+58
View File
@@ -0,0 +1,58 @@
import pytest
from kanta.exceptions import ReplayError
from kanta.serialization.framing import BinFramer
def test_roundtrip_with_sync_header_and_checksum():
framer = BinFramer()
first = framer.frame_change(b"c1", record_offset=0)
second = framer.frame_snapshot(b"snap", record_offset=len(first))
third = framer.frame_change(b"c2", record_offset=len(first) + len(second))
data = first + second + third
assert framer._sync_snapshot is not None
assert data[:4] == framer._sync_snapshot
snap_payload, resume_offset, snap_pos = framer.scan_last_snapshot(data)
assert snap_payload == b"snap"
assert snap_pos == len(first)
assert list(framer.iter_records(data, resume_offset)) == [
(False, b"c2", 0, len(first) + len(second))
]
assert list(framer.iter_records(data, 0)) == [
(False, b"c1", 0, 4),
(True, b"snap", 0, len(first)),
(False, b"c2", 0, len(first) + len(second)),
]
def test_no_serialized_offset_in_change_frame():
framer = BinFramer()
data = framer.frame_change(b"payload", record_offset=0)
assert data[4:8] == bytes((~b) & 0xFF for b in framer._sync_snapshot)
payload_len = int.from_bytes(data[8:12], "little")
assert len(data) == 4 + 4 + 4 + 8 + payload_len
def test_detects_tampered_checksum():
framer = BinFramer()
data = bytearray(framer.frame_change(b"payload", record_offset=0))
checksum_start = 4 + 4 + 4
data[checksum_start] ^= 0x01
with pytest.raises(ReplayError, match="invalid frame checksum") as exc_info:
list(framer.iter_records(bytes(data), 0))
assert exc_info.value.line_number == 0
assert exc_info.value.byte_pos == 4
def test_detects_invalid_frame_marker():
framer = BinFramer()
data = bytearray(framer.frame_change(b"payload", record_offset=0))
data[4:8] = b"BAD!"
with pytest.raises(ReplayError, match="invalid frame marker") as exc_info:
list(framer.iter_records(bytes(data), 0))
assert exc_info.value.line_number == 0
assert exc_info.value.byte_pos == 4
+391
View File
@@ -0,0 +1,391 @@
import asyncio
import sys
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from kanta.exceptions import DatabaseError, DataIntegrityError, FileLockError
from kanta.serialization import struct_to_dict
from .support import (
Data,
EvolvableDataV1,
EvolvableDataV2,
ExoticData,
User,
change_actions,
fixed_change,
make_kanta,
seed_single_change,
)
@pytest.mark.asyncio
async def test_load_empty(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
await kanta.open()
assert isinstance(kanta.data, Data)
assert kanta.data.users == {}
await kanta.close()
@pytest.mark.asyncio
async def test_open_overwrites_caller_owned_root_data(tmp_path, format_config):
path = tmp_path / "test.db"
seed_single_change(path, fixed_change("seed", {"counter": 7}), format_config)
root = Data(counter=99, users={"stale": User(name="Stale", age=1)})
kanta = make_kanta(path, root, format_config)
await kanta.open()
assert kanta.data is root
assert root.counter == 7
assert root.users == {}
await kanta.close()
@pytest.mark.asyncio
async def test_roundtrip(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
await kanta.flush()
await kanta.close()
kanta2 = make_kanta(path, Data, format_config)
await kanta2.open()
assert isinstance(kanta2.data, Data)
assert kanta2.data.counter == 1
await kanta2.close()
@pytest.mark.asyncio
async def test_rollback_on_error(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
try:
with kanta.transaction(action="inc") as data:
data.counter = 1
raise ValueError("boom")
except ValueError:
pass
assert kanta.data.counter == 0
assert isinstance(kanta.data, Data)
await kanta.close()
@pytest.mark.asyncio
async def test_bootstrap_creates_file(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
kanta.data = Data(counter=1)
kanta._impl.statedict = {}
with kanta.transaction(action="bootstrap") as data:
data.counter = 1
await kanta.flush()
await kanta.close()
assert path.exists()
@pytest.mark.asyncio
async def test_snapshot(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config, flush_interval=0.01)
await kanta.open()
kanta.data = Data(counter=1)
kanta._impl.statedict = struct_to_dict(kanta.data)
kanta._impl.snapshot._min_diffs = 1
kanta._impl.snapshot.request_force()
with kanta.transaction(action="inc") as data:
data.counter = 2
await kanta.flush()
await asyncio.sleep(0.05)
await kanta.close()
data = path.read_bytes()
_, serializer_cls = format_config
framer = serializer_cls().framer_cls()
snap_payload, _, _ = framer.scan_last_snapshot(data)
assert snap_payload is not None
@pytest.mark.asyncio
async def test_nested_struct_roundtrip(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["alice"] = User(name="Alice", age=30)
await kanta.flush()
await kanta.close()
kanta2 = make_kanta(path, Data, format_config)
await kanta2.open()
assert isinstance(kanta2.data, Data)
assert kanta2.data.users["alice"].name == "Alice"
assert kanta2.data.users["alice"].age == 30
with kanta2.transaction(action="update_user") as data:
data.users["alice"].age = 31
await kanta2.flush()
await kanta2.close()
kanta3 = make_kanta(path, Data, format_config)
await kanta3.open()
assert isinstance(kanta3.data, Data)
assert kanta3.data.users["alice"].name == "Alice"
assert kanta3.data.users["alice"].age == 31
await kanta3.close()
@pytest.mark.asyncio
async def test_background_flush(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config, flush_interval=0.01)
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
await asyncio.sleep(0.05)
await kanta.close()
assert path.exists()
reloaded = make_kanta(path, Data, format_config)
await reloaded.open()
assert reloaded.data.counter == 1
await reloaded.close()
@pytest.mark.asyncio
async def test_async_with_open_close(tmp_path, format_config):
path = tmp_path / "test.db"
async with make_kanta(path, Data, format_config) as kanta:
with kanta.transaction(action="inc") as data:
data.counter = 1
assert path.exists()
reloaded = make_kanta(path, Data, format_config)
await reloaded.open()
assert reloaded.data.counter == 1
await reloaded.close()
@pytest.mark.asyncio
async def test_open_twice_raises(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
with pytest.raises(DataIntegrityError, match="already open"):
await kanta.open()
await kanta.close()
@pytest.mark.asyncio
async def test_migrations_from_module(tmp_path, format_config):
path = tmp_path / "test.db"
mod = type(sys)("test_migrations")
def migrate_v1(d, ctx):
d["version"] = 1
mod.__dict__["migrate_v1"] = migrate_v1
seed_single_change(path, fixed_change("init", {"counter": 0}), format_config)
kanta = make_kanta(path, Data, format_config, migrations=mod)
await kanta.open()
assert kanta.version == 1
await kanta.close()
@pytest.mark.asyncio
async def test_msgspec_normalization_logs_migration(tmp_path, format_config):
path = tmp_path / "test.db"
seed_single_change(
path,
fixed_change("seed", {"users": {"alice": {"name": "Alice", "age": 30}}}),
format_config,
)
kanta = make_kanta(path, Data, format_config)
await kanta.open()
await kanta.close()
assert "migrate:msgspec" in change_actions(path, format_config)
@pytest.mark.asyncio
async def test_open_locked_file_raises_filelock_error(tmp_path, format_config):
path = tmp_path / "test.db"
kanta1 = make_kanta(path, Data, format_config)
await kanta1.open()
kanta2 = make_kanta(path, Data, format_config)
try:
with pytest.raises(FileLockError):
await kanta2.open()
finally:
await kanta1.close()
@pytest.mark.asyncio
async def test_flush_write_failure_bubbles_database_error(
tmp_path, format_config, monkeypatch
):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
def fail_write(_data: bytes) -> None:
raise OSError("simulated write failure")
monkeypatch.setattr(kanta._impl.file, "write", fail_write)
with pytest.raises(DatabaseError, match="Failed to flush database"):
await kanta.flush()
await kanta.close()
@pytest.mark.asyncio
async def test_background_write_failure_notifies_callback(
tmp_path, format_config, monkeypatch
):
path = tmp_path / "test.db"
errors: list[DatabaseError] = []
signaled = asyncio.Event()
def on_fatal_error(err: DatabaseError) -> None:
errors.append(err)
signaled.set()
kanta = make_kanta(
path,
Data,
format_config,
flush_interval=0.01,
fatal_error=on_fatal_error,
)
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
def fail_write(_data: bytes) -> None:
raise OSError("simulated background write failure")
monkeypatch.setattr(kanta._impl.file, "write", fail_write)
await asyncio.wait_for(signaled.wait(), timeout=1.0)
assert errors
assert "Failed to flush database" in str(errors[0])
assert kanta._impl.background_error is not None
await kanta.close()
@pytest.mark.asyncio
async def test_migrations_from_module_path(tmp_path, format_config):
path = tmp_path / "test.db"
module_name = "test_migrations_path"
mod = type(sys)(module_name)
def migrate_v1(d, ctx):
d["counter"] = 2
mod.__dict__["migrate_v1"] = migrate_v1
sys.modules[module_name] = mod
seed_single_change(path, fixed_change("init", {"counter": 0}), format_config)
try:
kanta = make_kanta(path, Data, format_config, migrations=module_name)
await kanta.open()
assert kanta.version == 1
assert kanta.data.counter == 2
await kanta.close()
finally:
sys.modules.pop(module_name, None)
@pytest.mark.asyncio
async def test_uuid_datetime_bytes_keys_and_values_roundtrip(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, ExoticData, format_config)
await kanta.open()
u = uuid4()
dt = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
bkey = b"blob-key"
bval = b"blob-value"
with kanta.transaction(action="set_exotic") as data:
data.uuid_values["u"] = u
data.uuid_keys[u] = 1
data.datetime_values["ts"] = dt
data.datetime_keys[dt] = 2
data.bytes_values["blob"] = bval
data.bytes_keys[bkey] = 3
await kanta.flush()
await kanta.close()
reloaded = make_kanta(path, ExoticData, format_config)
await reloaded.open()
assert reloaded.data.uuid_values["u"] == u
assert reloaded.data.uuid_keys[u] == 1
assert reloaded.data.datetime_values["ts"] == dt
assert reloaded.data.datetime_keys[dt] == 2
assert reloaded.data.bytes_values["blob"] == bval
assert reloaded.data.bytes_keys[bkey] == 3
await reloaded.close()
@pytest.mark.asyncio
async def test_schema_evolution_add_default_field_logs_migration(
tmp_path, format_config
):
path = tmp_path / "test.db"
kanta_v1 = make_kanta(path, EvolvableDataV1, format_config)
await kanta_v1.open()
with kanta_v1.transaction(action="seed") as data:
data.counter = 1
await kanta_v1.flush()
await kanta_v1.close()
kanta_v2 = make_kanta(path, EvolvableDataV2, format_config)
await kanta_v2.open()
assert kanta_v2.data.counter == 1
assert kanta_v2.data.enabled is True
await kanta_v2.close()
assert "migrate:msgspec" in change_actions(path, format_config)
+17
View File
@@ -0,0 +1,17 @@
import logging
from kanta import configure_logging, log_change
from kanta.logging import logger
def test_configure_logging():
configure_logging()
assert logger.level == logging.INFO
def test_log_change_no_diff(capsys):
logger.handlers.clear()
configure_logging()
log_change("test", {})
captured = capsys.readouterr()
assert "test" in captured.err
+53
View File
@@ -0,0 +1,53 @@
from types import ModuleType
from kanta.migrate import MigrationRegistry
def test_register_and_apply():
reg = MigrationRegistry()
@reg.register
def migrate_v1(d, ctx):
d["version"] = 1
@reg.register
def migrate_v2(d, ctx):
d["version"] = 2
state = {}
new_ver = reg.apply(state, current_version=0, silent=True)
assert new_ver == 2
assert state["version"] == 2
def test_no_migrations_needed():
reg = MigrationRegistry()
@reg.register
def migrate_v1(d, ctx):
d["x"] = 1
state = {"x": 1}
new_ver = reg.apply(state, current_version=1, silent=True)
assert new_ver == 1
def test_from_module():
mod = ModuleType("fake_migrations")
def migrate_v1(d, ctx):
d["v"] = 1
def migrate_v2(d, ctx):
d["v"] = 2
mod.__dict__["migrate_v1"] = migrate_v1
mod.__dict__["migrate_v2"] = migrate_v2
reg = MigrationRegistry.from_module(mod)
assert reg.dbver == 2
state = {}
new_ver = reg.apply(state, current_version=0, silent=True)
assert new_ver == 2
assert state["v"] == 2
+34
View File
@@ -0,0 +1,34 @@
from datetime import UTC, datetime
from kanta import ChangeRecord, Snapshot, replay
from kanta.serialization.framing import LineFramer
def test_empty_data():
rr = replay(b"")
assert rr.state == {}
assert rr.version == 0
def test_single_change():
rec = ChangeRecord(a="test", v=1, diff={"name": "Alice"})
data = b"" + __import__("msgspec").json.encode(rec) + b"\n"
rr = replay(data)
assert rr.state == {"name": "Alice"}
assert rr.version == 1
def test_snapshot_then_change():
snap = Snapshot(ts=datetime.now(UTC), v=1, state={"counter": 5})
line = LineFramer.SNAPSHOT_PREFIX + __import__("msgspec").json.encode(snap) + b"\n"
rec = ChangeRecord(a="inc", v=1, diff={"counter": 6})
line += __import__("msgspec").json.encode(rec) + b"\n"
rr = replay(line)
assert rr.state == {"counter": 6}
def test_migration_flag():
rec = ChangeRecord(a="migrate:v1", v=1, diff={"x": 1})
data = __import__("msgspec").json.encode(rec) + b"\n"
rr = replay(data)
assert rr.has_migration is True
+34
View File
@@ -0,0 +1,34 @@
from kanta.snapshot import SnapshotState
def test_no_write_below_min_diffs():
class FakeFile:
def __init__(self):
self.written = []
self.is_open = True
def write(self, data: bytes):
self.written.append(data)
ss = SnapshotState(min_diffs=10)
ss.record_changes(5)
f = FakeFile()
ss.maybe_write(f, 1, {"x": 1})
assert len(f.written) == 0
def test_force_writes():
class FakeFile:
def __init__(self):
self.written = []
self.is_open = True
def write(self, data: bytes):
self.written.append(data)
ss = SnapshotState(min_diffs=10)
ss.record_changes(15)
ss.request_force()
f = FakeFile()
ss.maybe_write(f, 1, {"x": 1})
assert len(f.written) == 1