From f489216c2ad687d12f9e8d80fb37461f227f3f7c Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 7 Aug 2026 17:12:29 +0000 Subject: [PATCH] All remaining events through logformat, cleaner migration message, prettier demo. --- demo/demo.py | 8 +++----- docs/database.md | 12 +++++++----- kanta/kantaimpl.py | 22 ++++++++++++++++------ kanta/logging.py | 19 +++++++++++-------- tests/test_kanta_integration.py | 6 +++--- tests/test_logemit.py | 8 ++++---- 6 files changed, 44 insertions(+), 31 deletions(-) diff --git a/demo/demo.py b/demo/demo.py index cf2a793..f988502 100644 --- a/demo/demo.py +++ b/demo/demo.py @@ -57,7 +57,7 @@ def resolve_user(value: str, path: str, previous: DictPre) -> str | None: async def main() -> None: filename.unlink(missing_ok=True) - print("# Database creation with v0 schema and basic ops, pretty logs") + print("Database creation with v0 schema and basic transactions:\n") # Open and close automatically; you can also `await kanta.open()` instead async with kanta_v0 as kanta: with kanta.transaction(action="create", user="userid001") as data: @@ -73,9 +73,7 @@ async def main() -> None: ) as data: data.counter = 2 - print( - "\n# A later version of our application with new data model, migrations and logfmt" - ) + print("\nA new data model, migrations and logfmt pretty names:\n") async with kanta_v1 as kanta: with kanta.transaction( action="update", user="userid002", extra=filename.name @@ -87,7 +85,7 @@ async def main() -> None: data.total = 99 raise ValueError("simulated failure") except ValueError: - print(f"# Reading does not need transaction: {data.total=}", flush=True) + print(f"\nReset rolled back: {data.total=} (we can always read data without tx)\n") with kanta.transaction(action="delete", user="userid002") as data: del data.users["userid001"] diff --git a/docs/database.md b/docs/database.md index a7ad4b2..3629e40 100644 --- a/docs/database.md +++ b/docs/database.md @@ -227,13 +227,15 @@ def resolve_user_key(value: str) -> str | None: #### Log Emitters - Every change-related message Kanta emits (transaction/bootstrap/migration - changes, `Created `, migration summaries, aborted transactions) is - described by a `kanta.logging.LogEvent` and dispatched through + changes, file created/opened lines, migration summaries, aborted + transactions) is described by a `kanta.logging.LogEvent` and dispatched + through `kanta.logging.emit_event`. Kanta's own output goes through the same mechanism: when no `logemit` callback handles an event, `kanta.logging.default_emit` renders it with the built-in formatting. - A `LogEvent` carries the event `kind` (`"change"`, `"created"`, - `"migrated"`, `"aborted"`), the preferred `logger` and `level`, the + `"opened"`, `"migrated"`, `"aborted"`), the preferred `logger` and `level`, + the `kanta` instance, 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. @@ -245,8 +247,8 @@ def resolve_user_key(value: str) -> str | None: - `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. It is settable: assign + and the `🛢️ created|opened|migrated ...` summaries. It is + settable: assign `event.header = ...` and return truthy to restyle the header while keeping the default diff routing. - `event.diff_lines` — a lazy property producing the pretty diff body for diff --git a/kanta/kantaimpl.py b/kanta/kantaimpl.py index a38e943..bef0ebf 100644 --- a/kanta/kantaimpl.py +++ b/kanta/kantaimpl.py @@ -242,9 +242,18 @@ class KantaImpl(PersistenceMixin, Generic[T]): ) self.version = rr.version self.mtime = rr.m - if log is not False: + if log is not False and not migrations_ran: logger = log if isinstance(log, logging.Logger) else bootstrap_logger - logger.debug("Using %s", self.filename.resolve()) + emit_event( + LogEvent( + kind="opened", + logger=logger, + level=logging.DEBUG, + kanta=self._kanta, + filename=str(self.filename.resolve()), + ), + self.callback_registry.logemit_handlers, + ) normalized = struct_to_dict(self.data, serializer=self.serializer) if self.readonly: self.statedict = copy.deepcopy(normalized) @@ -257,6 +266,11 @@ class KantaImpl(PersistenceMixin, Generic[T]): f"migrate:v{self.version}" if migrations_ran else "migrate:msgspec" ) record = self.queue_change(action, normalized, mtime=False) + # The migration summary introduces the diff, so log it first. + if migrations_ran and migration_result is not None: + await self._handle_migration_log( + migration_result, previous_version, log + ) if ( record is not None and log is not False @@ -277,10 +291,6 @@ class KantaImpl(PersistenceMixin, Generic[T]): ), self.callback_registry.logemit_handlers, ) - if migrations_ran and migration_result is not None: - await self._handle_migration_log( - migration_result, previous_version, log - ) if migrations_ran or record is not None: self.snapshot.request_force() await self.flush() diff --git a/kanta/logging.py b/kanta/logging.py index fe213a9..99ed4cd 100644 --- a/kanta/logging.py +++ b/kanta/logging.py @@ -40,10 +40,11 @@ class LogEvent(msgspec.Struct, kw_only=True): """All state describing one loggable event, passed to logemit callbacks. ``kind`` is ``"change"`` (transaction, bootstrap, or migration diff), - ``"created"`` (database file created), ``"migrated"`` (migration - summary), or ``"aborted"`` (transaction rolled back). ``logger`` and - ``level`` are Kanta's preferred destination; a callback may use them, - log elsewhere, or not log at all. + ``"created"`` (database file created), ``"opened"`` (database file + opened), ``"migrated"`` (migration summary), or ``"aborted"`` + (transaction rolled back). ``logger`` and ``level`` are Kanta's + preferred destination; a callback may use them, log elsewhere, or not + log at all. The event is mutable: a callback may modify it before returning a truthy value to pass it on, affecting later callbacks and the built-in fallback. @@ -75,8 +76,8 @@ class LogEvent(msgspec.Struct, kw_only=True): Covers every event kind: ``"[ ][ by ]"`` for changes, ``"[ ][ by ] transaction aborted: - "`` for aborts, and the plain ``Created``/``Migrated`` - summaries. + "`` for aborts, and the ``🛢️ `` file + summaries (created / opened / migrated). """ if self._header is None: self._header = self._build_header() @@ -93,11 +94,13 @@ class LogEvent(msgspec.Struct, kw_only=True): def _build_header(self) -> str: if self.kind == "created": - return f"Created {self.filename}" + return f"🛢️ {self.filename} created" + if self.kind == "opened": + return f"🛢️ {self.filename} opened" if self.kind == "migrated": migrations = ", ".join(self.migrations) return ( - f"Migrated {self.filename} " + f"🛢️ {self.filename} migrated " f"v{self.from_version} -> v{self.to_version}: {migrations}" ) if self.kind == "change": diff --git a/tests/test_kanta_integration.py b/tests/test_kanta_integration.py index 46b2ad1..a1140c5 100644 --- a/tests/test_kanta_integration.py +++ b/tests/test_kanta_integration.py @@ -634,7 +634,7 @@ async def test_open_log_true_logs_bootstrap(tmp_path, format_config, caplog): info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO] assert len(info_messages) >= 2 - assert "Created" in info_messages[0] + assert "created" in info_messages[0] assert "bootstrap" in info_messages[1] @@ -665,7 +665,7 @@ async def test_open_log_custom_logger_logs_bootstrap(tmp_path, format_config, ca info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO] assert len(info_messages) >= 2 - assert "Created" in info_messages[0] + assert "created" in info_messages[0] assert "bootstrap" in info_messages[1] @@ -685,7 +685,7 @@ async def test_open_existing_database_logs_using_on_debug( await kanta2.close() debug_messages = [r.message for r in caplog.records if r.levelno == logging.DEBUG] - assert any("Using" in m and str(path.resolve()) in m for m in debug_messages) + assert any("opened" in m and str(path.resolve()) in m for m in debug_messages) @pytest.mark.asyncio diff --git a/tests/test_logemit.py b/tests/test_logemit.py index 7613627..97acfdb 100644 --- a/tests/test_logemit.py +++ b/tests/test_logemit.py @@ -123,8 +123,8 @@ def test_default_emit_created_and_migrated(capsys): ) ) err = capsys.readouterr().err - assert "Created x.kantadb" in err - assert "Migrated x.kantadb v0 -> v1: migrate_v1 (rename)" in err + assert "🛢️ x.kantadb created" in err + assert "🛢️ x.kantadb migrated v0 -> v1: migrate_v1 (rename)" in err @pytest.mark.asyncio @@ -295,7 +295,7 @@ async def test_aborted_transaction_includes_resolved_user( def test_event_header_covers_all_kinds(): created = LogEvent(kind="created", logger=transaction_logger, filename="x.db") - assert created.header == "Created x.db" + assert created.header == "🛢️ x.db created" migrated = LogEvent( kind="migrated", @@ -305,7 +305,7 @@ def test_event_header_covers_all_kinds(): to_version=1, migrations=["migrate_v1 (rename)"], ) - assert migrated.header == "Migrated x.db v0 -> v1: migrate_v1 (rename)" + assert migrated.header == "🛢️ x.db migrated v0 -> v1: migrate_v1 (rename)" aborted = LogEvent( kind="aborted",