Improved log formatting support by @kanta.logfmt, which replaces old resolver and user_display arguments (breaking change).

This commit is contained in:
2026-06-13 21:22:05 +00:00
parent 4db046d627
commit 7e553bd868
11 changed files with 1091 additions and 122 deletions
+305
View File
@@ -0,0 +1,305 @@
from typing import Any
import pytest
from kanta import Kanta
from kanta.callbacks import DictPost, DictPre, LogFmt
from kanta.exceptions import DatabaseError
from .support import Data, User, make_kanta
def test_bootstrap_rejects_unannotated_param(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
with pytest.raises(TypeError, match="without an annotation or default"):
@kanta.bootstrap
def seed(data):
data.counter = 1
def test_bootstrap_accepts_unknown_with_default(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
@kanta.bootstrap
def seed(data: Data, extra: int = 0) -> None:
data.counter = extra + 1
# Should register without error.
def test_bootstrap_rejects_unknown_annotation(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
with pytest.raises(TypeError, match="unsupported annotation"):
@kanta.bootstrap
def seed(data: int):
pass
def test_logfmt_requires_value_annotation(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
with pytest.raises(TypeError, match="value parameter.*must be annotated"):
@kanta.logfmt
def resolve_names(previous: DictPre, current: DictPost) -> str | None:
return None
def test_logfmt_requires_return_annotation(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
with pytest.raises(TypeError, match="must annotate its return"):
@kanta.logfmt
def resolve_names(value: str, current: DictPost):
return None
def test_logfmt_rejects_async_callback(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
with pytest.raises(TypeError, match="must not be async"):
@kanta.logfmt
async def resolve_names(value: str, current: DictPost) -> str | None:
return None
@pytest.mark.asyncio
async def test_bootstrap_injects_data_by_type(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.bootstrap
def seed(data: Data) -> None:
data.counter = 7
await kanta.open()
assert kanta.data.counter == 7
await kanta.close()
@pytest.mark.asyncio
async def test_bootstrap_injects_kanta(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
seen: list[Kanta] = []
@kanta.bootstrap
def seed(data: Data, kanta_ref: Kanta) -> None:
seen.append(kanta_ref)
data.counter = 8
await kanta.open()
assert seen == [kanta]
assert kanta.data.counter == 8
await kanta.close()
@pytest.mark.asyncio
async def test_logfmt_injects_states(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.changes")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt
def resolve_users(value: str, current: DictPost) -> str | None:
return current.get("users", {}).get(value, {}).get("name")
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["uuid-1"] = User(name="Alice")
await kanta.close()
assert "Alice" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_class_injection(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.changes")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt
class UserLogFmt(LogFmt):
def resolve(self, value: str, path: str) -> str | None:
return self.current_state.get("users", {}).get(value, {}).get("name")
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["uuid-2"] = User(name="Bob")
await kanta.close()
assert "Bob" in caplog.text
@pytest.mark.asyncio
async def test_multiple_logfmt_chain(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.changes")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt
def resolve_a(value: str) -> str | None:
return "A" if value == "a" else None
@kanta.logfmt
def resolve_b(value: str) -> str | None:
return "B" if value == "b" else None
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["a"] = User(name="first")
data.users["b"] = User(name="second")
await kanta.close()
assert "A" in caplog.text
assert "B" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_path_context(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.changes")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt(path="users.uuid-1")
def resolve_user_key(value: str) -> str | None:
if value == "uuid-1":
return "user-alice"
return None
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["uuid-1"] = User(name="Alice")
await kanta.close()
assert "user-alice" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_decorator_path_filters_calls(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.changes")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt(path="counter")
def fmt_counter(value: Any) -> str | None:
if value == 1:
return "one"
return None
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["uuid-1"] = User(name="Alice")
data.counter = 1
await kanta.close()
assert "one" in caplog.text
assert "uuid-1" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_user_path_replaces_user_display(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.changes")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt(path="$user")
def resolve_user(value: str, current: DictPost) -> str | None:
return current.get("users", {}).get(value, {}).get("name")
await kanta.open()
with kanta.transaction(action="create_user", user="uuid-1") as data:
data.users["uuid-1"] = User(name="Alice")
await kanta.close()
assert "by Alice" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_non_string_value(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.changes")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt
def fmt_count(value: Any, path: str) -> str | None:
if path == "counter" and value == 1:
return "one"
return None
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
await kanta.close()
assert "one" in caplog.text
@pytest.mark.asyncio
async def test_fatal_error_injects_kanta_and_error(
tmp_path, format_config, monkeypatch
):
import asyncio
path = tmp_path / "test.db"
errors: list[DatabaseError] = []
kantas: list[Kanta] = []
signaled = asyncio.Event()
kanta = make_kanta(path, Data, format_config, flush_interval=0.01)
@kanta.fatal_error
def on_fatal(error: DatabaseError, kanta_ref: Kanta) -> None:
errors.append(error)
kantas.append(kanta_ref)
signaled.set()
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 kantas == [kanta]
await kanta.close()