From 3e1a86aee3bc1e52acd15e272baeda51e6a3e4f1 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 20 Jun 2026 18:15:08 +0000 Subject: [PATCH] Log new database creation, bootstrap like a transaction. --- kanta/kanta.py | 12 +++++---- kanta/kantaimpl.py | 28 ++++++++++++++++++-- tests/test_callbacks.py | 2 ++ tests/test_kanta_integration.py | 46 +++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 7 deletions(-) diff --git a/kanta/kanta.py b/kanta/kanta.py index 0541e44..80c425a 100644 --- a/kanta/kanta.py +++ b/kanta/kanta.py @@ -163,11 +163,13 @@ class Kanta(Generic[T]): readonly: If True, open the database read-only. No lock is acquired, no background flush task is started, and transactions are rejected. The file is not created if missing. - log: Controls migration logging. ``True`` (default) uses the - ``kanta.migrations`` logger. ``False`` suppresses the default - migration log. A :class:`~logging.Logger` instance writes - default migration output to that logger instead. Custom - ``@kanta.logmigr`` callbacks run regardless of this setting. + log: Controls bootstrap and migration logging. ``True`` (default) + uses the ``kanta.changes`` logger for bootstrap records and the + ``kanta.migrations`` logger for migration output. ``False`` + suppresses the default bootstrap and migration logs. A + :class:`~logging.Logger` instance writes default output to that + logger instead. Custom ``@kanta.logmigr`` callbacks run + regardless of this setting. Calling ``open`` more than once on the same instance is not allowed. diff --git a/kanta/kantaimpl.py b/kanta/kantaimpl.py index 843ee00..11a8b16 100644 --- a/kanta/kantaimpl.py +++ b/kanta/kantaimpl.py @@ -12,7 +12,7 @@ from typing import Any, Generic, TypeVar from kanta.callbacks import CallbackRegistry, InjectionContext from kanta.exceptions import DatabaseError, DataIntegrityError, ReplayError -from kanta.logging import log_change, migration_logger +from kanta.logging import _USER_PATH, changes_logger, log_change, migration_logger from kanta.migrations import MigrationResult, Migrations from kanta.persistence import PersistenceMixin from kanta.serialization import restore_data_in_place, struct_to_dict @@ -277,13 +277,37 @@ class KantaImpl(PersistenceMixin, Generic[T]): self.statedict = {} current = struct_to_dict(self.data, serializer=self.serializer) - self.queue_change( + record = self.queue_change( self.bootstrap_action, current, user=self.bootstrap_user, mtime=self.bootstrap_mtime, force=True, ) + + if record is not None and log is not False: + logger = log if isinstance(log, logging.Logger) else changes_logger + logger.info("Created %s", self.filename.resolve()) + logfmt = self.callback_registry.build_logfmt( + InjectionContext( + previous_state={}, + current_state=current, + kanta=self._kanta, + ) + ) + formatted_user = self.bootstrap_user + if formatted_user is not None and logfmt is not None: + resolved = logfmt(formatted_user, _USER_PATH) + if resolved is not None: + formatted_user = resolved + log_change( + self.bootstrap_action, + record.diff, + formatted_user, + previous={}, + logfmt=logfmt, + logger=logger, + ) except Exception: self.opened = False self.file.close() diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 014fc46..ffac1b7 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -177,6 +177,8 @@ async def test_logfmt_class_injection(tmp_path, format_config, caplog): @kanta.logfmt class UserLogFmt(LogFmt): def resolve(self, value: str, path: str) -> str | None: + if not isinstance(value, str): + return None return self.current_state.get("users", {}).get(value, {}).get("name") await kanta.open() diff --git a/tests/test_kanta_integration.py b/tests/test_kanta_integration.py index 677c355..01fad01 100644 --- a/tests/test_kanta_integration.py +++ b/tests/test_kanta_integration.py @@ -623,6 +623,52 @@ async def test_open_log_false_suppresses_migration_log(tmp_path, format_config, assert not info_messages +@pytest.mark.asyncio +async def test_open_log_true_logs_bootstrap(tmp_path, format_config, caplog): + path = tmp_path / "test.db" + kanta = make_kanta(path, Data, format_config) + + with caplog.at_level(logging.INFO, logger="kanta.changes"): + await kanta.open() + await kanta.close() + + 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 "bootstrap" in info_messages[1] + + +@pytest.mark.asyncio +async def test_open_log_false_suppresses_bootstrap_log(tmp_path, format_config, caplog): + path = tmp_path / "test.db" + kanta = make_kanta(path, Data, format_config) + + with caplog.at_level(logging.INFO, logger="kanta.changes"): + await kanta.open(log=False) + await kanta.close() + + info_messages = [r for r in caplog.records if r.levelno == logging.INFO] + assert not info_messages + + +@pytest.mark.asyncio +async def test_open_log_custom_logger_logs_bootstrap(tmp_path, format_config, caplog): + path = tmp_path / "test.db" + kanta = make_kanta(path, Data, format_config) + + custom_logger = logging.getLogger("custom.bootstrap") + custom_logger.setLevel(logging.INFO) + + with caplog.at_level(logging.INFO, logger="custom.bootstrap"): + await kanta.open(log=custom_logger) + await kanta.close() + + 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 "bootstrap" in info_messages[1] + + @pytest.mark.asyncio async def test_logmigr_callback_replaces_default_logging( tmp_path, format_config, caplog