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: '<action> by <user> transaction aborted: <e>'.
User resolution factored into _build_logfmt/_resolve_user helpers shared
by the change and abort paths.
This commit is contained in:
Leo Vasanko
2026-08-07 05:45:31 +00:00
parent 79faa220df
commit cc319ce065
5 changed files with 60 additions and 20 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ async def main() -> None:
data.counter = 2 data.counter = 2
try: try:
with kanta.transaction(action="reset") as data: with kanta.transaction(action="reset", user="foo") as data:
data.counter = 99 data.counter = 99
raise ValueError("simulated failure") raise ValueError("simulated failure")
except ValueError: except ValueError:
+4 -2
View File
@@ -234,8 +234,10 @@ def resolve_user_key(value: str) -> str | None:
`"migrated"`, `"aborted"`), the preferred `logger` and `level`, and all `"migrated"`, `"aborted"`), the preferred `logger` and `level`, and all
relevant state: `action`, `user`, `extra`, `error` (for aborted relevant state: `action`, `user`, `extra`, `error` (for aborted
transactions), `diff`, `previous`/`current` state dicts, the built `logfmt` transactions), `diff`, `previous`/`current` state dicts, the built `logfmt`
chain, and version info for migration events. Pretty `header` and chain, and version info for migration events. The default `"aborted"`
`diff_lines` are lazy properties, built only if accessed. 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.
- `@kanta.logemit` registers a callback receiving the event. The callback - `@kanta.logemit` registers a callback receiving the event. The callback
decides what is logged and where: it may log one or more messages on 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 `event.logger`, log somewhere else, or nothing at all. A falsy return
+5 -4
View File
@@ -137,10 +137,11 @@ def default_emit(ev: LogEvent) -> None:
return return
if ev.kind == "aborted": if ev.kind == "aborted":
message = str( line = Line().action(ev.action or "")
Line().action(ev.action or "")(f" transaction aborted: {ev.error}") if ev.user:
) line(" by ").user(ev.user)
ev.logger.log(ev.level, message) line(f" transaction aborted: {ev.error}")
ev.logger.log(ev.level, str(line))
return return
# kind == "change": diff lines go to the <logger>.diff child logger so # kind == "change": diff lines go to the <logger>.diff child logger so
+26 -13
View File
@@ -15,6 +15,25 @@ from kanta.serialization import restore_data_in_place, struct_to_dict
_logger = logging.getLogger(__name__) _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 @contextmanager
def transaction( def transaction(
impl, impl,
@@ -71,18 +90,7 @@ def transaction(
previous = impl.statedict previous = impl.statedict
record = impl.queue_change(action, new_dict, user=user, mtime=mtime) record = impl.queue_change(action, new_dict, user=user, mtime=mtime)
if record is not None: if record is not None:
logfmt = impl.callback_registry.build_logfmt( logfmt = _build_logfmt(impl, previous, new_dict)
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
if log is not False: if log is not False:
logger = ( logger = (
log if isinstance(log, logging.Logger) else transaction_logger log if isinstance(log, logging.Logger) else transaction_logger
@@ -92,7 +100,7 @@ def transaction(
kind="change", kind="change",
logger=logger, logger=logger,
action=action, action=action,
user=formatted_user, user=_resolve_user(logfmt, user),
extra=extra, extra=extra,
diff=record.diff, diff=record.diff,
previous=previous, previous=previous,
@@ -103,12 +111,17 @@ def transaction(
impl.callback_registry.logemit_handlers, impl.callback_registry.logemit_handlers,
) )
except Exception as exc: 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( emit_event(
LogEvent( LogEvent(
kind="aborted", kind="aborted",
logger=transaction_logger, logger=transaction_logger,
level=logging.WARNING, level=logging.WARNING,
action=action, action=action,
user=resolved_user,
error=exc, error=exc,
), ),
impl.callback_registry.logemit_handlers, impl.callback_registry.logemit_handlers,
+24
View File
@@ -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("\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 any(" transaction aborted: simulated failure" in m for m in messages)
assert kanta.data.counter == 0 # rolled back 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)