Remove logheader callback feature; keep Kanta-colored extra string in default header

This commit is contained in:
Leo Vasanko
2026-08-07 00:44:06 +00:00
parent 1cd99aef01
commit 9e19a1bf21
9 changed files with 29 additions and 497 deletions
+18 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
)
+1 -8
View File
@@ -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,9 +326,6 @@ 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)
diff_lines = format_diff(diff, previous, logfmt) if log_diff else []
-4
View File
@@ -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,
-24
View File
@@ -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()
-275
View File
@@ -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