From cc319ce0656e3407155058659025feb81c7e880b Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 7 Aug 2026 05:45:31 +0000 Subject: [PATCH] Include 'by user' in abort messages The aborted event now carries the transaction user, resolved through the logfmt chain against the pre-transaction state so it matches change headers. Default rendering: ' by transaction aborted: '. User resolution factored into _build_logfmt/_resolve_user helpers shared by the change and abort paths. --- demo/main.py | 2 +- docs/database.md | 6 ++++-- kanta/logging.py | 9 +++++---- kanta/transaction.py | 39 ++++++++++++++++++++++++++------------- tests/test_logemit.py | 24 ++++++++++++++++++++++++ 5 files changed, 60 insertions(+), 20 deletions(-) diff --git a/demo/main.py b/demo/main.py index 09ec190..3f88215 100644 --- a/demo/main.py +++ b/demo/main.py @@ -74,7 +74,7 @@ async def main() -> None: data.counter = 2 try: - with kanta.transaction(action="reset") as data: + with kanta.transaction(action="reset", user="foo") as data: data.counter = 99 raise ValueError("simulated failure") except ValueError: diff --git a/docs/database.md b/docs/database.md index 5464ab0..84eac47 100644 --- a/docs/database.md +++ b/docs/database.md @@ -234,8 +234,10 @@ 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. Pretty `header` and - `diff_lines` are lazy properties, built only if accessed. + chain, and version info for migration events. The default `"aborted"` + rendering is `[ by ] transaction aborted: ` with the + action and user colored and the user resolved via `logfmt`. Pretty + `header` and `diff_lines` are lazy properties, built only if accessed. - `@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 diff --git a/kanta/logging.py b/kanta/logging.py index 80e0bf2..592bf4d 100644 --- a/kanta/logging.py +++ b/kanta/logging.py @@ -137,10 +137,11 @@ def default_emit(ev: LogEvent) -> None: return if ev.kind == "aborted": - message = str( - Line().action(ev.action or "")(f" transaction aborted: {ev.error}") - ) - ev.logger.log(ev.level, message) + 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 .diff child logger so diff --git a/kanta/transaction.py b/kanta/transaction.py index 3c9bbfd..5dd8509 100644 --- a/kanta/transaction.py +++ b/kanta/transaction.py @@ -15,6 +15,25 @@ from kanta.serialization import restore_data_in_place, struct_to_dict _logger = logging.getLogger(__name__) +def _build_logfmt(impl, previous: dict, current: dict): + """Build the logfmt chain for a state transition.""" + return impl.callback_registry.build_logfmt( + InjectionContext( + previous_state=previous, + current_state=current, + kanta=impl._kanta, + ) + ) + + +def _resolve_user(logfmt, user: str | None) -> str | None: + """Resolve *user* for display via the logfmt chain (raw as fallback).""" + if user is None: + return None + resolved = logfmt(user, _USER_PATH) + return resolved if resolved is not None else user + + @contextmanager def transaction( impl, @@ -71,18 +90,7 @@ def transaction( previous = impl.statedict record = impl.queue_change(action, new_dict, user=user, mtime=mtime) if record is not None: - logfmt = impl.callback_registry.build_logfmt( - InjectionContext( - previous_state=previous, - current_state=new_dict, - kanta=impl._kanta, - ) - ) - formatted_user = user - if user is not None and logfmt is not None: - resolved = logfmt(user, _USER_PATH) - if resolved is not None: - formatted_user = resolved + logfmt = _build_logfmt(impl, previous, new_dict) if log is not False: logger = ( log if isinstance(log, logging.Logger) else transaction_logger @@ -92,7 +100,7 @@ def transaction( kind="change", logger=logger, action=action, - user=formatted_user, + user=_resolve_user(logfmt, user), extra=extra, diff=record.diff, previous=previous, @@ -103,12 +111,17 @@ def transaction( impl.callback_registry.logemit_handlers, ) except Exception as exc: + resolved_user = None + if user is not None: + logfmt = _build_logfmt(impl, impl.statedict, impl.statedict) + resolved_user = _resolve_user(logfmt, user) emit_event( LogEvent( kind="aborted", logger=transaction_logger, level=logging.WARNING, action=action, + user=resolved_user, error=exc, ), impl.callback_registry.logemit_handlers, diff --git a/tests/test_logemit.py b/tests/test_logemit.py index 5949242..6e21f6f 100644 --- a/tests/test_logemit.py +++ b/tests/test_logemit.py @@ -266,3 +266,27 @@ async def test_aborted_transaction_emits_event(tmp_path, format_config, caplog): assert any("\x1b[1;34mreset" in m for m in messages) # action color, no quotes assert any(" transaction aborted: simulated failure" in m for m in messages) assert kanta.data.counter == 0 # rolled back + + +@pytest.mark.asyncio +async def test_aborted_transaction_includes_resolved_user( + tmp_path, format_config, caplog +): + path = tmp_path / "test.db" + kanta = make_kanta(path, Data, format_config) + + @kanta.logfmt + def resolve(value: str, path: str) -> str | None: + return "Alice" if value == "u1" else None + + await kanta.open() + with caplog.at_level(logging.WARNING, logger="kanta.transaction"): + with pytest.raises(ValueError): + with kanta.transaction(action="reset", user="u1") as data: + data.counter = 99 + raise ValueError("boom") + await kanta.close() + + 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)