Files
kanta/kanta/kanta.py
T
LeoVasanko 753b7eba86 Add separate migration logging, logmigr callback, and log= override
- Split transaction logger (kanta.changes) and migration logger (kanta.migrations).
- Migrations.apply() now returns MigrationResult instead of logging.
- Kanta.open() emits one info summary per DB and debug transaction per migration.
- Add open(log=...) to suppress/redirect default migration logging.
- Add @kanta.logmigr callback for custom migration logging/summaries.
- Add transaction(log=...) to suppress/redirect transaction logging.
- Update tests for the new MigrationResult API and logging behaviour.
2026-06-15 23:13:03 +00:00

334 lines
11 KiB
Python

"""Kanta DB main public API"""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from types import ModuleType, SimpleNamespace
from typing import Generic, TypeVar
from kanta.kantaimpl import KantaImpl
from kanta.serialization import JsonSerializer, Serializer
from kanta.transaction import transaction as _transaction
T = TypeVar("T")
class Kanta(Generic[T]):
"""JSONL persistence layer for a msgspec.Struct database state.
The application defines its schema as a msgspec.Struct (e.g. ``Data``,
``Project``). The Kanta instance holds the live state as that struct type.
Internally it round-trips through plain dicts for diffing, replay,
and serialization.
A background task periodically flushes pending changes to disk.
Call `await kanta.open()` to start the background task,
and `await kanta.close()` to stop it.
All transactions are synchronous — they immediately affect the
in-memory ``kanta.data``. Persistence happens asynchronously in
the background (or via explicit ``await kanta.flush()``).
Usage::
class Data(msgspec.Struct):
users: dict[str, User] = {}
kanta = Kanta("data.db", Data())
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["alice"] = User(name="Alice")
await kanta.close()
"""
def __init__(
self,
filename: Path | str,
data: T,
*,
type: type[T] | None = None,
migrations: ModuleType | str | None = None,
serializer: Serializer | None = None,
flush_interval: float = 0.1,
):
"""Initialize a Kanta persistence instance.
Args:
filename: Path to the database file.
data: Caller-owned root msgspec.Struct state instance.
type: Optional explicit root type. Defaults to ``type(data)``.
migrations: Optional migrations module object or import path.
flush_interval: Background flush interval in seconds.
serializer: Optional serializer implementation.
Raises:
ImportError: If ``migrations`` is a string path that cannot be imported.
ValueError: If migration definitions are invalid.
"""
active_serializer = serializer if serializer is not None else JsonSerializer()
data_type = type if type is not None else data.__class__
self._impl = KantaImpl(
serializer=active_serializer,
filename=filename,
data=data,
type=data_type,
migrations=migrations,
flush_interval=flush_interval,
kanta=self,
)
@property
def data(self) -> T:
"""Current in-memory state object.
Returns:
The live state instance of the configured ``type``.
Notes:
Mutations should only be performed via :meth:`transaction`
to ensure proper diffing and persistence.
"""
return self._impl.data
@data.setter
def data(self, value: T) -> None:
"""Replace the in-memory state object.
Notes:
Mutations should only be performed via :meth:`transaction`
to ensure proper diffing and persistence.
Args:
value: New state object instance.
"""
self._impl.data = value
@property
def version(self) -> int:
"""Current schema/database version.
Returns:
Integer version derived from migrations/replay state.
"""
return self._impl.version
@property
def filename(self) -> Path:
"""Database file path.
Returns:
Filesystem path used for persistence.
"""
return self._impl.filename
@property
def ctx(self) -> SimpleNamespace:
"""User-writable context namespace.
Migration functions receive the ``Kanta`` instance and can read or
mutate ``kanta.ctx`` during migrations.
"""
return self._impl.ctx
@property
def mtime(self) -> datetime | None:
"""Last modification time carried forward from change records.
Returns:
The latest ``m`` value, or ``None`` if no modification time has
been set yet. System operations such as migrations do not update
this value.
"""
return self._impl.mtime
async def open(
self,
*,
create: bool = True,
readonly: bool = False,
log: bool | logging.Logger = True,
) -> None:
"""Open the database file and start background persistence.
This loads existing records, applies configured migrations, and starts
the background flush task.
Args:
create: Whether to create the database file when missing.
If False, opening fails when the file does not exist or is empty.
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.
Calling ``open`` more than once on the same instance is not allowed.
Raises:
kanta.exceptions.DatabaseError: If replay or decoding fails.
kanta.exceptions.DataIntegrityError: If the instance is already open.
"""
await self._impl.open(create=create, readonly=readonly, log=log)
async def __aenter__(self) -> Kanta[T]:
"""Enter async context manager and open the database.
Returns:
The current ``Kanta`` instance itself.
"""
await self.open()
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
"""Exit async context manager and close the database.
Args:
exc_type: Exception type raised inside the context, if any.
exc: Exception instance raised inside the context, if any.
tb: Traceback for the exception, if any.
"""
await self.close()
async def flush(self) -> None:
"""Asynchronously flush pending change records to disk."""
await self._impl.flush()
def request_snapshot(self) -> None:
"""Request a snapshot to be written on the next background iteration."""
self._impl.snapshot.request_force()
async def close(self) -> None:
"""Stop background task, flush pending changes, and close file lock."""
await self._impl.close()
def bootstrap(
self,
fn=None,
*,
action: str = "bootstrap",
user: str | None = None,
mtime: bool | datetime = True,
):
"""Register a bootstrap callback executed during :meth:`open`.
Can be used as ``@kanta.bootstrap`` or ``@kanta.bootstrap(...)``.
The callback receives the live ``data`` object and may be sync or async.
"""
def _register(callback):
self._impl.add_bootstrap(
callback=callback,
action=action,
user=user,
mtime=mtime,
)
return callback
if fn is None:
return _register
return _register(fn)
def fatal_error(self, fn=None):
"""Register fatal error handler callback.
Can be used as ``@kanta.fatal_error``.
The callback receives a :class:`kanta.exceptions.DatabaseError` and may
be sync or async.
"""
def _register(callback):
self._impl.add_fatal_error(callback)
return callback
if fn is None:
return _register
return _register(fn)
def logmigr(self, fn=None):
"""Register a migration logging callback.
Can be used as ``@kanta.logmigr``.
The callback receives a :class:`kanta.migrations.MigrationResult` and
may be sync or async. If registered, it replaces the default migration
logger output; the application is responsible for emitting any log
messages.
"""
def _register(callback):
self._impl.add_logmigr(callback)
return callback
if fn is None:
return _register
return _register(fn)
def logfmt(self, fn=None, *, path: str | None = None):
"""Register a transaction logfmt callback.
Can be used as ``@kanta.logfmt`` or ``@kanta.logfmt(path=...)``.
The callback is called for each value being rendered and receives the
value plus an optional ``path: str`` parameter. It must return
``str | None`` (or inherit from :class:`kanta.callbacks.LogFmt`).
When ``path`` is given, the callback is only invoked for values whose
dot-notation path matches the pattern (full match, shell-style wildcards
such as ``*`` are supported).
"""
def _register(callback):
self._impl.add_logfmt(callback, path=path)
return callback
if fn is None:
return _register
return _register(fn)
def transaction(
self,
action: str,
*,
user: str | None = None,
mtime: bool | datetime = True,
log: bool | logging.Logger = True,
):
"""Create a transactional mutation context manager.
Args:
action: Action label stored in the change record.
user: Optional user identifier stored in metadata and rendered in
the log header. Register a ``@kanta.logfmt`` callback to format
the user value; the path ``"$user"`` is passed for this case.
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
system operations that are not considered modifications. A
:class:`~datetime.datetime` value sets ``m`` to that explicit
time.
log: Controls transaction logging. ``True`` (default) uses the
``kanta.changes`` logger. ``False`` suppresses the transaction
log. A :class:`~logging.Logger` instance writes output to that
logger instead.
Returns:
A context manager yielding the live state object for mutation.
Notes:
On successful exit, a diff is queued for persistence.
If an exception is raised inside the context, in-memory changes are
rolled back.
"""
return _transaction(
self._impl,
action,
user=user,
mtime=mtime,
log=log,
)