Add kanta instance to LogEvent and make header settable

Every emitted event now carries the originating Kanta instance so logemit
callbacks can reach application state attached to it. The header property
gains a setter, formalizing restyle-then-delegate: assign ev.header and
return truthy to keep the default diff routing with a custom header.
This commit is contained in:
Leo Vasanko
2026-08-07 05:57:43 +00:00
parent c90db530fd
commit 799b2438be
5 changed files with 52 additions and 5 deletions
+7 -5
View File
@@ -231,16 +231,18 @@ def resolve_user_key(value: str) -> str | None:
mechanism: when no `logemit` callback handles an event, mechanism: when no `logemit` callback handles an event,
`kanta.logging.default_emit` renders it with the built-in formatting. `kanta.logging.default_emit` renders it with the built-in formatting.
- A `LogEvent` carries the event `kind` (`"change"`, `"created"`, - A `LogEvent` carries the event `kind` (`"change"`, `"created"`,
`"migrated"`, `"aborted"`), the preferred `logger` and `level`, and all `"migrated"`, `"aborted"`), the preferred `logger` and `level`, the
relevant state: `action`, `user`, `extra`, `error` (for aborted `kanta` instance, and all relevant state: `action`, `user`, `extra`,
transactions), `diff`, `previous`/`current` state dicts, the built `logfmt` `error` (for aborted transactions), `diff`, `previous`/`current` state
chain, and version info for migration events. dicts, the built `logfmt` chain, and version info for migration events.
- The built-in formatting is assembled from standard blocks that custom - The built-in formatting is assembled from standard blocks that custom
emitters can reuse as-is or replace piecemeal: emitters can reuse as-is or replace piecemeal:
- `event.header` — a lazy property producing the default one-line header - `event.header` — a lazy property producing the default one-line header
for any kind: `<action>[ <extra>][ by <user>]` for changes, for any kind: `<action>[ <extra>][ by <user>]` for changes,
`<action>[ by <user>] transaction aborted: <error>` for aborts, and the `<action>[ by <user>] transaction aborted: <error>` for aborts, and the
plain `Created`/`Migrated` summaries. plain `Created`/`Migrated` summaries. It is settable: assign
`event.header = ...` and return truthy to restyle the header while
keeping the default diff routing.
- `event.diff_lines` — a lazy property producing the pretty diff body for - `event.diff_lines` — a lazy property producing the pretty diff body for
change events (built only if accessed). change events (built only if accessed).
- `default_emit` itself is just `header` plus the `diff_lines` routing. - `default_emit` itself is just `header` plus the `diff_lines` routing.
+4
View File
@@ -131,6 +131,7 @@ class KantaImpl(PersistenceMixin, Generic[T]):
kind="change", kind="change",
logger=migration_log, logger=migration_log,
level=logging.DEBUG, level=logging.DEBUG,
kanta=self._kanta,
action=info.name, action=info.name,
diff=info.diff, diff=info.diff,
previous=info.before, previous=info.before,
@@ -143,6 +144,7 @@ class KantaImpl(PersistenceMixin, Generic[T]):
LogEvent( LogEvent(
kind="migrated", kind="migrated",
logger=migration_log, logger=migration_log,
kanta=self._kanta,
filename=str(self.filename), filename=str(self.filename),
from_version=previous_version, from_version=previous_version,
to_version=migration_result.version, to_version=migration_result.version,
@@ -324,6 +326,7 @@ class KantaImpl(PersistenceMixin, Generic[T]):
LogEvent( LogEvent(
kind="created", kind="created",
logger=logger, logger=logger,
kanta=self._kanta,
filename=str(self.filename.resolve()), filename=str(self.filename.resolve()),
), ),
self.callback_registry.logemit_handlers, self.callback_registry.logemit_handlers,
@@ -344,6 +347,7 @@ class KantaImpl(PersistenceMixin, Generic[T]):
LogEvent( LogEvent(
kind="change", kind="change",
logger=logger, logger=logger,
kanta=self._kanta,
action=self.bootstrap_action, action=self.bootstrap_action,
user=formatted_user, user=formatted_user,
diff=record.diff, diff=record.diff,
+10
View File
@@ -52,6 +52,7 @@ class LogEvent(msgspec.Struct, kw_only=True):
kind: str kind: str
logger: logging.Logger logger: logging.Logger
level: int = logging.INFO level: int = logging.INFO
kanta: Any = None
action: str | None = None action: str | None = None
user: str | None = None user: str | None = None
extra: str | None = None extra: str | None = None
@@ -80,6 +81,15 @@ class LogEvent(msgspec.Struct, kw_only=True):
self._header = self._build_header() self._header = self._build_header()
return self._header return self._header
@header.setter
def header(self, value: str) -> None:
"""Override the header, keeping the default diff routing.
A logemit callback can restyle the header and return a truthy value:
:func:`default_emit` then logs this header instead of building one.
"""
self._header = value
def _build_header(self) -> str: def _build_header(self) -> str:
if self.kind == "created": if self.kind == "created":
return f"Created {self.filename}" return f"Created {self.filename}"
+2
View File
@@ -99,6 +99,7 @@ def transaction(
LogEvent( LogEvent(
kind="change", kind="change",
logger=logger, logger=logger,
kanta=impl._kanta,
action=action, action=action,
user=_resolve_user(logfmt, user), user=_resolve_user(logfmt, user),
extra=extra, extra=extra,
@@ -120,6 +121,7 @@ def transaction(
kind="aborted", kind="aborted",
logger=transaction_logger, logger=transaction_logger,
level=logging.WARNING, level=logging.WARNING,
kanta=impl._kanta,
action=action, action=action,
user=resolved_user, user=resolved_user,
error=exc, error=exc,
+29
View File
@@ -315,3 +315,32 @@ def test_event_header_covers_all_kinds():
) )
assert "transaction aborted: boom" in aborted.header assert "transaction aborted: boom" in aborted.header
assert "alice" in aborted.header assert "alice" in aborted.header
@pytest.mark.asyncio
async def test_event_carries_kanta_instance(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
events = []
kanta.logemit(lambda ev: events.append(ev) or True)
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
await kanta.close()
assert events
assert all(ev.kanta is kanta for ev in events)
def test_header_is_settable_and_used_by_default_emit(capsys):
logging.getLogger("kanta").handlers.clear()
configure_logging()
def restyle(ev):
ev.header = f"CUSTOM {ev.action}"
return True
emit_event(_change_event(diff={"counter": 1}, previous={}), [restyle])
err = capsys.readouterr().err
assert "CUSTOM update" in err
assert "counter" in err # default diff routing still applies