Unify default emitter around universal LogEvent.header

All event kinds now share one shape: a one-line header plus an optional
diff body for changes. LogEvent.header is a lazy property covering every
kind (change, aborted, created, migrated), and default_emit reduces to
logging the header plus routing diff_lines to the .diff child logger.
Custom emitters can tap the same blocks - header, diff_lines, Line,
format_diff - instead of reimplementing formatting per message type.
This commit is contained in:
Leo Vasanko
2026-08-07 05:52:26 +00:00
parent cc319ce065
commit c90db530fd
4 changed files with 71 additions and 38 deletions
+7 -7
View File
@@ -73,13 +73,6 @@ async def main() -> None:
) as data:
data.counter = 2
try:
with kanta.transaction(action="reset", user="foo") as data:
data.counter = 99
raise ValueError("simulated failure")
except ValueError:
print(f"# Reading does not need transaction: {data.counter=}", flush=True)
print(
"\n# A later version of our application with new data model, migrations and logfmt"
)
@@ -89,6 +82,13 @@ async def main() -> None:
) as data:
data.total += 1
try:
with kanta.transaction(action="reset", user="userid001") as data:
data.total = 99
raise ValueError("simulated failure")
except ValueError:
print(f"# Reading does not need transaction: {data.total=}", flush=True)
with kanta.transaction(action="delete", user="userid002") as data:
del data.users["userid001"]
+10 -4
View File
@@ -234,10 +234,16 @@ def resolve_user_key(value: str) -> str | None:
`"migrated"`, `"aborted"`), the preferred `logger` and `level`, and all
relevant state: `action`, `user`, `extra`, `error` (for aborted
transactions), `diff`, `previous`/`current` state dicts, the built `logfmt`
chain, and version info for migration events. The default `"aborted"`
rendering is `<action>[ by <user>] transaction aborted: <error>` with the
action and user colored and the user resolved via `logfmt`. Pretty
`header` and `diff_lines` are lazy properties, built only if accessed.
chain, and version info for migration events.
- The built-in formatting is assembled from standard blocks that custom
emitters can reuse as-is or replace piecemeal:
- `event.header` — a lazy property producing the default one-line header
for any kind: `<action>[ <extra>][ by <user>]` for changes,
`<action>[ by <user>] transaction aborted: <error>` for aborts, and the
plain `Created`/`Migrated` summaries.
- `event.diff_lines` — a lazy property producing the pretty diff body for
change events (built only if accessed).
- `default_emit` itself is just `header` plus the `diff_lines` routing.
- `@kanta.logemit` registers a callback receiving the event. The callback
decides what is logged and where: it may log one or more messages on
`event.logger`, log somewhere else, or nothing at all. A falsy return
+29 -27
View File
@@ -70,13 +70,33 @@ class LogEvent(msgspec.Struct, kw_only=True):
@property
def header(self) -> str:
"""The default header line (colored), built on first access."""
"""The default one-line header for this event, built on first access.
Covers every event kind: ``"<action>[ <extra>][ by <user>]"`` for
changes, ``"<action>[ by <user>] transaction aborted: <error>"`` for
aborts, and the plain ``Created``/``Migrated`` summaries.
"""
if self._header is None:
self._header = format_action_header(
self.action or "", self.user, self.extra
)
self._header = self._build_header()
return self._header
def _build_header(self) -> str:
if self.kind == "created":
return f"Created {self.filename}"
if self.kind == "migrated":
migrations = ", ".join(self.migrations)
return (
f"Migrated {self.filename} "
f"v{self.from_version} -> v{self.to_version}: {migrations}"
)
if self.kind == "change":
return format_action_header(self.action or "", self.user, self.extra)
line = Line().action(self.action or "")
if self.user:
line(" by ").user(self.user)
line(f" transaction aborted: {self.error}")
return str(line)
@property
def diff_lines(self) -> list[str]:
"""Pretty-printed diff lines, built on first access and cached."""
@@ -118,34 +138,16 @@ def emit_event(
def default_emit(ev: LogEvent) -> None:
"""Emit *ev* with Kanta's built-in formatting.
Logs :attr:`LogEvent.header`; for change events the
:attr:`LogEvent.diff_lines` body follows on the ``<logger>.diff`` child
logger so it can be silenced or routed separately from the headers.
This is what runs when no logemit callback handles the event; custom
callbacks may call it to delegate events they do not care about.
"""
if ev.kind == "created":
ev.logger.log(ev.level, "Created %s", ev.filename)
if ev.kind != "change":
ev.logger.log(ev.level, ev.header)
return
if ev.kind == "migrated":
ev.logger.log(
ev.level,
"Migrated %s v%s -> v%s: %s",
ev.filename,
ev.from_version,
ev.to_version,
", ".join(ev.migrations),
)
return
if ev.kind == "aborted":
line = Line().action(ev.action or "")
if ev.user:
line(" by ").user(ev.user)
line(f" transaction aborted: {ev.error}")
ev.logger.log(ev.level, str(line))
return
# kind == "change": diff lines go to the <logger>.diff child logger so
# they can be silenced or routed separately from the headers.
diff_logger = logging.getLogger(f"{ev.logger.name}.diff")
lines = ev.diff_lines if ev.show_diff and diff_logger.isEnabledFor(ev.level) else []
+25
View File
@@ -290,3 +290,28 @@ async def test_aborted_transaction_includes_resolved_user(
messages = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
assert any(" by " in m and "Alice" in m for m in messages)
assert any(" transaction aborted: boom" in m for m in messages)
def test_event_header_covers_all_kinds():
created = LogEvent(kind="created", logger=transaction_logger, filename="x.db")
assert created.header == "Created x.db"
migrated = LogEvent(
kind="migrated",
logger=transaction_logger,
filename="x.db",
from_version=0,
to_version=1,
migrations=["migrate_v1 (rename)"],
)
assert migrated.header == "Migrated x.db v0 -> v1: migrate_v1 (rename)"
aborted = LogEvent(
kind="aborted",
logger=transaction_logger,
action="reset",
user="alice",
error=ValueError("boom"),
)
assert "transaction aborted: boom" in aborted.header
assert "alice" in aborted.header