diff --git a/demo/main.py b/demo/main.py index 5609918..6baad27 100644 --- a/demo/main.py +++ b/demo/main.py @@ -3,7 +3,7 @@ Run from the project root: python demo/main.py Demonstrates bootstrap, colored transaction diffs, logfmt value formatting, -custom log headers, logging toggles, rollback, migrations, and a custom clock. +logging toggles, rollback, migrations, and a custom clock. The database is recreated with fixed timestamps on every run; everything else lives in this file. """ @@ -21,16 +21,28 @@ from kanta.callbacks import DictPost, DictPre from kanta.logging import configure_logging -class DataV1(msgspec.Struct): - """Original schema (version 0).""" +filename = Path(__file__).with_name("demo.kantadb") +# For demonstration purposes, we use "original v0" and "modified v1" in this same script +# Normally your app would only have the latest supported data model + + +class Data(msgspec.Struct): # type: ignore - intentionally redefined later users: dict[str, dict] = {} counter: int = 0 -class Data(msgspec.Struct): - """Current schema: migration v1 renames counter to total.""" +kanta_v0 = Kanta(filename, Data()) + +@kanta_v0.bootstrap +def bootstrap(data: Data) -> None: + """Create the initial admin user.""" + data.users["u1"] = {"name": "Alice", "role": "admin"} + + +# Redefinition to simulate new version with counter renamed to total +class Data(msgspec.Struct): users: dict[str, dict] = {} total: int = 0 @@ -40,11 +52,9 @@ def migrate_v1(d: dict) -> None: d["total"] = d.pop("counter") -filename = Path(__file__).with_name("demo.kantadb") -# For demonstration purposes, we use "original v0" and "modified v1" in this same script -kanta_v0 = Kanta(filename, DataV1()) kanta_v1 = Kanta(filename, Data(), migrations=sys.modules[__name__]) + _now = datetime(2027, 1, 1, tzinfo=UTC) @@ -72,18 +82,6 @@ def resolve_user( return None -@kanta_v1.logheader -def header(action: str, user: str, extra: str) -> str: - """Custom header: Kanta colors the parts, we just arrange them.""" - return f"{user} {action} {extra}" - - -@kanta_v0.bootstrap -def seed(data: DataV1) -> None: - """Create the initial admin user.""" - data.users["u1"] = {"name": "Alice", "role": "admin"} - - def section(title: str) -> None: print(f"\n# {title}", flush=True) diff --git a/docs/database.md b/docs/database.md index a69c634..99ec5ca 100644 --- a/docs/database.md +++ b/docs/database.md @@ -213,30 +213,11 @@ def resolve_user_key(value: str) -> str | None: - By default a transaction is logged with an `action by user` header followed by the diff lines. Added paths are colored green, deleted paths red. - `kanta.transaction(..., extra="...")` accepts a display-only string that is - appended after the action in the default header (colored by Kanta); it is - never persisted in the `ChangeRecord`. -- Register a `@kanta.logheader` callback to compose a custom header. Declare - any of `action: str`, `user: str`, `extra: str`: Kanta passes the parts - with its header colors already applied (missing `user`/`extra` as empty - strings), so callbacks only arrange text — no color codes, fallbacks, or - padding. `DictPre`/`DictPost` state dicts and the `Kanta` instance can - also be injected. The callback must be synchronous and return `str | None`. -- Multiple logheader callbacks are stacked in registration order; the first - callback to return a non-`None` result wins. If all return `None`, Kanta - falls back to the default header. The `user` part has already been through - the `logfmt` formatters. + appended after the action in the header (colored by Kanta); it is never + persisted in the `ChangeRecord`. - The header and diff parts can be toggled independently per transaction: `kanta.transaction(..., log={"header": True, "diff": False})`. -```python -@kanta.logheader -def format_header(action: str, user: str, extra: str) -> str: - return f"{user} {action} {extra}" - -with kanta.transaction(action="update", user="alice", extra="Project X") as data: - ... -``` - ## Migrations - Migration source is configured on `Kanta(...)` via `migrations=`. diff --git a/kanta/callbacks.py b/kanta/callbacks.py index 0770536..0155a1e 100644 --- a/kanta/callbacks.py +++ b/kanta/callbacks.py @@ -61,9 +61,6 @@ class InjectionContext: previous_state: dict | None = None current_state: dict | None = None migration_result: MigrationResult | None = None - action: str | None = None - user: str | None = None - extra: str | None = None @dataclass @@ -104,7 +101,6 @@ class CallbackRegistry: "bootstrap": [], "fatal_error": [], "logmigr": [], - "logheader": [], } self._logfmt_callbacks: list[_LogFmtFunctionSpec | _LogFmtClassSpec] = [] @@ -135,18 +131,6 @@ class CallbackRegistry: if not callable(callback): raise TypeError(f"{kind} callback must be callable") - if kind == "logheader": - if inspect.iscoroutinefunction(callback): - raise TypeError("logheader callbacks must not be async") - return_ann = inspect.signature(callback).return_annotation - if return_ann is not inspect.Signature.empty: - resolved = self._resolve_raw_annotation(return_ann, callback) - if not self._is_optional_str(resolved): - raise TypeError( - f"logheader callback {callback.__name__} must return " - f"str | None, got {resolved!r}" - ) - params = self._validate_function(callback, kind) is_async = inspect.iscoroutinefunction(callback) @@ -233,26 +217,6 @@ class CallbackRegistry: return format_value - def resolve_logheader(self, ctx: InjectionContext) -> str | None: - """Invoke logheader callbacks; the first non-None result wins.""" - for reg in self._callbacks["logheader"]: - kwargs = self._build_kwargs(reg.params, ctx) - result = reg.callback(**kwargs) - if result is not None: - return result - return None - - @staticmethod - def _logheader_param_annotation(name: str, ann: Any) -> Any: - """Map logheader parameter names to their injection sentinels.""" - if CallbackRegistry._unwrap_optional(ann) is not str: - return None - return { - "action": _HeaderAction, - "user": _HeaderUser, - "extra": _HeaderExtra, - }.get(name) - def _validate_function( self, callback: Callable[..., Any], @@ -276,11 +240,6 @@ class CallbackRegistry: continue ann = self._resolve_raw_annotation(param.annotation, callback) - if kind == "logheader": - header_ann = self._logheader_param_annotation(name, ann) - if header_ann is not None: - params.append((name, header_ann)) - continue if not self._is_allowed(kind, ann): if param.default is inspect.Parameter.empty: raise TypeError( @@ -485,9 +444,9 @@ class CallbackRegistry: def _is_allowed(self, kind: str, ann: Any) -> bool: bare = self._unwrap_optional(ann) if self._matches_state_annotation(bare, "pre"): - return kind in {"logfmt", "logheader"} + return kind == "logfmt" if self._matches_state_annotation(bare, "post"): - return kind in {"logfmt", "logheader"} + return kind == "logfmt" if bare is DatabaseError: return kind == "fatal_error" if bare is MigrationResult: @@ -500,7 +459,6 @@ class CallbackRegistry: "fatal_error", "logfmt", "logmigr", - "logheader", } return False @@ -509,29 +467,19 @@ class CallbackRegistry: if kind == "bootstrap": if self._data_type is not None: parts.append(self._data_type.__name__) - if kind in {"bootstrap", "fatal_error", "logfmt", "logmigr", "logheader"}: + if kind in {"bootstrap", "fatal_error", "logfmt", "logmigr"}: if self._kanta_class is not None: parts.append(self._kanta_class.__name__) if kind == "fatal_error": parts.append("DatabaseError") if kind == "logmigr": parts.append("MigrationResult") - if kind == "logheader": - parts.append("action: str") - parts.append("user: str | None") - parts.append("extra: str | None") - if kind in {"logfmt", "logheader"}: + if kind == "logfmt": parts.append("Annotated[dict, 'pre']") parts.append("Annotated[dict, 'post']") return ", ".join(parts) if parts else "none" def _resolve_annotation(self, ann: Any, ctx: InjectionContext) -> Any: - if ann is _HeaderAction: - return ctx.action - if ann is _HeaderUser: - return ctx.user - if ann is _HeaderExtra: - return ctx.extra bare = self._unwrap_optional(ann) if self._matches_state_annotation(bare, "pre"): return ctx.previous_state @@ -589,18 +537,6 @@ class CallbackRegistry: return type(None) in args and any(arg is str for arg in args) -class _HeaderAction: - """Sentinel annotation injecting the transaction action.""" - - -class _HeaderUser: - """Sentinel annotation injecting the transaction user.""" - - -class _HeaderExtra: - """Sentinel annotation injecting the display-only extra metadata.""" - - class _Unresolved: pass diff --git a/kanta/kanta.py b/kanta/kanta.py index a7d9485..8d93960 100644 --- a/kanta/kanta.py +++ b/kanta/kanta.py @@ -313,31 +313,6 @@ class Kanta(Generic[T]): return _register return _register(fn) - def logheader(self, fn=None): - """Register a transaction log header callback. - - Can be used as ``@kanta.logheader``. The callback composes the header - line printed before a transaction diff from the parts it declares: - ``action: str``, ``user: str`` and ``extra: str``. Kanta applies its - header colors to the parts before calling the callback, and a missing - ``user``/``extra`` is passed as an empty string, so callbacks only - arrange text — no color codes, fallbacks, or padding. ``DictPre``/ - ``DictPost`` state dicts and the ``Kanta`` instance can also be - injected. The callback must be synchronous and return ``str`` (or - ``None`` to fall through to the next callback, then to the default - header). - - If registered, this replaces the default ``action by user`` header. - """ - - def _register(callback): - self._impl.add_logheader(callback) - return callback - - if fn is None: - return _register - return _register(fn) - def transaction( self, action: str, @@ -355,9 +330,8 @@ class Kanta(Generic[T]): the log header. Register a ``@kanta.logfmt`` callback to format the user value; the path ``"$user"`` is passed for this case. extra: Optional display-only string appended after the action in - the log header (colored by Kanta), or passed to a registered - ``@kanta.logheader`` callback. It is never persisted in the - change record. + the log header (colored by Kanta). It is never persisted in + the change record. mtime: Controls the modification time ``m``. ``True`` (default) sets ``m`` to the current UTC time. ``False`` omits ``m`` so the previous modification time remains in effect; this is used for diff --git a/kanta/kantaimpl.py b/kanta/kantaimpl.py index a2f2682..7596c8c 100644 --- a/kanta/kantaimpl.py +++ b/kanta/kantaimpl.py @@ -6,20 +6,13 @@ import asyncio import copy import importlib import logging -from collections.abc import Callable from datetime import UTC, datetime from types import SimpleNamespace from typing import Any, Generic, TypeVar from kanta.callbacks import CallbackRegistry, InjectionContext from kanta.exceptions import DatabaseError, DataIntegrityError, ReplayError -from kanta.logging import ( - _USER_PATH, - bootstrap_logger, - colorize_header_parts, - log_change, - migration_logger, -) +from kanta.logging import _USER_PATH, bootstrap_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 @@ -87,42 +80,6 @@ class KantaImpl(PersistenceMixin, Generic[T]): """Register one migration logging callback.""" self.callback_registry.register("logmigr", callback) - def add_logheader(self, callback) -> None: - """Register one transaction header formatting callback.""" - self.callback_registry.register("logheader", callback) - - def build_headerfmt( - self, - action: str, - user: str | None, - extra: str | None, - previous: dict | None, - current: dict | None, - ) -> Callable[..., str | None] | None: - """Build a headerfmt callable for ``log_change``. - - The logheader callbacks receive the header parts with Kanta's colors - already applied (missing user/extra as empty strings). Returns - ``None`` when no logheader callback is registered. - """ - if not self.callback_registry.has("logheader"): - return None - action_str, user_str, extra_str = colorize_header_parts(action, user, extra) - ctx = InjectionContext( - action=action_str, - user=user_str, - extra=extra_str, - previous_state=previous, - current_state=current, - kanta=self._kanta, - ) - registry = self.callback_registry - - def headerfmt(action: str, user: str, extra: str) -> str | None: - return registry.resolve_logheader(ctx) - - return headerfmt - async def _handle_migration_log( self, migration_result: MigrationResult, @@ -352,16 +309,12 @@ class KantaImpl(PersistenceMixin, Generic[T]): resolved = logfmt(formatted_user, _USER_PATH) if resolved is not None: formatted_user = resolved - headerfmt = self.build_headerfmt( - self.bootstrap_action, formatted_user, None, {}, current - ) log_change( self.bootstrap_action, record.diff, formatted_user, previous={}, logfmt=logfmt, - headerfmt=headerfmt, logger=logger, level=logging.INFO, ) diff --git a/kanta/logging.py b/kanta/logging.py index 10f4cc3..ed77125 100644 --- a/kanta/logging.py +++ b/kanta/logging.py @@ -303,7 +303,6 @@ def log_change( previous: dict | None = None, extra: str | None = None, logfmt: Callable[[Any, str], str | None] | None = None, - headerfmt: Callable[[str, str, str], str | None] | None = None, *, logger: logging.Logger = transaction_logger, level: int = logging.INFO, @@ -318,11 +317,8 @@ def log_change( user: Optional already-formatted user name to show in the header. previous: The previous state dict (for determining add vs update). extra: Optional display-only string appended after the action in the - default header (colored by Kanta), or passed to ``headerfmt``. + header (colored by Kanta). logfmt: Optional formatter callable ``(value, path) -> str | None``. - headerfmt: Optional header formatter callable receiving the - pre-colored ``(action, user, extra)`` parts and returning the - header line. Returning ``None`` falls back to the default header. logger: Logger to write to. Defaults to the ``kanta.transaction`` logger. level: Log level to use. Defaults to ``logging.INFO``. log_header: Whether to emit the header line. @@ -330,10 +326,7 @@ def log_change( """ header: str | None = None if log_header: - if headerfmt is not None: - header = headerfmt(action, user, extra) - if header is None: - header = format_action_header(action, user, extra) + header = format_action_header(action, user, extra) diff_lines = format_diff(diff, previous, logfmt) if log_diff else [] diff --git a/kanta/transaction.py b/kanta/transaction.py index f0de37e..3c49399 100644 --- a/kanta/transaction.py +++ b/kanta/transaction.py @@ -94,9 +94,6 @@ def transaction( if isinstance(log, logging.Logger) else transaction_logger ) - headerfmt = impl.build_headerfmt( - action, formatted_user, extra, previous, new_dict - ) log_change( action, record.diff, @@ -104,7 +101,6 @@ def transaction( previous, extra=extra, logfmt=logfmt, - headerfmt=headerfmt, logger=logger, log_header=log_header, log_diff=log_diff, diff --git a/tests/test_logging.py b/tests/test_logging.py index ad9e8d8..562a01a 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -80,30 +80,6 @@ def test_log_change_appends_extra_string(capsys): assert f"{_TARGET}mydb.db{_RESET}" in captured.err -def test_log_change_headerfmt_replaces_header(capsys): - kanta_logger = logging.getLogger("kanta") - kanta_logger.handlers.clear() - configure_logging() - log_change( - "update", - {}, - headerfmt=lambda action, user, extra: f"CUSTOM {action} {extra}", - extra="7", - ) - captured = capsys.readouterr() - assert "CUSTOM update 7" in captured.err - - -def test_log_change_headerfmt_none_falls_back(capsys): - kanta_logger = logging.getLogger("kanta") - kanta_logger.handlers.clear() - configure_logging() - log_change("update", {}, user="alice", headerfmt=lambda *args: None) - captured = capsys.readouterr() - assert "update" in captured.err - assert "alice" in captured.err - - def test_log_change_log_diff_false(capsys): kanta_logger = logging.getLogger("kanta") kanta_logger.handlers.clear() diff --git a/tests/test_logheader.py b/tests/test_logheader.py deleted file mode 100644 index bdbb6ca..0000000 --- a/tests/test_logheader.py +++ /dev/null @@ -1,275 +0,0 @@ -import logging - -import pytest - -from kanta import Kanta -from kanta.callbacks import DictPost, DictPre -from kanta.logging import _ACTION, _RESET, _TARGET, _USER, configure_logging - -from .support import Data, make_kanta, read_changes - - -@pytest.fixture(autouse=True) -def _reset_kanta_loggers(): - yield - for name in ("kanta", "kanta.transaction", "kanta.bootstrap", "kanta.migration"): - logger = logging.getLogger(name) - logger.setLevel(logging.NOTSET) - logger.propagate = True - logger.handlers.clear() - - -def test_logheader_rejects_async(tmp_path, format_config): - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - with pytest.raises(TypeError, match="must not be async"): - - @kanta.logheader - async def header(action: str) -> str: - return action - - -def test_logheader_rejects_bad_return_annotation(tmp_path, format_config): - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - with pytest.raises(TypeError, match="must return"): - - @kanta.logheader - def header(action: str) -> int: - return 1 - - -def test_logheader_rejects_unknown_annotation(tmp_path, format_config): - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - with pytest.raises(TypeError, match="unsupported annotation"): - - @kanta.logheader - def header(action: str, bogus: int) -> str: - return action - - -def test_logheader_rejects_dict_extra(tmp_path, format_config): - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - with pytest.raises(TypeError, match="unsupported annotation"): - - @kanta.logheader - def header(action: str, extra: dict) -> str: - return action - - -@pytest.mark.asyncio -async def test_logheader_replaces_default_header(tmp_path, format_config, caplog): - caplog.set_level(logging.INFO, logger="kanta.transaction") - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - @kanta.logheader - def header(action: str, user: str, extra: str) -> str: - return f"{user} {action} {extra}" - - await kanta.open(log=False) - with kanta.transaction(action="update", user="alice", extra="tgt") as data: - data.counter = 1 - await kanta.close() - - # caplog strips ANSI codes; the colors are verified via capsys below. - assert "alice update tgt" in caplog.text - # The diff body is still logged after the custom header. - assert "counter" in caplog.text - - -@pytest.mark.asyncio -async def test_logheader_parts_are_colored_by_kanta(tmp_path, format_config, capsys): - logging.getLogger("kanta").handlers.clear() - configure_logging() - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - @kanta.logheader - def header(action: str, user: str, extra: str) -> str: - return f"{user} {action} {extra}" - - await kanta.open(log=False) - with kanta.transaction(action="update", user="alice", extra="tgt") as data: - data.counter = 1 - await kanta.close() - - err = capsys.readouterr().err - assert f"{_USER}alice{_RESET}" in err - assert f"{_ACTION}update{_RESET}" in err - assert f"{_TARGET}tgt{_RESET}" in err - - -@pytest.mark.asyncio -async def test_logheader_missing_user_and_extra_are_empty( - tmp_path, format_config, caplog -): - caplog.set_level(logging.INFO, logger="kanta.transaction") - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - @kanta.logheader - def header(action: str, user: str, extra: str) -> str: - return f"<{user}><{extra}>" - - await kanta.open(log=False) - with kanta.transaction(action="update") as data: - data.counter = 1 - await kanta.close() - - assert "<><>" in caplog.text - - -@pytest.mark.asyncio -async def test_logheader_injects_states_and_kanta(tmp_path, format_config, caplog): - caplog.set_level(logging.INFO, logger="kanta.transaction") - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - @kanta.logheader - def header( - action: str, - previous: DictPre, - current: DictPost, - kanta: Kanta, - ) -> str: - return ( - f"counter {previous.get('counter')}" - f" -> {current.get('counter')} db={kanta.filename.name}" - ) - - await kanta.open(log=False) - with kanta.transaction(action="increment") as data: - data.counter = 5 - await kanta.close() - - assert "counter 0 -> 5 db=test.db" in caplog.text - - -@pytest.mark.asyncio -async def test_logheader_chain_first_non_none_wins(tmp_path, format_config, caplog): - caplog.set_level(logging.INFO, logger="kanta.transaction") - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - @kanta.logheader - def first(action: str) -> str | None: - return None - - @kanta.logheader - def second(action: str, extra: str) -> str: - return f"SECOND {extra}" - - await kanta.open(log=False) - with kanta.transaction(action="update", extra="marked") as data: - data.counter = 1 - await kanta.close() - - assert "SECOND marked" in caplog.text - - -@pytest.mark.asyncio -async def test_logheader_all_none_falls_back_to_default( - tmp_path, format_config, caplog -): - caplog.set_level(logging.INFO, logger="kanta.transaction") - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - @kanta.logheader - def header(action: str) -> str | None: - return None - - await kanta.open(log=False) - with kanta.transaction(action="update", user="alice") as data: - data.counter = 1 - await kanta.close() - - assert "update" in caplog.text - assert "alice" in caplog.text - - -@pytest.mark.asyncio -async def test_logheader_receives_formatted_user(tmp_path, format_config, caplog): - caplog.set_level(logging.INFO, logger="kanta.transaction") - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - @kanta.logfmt(path="$user") - def resolve_user(value: str) -> str | None: - return "Alice" - - @kanta.logheader - def header(action: str, user: str) -> str: - return f"actor={user}" - - await kanta.open(log=False) - with kanta.transaction(action="update", user="uuid-1") as data: - data.counter = 1 - await kanta.close() - - assert "actor=Alice" in caplog.text - - -@pytest.mark.asyncio -async def test_logheader_applies_to_bootstrap(tmp_path, format_config, caplog): - caplog.set_level(logging.INFO, logger="kanta.bootstrap") - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - @kanta.logheader - def header(action: str) -> str: - return f"BOOT {action}" - - await kanta.open() - await kanta.close() - - assert "BOOT bootstrap" in caplog.text - - -@pytest.mark.asyncio -async def test_transaction_extra_string_in_default_header( - tmp_path, format_config, caplog -): - caplog.set_level(logging.INFO, logger="kanta.transaction") - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - await kanta.open(log=False) - with kanta.transaction(action="export", extra="mydb.db") as data: - data.counter = 1 - await kanta.close() - - assert "export" in caplog.text - assert "mydb.db" in caplog.text - - -@pytest.mark.asyncio -async def test_extra_is_not_persisted(tmp_path, format_config): - path = tmp_path / "test.db" - kanta = make_kanta(path, Data, format_config) - - await kanta.open(log=False) - with kanta.transaction(action="update", user="alice", extra="session-3") as data: - data.counter = 1 - await kanta.close() - - record = read_changes(path, format_config)[-1] - assert record.a == "update" - assert record.u == "alice" - - -@pytest.mark.asyncio -async def test_transaction_log_dict_toggles(tmp_path, format_config, caplog): - caplog.set_level(logging.INFO, logger="kanta.transaction") - kanta = make_kanta(tmp_path / "test.db", Data, format_config) - - await kanta.open(log=False) - with kanta.transaction( - action="myaction", log={"header": True, "diff": False} - ) as data: - data.counter = 1 - with kanta.transaction( - action="otheraction", log={"header": False, "diff": True} - ) as data: - data.counter = 2 - await kanta.close() - - # First transaction: header only. - assert "myaction" in caplog.text - # Second transaction: diff only, no header. - assert "otheraction" not in caplog.text - assert "counter" in caplog.text