Remove logheader callback feature; keep Kanta-colored extra string in default header
This commit is contained in:
+18
-20
@@ -3,7 +3,7 @@
|
||||
Run from the project root: python demo/main.py
|
||||
|
||||
Demonstrates bootstrap, colored transaction diffs, logfmt value formatting,
|
||||
custom log headers, logging toggles, rollback, migrations, and a custom clock.
|
||||
logging toggles, rollback, migrations, and a custom clock.
|
||||
The database is recreated with fixed timestamps on every run; everything else
|
||||
lives in this file.
|
||||
"""
|
||||
@@ -21,16 +21,28 @@ from kanta.callbacks import DictPost, DictPre
|
||||
from kanta.logging import configure_logging
|
||||
|
||||
|
||||
class DataV1(msgspec.Struct):
|
||||
"""Original schema (version 0)."""
|
||||
filename = Path(__file__).with_name("demo.kantadb")
|
||||
|
||||
# For demonstration purposes, we use "original v0" and "modified v1" in this same script
|
||||
# Normally your app would only have the latest supported data model
|
||||
|
||||
|
||||
class Data(msgspec.Struct): # type: ignore - intentionally redefined later
|
||||
users: dict[str, dict] = {}
|
||||
counter: int = 0
|
||||
|
||||
|
||||
class Data(msgspec.Struct):
|
||||
"""Current schema: migration v1 renames counter to total."""
|
||||
kanta_v0 = Kanta(filename, Data())
|
||||
|
||||
|
||||
@kanta_v0.bootstrap
|
||||
def bootstrap(data: Data) -> None:
|
||||
"""Create the initial admin user."""
|
||||
data.users["u1"] = {"name": "Alice", "role": "admin"}
|
||||
|
||||
|
||||
# Redefinition to simulate new version with counter renamed to total
|
||||
class Data(msgspec.Struct):
|
||||
users: dict[str, dict] = {}
|
||||
total: int = 0
|
||||
|
||||
@@ -40,11 +52,9 @@ def migrate_v1(d: dict) -> None:
|
||||
d["total"] = d.pop("counter")
|
||||
|
||||
|
||||
filename = Path(__file__).with_name("demo.kantadb")
|
||||
# For demonstration purposes, we use "original v0" and "modified v1" in this same script
|
||||
kanta_v0 = Kanta(filename, DataV1())
|
||||
kanta_v1 = Kanta(filename, Data(), migrations=sys.modules[__name__])
|
||||
|
||||
|
||||
_now = datetime(2027, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
@@ -72,18 +82,6 @@ def resolve_user(
|
||||
return None
|
||||
|
||||
|
||||
@kanta_v1.logheader
|
||||
def header(action: str, user: str, extra: str) -> str:
|
||||
"""Custom header: Kanta colors the parts, we just arrange them."""
|
||||
return f"{user} {action} {extra}"
|
||||
|
||||
|
||||
@kanta_v0.bootstrap
|
||||
def seed(data: DataV1) -> None:
|
||||
"""Create the initial admin user."""
|
||||
data.users["u1"] = {"name": "Alice", "role": "admin"}
|
||||
|
||||
|
||||
def section(title: str) -> None:
|
||||
print(f"\n# {title}", flush=True)
|
||||
|
||||
|
||||
+2
-21
@@ -213,30 +213,11 @@ def resolve_user_key(value: str) -> str | None:
|
||||
- By default a transaction is logged with an `action by user` header followed
|
||||
by the diff lines. Added paths are colored green, deleted paths red.
|
||||
- `kanta.transaction(..., extra="...")` accepts a display-only string that is
|
||||
appended after the action in the default header (colored by Kanta); it is
|
||||
never persisted in the `ChangeRecord`.
|
||||
- Register a `@kanta.logheader` callback to compose a custom header. Declare
|
||||
any of `action: str`, `user: str`, `extra: str`: Kanta passes the parts
|
||||
with its header colors already applied (missing `user`/`extra` as empty
|
||||
strings), so callbacks only arrange text — no color codes, fallbacks, or
|
||||
padding. `DictPre`/`DictPost` state dicts and the `Kanta` instance can
|
||||
also be injected. The callback must be synchronous and return `str | None`.
|
||||
- Multiple logheader callbacks are stacked in registration order; the first
|
||||
callback to return a non-`None` result wins. If all return `None`, Kanta
|
||||
falls back to the default header. The `user` part has already been through
|
||||
the `logfmt` formatters.
|
||||
appended after the action in the header (colored by Kanta); it is never
|
||||
persisted in the `ChangeRecord`.
|
||||
- The header and diff parts can be toggled independently per transaction:
|
||||
`kanta.transaction(..., log={"header": True, "diff": False})`.
|
||||
|
||||
```python
|
||||
@kanta.logheader
|
||||
def format_header(action: str, user: str, extra: str) -> str:
|
||||
return f"{user} {action} {extra}"
|
||||
|
||||
with kanta.transaction(action="update", user="alice", extra="Project X") as data:
|
||||
...
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
- Migration source is configured on `Kanta(...)` via `migrations=`.
|
||||
|
||||
+4
-68
@@ -61,9 +61,6 @@ class InjectionContext:
|
||||
previous_state: dict | None = None
|
||||
current_state: dict | None = None
|
||||
migration_result: MigrationResult | None = None
|
||||
action: str | None = None
|
||||
user: str | None = None
|
||||
extra: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -104,7 +101,6 @@ class CallbackRegistry:
|
||||
"bootstrap": [],
|
||||
"fatal_error": [],
|
||||
"logmigr": [],
|
||||
"logheader": [],
|
||||
}
|
||||
self._logfmt_callbacks: list[_LogFmtFunctionSpec | _LogFmtClassSpec] = []
|
||||
|
||||
@@ -135,18 +131,6 @@ class CallbackRegistry:
|
||||
if not callable(callback):
|
||||
raise TypeError(f"{kind} callback must be callable")
|
||||
|
||||
if kind == "logheader":
|
||||
if inspect.iscoroutinefunction(callback):
|
||||
raise TypeError("logheader callbacks must not be async")
|
||||
return_ann = inspect.signature(callback).return_annotation
|
||||
if return_ann is not inspect.Signature.empty:
|
||||
resolved = self._resolve_raw_annotation(return_ann, callback)
|
||||
if not self._is_optional_str(resolved):
|
||||
raise TypeError(
|
||||
f"logheader callback {callback.__name__} must return "
|
||||
f"str | None, got {resolved!r}"
|
||||
)
|
||||
|
||||
params = self._validate_function(callback, kind)
|
||||
is_async = inspect.iscoroutinefunction(callback)
|
||||
|
||||
@@ -233,26 +217,6 @@ class CallbackRegistry:
|
||||
|
||||
return format_value
|
||||
|
||||
def resolve_logheader(self, ctx: InjectionContext) -> str | None:
|
||||
"""Invoke logheader callbacks; the first non-None result wins."""
|
||||
for reg in self._callbacks["logheader"]:
|
||||
kwargs = self._build_kwargs(reg.params, ctx)
|
||||
result = reg.callback(**kwargs)
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _logheader_param_annotation(name: str, ann: Any) -> Any:
|
||||
"""Map logheader parameter names to their injection sentinels."""
|
||||
if CallbackRegistry._unwrap_optional(ann) is not str:
|
||||
return None
|
||||
return {
|
||||
"action": _HeaderAction,
|
||||
"user": _HeaderUser,
|
||||
"extra": _HeaderExtra,
|
||||
}.get(name)
|
||||
|
||||
def _validate_function(
|
||||
self,
|
||||
callback: Callable[..., Any],
|
||||
@@ -276,11 +240,6 @@ class CallbackRegistry:
|
||||
continue
|
||||
|
||||
ann = self._resolve_raw_annotation(param.annotation, callback)
|
||||
if kind == "logheader":
|
||||
header_ann = self._logheader_param_annotation(name, ann)
|
||||
if header_ann is not None:
|
||||
params.append((name, header_ann))
|
||||
continue
|
||||
if not self._is_allowed(kind, ann):
|
||||
if param.default is inspect.Parameter.empty:
|
||||
raise TypeError(
|
||||
@@ -485,9 +444,9 @@ class CallbackRegistry:
|
||||
def _is_allowed(self, kind: str, ann: Any) -> bool:
|
||||
bare = self._unwrap_optional(ann)
|
||||
if self._matches_state_annotation(bare, "pre"):
|
||||
return kind in {"logfmt", "logheader"}
|
||||
return kind == "logfmt"
|
||||
if self._matches_state_annotation(bare, "post"):
|
||||
return kind in {"logfmt", "logheader"}
|
||||
return kind == "logfmt"
|
||||
if bare is DatabaseError:
|
||||
return kind == "fatal_error"
|
||||
if bare is MigrationResult:
|
||||
@@ -500,7 +459,6 @@ class CallbackRegistry:
|
||||
"fatal_error",
|
||||
"logfmt",
|
||||
"logmigr",
|
||||
"logheader",
|
||||
}
|
||||
return False
|
||||
|
||||
@@ -509,29 +467,19 @@ class CallbackRegistry:
|
||||
if kind == "bootstrap":
|
||||
if self._data_type is not None:
|
||||
parts.append(self._data_type.__name__)
|
||||
if kind in {"bootstrap", "fatal_error", "logfmt", "logmigr", "logheader"}:
|
||||
if kind in {"bootstrap", "fatal_error", "logfmt", "logmigr"}:
|
||||
if self._kanta_class is not None:
|
||||
parts.append(self._kanta_class.__name__)
|
||||
if kind == "fatal_error":
|
||||
parts.append("DatabaseError")
|
||||
if kind == "logmigr":
|
||||
parts.append("MigrationResult")
|
||||
if kind == "logheader":
|
||||
parts.append("action: str")
|
||||
parts.append("user: str | None")
|
||||
parts.append("extra: str | None")
|
||||
if kind in {"logfmt", "logheader"}:
|
||||
if kind == "logfmt":
|
||||
parts.append("Annotated[dict, 'pre']")
|
||||
parts.append("Annotated[dict, 'post']")
|
||||
return ", ".join(parts) if parts else "none"
|
||||
|
||||
def _resolve_annotation(self, ann: Any, ctx: InjectionContext) -> Any:
|
||||
if ann is _HeaderAction:
|
||||
return ctx.action
|
||||
if ann is _HeaderUser:
|
||||
return ctx.user
|
||||
if ann is _HeaderExtra:
|
||||
return ctx.extra
|
||||
bare = self._unwrap_optional(ann)
|
||||
if self._matches_state_annotation(bare, "pre"):
|
||||
return ctx.previous_state
|
||||
@@ -589,18 +537,6 @@ class CallbackRegistry:
|
||||
return type(None) in args and any(arg is str for arg in args)
|
||||
|
||||
|
||||
class _HeaderAction:
|
||||
"""Sentinel annotation injecting the transaction action."""
|
||||
|
||||
|
||||
class _HeaderUser:
|
||||
"""Sentinel annotation injecting the transaction user."""
|
||||
|
||||
|
||||
class _HeaderExtra:
|
||||
"""Sentinel annotation injecting the display-only extra metadata."""
|
||||
|
||||
|
||||
class _Unresolved:
|
||||
pass
|
||||
|
||||
|
||||
+2
-28
@@ -313,31 +313,6 @@ class Kanta(Generic[T]):
|
||||
return _register
|
||||
return _register(fn)
|
||||
|
||||
def logheader(self, fn=None):
|
||||
"""Register a transaction log header callback.
|
||||
|
||||
Can be used as ``@kanta.logheader``. The callback composes the header
|
||||
line printed before a transaction diff from the parts it declares:
|
||||
``action: str``, ``user: str`` and ``extra: str``. Kanta applies its
|
||||
header colors to the parts before calling the callback, and a missing
|
||||
``user``/``extra`` is passed as an empty string, so callbacks only
|
||||
arrange text — no color codes, fallbacks, or padding. ``DictPre``/
|
||||
``DictPost`` state dicts and the ``Kanta`` instance can also be
|
||||
injected. The callback must be synchronous and return ``str`` (or
|
||||
``None`` to fall through to the next callback, then to the default
|
||||
header).
|
||||
|
||||
If registered, this replaces the default ``action by user`` header.
|
||||
"""
|
||||
|
||||
def _register(callback):
|
||||
self._impl.add_logheader(callback)
|
||||
return callback
|
||||
|
||||
if fn is None:
|
||||
return _register
|
||||
return _register(fn)
|
||||
|
||||
def transaction(
|
||||
self,
|
||||
action: str,
|
||||
@@ -355,9 +330,8 @@ class Kanta(Generic[T]):
|
||||
the log header. Register a ``@kanta.logfmt`` callback to format
|
||||
the user value; the path ``"$user"`` is passed for this case.
|
||||
extra: Optional display-only string appended after the action in
|
||||
the log header (colored by Kanta), or passed to a registered
|
||||
``@kanta.logheader`` callback. It is never persisted in the
|
||||
change record.
|
||||
the log header (colored by Kanta). It is never persisted in
|
||||
the change record.
|
||||
mtime: Controls the modification time ``m``. ``True`` (default)
|
||||
sets ``m`` to the current UTC time. ``False`` omits ``m`` so the
|
||||
previous modification time remains in effect; this is used for
|
||||
|
||||
+1
-48
@@ -6,20 +6,13 @@ import asyncio
|
||||
import copy
|
||||
import importlib
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from kanta.callbacks import CallbackRegistry, InjectionContext
|
||||
from kanta.exceptions import DatabaseError, DataIntegrityError, ReplayError
|
||||
from kanta.logging import (
|
||||
_USER_PATH,
|
||||
bootstrap_logger,
|
||||
colorize_header_parts,
|
||||
log_change,
|
||||
migration_logger,
|
||||
)
|
||||
from kanta.logging import _USER_PATH, bootstrap_logger, log_change, migration_logger
|
||||
from kanta.migrations import MigrationResult, Migrations
|
||||
from kanta.persistence import PersistenceMixin
|
||||
from kanta.serialization import restore_data_in_place, struct_to_dict
|
||||
@@ -87,42 +80,6 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
||||
"""Register one migration logging callback."""
|
||||
self.callback_registry.register("logmigr", callback)
|
||||
|
||||
def add_logheader(self, callback) -> None:
|
||||
"""Register one transaction header formatting callback."""
|
||||
self.callback_registry.register("logheader", callback)
|
||||
|
||||
def build_headerfmt(
|
||||
self,
|
||||
action: str,
|
||||
user: str | None,
|
||||
extra: str | None,
|
||||
previous: dict | None,
|
||||
current: dict | None,
|
||||
) -> Callable[..., str | None] | None:
|
||||
"""Build a headerfmt callable for ``log_change``.
|
||||
|
||||
The logheader callbacks receive the header parts with Kanta's colors
|
||||
already applied (missing user/extra as empty strings). Returns
|
||||
``None`` when no logheader callback is registered.
|
||||
"""
|
||||
if not self.callback_registry.has("logheader"):
|
||||
return None
|
||||
action_str, user_str, extra_str = colorize_header_parts(action, user, extra)
|
||||
ctx = InjectionContext(
|
||||
action=action_str,
|
||||
user=user_str,
|
||||
extra=extra_str,
|
||||
previous_state=previous,
|
||||
current_state=current,
|
||||
kanta=self._kanta,
|
||||
)
|
||||
registry = self.callback_registry
|
||||
|
||||
def headerfmt(action: str, user: str, extra: str) -> str | None:
|
||||
return registry.resolve_logheader(ctx)
|
||||
|
||||
return headerfmt
|
||||
|
||||
async def _handle_migration_log(
|
||||
self,
|
||||
migration_result: MigrationResult,
|
||||
@@ -352,16 +309,12 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
||||
resolved = logfmt(formatted_user, _USER_PATH)
|
||||
if resolved is not None:
|
||||
formatted_user = resolved
|
||||
headerfmt = self.build_headerfmt(
|
||||
self.bootstrap_action, formatted_user, None, {}, current
|
||||
)
|
||||
log_change(
|
||||
self.bootstrap_action,
|
||||
record.diff,
|
||||
formatted_user,
|
||||
previous={},
|
||||
logfmt=logfmt,
|
||||
headerfmt=headerfmt,
|
||||
logger=logger,
|
||||
level=logging.INFO,
|
||||
)
|
||||
|
||||
+2
-9
@@ -303,7 +303,6 @@ def log_change(
|
||||
previous: dict | None = None,
|
||||
extra: str | None = None,
|
||||
logfmt: Callable[[Any, str], str | None] | None = None,
|
||||
headerfmt: Callable[[str, str, str], str | None] | None = None,
|
||||
*,
|
||||
logger: logging.Logger = transaction_logger,
|
||||
level: int = logging.INFO,
|
||||
@@ -318,11 +317,8 @@ def log_change(
|
||||
user: Optional already-formatted user name to show in the header.
|
||||
previous: The previous state dict (for determining add vs update).
|
||||
extra: Optional display-only string appended after the action in the
|
||||
default header (colored by Kanta), or passed to ``headerfmt``.
|
||||
header (colored by Kanta).
|
||||
logfmt: Optional formatter callable ``(value, path) -> str | None``.
|
||||
headerfmt: Optional header formatter callable receiving the
|
||||
pre-colored ``(action, user, extra)`` parts and returning the
|
||||
header line. Returning ``None`` falls back to the default header.
|
||||
logger: Logger to write to. Defaults to the ``kanta.transaction`` logger.
|
||||
level: Log level to use. Defaults to ``logging.INFO``.
|
||||
log_header: Whether to emit the header line.
|
||||
@@ -330,10 +326,7 @@ def log_change(
|
||||
"""
|
||||
header: str | None = None
|
||||
if log_header:
|
||||
if headerfmt is not None:
|
||||
header = headerfmt(action, user, extra)
|
||||
if header is None:
|
||||
header = format_action_header(action, user, extra)
|
||||
header = format_action_header(action, user, extra)
|
||||
|
||||
diff_lines = format_diff(diff, previous, logfmt) if log_diff else []
|
||||
|
||||
|
||||
@@ -94,9 +94,6 @@ def transaction(
|
||||
if isinstance(log, logging.Logger)
|
||||
else transaction_logger
|
||||
)
|
||||
headerfmt = impl.build_headerfmt(
|
||||
action, formatted_user, extra, previous, new_dict
|
||||
)
|
||||
log_change(
|
||||
action,
|
||||
record.diff,
|
||||
@@ -104,7 +101,6 @@ def transaction(
|
||||
previous,
|
||||
extra=extra,
|
||||
logfmt=logfmt,
|
||||
headerfmt=headerfmt,
|
||||
logger=logger,
|
||||
log_header=log_header,
|
||||
log_diff=log_diff,
|
||||
|
||||
@@ -80,30 +80,6 @@ def test_log_change_appends_extra_string(capsys):
|
||||
assert f"{_TARGET}mydb.db{_RESET}" in captured.err
|
||||
|
||||
|
||||
def test_log_change_headerfmt_replaces_header(capsys):
|
||||
kanta_logger = logging.getLogger("kanta")
|
||||
kanta_logger.handlers.clear()
|
||||
configure_logging()
|
||||
log_change(
|
||||
"update",
|
||||
{},
|
||||
headerfmt=lambda action, user, extra: f"CUSTOM {action} {extra}",
|
||||
extra="7",
|
||||
)
|
||||
captured = capsys.readouterr()
|
||||
assert "CUSTOM update 7" in captured.err
|
||||
|
||||
|
||||
def test_log_change_headerfmt_none_falls_back(capsys):
|
||||
kanta_logger = logging.getLogger("kanta")
|
||||
kanta_logger.handlers.clear()
|
||||
configure_logging()
|
||||
log_change("update", {}, user="alice", headerfmt=lambda *args: None)
|
||||
captured = capsys.readouterr()
|
||||
assert "update" in captured.err
|
||||
assert "alice" in captured.err
|
||||
|
||||
|
||||
def test_log_change_log_diff_false(capsys):
|
||||
kanta_logger = logging.getLogger("kanta")
|
||||
kanta_logger.handlers.clear()
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from kanta import Kanta
|
||||
from kanta.callbacks import DictPost, DictPre
|
||||
from kanta.logging import _ACTION, _RESET, _TARGET, _USER, configure_logging
|
||||
|
||||
from .support import Data, make_kanta, read_changes
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_kanta_loggers():
|
||||
yield
|
||||
for name in ("kanta", "kanta.transaction", "kanta.bootstrap", "kanta.migration"):
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(logging.NOTSET)
|
||||
logger.propagate = True
|
||||
logger.handlers.clear()
|
||||
|
||||
|
||||
def test_logheader_rejects_async(tmp_path, format_config):
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
with pytest.raises(TypeError, match="must not be async"):
|
||||
|
||||
@kanta.logheader
|
||||
async def header(action: str) -> str:
|
||||
return action
|
||||
|
||||
|
||||
def test_logheader_rejects_bad_return_annotation(tmp_path, format_config):
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
with pytest.raises(TypeError, match="must return"):
|
||||
|
||||
@kanta.logheader
|
||||
def header(action: str) -> int:
|
||||
return 1
|
||||
|
||||
|
||||
def test_logheader_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.logheader
|
||||
def header(action: str, bogus: int) -> str:
|
||||
return action
|
||||
|
||||
|
||||
def test_logheader_rejects_dict_extra(tmp_path, format_config):
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
with pytest.raises(TypeError, match="unsupported annotation"):
|
||||
|
||||
@kanta.logheader
|
||||
def header(action: str, extra: dict) -> str:
|
||||
return action
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logheader_replaces_default_header(tmp_path, format_config, caplog):
|
||||
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
@kanta.logheader
|
||||
def header(action: str, user: str, extra: str) -> str:
|
||||
return f"{user} {action} {extra}"
|
||||
|
||||
await kanta.open(log=False)
|
||||
with kanta.transaction(action="update", user="alice", extra="tgt") as data:
|
||||
data.counter = 1
|
||||
await kanta.close()
|
||||
|
||||
# caplog strips ANSI codes; the colors are verified via capsys below.
|
||||
assert "alice update tgt" in caplog.text
|
||||
# The diff body is still logged after the custom header.
|
||||
assert "counter" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logheader_parts_are_colored_by_kanta(tmp_path, format_config, capsys):
|
||||
logging.getLogger("kanta").handlers.clear()
|
||||
configure_logging()
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
@kanta.logheader
|
||||
def header(action: str, user: str, extra: str) -> str:
|
||||
return f"{user} {action} {extra}"
|
||||
|
||||
await kanta.open(log=False)
|
||||
with kanta.transaction(action="update", user="alice", extra="tgt") as data:
|
||||
data.counter = 1
|
||||
await kanta.close()
|
||||
|
||||
err = capsys.readouterr().err
|
||||
assert f"{_USER}alice{_RESET}" in err
|
||||
assert f"{_ACTION}update{_RESET}" in err
|
||||
assert f"{_TARGET}tgt{_RESET}" in err
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logheader_missing_user_and_extra_are_empty(
|
||||
tmp_path, format_config, caplog
|
||||
):
|
||||
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
@kanta.logheader
|
||||
def header(action: str, user: str, extra: str) -> str:
|
||||
return f"<{user}><{extra}>"
|
||||
|
||||
await kanta.open(log=False)
|
||||
with kanta.transaction(action="update") as data:
|
||||
data.counter = 1
|
||||
await kanta.close()
|
||||
|
||||
assert "<><>" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logheader_injects_states_and_kanta(tmp_path, format_config, caplog):
|
||||
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
@kanta.logheader
|
||||
def header(
|
||||
action: str,
|
||||
previous: DictPre,
|
||||
current: DictPost,
|
||||
kanta: Kanta,
|
||||
) -> str:
|
||||
return (
|
||||
f"counter {previous.get('counter')}"
|
||||
f" -> {current.get('counter')} db={kanta.filename.name}"
|
||||
)
|
||||
|
||||
await kanta.open(log=False)
|
||||
with kanta.transaction(action="increment") as data:
|
||||
data.counter = 5
|
||||
await kanta.close()
|
||||
|
||||
assert "counter 0 -> 5 db=test.db" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logheader_chain_first_non_none_wins(tmp_path, format_config, caplog):
|
||||
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
@kanta.logheader
|
||||
def first(action: str) -> str | None:
|
||||
return None
|
||||
|
||||
@kanta.logheader
|
||||
def second(action: str, extra: str) -> str:
|
||||
return f"SECOND {extra}"
|
||||
|
||||
await kanta.open(log=False)
|
||||
with kanta.transaction(action="update", extra="marked") as data:
|
||||
data.counter = 1
|
||||
await kanta.close()
|
||||
|
||||
assert "SECOND marked" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logheader_all_none_falls_back_to_default(
|
||||
tmp_path, format_config, caplog
|
||||
):
|
||||
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
@kanta.logheader
|
||||
def header(action: str) -> str | None:
|
||||
return None
|
||||
|
||||
await kanta.open(log=False)
|
||||
with kanta.transaction(action="update", user="alice") as data:
|
||||
data.counter = 1
|
||||
await kanta.close()
|
||||
|
||||
assert "update" in caplog.text
|
||||
assert "alice" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logheader_receives_formatted_user(tmp_path, format_config, caplog):
|
||||
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
@kanta.logfmt(path="$user")
|
||||
def resolve_user(value: str) -> str | None:
|
||||
return "Alice"
|
||||
|
||||
@kanta.logheader
|
||||
def header(action: str, user: str) -> str:
|
||||
return f"actor={user}"
|
||||
|
||||
await kanta.open(log=False)
|
||||
with kanta.transaction(action="update", user="uuid-1") as data:
|
||||
data.counter = 1
|
||||
await kanta.close()
|
||||
|
||||
assert "actor=Alice" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logheader_applies_to_bootstrap(tmp_path, format_config, caplog):
|
||||
caplog.set_level(logging.INFO, logger="kanta.bootstrap")
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
@kanta.logheader
|
||||
def header(action: str) -> str:
|
||||
return f"BOOT {action}"
|
||||
|
||||
await kanta.open()
|
||||
await kanta.close()
|
||||
|
||||
assert "BOOT bootstrap" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transaction_extra_string_in_default_header(
|
||||
tmp_path, format_config, caplog
|
||||
):
|
||||
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
await kanta.open(log=False)
|
||||
with kanta.transaction(action="export", extra="mydb.db") as data:
|
||||
data.counter = 1
|
||||
await kanta.close()
|
||||
|
||||
assert "export" in caplog.text
|
||||
assert "mydb.db" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extra_is_not_persisted(tmp_path, format_config):
|
||||
path = tmp_path / "test.db"
|
||||
kanta = make_kanta(path, Data, format_config)
|
||||
|
||||
await kanta.open(log=False)
|
||||
with kanta.transaction(action="update", user="alice", extra="session-3") as data:
|
||||
data.counter = 1
|
||||
await kanta.close()
|
||||
|
||||
record = read_changes(path, format_config)[-1]
|
||||
assert record.a == "update"
|
||||
assert record.u == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transaction_log_dict_toggles(tmp_path, format_config, caplog):
|
||||
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||
|
||||
await kanta.open(log=False)
|
||||
with kanta.transaction(
|
||||
action="myaction", log={"header": True, "diff": False}
|
||||
) as data:
|
||||
data.counter = 1
|
||||
with kanta.transaction(
|
||||
action="otheraction", log={"header": False, "diff": True}
|
||||
) as data:
|
||||
data.counter = 2
|
||||
await kanta.close()
|
||||
|
||||
# First transaction: header only.
|
||||
assert "myaction" in caplog.text
|
||||
# Second transaction: diff only, no header.
|
||||
assert "otheraction" not in caplog.text
|
||||
assert "counter" in caplog.text
|
||||
Reference in New Issue
Block a user