diff --git a/demo/main.py b/demo/main.py index 3f88215..1bdb436 100644 --- a/demo/main.py +++ b/demo/main.py @@ -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"] diff --git a/docs/database.md b/docs/database.md index 84eac47..35fe7b3 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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 `[ 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. + 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: `[ ][ by ]` for changes, + `[ by ] transaction aborted: ` 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 diff --git a/kanta/logging.py b/kanta/logging.py index 592bf4d..293484f 100644 --- a/kanta/logging.py +++ b/kanta/logging.py @@ -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: ``"[ ][ by ]"`` for + changes, ``"[ by ] transaction aborted: "`` 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 ``.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 .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 [] diff --git a/tests/test_logemit.py b/tests/test_logemit.py index 6e21f6f..311f39a 100644 --- a/tests/test_logemit.py +++ b/tests/test_logemit.py @@ -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