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:
+1
-1
@@ -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:
|
||||
|
||||
+4
-2
@@ -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 `<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
|
||||
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
|
||||
|
||||
+5
-4
@@ -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 <logger>.diff child logger so
|
||||
|
||||
+26
-13
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user