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
|
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
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user