Files
kanta/kanta/logging.py
T
Leo Vasanko e42f81f44f 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.
2026-08-07 01:26:50 +00:00

400 lines
14 KiB
Python

"""Database change logging with pretty-printed diffs.
Provides loggers for JSONL database changes, bootstrap events, and
migrations. Diff output is formatted in a human-readable path notation
style with color coding.
"""
import logging
import re
import sys
from collections.abc import Callable
from typing import Any
transaction_logger = logging.getLogger("kanta.transaction")
bootstrap_logger = logging.getLogger("kanta.bootstrap")
migration_logger = logging.getLogger("kanta.migration")
# Pattern to match control characters and bidirectional overrides
_UNSAFE_CHARS = re.compile(
r"[\x00-\x1f\x7f-\x9f"
r"\u200e\u200f"
r"\u202a-\u202e"
r"\u2066-\u2069"
r"]"
)
# ANSI color codes
_RESET = "\033[0m"
_SEP = "\033[38;5;242m" # Dark grey for separators
_PATH_PREFIX = "\033[38;5;242m" # Dark grey for path prefix
_PATH_FINAL = "\033[38;5;250m" # Default for final element
_DELETE = "\033[1;31m" # Red for deletions
_ADD = "\033[0;32m" # Green for additions
_ACTION = "\033[1;34m" # Bold blue for action name
_USER = "\033[0;34m" # Blue for user display
_TARGET = "\033[38;5;250m" # White for the extra/target display
# Metadata path used when formatting the transaction actor.
_USER_PATH = "$user"
def _join_path(path: str, key: str) -> str:
"""Append *key* to a dot-notation *path*."""
if not path:
return key
return f"{path}.{key}"
def _format_value(
value: Any,
path: str,
*,
max_len: int = 60,
logfmt: Callable[[Any, str], str | None] | None = None,
) -> str:
"""Format a value for display, truncating if needed."""
if logfmt is not None:
resolved = logfmt(value, path)
if resolved is not None:
return resolved
if value is None:
return "null"
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
value = _UNSAFE_CHARS.sub("", value)
if len(value) > max_len:
return value[: max_len - 3] + "..."
return value
if isinstance(value, dict):
if not value:
return "{}"
all_true = all(v is True for v in value.values())
parts = []
for k, v in value.items():
key_path = _join_path(path, str(k))
key_display = _format_value(k, key_path, max_len=30, logfmt=logfmt)
if all_true:
parts.append(key_display)
else:
val_display = _format_value(v, key_path, max_len=30, logfmt=logfmt)
parts.append(f"{key_display}: {val_display}")
return "{" + ", ".join(parts) + "}"
if isinstance(value, list):
if not value:
return "[]"
parts = []
for i, v in enumerate(value):
item_path = _join_path(path, str(i))
parts.append(_format_value(v, item_path, max_len=30, logfmt=logfmt))
return "[" + ", ".join(parts) + "]"
text = str(value)
if len(text) > max_len:
text = text[: max_len - 3] + "..."
return text
def _format_path_components(
path: list[str], logfmt: Callable[[Any, str], str | None] | None
) -> list[str]:
"""Return path components after applying formatters."""
if not path:
return []
result = []
for i, component in enumerate(path):
prefix_path = ".".join(path[: i + 1])
display = component
if logfmt is not None:
resolved = logfmt(component, prefix_path)
if resolved is not None:
display = resolved
result.append(display)
return result
def _format_path(
path: list[str],
logfmt: Callable[[Any, str], str | None] | None,
final_color: str = _PATH_FINAL,
) -> str:
"""Format a path as dot notation with prefix in dark grey, final colored."""
components = _format_path_components(path, logfmt)
if not components:
return ""
if len(components) == 1:
return f"{final_color}{components[0]}{_RESET}"
prefix = ".".join(components[:-1])
final = components[-1]
return f"{_PATH_PREFIX}{prefix}.{_RESET}{final_color}{final}{_RESET}"
def _get_nested(data: dict | None, path: list[str]) -> Any:
"""Get a nested value from a dict by path, or None if not found."""
if data is None:
return None
current = data
for key in path:
if not isinstance(current, dict) or key not in current:
return None
current = current[key]
return current
def _collect_changes(
diff: dict,
path: list[str],
changes: list[tuple[str, list[str], Any]],
previous: dict | None,
) -> None:
"""Recursively collect changes from a diff into a flat list.
Each change is a tuple of (change_type, path, new_value).
change_type is one of: 'add', 'update', 'delete'
"""
if not isinstance(diff, dict):
existed = _get_nested(previous, path) is not None
changes.append(("update" if existed else "add", path, diff))
return
for key, value in diff.items():
if key == "$delete":
if isinstance(value, list):
for deleted_key in value:
changes.append(("delete", path + [str(deleted_key)], None))
else:
changes.append(("delete", path + [str(value)], None))
elif key == "$replace":
old_collection = _get_nested(previous, path)
old_keys = (
set(old_collection.keys())
if isinstance(old_collection, dict)
else set()
)
new_keys = set(value.keys()) if isinstance(value, dict) else set()
for deleted_key in old_keys - new_keys:
changes.append(("delete", path + [str(deleted_key)], None))
if isinstance(value, dict):
for rkey, rval in value.items():
existed = rkey in old_keys
changes.append(
("update" if existed else "add", path + [str(rkey)], rval)
)
elif value or not old_keys:
changes.append(
("update" if old_collection is not None else "add", path, value)
)
elif isinstance(key, str) and key.startswith("$"):
changes.append(("add", path, {key: value}))
else:
new_path = path + [str(key)]
existed = _get_nested(previous, new_path) is not None
if existed:
_collect_changes(value, new_path, changes, previous)
else:
changes.append(("add", new_path, value))
def _format_change_lines(
change_type: str,
path: list[str],
value: Any,
logfmt: Callable[[Any, str], str | None] | None = None,
) -> list[str]:
"""Format a single change as one or more lines."""
if change_type == "delete":
components = _format_path_components(path, logfmt)
if len(components) == 1:
return [f" {_DELETE}{components[0]}{_RESET}"]
prefix = ".".join(components[:-1])
final = components[-1]
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final}{_RESET}"]
if change_type == "add":
path_str = _format_path(path, logfmt, final_color=_ADD)
if isinstance(value, dict) and value:
lines = [f" {path_str} {_SEP}={_RESET}"]
formatted_items = []
base_path = ".".join(path)
for k, v in value.items():
key_path = _join_path(base_path, str(k))
key_display = _format_value(k, key_path, max_len=30, logfmt=logfmt)
v_str = _format_value(v, key_path, max_len=30, logfmt=logfmt)
formatted_items.append((key_display, v_str))
max_key_len = max(len(k) for k, _ in formatted_items)
field_width = max(max_key_len, 12)
for k_display, v_str in formatted_items:
padding = " " * (field_width - len(k_display))
lines.append(f" {k_display}{_SEP}:{_RESET}{padding} {v_str}")
return lines
value_str = _format_value(value, ".".join(path), logfmt=logfmt)
return [f" {path_str} {_SEP}={_RESET} {value_str}"]
value_str = _format_value(value, ".".join(path), logfmt=logfmt)
path_str = _format_path(path, logfmt=logfmt)
return [f" {path_str} {_SEP}={_RESET} {value_str}"]
def format_diff(
diff: dict,
previous: dict | None = None,
logfmt: Callable[[Any, str], str | None] | None = None,
) -> list[str]:
"""Format a JSON diff as human-readable lines.
Args:
diff: The JSON diff dict.
previous: The previous state dict (for determining add vs update).
logfmt: Optional formatter callable ``(value, path) -> str | None``.
``path`` is a dot-notation string; ``"$user"`` is used for the
transaction actor. If the callable returns ``None``, default
formatting is used.
Returns a list of formatted lines (without newlines).
"""
changes: list[tuple[str, list[str], Any]] = []
_collect_changes(diff, [], changes, previous)
if not changes:
return []
lines = []
for change_type, path, value in changes:
lines.extend(_format_change_lines(change_type, path, value, logfmt))
return lines
def colorize_header_parts(
action: str,
user: str | None = None,
extra: str | None = None,
) -> tuple[str, str, str]:
"""Apply Kanta's header colors to the action, user, and extra parts.
``None`` user/extra become empty strings so custom header callbacks can
interpolate the parts directly without fallbacks.
"""
action_str = f"{_ACTION}{action}{_RESET}"
user_str = f"{_USER}{user}{_RESET}" if user else ""
extra_str = f"{_TARGET}{extra}{_RESET}" if extra else ""
return action_str, user_str, extra_str
def format_action_header(
action: str,
user: str | None = None,
extra: str | None = None,
) -> str:
"""Format the default action header line."""
action_str, user_str, extra_str = colorize_header_parts(action, user, extra)
header = action_str
if extra_str:
header = f"{header} {extra_str}"
if user_str:
header = f"{header} by {user_str}"
return header
def log_change(
action: str,
diff: dict,
user: str | None = None,
previous: dict | None = None,
extra: str | None = None,
logfmt: Callable[[Any, str], str | None] | None = None,
*,
logger: logging.Logger = transaction_logger,
level: int = logging.INFO,
log_diff: bool = True,
) -> None:
"""Log a database change with pretty-printed diff.
Args:
action: The action name (e.g., "login", "admin:delete_user").
diff: The JSON diff dict.
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
header (colored by Kanta).
logfmt: Optional formatter callable ``(value, path) -> str | None``.
logger: Logger to write to. Defaults to the ``kanta.transaction`` logger.
level: Log level to use. Defaults to ``logging.INFO``.
log_diff: Whether to build and emit the diff lines. ``False`` skips
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.
"""
diff_logger = logging.getLogger(f"{logger.name}.diff")
diff_lines = (
format_diff(diff, previous, logfmt)
if log_diff and diff_logger.isEnabledFor(level)
else []
)
header = format_action_header(action, user, extra)
if not diff_lines:
logger.log(level, header)
return
if len(diff_lines) == 1:
diff_logger.log(level, f"{header}{diff_lines[0]}")
return
logger.log(level, header)
for line in diff_lines:
diff_logger.log(level, line)
def configure_logging(
*,
skiproot: bool = True,
bootstrap: bool = True,
migration: bool = True,
transaction: bool = True,
diff: bool = True,
) -> None:
"""Configure Kanta's default logging output.
Args:
skiproot: If ``True`` (default), attach a no-prefix stderr handler to
the ``kanta`` logger and set ``kanta.propagate = False`` so Kanta
output is rendered directly without propagating to the root logger.
If ``False``, the child logger enable flags are still applied, but
no handler is added and ``kanta`` propagation is left untouched so
the application's root logger handles Kanta output.
bootstrap: Whether bootstrap logs are enabled.
migration: Whether migration 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
default output can call it, but most applications will configure logging
themselves.
"""
logging.getLogger("kanta.transaction.diff").disabled = not diff
for name, enabled in (
("kanta.bootstrap", bootstrap),
("kanta.migration", migration),
("kanta.transaction", transaction),
):
logging.getLogger(name).propagate = enabled
if not skiproot:
return
target = logging.getLogger("kanta")
target.propagate = False
if not target.handlers:
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s"))
target.addHandler(handler)
target.setLevel(logging.INFO)