Initial commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user