Replace log dict toggles with logdiff kwarg and kanta.transaction.diff logger

Per-transaction logdiff=False skips building and printing the diff body,
logging only the header. Globally, configure_logging(diff=False) disables
the kanta.transaction.diff child logger, which now carries all diff lines,
so applications can route or silence diffs separately from headers.
This commit is contained in:
Leo Vasanko
2026-08-07 01:26:50 +00:00
parent 9e19a1bf21
commit e42f81f44f
7 changed files with 132 additions and 110 deletions
+37 -57
View File
@@ -1,15 +1,5 @@
"""Kanta feature demo. #!/usr/bin/env -S uv run
Run from the project root: python demo/main.py
Demonstrates bootstrap, colored transaction diffs, logfmt value formatting,
logging toggles, rollback, migrations, and a custom clock.
The database is recreated with fixed timestamps on every run; everything else
lives in this file.
"""
import asyncio import asyncio
import logging
import sys import sys
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -38,34 +28,24 @@ kanta_v0 = Kanta(filename, Data())
@kanta_v0.bootstrap @kanta_v0.bootstrap
def bootstrap(data: Data) -> None: def bootstrap(data: Data) -> None:
"""Create the initial admin user.""" """Create the initial admin user."""
data.users["u1"] = {"name": "Alice", "role": "admin"} data.users["userid001"] = {"name": "Alice", "role": "admin"}
# Redefinition to simulate new version with counter renamed to total # Redefinition to simulate new version
class Data(msgspec.Struct): class Data(msgspec.Struct):
users: dict[str, dict] = {} users: dict[str, dict] = {}
total: int = 0 total: int = 0 # Replaces old counter field
lang: str = "en" # New field
def migrate_v1(d: dict) -> None: def migrate_v1(d: dict) -> None:
"""Rename counter to total""" """Rename counter to total"""
d["total"] = d.pop("counter") d["total"] = d["counter"]
kanta_v1 = Kanta(filename, Data(), migrations=sys.modules[__name__]) kanta_v1 = Kanta(filename, Data(), migrations=sys.modules[__name__])
_now = datetime(2027, 1, 1, tzinfo=UTC)
@kanta_v0.clock
@kanta_v1.clock
def fake_clock() -> datetime:
global _now
_now += timedelta(hours=1)
return _now
@kanta_v0.logfmt @kanta_v0.logfmt
@kanta_v1.logfmt @kanta_v1.logfmt
def resolve_user( def resolve_user(
@@ -82,61 +62,61 @@ def resolve_user(
return None return None
def section(title: str) -> None:
print(f"\n# {title}", flush=True)
async def main() -> None: async def main() -> None:
filename.unlink(missing_ok=True) filename.unlink(missing_ok=True)
section("Database creation with v0 schema and basic access") print("# Database creation with v0 schema and basic ops, pretty logs", flush=True)
# Open and close automatically; you can also `await kanta.open()` instead # Open and close automatically; you can also `await kanta.open()` instead
async with kanta_v0 as kanta: async with kanta_v0 as kanta:
with kanta.transaction(action="create", user="u2") as data: with kanta.transaction(action="create", user="userid001") as data:
data.users["u2"] = {"name": "Bob", "role": "user"} data.users["userid002"] = {"name": "Bob", "role": "user"}
with kanta.transaction(action="update", user="u1") as data: with kanta.transaction(action="update", user="userid001") as data:
data.users["u2"]["role"] = "editor" data.users["userid002"]["role"] = "editor"
data.counter = 1 data.counter = 1
with kanta.transaction(action="delete", user="u1") as data: with kanta.transaction(action="delete", user="userid002") as data:
del data.users["u2"] del data.users["userid001"]
# Display-only extra string, appended after the action. # Display-only extra string, appended after the action.
with kanta.transaction(action="export", user="u1", extra=filename.name) as data: with kanta.transaction(
action="export", user="userid002", extra="extra info"
) as data:
data.counter = 2 data.counter = 2
# Compact logging, diff only: a system fix stamped by the clock, but the
# modification time (m) is not updated.
with kanta.transaction(
action="repair", mtime=False, log={"header": False, "diff": True}
) as data:
data.users["u3"] = {"name": "Carol", "role": "user"}
# A failing transaction rolls back and logs a warning.
try: try:
with kanta.transaction(action="reset", user="u1") as data: with kanta.transaction(action="reset") as data:
data.counter = 99 data.counter = 99
raise ValueError("simulated failure") raise ValueError("simulated failure")
except ValueError: except ValueError:
pass print(f"# Reading does not need transaction: {data.counter=}", flush=True)
# Compact logging, header only. with kanta.transaction(action="import", logdiff=False) as data:
with kanta.transaction(
action="import", user="u1", log={"header": True, "diff": False}
) as data:
data.counter = 3 data.counter = 3
section("A later version of our application with new data model and migrations") print(
"\n# A later version of our application with new data model and migrations",
flush=True,
)
async with kanta_v1 as kanta: async with kanta_v1 as kanta:
with kanta.transaction(action="update", user="u1", extra=filename.name) as data: with kanta.transaction(
action="update", user="userid002", extra=filename.name
) as data:
data.total = 4 data.total = 4
with kanta.transaction(action="create", user="u3", extra="Dave (u4)") as data:
data.users["u4"] = {"name": "Dave", "role": "user"} # Fake clock for deterministic timestamps
_now = datetime(2027, 1, 1, tzinfo=UTC)
@kanta_v0.clock
@kanta_v1.clock
def fake_clock() -> datetime:
global _now
_now += timedelta(hours=1)
return _now
if __name__ == "__main__": if __name__ == "__main__":
configure_logging() configure_logging()
logging.getLogger("kanta").setLevel(logging.DEBUG) # show migration diffs
asyncio.run(main()) asyncio.run(main())
+6 -2
View File
@@ -215,8 +215,12 @@ def resolve_user_key(value: str) -> str | None:
- `kanta.transaction(..., extra="...")` accepts a display-only string that is - `kanta.transaction(..., extra="...")` accepts a display-only string that is
appended after the action in the header (colored by Kanta); it is never appended after the action in the header (colored by Kanta); it is never
persisted in the `ChangeRecord`. persisted in the `ChangeRecord`.
- The header and diff parts can be toggled independently per transaction: - `kanta.transaction(..., logdiff=False)` skips building and printing the diff
`kanta.transaction(..., log={"header": True, "diff": False})`. body and logs only the header, which is useful for large or noisy
changesets. Diff output can also be disabled globally with
`configure_logging(diff=False)`; diff lines are emitted on the
`kanta.transaction.diff` child logger so applications can route or silence
them separately from the headers.
## Migrations ## Migrations
+9 -4
View File
@@ -320,7 +320,8 @@ class Kanta(Generic[T]):
user: str | None = None, user: str | None = None,
extra: str | None = None, extra: str | None = None,
mtime: bool | datetime = True, mtime: bool | datetime = True,
log: bool | logging.Logger | dict[str, bool] = True, log: bool | logging.Logger = True,
logdiff: bool = True,
): ):
"""Create a transactional mutation context manager. """Create a transactional mutation context manager.
@@ -341,9 +342,12 @@ class Kanta(Generic[T]):
log: Controls transaction logging. ``True`` (default) uses the log: Controls transaction logging. ``True`` (default) uses the
``kanta.transaction`` logger. ``False`` suppresses the ``kanta.transaction`` logger. ``False`` suppresses the
transaction log. A :class:`~logging.Logger` instance writes transaction log. A :class:`~logging.Logger` instance writes
output to that logger instead. A dict such as output to that logger instead.
``{"header": True, "diff": False}`` toggles the header and logdiff: Whether to build and print the diff body. ``False``
diff parts independently. skips diff formatting entirely and logs only the header,
which is useful for large or noisy changesets. Diff output
can also be disabled globally with
``configure_logging(diff=False)``.
Returns: Returns:
A context manager yielding the live state object for mutation. A context manager yielding the live state object for mutation.
@@ -360,4 +364,5 @@ class Kanta(Generic[T]):
extra=extra, extra=extra,
mtime=mtime, mtime=mtime,
log=log, log=log,
logdiff=logdiff,
) )
+26 -18
View File
@@ -306,7 +306,6 @@ def log_change(
*, *,
logger: logging.Logger = transaction_logger, logger: logging.Logger = transaction_logger,
level: int = logging.INFO, level: int = logging.INFO,
log_header: bool = True,
log_diff: bool = True, log_diff: bool = True,
) -> None: ) -> None:
"""Log a database change with pretty-printed diff. """Log a database change with pretty-printed diff.
@@ -321,30 +320,33 @@ def log_change(
logfmt: Optional formatter callable ``(value, path) -> str | None``. logfmt: Optional formatter callable ``(value, path) -> str | None``.
logger: Logger to write to. Defaults to the ``kanta.transaction`` logger. logger: Logger to write to. Defaults to the ``kanta.transaction`` logger.
level: Log level to use. Defaults to ``logging.INFO``. level: Log level to use. Defaults to ``logging.INFO``.
log_header: Whether to emit the header line. log_diff: Whether to build and emit the diff lines. ``False`` skips
log_diff: Whether to emit the diff lines. diff formatting entirely and only the header is logged.
Diff lines are emitted on the ``<logger.name>.diff`` child logger, so they
can be silenced globally without losing the headers (see
:func:`configure_logging`). When the child logger would not emit at the
given level, diff formatting is skipped altogether.
""" """
header: str | None = None diff_logger = logging.getLogger(f"{logger.name}.diff")
if log_header: diff_lines = (
header = format_action_header(action, user, extra) format_diff(diff, previous, logfmt)
if log_diff and diff_logger.isEnabledFor(level)
diff_lines = format_diff(diff, previous, logfmt) if log_diff else [] else []
)
if header is None: header = format_action_header(action, user, extra)
for line in diff_lines:
logger.log(level, line)
return
if not diff_lines: if not diff_lines:
logger.log(level, header) logger.log(level, header)
return return
if len(diff_lines) == 1: if len(diff_lines) == 1:
logger.log(level, f"{header}{diff_lines[0]}") diff_logger.log(level, f"{header}{diff_lines[0]}")
else: return
logger.log(level, header)
for line in diff_lines: logger.log(level, header)
logger.log(level, line) for line in diff_lines:
diff_logger.log(level, line)
def configure_logging( def configure_logging(
@@ -353,6 +355,7 @@ def configure_logging(
bootstrap: bool = True, bootstrap: bool = True,
migration: bool = True, migration: bool = True,
transaction: bool = True, transaction: bool = True,
diff: bool = True,
) -> None: ) -> None:
"""Configure Kanta's default logging output. """Configure Kanta's default logging output.
@@ -366,11 +369,16 @@ def configure_logging(
bootstrap: Whether bootstrap logs are enabled. bootstrap: Whether bootstrap logs are enabled.
migration: Whether migration logs are enabled. migration: Whether migration logs are enabled.
transaction: Whether transaction logs are enabled. transaction: Whether transaction logs are enabled.
diff: Whether transaction diff lines are enabled. When ``False``,
only transaction headers are printed and diff formatting is
skipped. Per transaction this is controlled by the ``logdiff``
argument of :meth:`Kanta.transaction`.
This helper is not called automatically; applications that want Kanta's This helper is not called automatically; applications that want Kanta's
default output can call it, but most applications will configure logging default output can call it, but most applications will configure logging
themselves. themselves.
""" """
logging.getLogger("kanta.transaction.diff").disabled = not diff
for name, enabled in ( for name, enabled in (
("kanta.bootstrap", bootstrap), ("kanta.bootstrap", bootstrap),
("kanta.migration", migration), ("kanta.migration", migration),
+6 -14
View File
@@ -23,7 +23,8 @@ def transaction(
user: str | None = None, user: str | None = None,
extra: str | None = None, extra: str | None = None,
mtime: bool | datetime = True, mtime: bool | datetime = True,
log: bool | logging.Logger | dict[str, bool] = True, log: bool | logging.Logger = True,
logdiff: bool = True,
): ):
"""Wrap writes in a transaction and yield the live db object.""" """Wrap writes in a transaction and yield the live db object."""
if impl.readonly: if impl.readonly:
@@ -83,17 +84,9 @@ def transaction(
if resolved is not None: if resolved is not None:
formatted_user = resolved formatted_user = resolved
if log is not False: if log is not False:
if isinstance(log, dict): logger = (
log_header = bool(log.get("header", True)) log if isinstance(log, logging.Logger) else transaction_logger
log_diff = bool(log.get("diff", True)) )
logger = transaction_logger
else:
log_header = log_diff = True
logger = (
log
if isinstance(log, logging.Logger)
else transaction_logger
)
log_change( log_change(
action, action,
record.diff, record.diff,
@@ -102,8 +95,7 @@ def transaction(
extra=extra, extra=extra,
logfmt=logfmt, logfmt=logfmt,
logger=logger, logger=logger,
log_header=log_header, log_diff=logdiff,
log_diff=log_diff,
) )
except Exception: except Exception:
_logger.warning("Transaction '%s' failed, rolling back changes", action) _logger.warning("Transaction '%s' failed, rolling back changes", action)
+20
View File
@@ -738,6 +738,26 @@ async def test_transaction_log_false_suppresses_log(tmp_path, format_config, cap
assert not info_messages assert not info_messages
@pytest.mark.asyncio
async def test_transaction_logdiff_false_logs_header_only(
tmp_path, format_config, caplog
):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
with caplog.at_level(logging.INFO, logger="kanta.transaction"):
with kanta.transaction(action="inc", logdiff=False) as data:
data.counter = 1
await kanta.close()
messages = [r.message for r in caplog.records if r.levelno == logging.INFO]
assert len(messages) == 1
assert "inc" in messages[0]
assert "counter" not in messages[0]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transaction_log_custom_logger(tmp_path, format_config, caplog): async def test_transaction_log_custom_logger(tmp_path, format_config, caplog):
path = tmp_path / "test.db" path = tmp_path / "test.db"
+28 -15
View File
@@ -30,10 +30,17 @@ def test_colorize_header_parts_missing_user_and_extra():
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _reset_kanta_loggers(): def _reset_kanta_loggers():
yield yield
for name in ("kanta", "kanta.transaction", "kanta.bootstrap", "kanta.migration"): for name in (
"kanta",
"kanta.transaction",
"kanta.transaction.diff",
"kanta.bootstrap",
"kanta.migration",
):
logger = logging.getLogger(name) logger = logging.getLogger(name)
logger.setLevel(logging.NOTSET) logger.setLevel(logging.NOTSET)
logger.propagate = True logger.propagate = True
logger.disabled = False
logger.handlers.clear() logger.handlers.clear()
@@ -80,30 +87,36 @@ def test_log_change_appends_extra_string(capsys):
assert f"{_TARGET}mydb.db{_RESET}" in captured.err assert f"{_TARGET}mydb.db{_RESET}" in captured.err
def test_log_change_log_diff_false(capsys): def test_log_change_log_diff_false(capsys, monkeypatch):
kanta_logger = logging.getLogger("kanta") kanta_logger = logging.getLogger("kanta")
kanta_logger.handlers.clear() kanta_logger.handlers.clear()
configure_logging() configure_logging()
def _boom(*args, **kwargs):
raise AssertionError("format_diff should not be called")
monkeypatch.setattr("kanta.logging.format_diff", _boom)
log_change("update", {"counter": 5}, previous={}, log_diff=False) log_change("update", {"counter": 5}, previous={}, log_diff=False)
captured = capsys.readouterr() captured = capsys.readouterr()
assert "update" in captured.err assert "update" in captured.err
assert "counter" not in captured.err assert "counter" not in captured.err
def test_log_change_log_header_false(capsys): def test_configure_logging_diff_false(capsys):
kanta_logger = logging.getLogger("kanta") kanta_logger = logging.getLogger("kanta")
kanta_logger.handlers.clear() kanta_logger.handlers.clear()
configure_logging() configure_logging(diff=False)
log_change("update", {"counter": 5}, previous={}, log_header=False) log_change("update", {"counter": 5}, previous={})
captured = capsys.readouterr()
assert "update" in captured.err
assert "counter" not in captured.err
def test_configure_logging_diff_true_reenables(capsys):
kanta_logger = logging.getLogger("kanta")
kanta_logger.handlers.clear()
configure_logging(diff=False)
configure_logging(diff=True)
log_change("update", {"counter": 5}, previous={})
captured = capsys.readouterr() captured = capsys.readouterr()
assert "update" not in captured.err
assert "counter" in captured.err assert "counter" in captured.err
def test_log_change_both_disabled_logs_nothing(capsys):
kanta_logger = logging.getLogger("kanta")
kanta_logger.handlers.clear()
configure_logging()
log_change("update", {"counter": 5}, previous={}, log_header=False, log_diff=False)
captured = capsys.readouterr()
assert captured.err == ""