32 Commits
Author SHA1 Message Date
LeoVasanko 94c5ddeaba Inject state dicts by name (prev/state), rename MigrationResult to MigrationReport.
State dict injection now works by parameter name (prev/state, annotation
not checked) with DictPrev/DictState tags taking precedence; DictPre/DictPost
remain as aliases. MigrationResult is renamed to MigrationReport with fields
original/version/applied; the old type alias and a deprecated .migrations
property remain. Old symbols stay covered by the original tests; new-style
tests import from the kanta root. Includes some unrelated ruff formatting.
2026-08-27 14:50:34 +00:00
LeoVasanko 4ef74e027f Re-export from kanta configure_logging. 2026-08-27 13:41:31 +00:00
LeoVasanko 6839b48f6d Support filesystem paths in --data, --migrations, and --kanta arguments. 2026-08-13 21:31:21 +00:00
LeoVasanko 955fdd8e1c Locate only the current Python version's site-packages in nearby .venv dirs. 2026-08-13 21:27:14 +00:00
LeoVasanko 20694576c7 Scope sys.path additions around dynamic imports and include nearby .venv site-packages. 2026-08-13 21:23:28 +00:00
LeoVasanko 8be44bd490 Add CWD to sys.path so CLI can import target modules. 2026-08-13 21:12:59 +00:00
LeoVasanko f8a0a85158 Pretty-print snapshot lines and drop microseconds from CLI timestamps. 2026-08-13 21:12:33 +00:00
LeoVasanko 8e436295aa Add experimental kanta CLI for inspecting databases. 2026-08-13 18:44:46 +00:00
LeoVasanko 0b8c1d2da9 Ruff 2026-08-11 01:34:39 +00:00
LeoVasanko 33a8c07043 Make tests follow the earlier change of merging version and msgspec migration records into one. 2026-08-11 01:34:06 +00:00
LeoVasanko be3acaed3e Smarter change record formatting to 80ch wide, shortened with ellipsis character rather than three dots. 2026-08-11 01:21:34 +00:00
LeoVasanko f489216c2a All remaining events through logformat, cleaner migration message, prettier demo. 2026-08-07 17:12:29 +00:00
LeoVasanko 08f3c44f1f Group all migration events into one row migrate:vN (if version changed) OR migrate:msgspec
- Log as a single event
- Logging config has debug parameter to lower level to DEBUG, showing migration diffs
2026-08-07 16:20:56 +00:00
LeoVasanko e0046ae9d9 Cleanup 2026-08-07 16:10:46 +00:00
LeoVasanko c101f187d8 Implement richer, fully customizable logging; customizable timestamps (#1)
- `@kanta.logemit` handler for completely customizable logging output, with `LogEvent` structure and `kanta.tty.Line` helper to create colorized text and fixed width fields
- `configure_logging(diff=False)` to disable diff display globally (supplementing per-transaction `logdiff=False`)
- `transaction(extra: Any = ...)` for passing extra strings or custom metadata to logs
- `@kanta.clock` to provide user controlled clock for deterministic database outputs
- Added a demo script that shows basic functions, migrations, logfmt etc.
2026-08-07 15:08:28 +00:00
LeoVasanko 3a56bfbb10 Add kanta.bootstrap logger and configurable logging setup
- Bootstrap records are now logged via kanta.bootstrap at INFO level.
- Existing databases log 'Using <path>' at DEBUG on kanta.bootstrap.
- New databases log 'Created <path>' at INFO on kanta.bootstrap.
- Renamed loggers: kanta.changes -> kanta.transaction, kanta.migrations -> kanta.migration.
- configure_logging() gains bootstrap/migration/transaction/skiproot kwargs.
- Default configure_logging() attaches a no-prefix stderr handler to kanta and stops propagation.
- With skiproot=False, child logger propagation flags are still applied but kanta itself is left untouched.
- Updated tests and docstrings.
2026-06-20 19:10:58 +00:00
LeoVasanko 55fa475a13 Log new database creation, bootstrap like a transaction. 2026-06-20 18:15:08 +00:00
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
LeoVasanko 42789e6619 Revised migration context: don't store changerecord unless something was changed, and always snapshot if and after changes done. 2026-06-15 22:14:03 +00:00
LeoVasanko c4726e6728 Stricter versioning: always store migrate version record, file must be within versions included in migrations. Log only for migrations that made changes. Allow deleting older migration functions when no longer required. 2026-06-15 03:09:45 +00:00
LeoVasanko 66e92739ab Refactor Migrations internals, add caching to avoid reloading per each Kanta instance. Naming changed from MigrationRegistry to Migration, module from migrate to kanta.migrations. 2026-06-15 01:12:42 +00:00
LeoVasanko 4dc2f0648e Fix creation of new database, ensuring that a bootstrap record of the initial state is always written. 2026-06-15 00:27:49 +00:00
LeoVasanko bec4635460 Add kanta.ctx SimpleNamespace for user variables. This is passed to migration functions if they take a second argument. Remove the old MigrationCtx system. 2026-06-14 19:54:23 +00:00
LeoVasanko c04a245366 Add read only mode that doesn't use locking. Useful for inspection while the database is in use or when no changes are intended (except in RAM). 2026-06-14 19:35:06 +00:00
LeoVasanko b501fcca86 Only Kanta exported at root, others available from submodules. 2026-06-13 21:26:35 +00:00
LeoVasanko 7e553bd868 Improved log formatting support by @kanta.logfmt, which replaces old resolver and user_display arguments (breaking change). 2026-06-13 21:22:05 +00:00
LeoVasanko 4db046d627 Implement proper bootstrap and fatal error handling via decorators. Breaks compatibility. 2026-06-13 02:53:09 +00:00
LeoVasanko acabc049e7 Fixed typing. 2026-06-13 00:59:18 +00:00
LeoVasanko 43f2cdbd53 Removed useless lock. 2026-06-13 00:55:05 +00:00
LeoVasanko ebfed10f24 Proper ts/mtime setting and tracking. Added Kanta.mtime property for reading current change time. 2026-06-13 00:49:35 +00:00
LeoVasanko e8d123a748 Don't make package root a module (debug). 2026-06-13 00:31:07 +00:00
LeoVasanko 56b955cfdf Fix imports 2026-06-12 19:23:23 +00:00
42 changed files with 5925 additions and 461 deletions
+74
View File
@@ -51,6 +51,80 @@ asyncio.run(main())
3. Let Kanta flush queued changes to disk in the background. 3. Let Kanta flush queued changes to disk in the background.
4. Use snapshots and replay for fast startup and full history. 4. Use snapshots and replay for fast startup and full history.
## Bootstrap and Open Modes
When `open()` creates a brand-new database, it always writes a single bootstrap
change record from the initial data object you passed to `Kanta(...)`. The
simplest bootstrap is therefore the object itself — no extra code is required.
Bootstrap handlers are optional. Use them only when you need to modify the
initial state at creation time, for example to seed defaults or perform
expensive/external setup that should happen exactly once:
```python
kanta = Kanta("data.kantadb", Data())
@kanta.bootstrap(action="seed", user="system")
def seed_defaults(data) -> None:
data.users["admin"] = User(name="Admin")
await kanta.open()
```
You can also use `@kanta.bootstrap` with no arguments and async handlers:
```python
@kanta.bootstrap
async def bootstrap_async(data) -> None:
data.counter = 1
```
Whether or not handlers are registered, exactly one bootstrap change record is
written when a new database is created. The record contains the initial object,
or the state after all bootstrap handlers have run. When handlers are present:
- they run in registration order,
- bootstrap metadata (`action`, `user`, `mtime`) is taken from the last
registration.
If any bootstrap handler raises, Kanta closes and removes the database file,
then re-raises the error.
`open()` also supports strict open mode:
```python
await kanta.open(create=False)
```
With `create=False`, open fails if the database file does not exist or is
empty.
Read-only mode opens an existing database without locking it or starting the
background flush task. This is useful for readers that must not block the
writer or modify the file:
```python
await kanta.open(readonly=True)
```
In read-only mode, records are replayed and migrations are applied in memory,
but transactions and explicit flushes are rejected and the file is never
created if missing.
## Fatal Error Handlers
Fatal background write errors can be observed with a decorator:
```python
import os
import signal
@kanta.fatal_error
async def on_fatal(err):
os.kill(os.getpid(), signal.SIGTERM) # Die
```
Multiple fatal handlers are supported and run in registration order.
## Migrations ## Migrations
Adding or removing a field and other such simple operations are automatic, but when the time comes to really change your data model, implement a `migrate_v1` function that converts your old data to the new form. This works on plain built-in dict and other types, to avoid needing to preserve old versions of your structs. Adding or removing a field and other such simple operations are automatic, but when the time comes to really change your data model, implement a `migrate_v1` function that converts your old data to the new form. This works on plain built-in dict and other types, to avoid needing to preserve old versions of your structs.
-16
View File
@@ -1,16 +0,0 @@
"""Project-root shim package for local development layout.
This forwards imports to the inner `kanta/` package directory so
`from kanta import ...` works when running tests from the workspace root.
"""
import importlib
from pathlib import Path
_inner_pkg = Path(__file__).with_name("kanta")
if str(_inner_pkg) not in __path__:
__path__.append(str(_inner_pkg))
_pkg = importlib.import_module(".kanta", __name__)
__all__ = list(getattr(_pkg, "__all__", ()))
globals().update({name: getattr(_pkg, name) for name in __all__})
+1
View File
@@ -0,0 +1 @@
demo.kantadb
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env -S uv run
import asyncio
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
import msgspec
from kanta import Kanta, configure_logging
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
kanta_v0 = Kanta(filename, Data())
@kanta_v0.bootstrap
def bootstrap(data: Data) -> None:
"""Create the initial admin user."""
data.users["userid001"] = {"name": "Alice", "role": "admin"}
# Redefinition to simulate new version
class Data(msgspec.Struct):
users: dict[str, dict] = {}
total: int = 0 # Replaces old counter field
lang: str = "en" # New field
def migrate_v1(d: dict) -> None:
"""Rename counter to total"""
d["total"] = d["counter"]
kanta_v1 = Kanta(filename, Data(), migrations=sys.modules[__name__])
@kanta_v1.logfmt
def resolve_user(value: str, path: str, state: dict) -> str | None:
"""Resolve user ids to names from the database state itself."""
if path != "$user" and not path.startswith("users."):
return None
return state.get("users", {}).get(value, {}).get("name")
async def main() -> None:
filename.unlink(missing_ok=True)
print("Database creation with v0 schema and basic transactions:\n")
# Open and close automatically; you can also `await kanta.open()` instead
async with kanta_v0 as kanta:
with kanta.transaction(action="create", user="userid001") as data:
data.users["userid002"] = {"name": "Bob", "role": "user"}
with kanta.transaction(action="update", user="userid001") as data:
data.users["userid002"]["role"] = "editor"
data.counter = 1
# Display-only extra string, appended after the action.
with kanta.transaction(
action="export", user="userid002", extra="extra info"
) as data:
data.counter = 2
print("\nA new data model, migrations and logfmt pretty names:\n")
async with kanta_v1 as kanta:
with kanta.transaction(
action="update", user="userid002", extra=filename.name
) as data:
data.total += 1
try:
with kanta.transaction(action="reset", user="userid001") as data:
data.total = 99
raise ValueError("simulated failure")
except ValueError:
print(
f"\nReset rolled back: {data.total=} (we can always read data without tx)\n"
)
with kanta.transaction(action="delete", user="userid002") as data:
del data.users["userid001"]
# 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__":
configure_logging(debug=True)
asyncio.run(main())
+202 -3
View File
@@ -76,21 +76,37 @@ history.
- In-memory data is defined by an application `msgspec.Struct` type. - In-memory data is defined by an application `msgspec.Struct` type.
- Kanta round-trips through plain builtins for persistence and diffing. - Kanta round-trips through plain builtins for persistence and diffing.
- Dict keys are serialized as strings (`str_keys=True`) for stable JSON form. - Dict keys are serialized as strings (`str_keys=True`) for stable JSON form.
- Normalization changes introduced by struct decode/encode are logged as - Normalization changes introduced by struct decode/encode are logged together
`migrate:msgspec` when they produce a diff. with migrations as `migrate:vN`, or as `migrate:msgspec` when no migration
ran but normalization still produces a diff.
## Transaction Semantics ## Transaction Semantics
- `kanta.transaction(action=...)` captures a pre-transaction snapshot dict. - `kanta.transaction(action=...)` captures a pre-transaction snapshot dict.
- By default a transaction updates the modification time `m` to the current UTC
time.
- `mtime=True|False|datetime` controls the modification time `m`:
- `True` (default) sets `m` to the current UTC time.
- `False` omits `m`, leaving the previous modification time in effect.
- A `datetime` sets `m` to that explicit value.
- System operations such as `migrate:msgspec` use `mtime=False` so they are not
considered modifications and do not advance `m`.
- On success: - On success:
- compute diff between previous builtins and current builtins, - compute diff between previous builtins and current builtins,
- queue a `ChangeRecord` if non-empty. - queue a `ChangeRecord` if non-empty,
- update `kanta.mtime` when the change carries an `m` value.
- On exception: - On exception:
- restore in-memory data from snapshot, - restore in-memory data from snapshot,
- re-raise the exception. - re-raise the exception.
Nested transactions are rejected. Nested transactions are rejected.
## Modification Time
`kanta.mtime` exposes the last modification time carried forward from change
records. It is updated by normal transactions and preserved across snapshots and
reloads, while system operations such as migrations leave it unchanged.
## Flush and Lifecycle ## Flush and Lifecycle
- Writes are queued in memory. - Writes are queued in memory.
@@ -99,6 +115,189 @@ Nested transactions are rejected.
- `kanta.close()` performs final flush and releases file resources. - `kanta.close()` performs final flush and releases file resources.
- `async with Kanta(...)` guarantees open/close lifecycle management. - `async with Kanta(...)` guarantees open/close lifecycle management.
### Open Modes
- `await kanta.open()` (default) creates the database file if missing.
- `await kanta.open(create=False)` fails when the file is missing or empty.
- `await kanta.open(readonly=True)` opens an existing database read-only.
- The file is opened without acquiring a lock and without a background flush
task.
- Existing records are replayed and migrations are still applied in memory.
- Transactions and explicit flushes are rejected.
- The file is never created if missing.
### Callbacks
All callbacks are registered via decorators and receive arguments by their
annotation types. Parameters without a supported annotation are only allowed
when they have a default value.
#### Bootstrap Callbacks
- When `open()` creates a new database, it always writes a single bootstrap
`ChangeRecord`.
- The simplest bootstrap is the initial data object passed to `Kanta(...)`;
bootstrap callbacks are optional and only needed when you want to modify or
enrich that object at creation time.
- Register callbacks via:
- `@kanta.bootstrap`
- `@kanta.bootstrap(action=..., user=..., mtime=...)`
- Bootstrap callbacks may be sync or async. The live root data object is
injected by annotating a parameter with the struct type passed to `Kanta`,
and the `Kanta` instance itself can be injected by annotating a parameter
with `Kanta`.
- Multiple bootstrap callbacks are supported:
- callbacks execute in registration order,
- exactly one bootstrap `ChangeRecord` is queued,
- bootstrap metadata (`action`, `user`, `mtime`) is taken from the last
callback registration.
- If no bootstrap callbacks are registered, the bootstrap record still uses
`action="bootstrap"` and contains the initial data object.
- If any bootstrap callback raises, Kanta closes and removes the database file,
then re-raises the exception.
#### Fatal Error Handlers
- Fatal background persistence errors can be handled with `@kanta.fatal_error`.
- Handlers may be sync or async. The `DatabaseError` is injected by annotating
a parameter with `DatabaseError`; `Kanta` may also be injected.
- Multiple handlers are supported and invoked in registration order. A failing
handler is logged and does not prevent subsequent handlers from running.
#### Clock
- `@kanta.clock` registers a callback `() -> datetime` that replaces the
default UTC clock. Its value is used for all record timestamps (`ts`, and
`m` when `mtime` is `True`) and for snapshot timestamps.
- The clock is only read when a timestamp is actually produced; no-op
transactions and skipped snapshot checks do not read it.
- Register before `open()` so that bootstrap and migration records use the
custom clock as well. This is mainly useful for tests and reproducible
demos.
#### Transaction Log Formatting
- Logfmt callbacks prettify identifiers in the change log and are registered with
`@kanta.logfmt`.
- A logfmt callback is called for every value Kanta renders: diff values, path
components, and the transaction `user`. It receives the value as its first
parameter and optionally a `path: str` parameter with the dot-notation path
to the value. The special path `"$user"` is used when rendering the
transaction actor, replacing the old `user_display` parameter.
- The callback returns `str | None`: a string replaces the default rendering,
while `None` means "fall through to the next formatter".
- State dicts are injected by parameter name or annotation tag, which share
the same vocabulary: `prev` receives the previous state dict and `state`
the current one. Matching by name ignores the annotation entirely. The
`DictPrev`/`DictState` aliases (`Annotated[dict, "prev"]` /
`Annotated[dict, "state"]`) work under any parameter name, and a tag takes
precedence over the name. `DictPre` and `DictPost` are kept as aliases of
`DictPrev` and `DictState`. The `Kanta` instance can also be injected.
- Alternatively, a logfmt callback can be a class inheriting from `LogFmt`; the
framework instantiates it with the state dicts and calls its
`resolve(value, path) -> str | None` method.
- Multiple logfmt callbacks are stacked in registration order; the first
callback to return a non-`None` result wins. If none handle a value, Kanta
falls back to its default formatting.
The decorator accepts an optional ``path`` so the callback only runs for
values at that exact path:
```python
@kanta.logfmt(path="$user")
def resolve_user(value: str, state: dict) -> str | None:
return state.get("users", {}).get(value, {}).get("name")
@kanta.logfmt(path="users.uuid-1")
def resolve_user_key(value: str) -> str | None:
return names_by_id.get(value)
```
#### Transaction Log Headers
- 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 value that is
shown after the action in the header. Anything other than `None` is
printed str-converted (colored by Kanta), unless a custom logemit handler
does something else with it; it is never persisted in the `ChangeRecord`.
- `kanta.transaction(..., logdiff=False)` skips building and printing the diff
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.
#### Log Emitters
- Every change-related message Kanta emits (transaction/bootstrap/migration
changes, file created/opened lines, migration summaries, aborted
transactions) is described by a `kanta.logging.LogEvent` and dispatched
through
`kanta.logging.emit_event`. Kanta's own output goes through the same
mechanism: when no `logemit` callback handles an event,
`kanta.logging.default_emit` renders it with the built-in formatting.
- A `LogEvent` carries the event `kind` (`"change"`, `"created"`,
`"opened"`, `"migrated"`, `"aborted"`), the preferred `logger` and `level`,
the
`kanta` instance, and all relevant state: `action`, `user`, `extra`,
`error` (for aborted transactions), `diff`, `previous`/`current` state
dicts, the built `logfmt` chain, and version info for migration events.
Application-specific context (e.g. a connection id) can be stored in
`kanta.ctx` — a user-writable namespace — and read back in callbacks as
`event.kanta.ctx`, which also covers creation/bootstrap events.
- The built-in formatting is assembled from standard blocks that custom
emitters can reuse as-is or replace piecemeal:
- `event.header` — a lazy property producing the default one-line header
for any kind: `<action>[ <extra>][ by <user>]` for changes,
`<action>[ <extra>][ by <user>] transaction aborted: <error>` for aborts,
and the `🛢️ <file> created|opened|migrated ...` summaries. It is
settable: assign
`event.header = ...` and return truthy to restyle the header while
keeping the default diff routing.
- `event.diff_lines` — a lazy property producing the pretty diff body for
change events (built only if accessed).
- `default_emit` itself is just `header` plus the `diff_lines` routing.
- `@kanta.logemit` registers a callback receiving the event. The callback
decides what is logged and where: it may log one or more messages on
`event.logger`, log somewhere else, or nothing at all. A falsy return
value marks the event handled and stops the chain; a truthy return value
passes the event — possibly modified — to the next registered callback.
When all callbacks pass, `default_emit` renders the event; a callback may
also call `default_emit(event)` itself to delegate events it does not
customize. Operational diagnostics (integrity errors, background flush
failures) do not go through this mechanism.
- Logging never breaks functionality: a crashing `logemit` callback is
reported with `logger.exception` and the event falls back to the built-in
formatting; if the built-in formatting itself fails, the error is reported
and swallowed. The same applies to `logfmt` callbacks (a failing one is
treated as a fall-through) and `logmigr` callbacks.
```python
@kanta.logemit
def emit(ev: LogEvent):
if ev.kind != "change":
return default_emit(ev) # delegate, no chaining needed
# Restyle the header; default_emit keeps routing the diff body.
ev.header = str(Line().user(ev.user or "-", width=20)(" ").action(ev.action))
return True
```
#### Terminal Formatting Helpers
- `kanta.tty` provides the building blocks used by Kanta's own rendering:
- `colors`: the mutable color palette. Colors are bare SGR parameter
strings (e.g. `"1;34"`, `"38;5;226"`) without escape framing. Attributes
are read at render time, so assignments (`colors.action = "36"`) and
additions (`colors.session = "38;5;226"`) take effect immediately.
- `Line`: builds a terminal string part by part. Calling it appends
content (`str`-converted); `.<colorname>` arms a palette color for the
next call only, and the reset is folded into a single escape sequence
with whatever color comes next. `width=`/`align=` pad by display width;
`str(line)` finishes the line and restores default colors.
- `strip_ansi`, `displaywidth` (wide chars and emoji count correctly) and
`pad` for working with pre-colored strings.
## Migrations ## Migrations
- Migration source is configured on `Kanta(...)` via `migrations=`. - Migration source is configured on `Kanta(...)` via `migrations=`.
+12 -21
View File
@@ -1,26 +1,17 @@
from .diff import compute_diff from .callbacks import DictPrev, DictState, LogFmt
from .diff import replay_jsonl as replay from .exceptions import DatabaseError
from .exceptions import DatabaseError, DataIntegrityError, FileLockError, ReplayError
from .filelock import LockedFile
from .kanta import Kanta from .kanta import Kanta
from .logging import configure_logging, format_diff, log_change from .logging import LogEvent, configure_logging
from .serialization import JsonSerializer, MsgPackSerializer from .migrations import MigrationReport
from .structs import ChangeRecord, Snapshot
__all__ = [ __all__ = [
"ChangeRecord",
"compute_diff",
"configure_logging",
"DataIntegrityError",
"DatabaseError",
"FileLockError",
"format_diff",
"JsonSerializer",
"Kanta", "Kanta",
"LockedFile", "DatabaseError",
"log_change", "configure_logging",
"MsgPackSerializer", # Callback argument types
"ReplayError", "DictPrev",
"replay", "DictState",
"Snapshot", "LogEvent",
"LogFmt",
"MigrationReport",
] ]
+585
View File
@@ -0,0 +1,585 @@
"""Module-level CLI for reading a kantadb file and printing its change log."""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import importlib
import importlib.util
import logging
import sys
import tempfile
from pathlib import Path
from typing import Any
import msgspec
from kanta import Kanta
from kanta.callbacks import InjectionContext
from kanta.exceptions import DatabaseError, DataIntegrityError, ReplayError
from kanta.logging import LogEvent, emit_event, migration_logger
from kanta.replaylog import (
RangeNotFoundError,
Selection,
SnapshotEvent,
_snapshot_lines,
end_of_file,
record_change_event,
record_label,
replay_events,
scan_events,
select,
)
from kanta.serialization import Serializer, dict_to_struct, struct_to_dict
from kanta.structs import ChangeRecord, Snapshot
from kanta.tty import Line
EXIT_SUCCESS = 0
EXIT_GENERIC = 1
EXIT_RANGE_ERROR = 2
EXIT_PARSE_ERROR = 10
EXIT_MIGRATION_ERROR = 20
EXIT_VALIDATION_ERROR = 21
_logger = logging.getLogger(__name__)
class _CliError(Exception):
"""A user-facing error message paired with a process exit code."""
def __init__(self, message: str, code: int = EXIT_GENERIC) -> None:
self.code = code
super().__init__(message)
def _import_dotted(path: str) -> Any:
"""Import ``module.submodule.Attr`` or a filesystem path and return the attribute."""
if _is_file_path(path):
return _import_from_file(path)
if "." not in path:
raise ValueError(f"dotted path must contain a dot: {path!r}")
module_name, attr_name = path.rsplit(".", 1)
module = importlib.import_module(module_name)
try:
return getattr(module, attr_name)
except AttributeError as exc:
raise ImportError(f"{path!r} not found in {module_name!r}") from exc
def _is_file_path(path: str) -> bool:
"""Return True if *path* looks like a filesystem path rather than a dotted name."""
return "/" in path or "\\" in path or ":" in path
def _import_from_file(path: str) -> Any:
"""Import a module or attribute from a filesystem path.
*path* may be ``path/to/file.py`` (returns the module) or
``path/to/file.py:symbol`` (returns ``symbol`` from the module).
"""
if ":" in path:
file_path, symbol = path.rsplit(":", 1)
else:
file_path, symbol = path, None
file_path = Path(file_path).resolve()
if not file_path.exists():
raise ImportError(f"{file_path!r} not found")
if not file_path.is_file():
raise ImportError(f"{file_path!r} is not a file")
module_name = f"_kanta_cli_{file_path.stem}_{file_path.stat().st_ino}"
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
raise ImportError(f"cannot load {file_path!r}")
module = importlib.util.module_from_spec(spec)
file_dir = str(file_path.parent)
added_dir = False
if file_dir not in sys.path:
sys.path.insert(0, file_dir)
added_dir = True
try:
sys.modules[module_name] = module
spec.loader.exec_module(module)
finally:
if added_dir:
sys.path.remove(file_dir)
if symbol is None:
return module
try:
return getattr(module, symbol)
except AttributeError as exc:
raise ImportError(f"{symbol!r} not found in {file_path!r}") from exc
def _import_kanta_object(path: str) -> Any:
"""Import a Kanta object by module or filesystem path.
If ``path`` names an importable module, look up an object named
``kanta`` in it; otherwise treat ``path`` as ``module.attr`` or
``path/to/file.py[:kanta]`` referring directly to the object.
"""
if _is_file_path(path):
if ":" in path:
return _import_from_file(path)
module = _import_from_file(path)
try:
return getattr(module, "kanta")
except AttributeError as exc:
raise ImportError(f"no 'kanta' object found in {path!r}") from exc
try:
spec = importlib.util.find_spec(path)
except ImportError:
spec = None
if spec is not None:
module = importlib.import_module(path)
try:
return getattr(module, "kanta")
except AttributeError as exc:
raise ImportError(f"no 'kanta' object found in module {path!r}") from exc
return _import_dotted(path)
def _format_ts(dt) -> str:
"""Return a local-looking timestamp without a timezone offset or microseconds."""
return dt.replace(tzinfo=None, microsecond=0).isoformat(sep=" ")
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="kanta",
description="Read a kantadb file and print each change record to the console.",
)
parser.add_argument(
"file",
help="Path to the kantadb file, or '-' to read from stdin.",
)
parser.add_argument(
"-d",
"--data",
metavar="MOD",
help=(
"Dotted path or filesystem path to the root data type."
" Examples: myapp.models.Data, myapp/models.py:Data."
),
)
parser.add_argument(
"-m",
"--migrations",
metavar="MOD",
help=(
"Dotted path or filesystem path to the migrations module."
" Examples: myapp.migrations, myapp/migrations.py."
),
)
parser.add_argument(
"-k",
"--kanta",
metavar="MOD",
help=(
"Module path or filesystem path to an existing Kanta object to use."
" Either a module containing an object named 'kanta' (e.g. myapp.db),"
" a dotted path to the object (e.g. myapp.db.kanta), or a file path"
" (e.g. myapp/db.py or myapp/db.py:kanta). Its type, migrations, and"
" logfmt/logemit callbacks are used. Cannot be combined with -d or -m."
),
)
parser.add_argument(
"-o",
"--output",
help="Write the final replayed state as JSON to this file, or '-' for stdout.",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Suppress normal change/snapshot logs; only print warnings and errors.",
)
parser.add_argument(
"-r",
"--range",
help=(
"Python-style range to process. Units: plain number = change index,"
" lN = line number, sN = snapshot, vN = version. Negative snapshot"
" values count from the end (s-1 is the last snapshot). Use ':' for"
" half-open ranges and '..' for inclusive end ranges. Examples:"
" '2:5', '2..5', 'l10:l20', 's1:s3', 'v0:v2', 's-1:', ':-1', '-1'."
),
)
args = parser.parse_args(argv)
if args.kanta and (args.data or args.migrations):
parser.error("-k/--kanta cannot be used together with -d or -m")
return args
def _print_change_log(
label: str,
record: ChangeRecord,
previous: dict[str, Any],
current: dict[str, Any],
kanta: Kanta[Any],
) -> None:
"""Log a single change record to stderr.
The record is dispatched as a :class:`LogEvent` through the Kanta
object's logemit handlers; the CLI's own rendering (with the ``l<N>``
label and timestamp) is the fallback when no handler claims the event.
"""
event = record_change_event(record, previous, current, kanta)
def render(ev) -> None:
ts = _format_ts(record.ts)
lines = ev.diff_lines
if not lines:
print(f"{label} {ts} {ev.header}", file=sys.stderr)
elif len(lines) == 1:
print(f"{label} {ts} {ev.header}{lines[0]}", file=sys.stderr)
else:
print(f"{label} {ts} {ev.header}", file=sys.stderr)
for line in lines:
print(line, file=sys.stderr)
print(file=sys.stderr)
emit_event(
event,
kanta._impl.callback_registry.logemit_handlers,
fallback=render,
)
def _format_size(n: int) -> str:
"""Return a human-readable byte size."""
if n < 1024:
return f"{n} B"
if n < 1024 * 1024:
return f"{n / 1024:.1f} kB"
return f"{n / (1024 * 1024):.1f} MB"
def _find_venv_site_packages(start: Path) -> list[Path]:
"""Return site-packages dirs of ``.venv`` directories from *start* to parents."""
py_dir = f"python{sys.version_info.major}.{sys.version_info.minor}"
found: list[Path] = []
for parent in [start, *start.parents]:
venv = parent / ".venv"
if not venv.is_dir():
continue
site_packages = venv / "lib" / py_dir / "site-packages"
if site_packages.is_dir():
found.append(site_packages)
continue
# Windows layout
win_site = venv / "Lib" / "site-packages"
if win_site.is_dir():
found.append(win_site)
return found
@contextlib.contextmanager
def _extra_import_paths():
"""Temporarily add current dir and nearby venv site-packages to ``sys.path``.
The current directory is inserted first, then local ``.venv`` site-packages,
then any parent ``.venv`` site-packages. Only paths that were not already
present are added, and only those added paths are removed on exit.
"""
paths_to_add = [str(Path.cwd())]
paths_to_add.extend(str(p) for p in _find_venv_site_packages(Path.cwd()))
added: list[str] = []
for path in reversed(paths_to_add):
if path not in sys.path:
sys.path.insert(0, path)
added.append(path)
try:
yield
finally:
for path in added:
if path in sys.path:
sys.path.remove(path)
def _print_snapshot_indicator(
label: str,
snap: Snapshot,
index: int,
serializer: Serializer,
) -> None:
"""Print a snapshot indicator line to stderr.
``snapshot s<N>`` is rendered in bright white; the version, optional mtime
and data size are printed in normal and dark colors respectively.
"""
ts = _format_ts(snap.ts)
line = Line().snapshot("snapshot").snapshot(f" s{index}")
line.target(f" v{snap.v}")
if snap.m is not None:
line.target(f" {_format_ts(snap.m)}")
size = len(serializer.encode(snap.state))
line.path_prefix(f" {_format_size(size)}")
print(f"{label} {ts} {line}", file=sys.stderr)
async def _log_migration(
kanta: Kanta[Any],
filename: Path,
result,
previous_version: int,
quiet: bool,
) -> None:
"""Log an applied migration through the Kanta instance's callbacks.
Routes to the object's logmigr callbacks when registered (like
:meth:`KantaImpl._handle_migration_log`); otherwise emits a ``migrated``
event through its logemit handlers, falling back to a stderr line.
"""
registry = kanta._impl.callback_registry
if registry.has("logmigr"):
try:
await registry.invoke(
"logmigr",
InjectionContext(kanta=kanta, report=result),
)
except Exception:
_logger.exception("logmigr callback failed")
return
if quiet:
return
descriptions = [f"{m.name} ({m.description})" for m in result.applied if m.changed]
emit_event(
LogEvent(
kind="migrated",
logger=migration_logger,
kanta=kanta,
filename=str(filename),
from_version=previous_version,
to_version=result.version,
migrations=descriptions,
),
registry.logemit_handlers,
fallback=lambda ev: print(ev.header, file=sys.stderr),
)
def _get_kanta(args: argparse.Namespace, filename: Path) -> tuple[Kanta[Any], bool]:
"""Return the Kanta instance to work with, and whether the CLI owns it.
With ``-k`` the existing object is used as-is (and never closed by us);
otherwise an instance is constructed with an empty dict state.
"""
if args.kanta:
try:
obj = _import_kanta_object(args.kanta)
except (ImportError, ValueError) as exc:
raise _CliError(f"Invalid --kanta value: {exc}") from exc
if not isinstance(obj, Kanta):
raise _CliError(
f"Invalid --kanta value: {args.kanta!r} is not a Kanta object"
)
return obj, False
try:
return Kanta(filename, {}, type=dict, migrations=args.migrations), True
except Exception as exc:
if args.migrations:
raise _CliError(f"Migration error: {exc}", EXIT_MIGRATION_ERROR) from exc
raise _CliError(f"Failed to initialize database: {exc}") from exc
async def _run(args: argparse.Namespace) -> int:
cleanup_path: Path | None = None
if args.file == "-":
content = sys.stdin.buffer.read()
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".kantadb")
tmp.write(content)
tmp.close()
filename = Path(tmp.name)
cleanup_path = filename
else:
filename = Path(args.file)
if not filename.exists():
raise _CliError(f"File not found: {filename}")
content = filename.read_bytes()
data_type: type[Any] | None = None
if args.data:
with _extra_import_paths():
try:
data_type = _import_dotted(args.data)
except (ImportError, ValueError) as exc:
raise _CliError(f"Invalid --data value: {exc}") from exc
kanta: Kanta[Any] | None = None
kanta_owned = False
kanta_typed: Kanta[Any] | None = None
try:
with _extra_import_paths():
kanta, kanta_owned = _get_kanta(args, filename)
if data_type is None and args.kanta and kanta._impl.data_type is not dict:
data_type = kanta._impl.data_type
# Decode and validate the whole file into positioned events.
try:
events, change_count = scan_events(content, kanta)
except ReplayError as exc:
raise _CliError(str(exc), EXIT_PARSE_ERROR) from exc
except Exception as exc:
raise _CliError(
f"Failed to replay records from {filename}: {exc}",
EXIT_PARSE_ERROR,
) from exc
snapshot_line_to_index = {
line: idx for idx, line in enumerate(_snapshot_lines(events))
}
# Resolve -r into a line range or a single snapshot selection.
try:
selection = (
select(args.range, events, change_count)
if args.range is not None
else Selection(0, end_of_file(events))
)
except RangeNotFoundError as exc:
raise _CliError(str(exc), EXIT_RANGE_ERROR) from exc
except ValueError as exc:
raise _CliError(f"Invalid --range value: {exc}") from exc
if selection.snapshot is not None:
snap_event = selection.snapshot
state = snap_event.snap.state
version = snap_event.snap.v
if not args.quiet:
_print_snapshot_indicator(
record_label(snap_event.line_number, snap_event.record_index),
snap_event.snap,
snapshot_line_to_index[snap_event.line_number],
kanta._impl.serializer,
)
print(file=sys.stderr)
else:
# Replay up to the range end, printing logs within the range.
state = {}
version = 0
printed = False
for event, previous, current in replay_events(events, selection.end_line):
state = current
version = event.version
if event.line_number < selection.start_line or args.quiet:
continue
label = record_label(event.line_number, event.record_index)
if isinstance(event, SnapshotEvent):
_print_snapshot_indicator(
label,
event.snap,
snapshot_line_to_index[event.line_number],
kanta._impl.serializer,
)
else:
assert previous is not None
_print_change_log(label, event.record, previous, current, kanta)
printed = True
if printed:
print(file=sys.stderr)
# Apply optional migrations to the range-end state.
if kanta._impl.migrations is not None:
try:
previous_version = version
result = kanta._impl.migrations.apply(state, version, kanta)
version = result.version
except Exception as exc:
raise _CliError(
f"Migration error: {exc}", EXIT_MIGRATION_ERROR
) from exc
if version != previous_version:
await _log_migration(
kanta, filename, result, previous_version, args.quiet
)
output_state: dict[str, Any]
if data_type is not None:
try:
data = dict_to_struct(
state, data_type, serializer=kanta._impl.serializer
)
except (
msgspec.ValidationError,
msgspec.DecodeError,
TypeError,
ValueError,
) as exc:
raise _CliError(
f"Validation error: {exc}", EXIT_VALIDATION_ERROR
) from exc
if args.kanta:
# The file was already fully decoded and validated above with
# the object's own serializer, and its migrations were applied
# to the state; no need to re-open through a new instance.
print(f"{data}", file=sys.stderr)
output_state = struct_to_dict(data, serializer=kanta._impl.serializer)
else:
kanta_typed = Kanta(
filename, data, type=data_type, migrations=args.migrations
)
try:
await kanta_typed.open(create=False, readonly=True, log=False)
print(f"{data}", file=sys.stderr)
except (msgspec.ValidationError, msgspec.DecodeError) as exc:
raise _CliError(
f"Validation error: {exc}", EXIT_VALIDATION_ERROR
) from exc
except DataIntegrityError as exc:
raise _CliError(f"Parse error: {exc}", EXIT_PARSE_ERROR) from exc
except DatabaseError as exc:
if not args.migrations or exc.cause_type == "ReplayError":
raise _CliError(
f"Parse error: {exc}", EXIT_PARSE_ERROR
) from exc
raise _CliError(
f"Migration error: {exc}", EXIT_MIGRATION_ERROR
) from exc
except Exception as exc:
if args.migrations:
raise _CliError(
f"Migration error: {exc}", EXIT_MIGRATION_ERROR
) from exc
raise _CliError(f"Failed to open {filename}: {exc}") from exc
output_state = kanta_typed._impl.statedict
else:
output_state = state
if args.output:
try:
out_bytes = msgspec.json.encode(output_state)
if args.output == "-":
sys.stdout.buffer.write(out_bytes)
else:
Path(args.output).write_bytes(out_bytes)
except Exception as exc: # pragma: no cover
raise _CliError(f"Failed to write output: {exc}") from exc
return EXIT_SUCCESS
finally:
if kanta_typed is not None:
await kanta_typed.close()
if kanta is not None and kanta_owned:
await kanta.close()
if cleanup_path is not None:
cleanup_path.unlink(missing_ok=True)
def main(argv: list[str] | None = None) -> int:
"""Entry point for ``python -m kanta``."""
args = _parse_args(argv)
try:
return asyncio.run(_run(args))
except _CliError as exc:
print(exc, file=sys.stderr)
return exc.code
if __name__ == "__main__":
sys.exit(main())
+616
View File
@@ -0,0 +1,616 @@
"""Unified decorator-based callback registry for Kanta.
Callbacks are registered once and invoked with arguments filled from their
parameter names (state dicts: ``prev`` / ``state``) and annotation types.
Unknown arguments are only permitted when they have a default value.
Log formatters are a special case: they are called per value being rendered
and receive the value plus an optional ``path`` string. They return
``str | None``; ``None`` means "fall through to the next formatter".
Log emitters (``logemit``) are another special case: plain callables that
receive a :class:`kanta.logging.LogEvent` and are dispatched by
:func:`kanta.logging.emit_event`.
"""
from __future__ import annotations
import inspect
import logging
import types
from collections.abc import Callable
from dataclasses import dataclass
from typing import Annotated, Any, Union, get_args, get_origin
from kanta.exceptions import DatabaseError
from kanta.migrations import MigrationReport
DictPrev = DictPre = Annotated[dict, "prev"]
DictState = DictPost = Annotated[dict, "state"]
# State-dict injection keys, shared by parameter names and annotation tags:
# a callback parameter named *or* tagged ``prev``/``state`` receives the
# previous or current state dict respectively. Matching by name does not
# check the annotation; an explicit tag takes precedence over the name.
_STATE_KINDS = {"prev": "previous_state", "state": "current_state"}
def _state_key(text: Any) -> str | None:
"""Return the state kind for a parameter name or annotation tag."""
return text if text in _STATE_KINDS else None
def _state_tag(ann: Any) -> str | None:
"""Return the state tag of an ``Annotated[dict, ...]`` annotation, if any."""
if get_origin(ann) is not Annotated:
return None
args = get_args(ann)
if not args or args[0] is not dict:
return None
for meta in args[1:]:
key = _state_key(meta)
if key is not None:
return key
return None
_logger = logging.getLogger(__name__)
class LogFmt:
"""Base class for stateful logfmt callbacks.
Subclasses only need to override :meth:`resolve`. The framework injects
the previous and current state dicts through ``__init__`` and exposes them
as ``previous_state`` and ``state``.
"""
def __init__(
self,
prev: dict | None = None,
state: dict | None = None,
) -> None:
self.previous_state = prev
self.state = state
self.current_state = state # deprecated alias for ``state``
def __call__(self, value: Any, path: str) -> str | None:
return self.resolve(value, path)
def resolve(self, value: Any, path: str) -> str | None:
"""Resolve *value* into a display string.
The default implementation returns ``None`` so other formatters are
tried.
"""
return None
@dataclass
class InjectionContext:
"""Runtime values available for injection into callbacks."""
kanta: Any | None = None
data: Any | None = None
error: DatabaseError | None = None
previous_state: dict | None = None
current_state: dict | None = None
report: MigrationReport | None = None
@dataclass
class _CallbackRegistration:
callback: Callable[..., Any]
params: list[tuple[str, type]]
is_async: bool = False
@dataclass
class _LogFmtFunctionSpec:
callback: Callable[..., Any]
value_type: type | Any
has_path: bool
inject_params: list[tuple[str, type]]
path: str | None = None
@dataclass
class _LogFmtClassSpec:
cls: type[LogFmt]
inject_params: list[tuple[str, type]]
path: str | None = None
class CallbackRegistry:
"""Stores and invokes callbacks, resolving arguments by annotation."""
def __init__(
self,
*,
kanta_class: type | None = None,
data_type: type | None = None,
) -> None:
self._kanta_class = kanta_class
self._data_type = data_type
self._callbacks: dict[str, list[_CallbackRegistration]] = {
"bootstrap": [],
"fatal_error": [],
"logmigr": [],
}
self._logfmt_callbacks: list[_LogFmtFunctionSpec | _LogFmtClassSpec] = []
self._logemit_callbacks: list[Callable[..., Any]] = []
def register(
self,
kind: str,
callback: Callable[..., Any],
*,
path: str | None = None,
) -> Callable[..., Any]:
"""Register *callback* for *kind* after validating its signature."""
if kind == "logfmt":
if inspect.isclass(callback):
self._logfmt_callbacks.append(
self._validate_logfmt_class(callback, path=path)
)
else:
self._logfmt_callbacks.append(
self._validate_logfmt_function(callback, path=path)
)
return callback
if kind == "logemit":
if inspect.isclass(callback) or not callable(callback):
raise TypeError("logemit callbacks must be functions")
if inspect.iscoroutinefunction(callback):
raise TypeError("logemit callbacks must not be async")
self._logemit_callbacks.append(callback)
return callback
if kind not in self._callbacks:
raise ValueError(f"unknown callback kind: {kind}")
if inspect.isclass(callback):
raise TypeError(f"{kind} callbacks must be functions, not classes")
if not callable(callback):
raise TypeError(f"{kind} callback must be callable")
params = self._validate_function(callback, kind)
is_async = inspect.iscoroutinefunction(callback)
self._callbacks[kind].append(
_CallbackRegistration(
callback=callback,
params=params,
is_async=is_async,
)
)
return callback
async def invoke(
self,
kind: str,
ctx: InjectionContext,
*,
on_error: Callable[[Exception, Callable[..., Any]], bool | None] | None = None,
) -> list[Any]:
"""Invoke all callbacks of *kind* with arguments from *ctx*.
If *on_error* is provided it is called for each exception and may return
``False`` to stop invoking further callbacks. When *on_error* is not
provided the first exception is raised immediately.
"""
results: list[Any] = []
for reg in self._callbacks[kind]:
try:
kwargs = self._build_kwargs(reg.params, ctx)
result = reg.callback(**kwargs)
if inspect.isawaitable(result):
result = await result
results.append(result)
except Exception as exc:
if on_error is None:
raise
if on_error(exc, reg.callback) is False:
break
return results
def has(self, kind: str) -> bool:
"""Return True if any callback of *kind* is registered."""
if kind == "logfmt":
return bool(self._logfmt_callbacks)
if kind == "logemit":
return bool(self._logemit_callbacks)
return bool(self._callbacks[kind])
@property
def logemit_handlers(self) -> list[Callable[..., Any]]:
"""Registered logemit callbacks in registration order."""
return self._logemit_callbacks
def build_logfmt(self, ctx: InjectionContext) -> Callable[[Any, str], str | None]:
"""Build a chained formatter from registered logfmt callbacks."""
formatters: list[tuple[Callable[[Any, str], str | None], str | None]] = []
for spec in self._logfmt_callbacks:
if isinstance(spec, _LogFmtClassSpec):
kwargs = self._build_kwargs(spec.inject_params, ctx)
instance: Callable[[Any, str], str | None] = spec.cls(**kwargs)
formatters.append((instance, spec.path))
else:
kwargs = self._build_kwargs(spec.inject_params, ctx)
def make_formatter(
callback: Callable[..., Any] = spec.callback,
value_type: type | Any = spec.value_type,
has_path: bool = spec.has_path,
state_kwargs: dict[str, Any] = kwargs,
) -> Callable[[Any, str], str | None]:
def formatter(value: Any, path: str) -> str | None:
if value_type is str and not isinstance(value, str):
return None
call_kwargs = dict(state_kwargs)
if has_path:
call_kwargs["path"] = path
return callback(value, **call_kwargs)
return formatter
formatters.append((make_formatter(), spec.path))
def format_value(value: Any, path: str) -> str | None:
for fn, pattern in formatters:
if pattern is not None and path != pattern:
continue
try:
resolved = fn(value, path)
except Exception:
# Formatting must never break functionality; a failing
# callback is reported and treated as a fall-through.
_logger.exception("logfmt callback %r failed", fn)
continue
if resolved is not None:
return resolved
return None
return format_value
def _validate_function(
self,
callback: Callable[..., Any],
kind: str,
) -> list[tuple[str, type]]:
sig = inspect.signature(callback)
params: list[tuple[str, type]] = []
for name, param in sig.parameters.items():
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
raise TypeError(
f"{kind} callback {callback.__name__} must not use "
f"*args or **kwargs"
)
if param.annotation is inspect.Parameter.empty:
if _state_key(name) is not None:
params.append((name, dict))
continue
if param.default is inspect.Parameter.empty:
raise TypeError(
f"{kind} callback {callback.__name__} has parameter "
f"'{name}' without an annotation or default value"
)
continue
ann = self._resolve_raw_annotation(param.annotation, callback)
if _state_key(name) is not None:
# The name alone selects state injection; an explicit tag
# still overrides it. The annotation is not checked.
params.append((name, ann))
continue
if not self._is_allowed(kind, ann):
if param.default is inspect.Parameter.empty:
raise TypeError(
f"{kind} callback {callback.__name__} has parameter "
f"'{name}' with unsupported annotation {ann!r}. "
f"Allowed: {self._allowed_message(kind)}"
)
continue
params.append((name, ann))
return params
def _validate_logfmt_function(
self,
callback: Callable[..., Any],
*,
path: str | None = None,
) -> _LogFmtFunctionSpec:
sig = inspect.signature(callback)
if inspect.iscoroutinefunction(callback):
raise TypeError("logfmt callbacks must not be async")
params = list(sig.parameters.items())
if not params:
raise TypeError(
f"logfmt callback {callback.__name__} must accept a value parameter"
)
value_name, value_param = params[0]
if value_param.kind in (value_param.VAR_POSITIONAL, value_param.VAR_KEYWORD):
raise TypeError(
f"logfmt callback {callback.__name__} must not use *args or **kwargs"
)
if value_param.annotation is inspect.Parameter.empty:
raise TypeError(
f"logfmt callback {callback.__name__} value parameter "
f"'{value_name}' must be annotated as str or Any"
)
value_ann = self._resolve_raw_annotation(value_param.annotation, callback)
value_bare = self._unwrap_optional(value_ann)
if value_bare is str:
value_type = str
elif value_bare is Any:
value_type = Any
else:
raise TypeError(
f"logfmt callback {callback.__name__} value parameter "
f"'{value_name}' must be annotated as str or Any, got {value_ann!r}"
)
has_path = False
inject_params: list[tuple[str, type]] = []
for name, param in params[1:]:
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
raise TypeError(
f"logfmt callback {callback.__name__} must not use "
f"*args or **kwargs"
)
if param.annotation is inspect.Parameter.empty:
if _state_key(name) is not None:
inject_params.append((name, dict))
continue
if param.default is inspect.Parameter.empty:
raise TypeError(
f"logfmt callback {callback.__name__} has parameter "
f"'{name}' without an annotation or default value"
)
continue
ann = self._resolve_raw_annotation(param.annotation, callback)
if _state_key(name) is not None:
# The name alone selects state injection; an explicit tag
# still overrides it. The annotation is not checked.
inject_params.append((name, ann))
continue
if name == "path" and self._unwrap_optional(ann) is str:
has_path = True
continue
if self._is_allowed("logfmt", ann):
inject_params.append((name, ann))
continue
if param.default is inspect.Parameter.empty:
raise TypeError(
f"logfmt callback {callback.__name__} has parameter "
f"'{name}' with unsupported annotation {ann!r}. "
f"Allowed: str path, {self._allowed_message('logfmt')}"
)
if sig.return_annotation is not inspect.Signature.empty:
return_ann = self._resolve_raw_annotation(sig.return_annotation, callback)
if not self._is_optional_str(return_ann):
raise TypeError(
f"logfmt callback {callback.__name__} must return str | None, "
f"got {return_ann!r}"
)
return _LogFmtFunctionSpec(
callback=callback,
value_type=value_type,
has_path=has_path,
inject_params=inject_params,
path=path,
)
def _validate_logfmt_class(
self,
cls: type[LogFmt],
*,
path: str | None = None,
) -> _LogFmtClassSpec:
if not issubclass(cls, LogFmt):
raise TypeError("logfmt classes must inherit from kanta.callbacks.LogFmt")
if inspect.iscoroutinefunction(cls.__init__):
raise TypeError("logfmt class __init__ must not be async")
sig = inspect.signature(cls.__init__)
inject_params: list[tuple[str, type]] = []
first = True
for name, param in sig.parameters.items():
if first and name == "self":
first = False
continue
first = False
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
raise TypeError(
f"logfmt class {cls.__name__}.__init__ must not use "
f"*args or **kwargs"
)
if param.annotation is inspect.Parameter.empty:
if _state_key(name) is not None:
inject_params.append((name, dict))
continue
if param.default is inspect.Parameter.empty:
raise TypeError(
f"logfmt class {cls.__name__}.__init__ has parameter "
f"'{name}' without an annotation or default value"
)
continue
ann = self._resolve_raw_annotation(param.annotation, cls.__init__)
if _state_key(name) is not None:
# The name alone selects state injection; an explicit tag
# still overrides it. The annotation is not checked.
inject_params.append((name, ann))
continue
if self._is_allowed("logfmt", ann):
inject_params.append((name, ann))
continue
if param.default is inspect.Parameter.empty:
raise TypeError(
f"logfmt class {cls.__name__}.__init__ has parameter "
f"'{name}' with unsupported annotation {ann!r}. "
f"Allowed: {self._allowed_message('logfmt')}"
)
resolve = getattr(cls, "resolve", None)
if resolve is None:
raise TypeError(f"logfmt class {cls.__name__} must define a resolve method")
resolve_sig = inspect.signature(resolve)
resolve_params = list(resolve_sig.parameters.items())
if not resolve_params or resolve_params[0][0] != "self":
raise TypeError(
f"logfmt class {cls.__name__}.resolve must have 'self' as first parameter"
)
if len(resolve_params) < 2:
raise TypeError(
f"logfmt class {cls.__name__}.resolve must accept a value parameter"
)
value_name, value_param = resolve_params[1]
value_ann = self._resolve_raw_annotation(value_param.annotation, resolve)
value_bare = self._unwrap_optional(value_ann)
if value_bare not in (inspect.Parameter.empty, str, Any):
raise TypeError(
f"logfmt class {cls.__name__}.resolve value parameter "
f"'{value_name}' must be annotated as str or Any, got {value_ann!r}"
)
path_found = False
for name, param in resolve_params[2:]:
path_ann = self._resolve_raw_annotation(param.annotation, resolve)
path_bare = self._unwrap_optional(path_ann)
if name == "path" and path_bare in (inspect.Parameter.empty, str):
path_found = True
break
if not path_found:
raise TypeError(
f"logfmt class {cls.__name__}.resolve must accept a 'path: str' parameter"
)
if resolve_sig.return_annotation is not inspect.Signature.empty:
return_ann = self._resolve_raw_annotation(
resolve_sig.return_annotation, resolve
)
if not self._is_optional_str(return_ann):
raise TypeError(
f"logfmt class {cls.__name__}.resolve must return str | None, "
f"got {return_ann!r}"
)
return _LogFmtClassSpec(cls=cls, inject_params=inject_params, path=path)
def _build_kwargs(
self,
params: list[tuple[str, type]],
ctx: InjectionContext,
) -> dict[str, Any]:
kwargs: dict[str, Any] = {}
for name, ann in params:
value = self._resolve_annotation(name, ann, ctx)
if value is _UNRESOLVED:
raise RuntimeError(f"no value available for annotation {ann!r}")
kwargs[name] = value
return kwargs
def _is_allowed(self, kind: str, ann: Any) -> bool:
bare = self._unwrap_optional(ann)
if _state_tag(bare) is not None:
return kind == "logfmt"
if bare is DatabaseError:
return kind == "fatal_error"
if bare is MigrationReport:
return kind == "logmigr"
if self._data_type is not None and bare is self._data_type:
return kind == "bootstrap"
if self._kanta_class is not None and bare is self._kanta_class:
return kind in {
"bootstrap",
"fatal_error",
"logfmt",
"logmigr",
}
return False
def _allowed_message(self, kind: str) -> str:
parts: list[str] = []
if kind == "bootstrap":
if self._data_type is not None:
parts.append(self._data_type.__name__)
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("MigrationReport")
if kind == "logfmt":
parts.append("prev: dict")
parts.append("state: dict")
return ", ".join(parts) if parts else "none"
def _resolve_annotation(self, name: str, ann: Any, ctx: InjectionContext) -> Any:
bare = self._unwrap_optional(ann)
# An explicit tag takes precedence over the parameter name.
tag = _state_tag(bare)
if tag is not None:
return getattr(ctx, _STATE_KINDS[tag])
key = _state_key(name)
if key is not None:
return getattr(ctx, _STATE_KINDS[key])
if bare is DatabaseError:
return ctx.error
if bare is MigrationReport:
return ctx.report
if self._data_type is not None and bare is self._data_type:
return ctx.data
if self._kanta_class is not None and bare is self._kanta_class:
return ctx.kanta
return _UNRESOLVED
def _resolve_raw_annotation(
self,
raw_ann: Any,
callback: Callable[..., Any],
) -> Any:
if isinstance(raw_ann, str):
try:
return eval(raw_ann, callback.__globals__)
except Exception as exc:
raise TypeError(
f"could not resolve annotation {raw_ann!r} for "
f"{callback.__name__}: {exc}"
) from exc
return raw_ann
@staticmethod
def _unwrap_optional(ann: Any) -> Any:
origin = get_origin(ann)
if origin not in (Union, types.UnionType):
return ann
args = [arg for arg in get_args(ann) if arg is not type(None)]
return args[0] if len(args) == 1 else ann
@staticmethod
def _is_optional_str(ann: Any) -> bool:
origin = get_origin(ann)
if origin not in (Union, types.UnionType):
return ann is str
args = get_args(ann)
return type(None) in args and any(arg is str for arg in args)
class _Unresolved:
pass
_UNRESOLVED = _Unresolved()
+1 -1
View File
@@ -2,7 +2,7 @@
import jsondiff import jsondiff
from kanta.kanta.structs import ChangeRecord from kanta.structs import ChangeRecord
from kanta.serialization.base import ReplayResult, replay from kanta.serialization.base import ReplayResult, replay
from kanta.serialization.framing import LineFramer from kanta.serialization.framing import LineFramer
from kanta.serialization.json import JsonSerializer from kanta.serialization.json import JsonSerializer
+42 -26
View File
@@ -34,6 +34,7 @@ if sys.platform == "win32":
_GENERIC_READ = 0x80000000 _GENERIC_READ = 0x80000000
_GENERIC_WRITE = 0x40000000 _GENERIC_WRITE = 0x40000000
_FILE_SHARE_READ = 0x00000001 _FILE_SHARE_READ = 0x00000001
_FILE_SHARE_WRITE = 0x00000002
_OPEN_EXISTING = 3 _OPEN_EXISTING = 3
_OPEN_ALWAYS = 4 _OPEN_ALWAYS = 4
_FILE_ATTRIBUTE_NORMAL = 0x80 _FILE_ATTRIBUTE_NORMAL = 0x80
@@ -91,15 +92,16 @@ else:
class LockedFile: class LockedFile:
"""A file opened with an exclusive write lock. """A file opened for read+write with an optional exclusive lock.
Usage:: Usage::
f = LockedFile() f = LockedFile()
f.open(path) # open + lock (read+write) f.open(path) # open + lock (read+write)
content = f.read() # read entire content f.open(path, readonly=True) # open read-only without locking
f.write(data) # append data (seeks to end first) content = f.read() # read entire content
f.close() # release lock + close fd f.write(data) # append data (seeks to end first)
f.close() # release lock + close fd
Unix: fcntl.flock (advisory) — read-only callers that don't flock are unaffected. Unix: fcntl.flock (advisory) — read-only callers that don't flock are unaffected.
Windows: CreateFileW with FILE_SHARE_READ — OS blocks other writers. Windows: CreateFileW with FILE_SHARE_READ — OS blocks other writers.
@@ -108,12 +110,13 @@ class LockedFile:
def __init__(self) -> None: def __init__(self) -> None:
self._fd: int | None = None # Unix fd or Windows HANDLE self._fd: int | None = None # Unix fd or Windows HANDLE
def open(self, path: Path, *, create: bool = False) -> None: def open(self, path: Path, *, create: bool = False, readonly: bool = False) -> None:
"""Open *path* for read+write with an exclusive lock. """Open *path* and optionally acquire an exclusive lock.
Args: Args:
path: File to open and lock. path: File to open and lock.
create: If True, create the file if it doesn't exist (bootstrap). create: If True, create the file if it doesn't exist (bootstrap).
readonly: If True, open read-only without acquiring a lock.
Raises: Raises:
FileLockError: If the file is locked by another process or not found. FileLockError: If the file is locked by another process or not found.
@@ -122,16 +125,18 @@ class LockedFile:
return # Already open (idempotent) return # Already open (idempotent)
if sys.platform == "win32": if sys.platform == "win32":
self._open_win32(path, create) self._open_win32(path, create, readonly)
else: else:
self._open_unix(path, create) self._open_unix(path, create, readonly)
def open_and_read(self, path: Path, create: bool = False) -> bytes: def open_and_read(
"""Open *path* with exclusive lock and read all content. self, path: Path, create: bool = False, readonly: bool = False
) -> bytes:
"""Open *path* and read all content.
Combined operation for efficient use with asyncio.to_thread(). Combined operation for efficient use with asyncio.to_thread().
""" """
self.open(path, create=create) self.open(path, create=create, readonly=readonly)
return self.read() return self.read()
def read(self) -> bytes: def read(self) -> bytes:
@@ -188,20 +193,24 @@ class LockedFile:
# -- Unix ---------------------------------------------------------------- # -- Unix ----------------------------------------------------------------
def _open_unix(self, path: Path, create: bool) -> None: def _open_unix(self, path: Path, create: bool, readonly: bool) -> None:
flags = os.O_RDWR | (os.O_CREAT if create else 0) if readonly:
flags = os.O_RDONLY
else:
flags = os.O_RDWR | (os.O_CREAT if create else 0)
try: try:
fd = os.open(path, flags, 0o666) fd = os.open(path, flags, 0o666)
except FileNotFoundError: except FileNotFoundError:
_fatal(f"Database file not found: {path.resolve()}", db_path=path) _fatal(f"Database file not found: {path.resolve()}", db_path=path)
try: if not readonly:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) try:
except OSError: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
os.close(fd) except OSError:
_fatal( os.close(fd)
f"{path.resolve()}: database already locked by another instance", _fatal(
db_path=path, f"{path.resolve()}: database already locked by another instance",
) db_path=path,
)
self._fd = fd self._fd = fd
def _read_unix(self) -> bytes: def _read_unix(self) -> bytes:
@@ -220,12 +229,19 @@ class LockedFile:
# -- Windows ------------------------------------------------------------- # -- Windows -------------------------------------------------------------
def _open_win32(self, path: Path, create: bool) -> None: def _open_win32(self, path: Path, create: bool, readonly: bool) -> None:
disposition = _OPEN_ALWAYS if create else _OPEN_EXISTING if readonly:
disposition = _OPEN_EXISTING
access = _GENERIC_READ
share = _FILE_SHARE_READ | _FILE_SHARE_WRITE
else:
disposition = _OPEN_ALWAYS if create else _OPEN_EXISTING
access = _GENERIC_READ | _GENERIC_WRITE
share = _FILE_SHARE_READ
handle = _kernel32.CreateFileW( handle = _kernel32.CreateFileW(
str(path), str(path),
_GENERIC_READ | _GENERIC_WRITE, access,
_FILE_SHARE_READ, share,
None, None,
disposition, disposition,
_FILE_ATTRIBUTE_NORMAL, _FILE_ATTRIBUTE_NORMAL,
+212 -21
View File
@@ -1,14 +1,13 @@
"""JSONL persistence layer with background flush task.""" """Kanta DB main public API"""
from __future__ import annotations from __future__ import annotations
import logging
from collections.abc import Callable from datetime import datetime
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType, SimpleNamespace
from typing import Any, Generic, TypeVar from typing import Any, Generic, TypeVar
from kanta.exceptions import DatabaseError from kanta.kantaimpl import KantaImpl
from kanta.kanta.kantaimpl import KantaImpl
from kanta.serialization import JsonSerializer, Serializer from kanta.serialization import JsonSerializer, Serializer
from kanta.transaction import transaction as _transaction from kanta.transaction import transaction as _transaction
@@ -52,9 +51,7 @@ class Kanta(Generic[T]):
*, *,
type: type[T] | None = None, type: type[T] | None = None,
migrations: ModuleType | str | None = None, migrations: ModuleType | str | None = None,
migration_ctx: Any | None = None,
serializer: Serializer | None = None, serializer: Serializer | None = None,
fatal_error: Callable[[DatabaseError], None] | None = None,
flush_interval: float = 0.1, flush_interval: float = 0.1,
): ):
"""Initialize a Kanta persistence instance. """Initialize a Kanta persistence instance.
@@ -64,11 +61,8 @@ class Kanta(Generic[T]):
data: Caller-owned root msgspec.Struct state instance. data: Caller-owned root msgspec.Struct state instance.
type: Optional explicit root type. Defaults to ``type(data)``. type: Optional explicit root type. Defaults to ``type(data)``.
migrations: Optional migrations module object or import path. migrations: Optional migrations module object or import path.
migration_ctx: Optional context object passed to migration functions.
flush_interval: Background flush interval in seconds. flush_interval: Background flush interval in seconds.
serializer: Optional serializer implementation. serializer: Optional serializer implementation.
fatal_error: Optional callback invoked immediately when the
background writer encounters a DatabaseError.
Raises: Raises:
ImportError: If ``migrations`` is a string path that cannot be imported. ImportError: If ``migrations`` is a string path that cannot be imported.
@@ -79,13 +73,12 @@ class Kanta(Generic[T]):
self._impl = KantaImpl( self._impl = KantaImpl(
serializer=active_serializer, serializer=active_serializer,
fatal_error=fatal_error,
filename=filename, filename=filename,
data=data, data=data,
type=data_type, type=data_type,
migrations=migrations, migrations=migrations,
migration_ctx=migration_ctx,
flush_interval=flush_interval, flush_interval=flush_interval,
kanta=self,
) )
@property @property
@@ -132,19 +125,62 @@ class Kanta(Generic[T]):
""" """
return self._impl.filename return self._impl.filename
async def open(self) -> None: @property
def ctx(self) -> SimpleNamespace:
"""User-writable context namespace.
Migration functions receive the ``Kanta`` instance and can read or
mutate ``kanta.ctx`` during migrations. Applications can also store
arbitrary data here (e.g. a connection id); since
:class:`kanta.logging.LogEvent` carries the Kanta instance, logemit
callbacks can read it as ``event.kanta.ctx``.
"""
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. """Open the database file and start background persistence.
This loads existing records, applies configured migrations, and starts This loads existing records, applies configured migrations, and starts
the background flush task. 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 bootstrap and migration logging. ``True`` (default)
uses the ``kanta.bootstrap`` logger for bootstrap records and
the ``kanta.migration`` 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. Calling ``open`` more than once on the same instance is not allowed.
Raises: Raises:
kanta.exceptions.DatabaseError: If replay or decoding fails. kanta.exceptions.DatabaseError: If replay or decoding fails.
kanta.exceptions.DataIntegrityError: If the instance is already open. kanta.exceptions.DataIntegrityError: If the instance is already open.
""" """
await self._impl.open() await self._impl.open(create=create, readonly=readonly, log=log)
async def __aenter__(self) -> Kanta[T]: async def __aenter__(self) -> Kanta[T]:
"""Enter async context manager and open the database. """Enter async context manager and open the database.
@@ -177,21 +213,170 @@ class Kanta(Generic[T]):
"""Stop background task, flush pending changes, and close file lock.""" """Stop background task, flush pending changes, and close file lock."""
await self._impl.close() 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 clock(self, fn=None):
"""Register a clock callback replacing the default UTC clock.
Can be used as ``@kanta.clock``. The callback takes no arguments and
must return a :class:`~datetime.datetime`; its value is used for all
record timestamps (``ts``, and ``m`` when ``mtime`` is ``True``) and
snapshot timestamps. The clock is only read when a timestamp is
actually produced, so read-count-dependent clocks (e.g. advancing on
every read) stay deterministic. Register before :meth:`open` so that
bootstrap and migration records use the custom clock as well. This is
mainly useful for tests and reproducible demos.
"""
def _register(callback):
self._impl.add_clock(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.MigrationReport` 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 logemit(self, fn=None):
"""Register a log emitter callback.
Can be used as ``@kanta.logemit``. The callback receives a single
:class:`kanta.logging.LogEvent` describing the event, including the
preferred logger and level, and decides what (if anything) is logged
and where.
A falsy return value marks the event as handled and stops the chain.
A truthy return value passes the event — possibly modified — to the
next registered callback; when all callbacks pass, Kanta renders the
event with its built-in formatting
(:func:`kanta.logging.default_emit`), which a callback may also call
itself to delegate events it does not care about.
"""
def _register(callback):
self._impl.add_logemit(callback)
return callback
if fn is None:
return _register
return _register(fn)
def transaction( def transaction(
self, self,
action: str, action: str,
*, *,
user: str | None = None, user: str | None = None,
user_display: str | None = None, extra: Any = None,
resolver: Any = None, mtime: bool | datetime = True,
log: bool | logging.Logger = True,
logdiff: bool = True,
): ):
"""Create a transactional mutation context manager. """Create a transactional mutation context manager.
Args: Args:
action: Action label stored in the change record. action: Action label stored in the change record.
user: Optional user identifier stored in metadata. user: Optional user identifier stored in metadata and rendered in
user_display: Optional display name used for logging/resolution. the log header. Register a ``@kanta.logfmt`` callback to format
resolver: Optional callable for resolving identifiers in logs. the user value; the path ``"$user"`` is passed for this case.
extra: Optional display-only value shown after the action in the
log header. Anything other than ``None`` is printed
str-converted (colored by Kanta), unless a custom
``@kanta.logemit`` handler does something else with it. 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
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.transaction`` logger. ``False`` suppresses the
transaction log. A :class:`~logging.Logger` instance writes
output to that logger instead.
logdiff: Whether to build and print the diff body. ``False``
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.
@@ -202,5 +387,11 @@ class Kanta(Generic[T]):
rolled back. rolled back.
""" """
return _transaction( return _transaction(
self._impl, action, user=user, user_display=user_display, resolver=resolver self._impl,
action,
user=user,
extra=extra,
mtime=mtime,
log=log,
logdiff=logdiff,
) )
+286 -26
View File
@@ -7,10 +7,19 @@ import copy
import importlib import importlib
import logging import logging
from datetime import UTC, datetime from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any, Generic, TypeVar from typing import Any, Generic, TypeVar
from kanta.callbacks import CallbackRegistry, InjectionContext
from kanta.exceptions import DatabaseError, DataIntegrityError, ReplayError from kanta.exceptions import DatabaseError, DataIntegrityError, ReplayError
from kanta.migrate import MigrationRegistry from kanta.logging import (
_USER_PATH,
LogEvent,
bootstrap_logger,
emit_event,
migration_logger,
)
from kanta.migrations import MigrationReport, Migrations
from kanta.persistence import PersistenceMixin from kanta.persistence import PersistenceMixin
from kanta.serialization import restore_data_in_place, struct_to_dict from kanta.serialization import restore_data_in_place, struct_to_dict
from kanta.serialization.base import replay from kanta.serialization.base import replay
@@ -20,34 +29,121 @@ _logger = logging.getLogger(__name__)
T = TypeVar("T") T = TypeVar("T")
def _log_callback_error(callback_error, callback):
"""Report a failing logging callback and continue with the next one."""
_logger.exception("Log callback %r failed: %s", callback, callback_error)
class KantaImpl(PersistenceMixin, Generic[T]): class KantaImpl(PersistenceMixin, Generic[T]):
"""Internal state and logic for Kanta.""" """Internal state and logic for Kanta."""
def __init__(self, **kwargs: Any): def __init__(self, **kwargs: Any):
self.data_type = kwargs.pop("type") self.data_type = kwargs.pop("type")
self.data: T = kwargs.pop("data") self.data: T = kwargs.pop("data")
self.migrations = kwargs.pop("migrations", None) self._kanta = kwargs.pop("kanta", None)
self.migration_ctx = kwargs.pop("migration_ctx", None) migrations = kwargs.pop("migrations", None)
self.ctx = SimpleNamespace()
super().__init__(**kwargs) super().__init__(**kwargs)
self.migration_registry: MigrationRegistry | None = None self.migrations: Migrations | None = None
if self.migrations is not None: if migrations is not None:
module = ( module = (
importlib.import_module(self.migrations) importlib.import_module(migrations)
if isinstance(self.migrations, str) if isinstance(migrations, str)
else self.migrations else migrations
) )
self.migration_registry = MigrationRegistry.from_module(module) self.migrations = Migrations.from_module(module)
self.in_transaction = False self.in_transaction = False
self.transaction_snapshot: dict[str, Any] | None = None self.transaction_snapshot: dict[str, Any] | None = None
self.opened = False self.opened = False
self.readonly = False
self.bootstrap_action = "bootstrap"
self.bootstrap_user: str | None = None
self.bootstrap_mtime: bool | datetime = True
self.statedict = struct_to_dict(self.data, serializer=self.serializer) self.callback_registry = CallbackRegistry(
self.version = ( kanta_class=type(self._kanta) if self._kanta is not None else None,
self.migration_registry.dbver if self.migration_registry is not None else 0 data_type=self.data_type,
) )
async def open(self) -> None: self.statedict = struct_to_dict(self.data, serializer=self.serializer)
self.version = self.migrations.dbver if self.migrations is not None else 0
def add_bootstrap(
self,
*,
callback,
action: str,
user: str | None,
mtime: bool | datetime,
) -> None:
"""Add bootstrap callback and update bootstrap metadata."""
self.callback_registry.register("bootstrap", callback)
self.bootstrap_action = action
self.bootstrap_user = user
self.bootstrap_mtime = mtime
def add_logfmt(self, callback, *, path: str | None = None) -> None:
"""Register one transaction logfmt callback."""
self.callback_registry.register("logfmt", callback, path=path)
def add_logmigr(self, callback) -> None:
"""Register one migration logging callback."""
self.callback_registry.register("logmigr", callback)
def add_logemit(self, callback) -> None:
"""Register one log emitter callback."""
self.callback_registry.register("logemit", callback)
async def _handle_migration_log(
self,
report: MigrationReport,
log: bool | logging.Logger,
) -> None:
"""Route migration logging to callback or default logger."""
assert isinstance(report, MigrationReport)
if self.callback_registry.has("logmigr"):
await self.callback_registry.invoke(
"logmigr",
InjectionContext(
kanta=self._kanta,
report=report,
),
on_error=_log_callback_error,
)
return
if log is False:
return
migration_log = log if isinstance(log, logging.Logger) else migration_logger
changed = [m for m in report.applied if m.changed]
if not changed:
return
descriptions = [f"{m.name} ({m.description})" for m in changed]
emit_event(
LogEvent(
kind="migrated",
logger=migration_log,
kanta=self._kanta,
filename=str(self.filename),
from_version=report.original,
to_version=report.version,
migrations=descriptions,
),
self.callback_registry.logemit_handlers,
)
async def open(
self,
*,
create: bool = True,
readonly: bool = False,
log: bool | logging.Logger = True,
) -> None:
"""Open the database: load from disk, apply migrations, start background task.""" """Open the database: load from disk, apply migrations, start background task."""
if self.opened: if self.opened:
raise DataIntegrityError( raise DataIntegrityError(
@@ -56,12 +152,35 @@ class KantaImpl(PersistenceMixin, Generic[T]):
action="open", action="open",
) )
self.readonly = readonly
existed_before_open = self.filename.exists()
# Read-only mode never creates the file.
open_create = create and not readonly
content = await asyncio.to_thread( content = await asyncio.to_thread(
self.file.open_and_read, self.file.open_and_read,
self.filename, self.filename,
create=True, create=open_create,
readonly=readonly,
) )
if not create and (not existed_before_open or not content):
self.file.close()
reason = (
"database file did not exist"
if not existed_before_open
else "database file is empty"
)
raise DataIntegrityError(
f"Cannot open database: {reason}",
db_path=self.filename,
action="open",
)
# From this point the file is open and must be closed via close().
self.opened = True
if content: if content:
try: try:
rr = replay( rr = replay(
@@ -91,12 +210,29 @@ class KantaImpl(PersistenceMixin, Generic[T]):
cause_type=type(e).__name__, cause_type=type(e).__name__,
) from e ) from e
if self.migration_registry is not None: migration_report = None
rr.version = self.migration_registry.apply( state_before_migrations = None
rr.state, rr.version, self.migration_ctx previous_version = rr.version
if self.migrations is not None:
state_before_migrations = copy.deepcopy(rr.state)
migration_report = self.migrations.apply(
rr.state, rr.version, self._kanta
) )
rr.version = migration_report.version
self.statedict = copy.deepcopy(rr.state) migrations_ran = rr.version != previous_version
self.snapshot.ts = (
datetime.fromtimestamp(rr.last_snapshot_mtime, UTC)
if rr.last_snapshot_mtime is not None
else None
)
self.statedict = copy.deepcopy(
state_before_migrations
if state_before_migrations is not None
else rr.state
)
self.data = restore_data_in_place( self.data = restore_data_in_place(
self.data, self.data,
rr.state, rr.state,
@@ -104,17 +240,140 @@ class KantaImpl(PersistenceMixin, Generic[T]):
serializer=self.serializer, serializer=self.serializer,
) )
self.version = rr.version self.version = rr.version
self.mtime = rr.m
if log is not False and not migrations_ran:
logger = log if isinstance(log, logging.Logger) else bootstrap_logger
emit_event(
LogEvent(
kind="opened",
logger=logger,
level=logging.DEBUG,
kanta=self._kanta,
filename=str(self.filename.resolve()),
),
self.callback_registry.logemit_handlers,
)
normalized = struct_to_dict(self.data, serializer=self.serializer) normalized = struct_to_dict(self.data, serializer=self.serializer)
self.queue_change("migrate:msgspec", normalized) if self.readonly:
self.snapshot.ts = ( self.statedict = copy.deepcopy(normalized)
datetime.fromtimestamp(rr.last_snapshot_mtime, UTC) else:
if rr.last_snapshot_mtime is not None # One record per open: migration changes and normalization are
else None # grouped into migrate:vN, or migrate:msgspec when only the
# serialization drifted.
previous = self.statedict
action = (
f"migrate:v{self.version}" if migrations_ran else "migrate:msgspec"
)
record = self.queue_change(action, normalized, mtime=False)
# The migration summary introduces the diff, so log it first.
if migrations_ran and migration_report is not None:
await self._handle_migration_log(migration_report, log)
if (
record is not None
and log is not False
and not (migrations_ran and self.callback_registry.has("logmigr"))
):
logger = (
log if isinstance(log, logging.Logger) else migration_logger
)
emit_event(
LogEvent(
kind="change",
logger=logger,
level=logging.DEBUG,
kanta=self._kanta,
action=action,
diff=record.diff,
previous=previous,
),
self.callback_registry.logemit_handlers,
)
if migrations_ran or record is not None:
self.snapshot.request_force()
await self.flush()
self.snapshot.maybe_write(
self.file,
self.version,
self.statedict,
m=self.mtime,
now=self.now,
)
elif self.readonly:
self.opened = False
self.file.close()
raise DataIntegrityError(
"Cannot open empty database in read-only mode",
db_path=self.filename,
action="open",
) )
else:
try:
if self.callback_registry.has("bootstrap"):
await self.callback_registry.invoke(
"bootstrap",
InjectionContext(data=self.data, kanta=self._kanta),
)
self.opened = True self.statedict = {}
current = struct_to_dict(self.data, serializer=self.serializer)
record = self.queue_change(
self.bootstrap_action,
current,
user=self.bootstrap_user,
mtime=self.bootstrap_mtime,
force=True,
)
self.background_task = asyncio.create_task(self._background_loop()) if record is not None and log is not False:
logger = (
log if isinstance(log, logging.Logger) else bootstrap_logger
)
emit_event(
LogEvent(
kind="created",
logger=logger,
kanta=self._kanta,
filename=str(self.filename.resolve()),
),
self.callback_registry.logemit_handlers,
)
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
emit_event(
LogEvent(
kind="change",
logger=logger,
kanta=self._kanta,
action=self.bootstrap_action,
user=formatted_user,
diff=record.diff,
previous={},
current=current,
logfmt=logfmt,
),
self.callback_registry.logemit_handlers,
)
except Exception:
self.opened = False
self.file.close()
try:
await asyncio.to_thread(self.filename.unlink, missing_ok=True)
except FileNotFoundError:
pass
raise
if not self.readonly:
self.background_task = asyncio.create_task(self._background_loop())
async def close(self) -> None: async def close(self) -> None:
"""Stop the background task, flush pending changes, and release the file lock.""" """Stop the background task, flush pending changes, and release the file lock."""
@@ -131,7 +390,8 @@ class KantaImpl(PersistenceMixin, Generic[T]):
# Always run a final flush in case the background task never reached # Always run a final flush in case the background task never reached
# its cancellation handler. # its cancellation handler.
await self.flush() if not self.readonly:
await self.flush()
self.file.close() self.file.close()
self.opened = False self.opened = False
+366 -110
View File
@@ -1,16 +1,27 @@
"""Database change logging with pretty-printed diffs. """Database change logging with pretty-printed diffs.
Provides a logger for JSONL database changes that formats diffs All change-related output is described by a :class:`LogEvent` and dispatched
in a human-readable path.notation style with color coding. through :func:`emit_event`, which runs any registered ``logemit`` callbacks
and falls back to :func:`default_emit` for the built-in formatting. Diff
output is formatted in a human-readable path notation style with color
coding; see :mod:`kanta.tty` for the color palette and line builder.
""" """
import logging import logging
import re import re
import sys import sys
from collections.abc import Callable from collections.abc import Callable, Iterable
from typing import Any from typing import Any
logger = logging.getLogger("kanta.changes") import msgspec
from kanta.tty import Line, displaywidth
transaction_logger = logging.getLogger("kanta.transaction")
bootstrap_logger = logging.getLogger("kanta.bootstrap")
migration_logger = logging.getLogger("kanta.migration")
_logger = logging.getLogger(__name__)
# Pattern to match control characters and bidirectional overrides # Pattern to match control characters and bidirectional overrides
_UNSAFE_CHARS = re.compile( _UNSAFE_CHARS = re.compile(
@@ -21,21 +32,182 @@ _UNSAFE_CHARS = re.compile(
r"]" r"]"
) )
# ANSI color codes # Metadata path used when formatting the transaction actor.
_RESET = "\033[0m" _USER_PATH = "$user"
_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 class LogEvent(msgspec.Struct, kw_only=True):
_DELETE = "\033[1;31m" # Red for deletions """All state describing one loggable event, passed to logemit callbacks.
_ADD = "\033[0;32m" # Green for additions
_ACTION = "\033[1;34m" # Bold blue for action name ``kind`` is ``"change"`` (transaction, bootstrap, or migration diff),
_USER = "\033[0;34m" # Blue for user display ``"created"`` (database file created), ``"opened"`` (database file
opened), ``"migrated"`` (migration summary), or ``"aborted"``
(transaction rolled back). ``logger`` and ``level`` are Kanta's
preferred destination; a callback may use them, log elsewhere, or not
log at all.
The event is mutable: a callback may modify it before returning a truthy
value to pass it on, affecting later callbacks and the built-in fallback.
"""
kind: str
logger: logging.Logger
level: int = logging.INFO
kanta: Any = None
action: str | None = None
user: str | None = None
extra: Any = None
error: BaseException | None = None
diff: dict = msgspec.field(default_factory=dict)
previous: dict | None = None
current: dict | None = None
logfmt: Callable[[Any, str], str | None] | None = None
show_diff: bool = True
filename: str | None = None
from_version: int | None = None
to_version: int | None = None
migrations: list[str] = msgspec.field(default_factory=list)
_header: str | None = None
_diff_lines: list[str] | None = None
@property
def header(self) -> str:
"""The default one-line header for this event, built on first access.
Covers every event kind: ``"<action>[ <extra>][ by <user>]"`` for
changes, ``"<action>[ <extra>][ by <user>] transaction aborted:
<error>"`` for aborts, and the ``🛢️ <filename> <verb>`` file
summaries (created / opened / migrated).
"""
if self._header is None:
self._header = self._build_header()
return self._header
@header.setter
def header(self, value: str) -> None:
"""Override the header, keeping the default diff routing.
A logemit callback can restyle the header and return a truthy value:
:func:`default_emit` then logs this header instead of building one.
"""
self._header = value
def _build_header(self) -> str:
if self.kind == "created":
return f"🛢️ {self.filename} created"
if self.kind == "opened":
return f"🛢️ {self.filename} opened"
if self.kind == "migrated":
migrations = ", ".join(self.migrations)
return (
f"🛢️ {self.filename} migrated "
f"v{self.from_version} -> v{self.to_version}: {migrations}"
)
if self.kind == "change":
return format_action_header(self.action or "", self.user, self.extra)
line = Line().action(self.action or "")
if self.extra:
line(" ").target(self.extra)
if self.user:
line(" by ").user(self.user)
line(f" transaction aborted: {self.error}")
return str(line)
@property
def diff_lines(self) -> list[str]:
"""Pretty-printed diff lines, built on first access and cached."""
if self._diff_lines is None:
self._diff_lines = format_diff(self.diff, self.previous, self.logfmt)
return self._diff_lines
def emit_event(
ev: LogEvent,
handlers: Iterable[Callable[[LogEvent], Any]] = (),
*,
fallback: Callable[[LogEvent], None] | None = None,
) -> None:
"""Dispatch *ev* through registered logemit handlers.
Each handler receives the event and may log it (or not) as it sees fit.
A falsy return value stops the chain: the event is considered handled.
A truthy return value passes the event — possibly modified — to the next
handler. When all handlers pass, the *fallback* renders the event;
the default fallback is :func:`default_emit` with the built-in formatting.
Logging must never break functionality: a crashing handler is reported
and the chain falls back to the fallback rendering, and a failure in
the fallback itself is reported and swallowed.
"""
render = fallback if fallback is not None else default_emit
try:
for handler in handlers:
try:
proceed = handler(ev)
except Exception:
_logger.exception("logemit callback failed, using default formatting")
break
if not proceed:
return
render(ev)
except Exception:
_logger.exception("failed to emit %s log event", ev.kind)
def default_emit(ev: LogEvent) -> None:
"""Emit *ev* with Kanta's built-in formatting.
Logs :attr:`LogEvent.header`; for change events the
:attr:`LogEvent.diff_lines` body follows on the ``<logger>.diff`` child
logger so it can be silenced or routed separately from the headers.
This is what runs when no logemit callback handles the event; custom
callbacks may call it to delegate events they do not care about.
"""
if ev.kind != "change":
ev.logger.log(ev.level, ev.header)
return
diff_logger = logging.getLogger(f"{ev.logger.name}.diff")
lines = ev.diff_lines if ev.show_diff and diff_logger.isEnabledFor(ev.level) else []
if not lines:
ev.logger.log(ev.level, ev.header)
return
if len(lines) == 1:
diff_logger.log(ev.level, f"{ev.header}{lines[0]}")
return
ev.logger.log(ev.level, ev.header)
for line in lines:
diff_logger.log(ev.level, line)
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 _dim_ellipsis() -> str:
"""Return the truncation ellipsis in the palette's ellipsis color."""
return str(Line().ellipsis(""))
def _format_value( def _format_value(
value: Any, max_len: int = 60, resolver: Callable[[str], str] | None = None value: Any,
path: str,
*,
max_len: int = 60,
logfmt: Callable[[Any, str], str | None] | None = None,
) -> str: ) -> str:
"""Format a value for display, truncating if needed.""" """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: if value is None:
return "null" return "null"
if isinstance(value, bool): if isinstance(value, bool):
@@ -44,12 +216,8 @@ def _format_value(
return str(value) return str(value)
if isinstance(value, str): if isinstance(value, str):
value = _UNSAFE_CHARS.sub("", value) value = _UNSAFE_CHARS.sub("", value)
if resolver is not None:
resolved = resolver(value)
if resolved != value:
return resolved
if len(value) > max_len: if len(value) > max_len:
return value[: max_len - 3] + "..." return value[: max_len - 1] + _dim_ellipsis()
return value return value
if isinstance(value, dict): if isinstance(value, dict):
if not value: if not value:
@@ -57,35 +225,63 @@ def _format_value(
all_true = all(v is True for v in value.values()) all_true = all(v is True for v in value.values())
parts = [] parts = []
for k, v in value.items(): for k, v in value.items():
key_display = resolver(k) if resolver is not None else k key_path = _join_path(path, str(k))
key_display = _format_value(k, key_path, max_len=30, logfmt=logfmt)
if all_true: if all_true:
parts.append(key_display) parts.append(key_display)
else: else:
val_display = _format_value(v, max_len=30, resolver=resolver) val_display = _format_value(v, key_path, max_len=30, logfmt=logfmt)
parts.append(f"{key_display}: {val_display}") parts.append(f"{key_display}: {val_display}")
return "{" + ", ".join(parts) + "}" return "{" + ", ".join(parts) + "}"
if isinstance(value, list): if isinstance(value, list):
if not value: if not value:
return "[]" return "[]"
parts = [_format_value(v, max_len=30, resolver=resolver) for v in value] 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) + "]" return "[" + ", ".join(parts) + "]"
text = str(value) text = str(value)
if len(text) > max_len: if len(text) > max_len:
text = text[: max_len - 3] + "..." text = text[: max_len - 1] + _dim_ellipsis()
return text return text
def _format_path(path: list[str], resolver: Callable[[str], str] | None = None) -> str: def _format_path_components(
"""Format a path as dot notation with prefix in dark grey, final in default.""" path: list[str], logfmt: Callable[[Any, str], str | None] | None
) -> list[str]:
"""Return path components after applying formatters."""
if not path: 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.
*final_color* names a color in the :data:`kanta.tty.colors` palette.
"""
components = _format_path_components(path, logfmt)
if not components:
return "" return ""
if resolver is not None: line = Line()
path = [resolver(p) for p in path] if len(components) > 1:
if len(path) == 1: line.path_prefix(".".join(components[:-1]) + ".")
return f"{_PATH_FINAL}{path[0]}{_RESET}" getattr(line, final_color)(components[-1])
prefix = ".".join(path[:-1]) return str(line)
final = path[-1]
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
def _get_nested(data: dict | None, path: list[str]) -> Any: def _get_nested(data: dict | None, path: list[str]) -> Any:
@@ -158,74 +354,66 @@ def _format_change_lines(
change_type: str, change_type: str,
path: list[str], path: list[str],
value: Any, value: Any,
resolver: Callable[[str], str] | None = None, logfmt: Callable[[Any, str], str | None] | None = None,
) -> list[str]: ) -> list[str]:
"""Format a single change as one or more lines.""" """Format a single change as one or more lines."""
def fmt_value(v: Any, child_path: list[str]) -> str:
return _format_value(v, resolver=resolver)
formatted_path = list(path)
if resolver is not None:
formatted_path = [resolver(p) for p in formatted_path]
if change_type == "delete": if change_type == "delete":
if len(formatted_path) == 1: components = _format_path_components(path, logfmt)
return [f" {_DELETE}{formatted_path[0]}{_RESET}"] line = Line()(" ")
prefix = ".".join(formatted_path[:-1]) if len(components) > 1:
final = formatted_path[-1] line.path_prefix(".".join(components[:-1]) + ".")
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final}{_RESET}"] line.delete(components[-1], "")
return [str(line)]
if change_type == "add": if change_type == "add":
path_str = _format_path(path, logfmt, final_color="add")
if isinstance(value, dict) and value: if isinstance(value, dict) and value:
lines = [] lines = [str(Line()(" ", path_str, " ").sep("="))]
if len(formatted_path) == 1: base_path = ".".join(path)
lines.append(f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET}") keys = []
else: for k in value:
prefix = ".".join(formatted_path[:-1]) key_path = _join_path(base_path, str(k))
final = formatted_path[-1] keys.append((k, _format_value(k, key_path, max_len=30, logfmt=logfmt)))
lines.append( field_width = max(displaywidth(kd) for _, kd in keys)
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET}" field_width = max(field_width, 12)
) # Each item line is " {key:{field_width}}: {value}"; budget the
# value so the whole line fits in 80 columns.
value_width = max(80 - 4 - field_width - 2, 20)
formatted_items = [] formatted_items = []
for k, v in value.items(): for (k, key_display), v in zip(keys, value.values()):
k_display = resolver(k) if resolver is not None else k key_path = _join_path(base_path, str(k))
v_str = fmt_value(v, path + [k]) v_str = _format_value(v, key_path, max_len=value_width, logfmt=logfmt)
formatted_items.append((k_display, v_str)) formatted_items.append((key_display, v_str))
max_key_len = max(len(k) for k, _ in formatted_items) return lines + [
field_width = max(max_key_len, 12) str(
for k_display, v_str in formatted_items: Line()(" ", k).sep(":")(
padding = " " * (field_width - len(k_display)) " " * (field_width - displaywidth(k)), " ", v
lines.append(f" {k_display}{_SEP}:{_RESET}{padding} {v_str}") )
return lines )
else: for k, v in formatted_items
value_str = fmt_value(value, path)
if len(formatted_path) == 1:
return [
f" {_ADD}{formatted_path[0]}{_RESET} {_SEP}={_RESET} {value_str}"
]
prefix = ".".join(formatted_path[:-1])
final = formatted_path[-1]
return [
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_SEP}={_RESET} {value_str}"
] ]
value_str = _format_value(value, ".".join(path), logfmt=logfmt)
return [str(Line()(" ", path_str, " ").sep("=")(" ", value_str))]
value_str = fmt_value(value, path) value_str = _format_value(value, ".".join(path), logfmt=logfmt)
path_str = _format_path(path, resolver=resolver) path_str = _format_path(path, logfmt=logfmt)
return [f" {path_str} {_SEP}={_RESET} {value_str}"] return [str(Line()(" ", path_str, " ").sep("=")(" ", value_str))]
def format_diff( def format_diff(
diff: dict, diff: dict,
previous: dict | None = None, previous: dict | None = None,
resolver: Callable[[str], str] | None = None, logfmt: Callable[[Any, str], str | None] | None = None,
) -> list[str]: ) -> list[str]:
"""Format a JSON diff as human-readable lines. """Format a JSON diff as human-readable lines.
Args: Args:
diff: The JSON diff dict. diff: The JSON diff dict.
previous: The previous state dict (for determining add vs update). previous: The previous state dict (for determining add vs update).
resolver: Optional callable to resolve path components (e.g. UUID→name). 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). Returns a list of formatted lines (without newlines).
""" """
@@ -235,55 +423,123 @@ def format_diff(
return [] return []
lines = [] lines = []
for change_type, path, value in changes: for change_type, path, value in changes:
lines.extend(_format_change_lines(change_type, path, value, resolver)) lines.extend(_format_change_lines(change_type, path, value, logfmt))
return lines return lines
def format_action_header(action: str, user_display: str | None = None) -> str: def format_action_header(
"""Format the action header line.""" action: str,
action_str = f"{_ACTION}{action}{_RESET}" user: str | None = None,
if user_display: extra: Any = None,
user_str = f"{_USER}{user_display}{_RESET}" ) -> str:
return f"{action_str} by {user_str}" """Format the default action header line."""
return action_str line = Line().action(action)
if extra is not None and (extra := f"{extra}"):
line(" ").target(extra)
if user is not None and (user := f"{user}"):
line(" by ").user(user)
return str(line)
def log_change( def log_change(
action: str, action: str,
diff: dict, diff: dict,
user_display: str | None = None, user: str | None = None,
previous: dict | None = None, previous: dict | None = None,
resolver: Callable[[str], str] | None = None, extra: Any = None,
logfmt: Callable[[Any, str], str | None] | None = None,
*,
logger: logging.Logger = transaction_logger,
level: int = logging.INFO,
log_diff: bool = True,
) -> None: ) -> None:
"""Log a database change with pretty-printed diff. """Log a database change with the built-in formatting.
Compatibility wrapper around :func:`emit_event` with no handlers; Kanta
itself builds a :class:`LogEvent` and dispatches it with the registered
logemit callbacks.
Args: Args:
action: The action name (e.g., "login", "admin:delete_user"). action: The action name (e.g., "login", "admin:delete_user").
diff: The JSON diff dict. diff: The JSON diff dict.
user_display: Optional display name of the user who performed the action. user: Optional already-formatted user name to show in the header.
previous: The previous state dict (for determining add vs update). previous: The previous state dict (for determining add vs update).
resolver: Optional callable to resolve path components (e.g. UUID→name). extra: Optional display-only value shown after the action in the
header. Anything other than ``None`` is printed str-converted
(colored by Kanta), unless a custom logemit handler does
something else with it.
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.
""" """
header = format_action_header(action, user_display) emit_event(
diff_lines = format_diff(diff, previous, resolver) LogEvent(
kind="change",
logger=logger,
level=level,
action=action,
user=user,
extra=extra,
diff=diff,
previous=previous,
logfmt=logfmt,
show_diff=log_diff,
)
)
if not diff_lines:
logger.info(header) def configure_logging(
*,
skiproot: bool = True,
bootstrap: bool = True,
migration: bool = True,
transaction: bool = True,
diff: bool = True,
debug: bool = False,
) -> 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`.
debug: Whether to set the ``kanta`` logger level to ``DEBUG`` instead
of ``INFO``. This reveals debug-level output such as migration
diffs, which are hidden by default.
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 return
if len(diff_lines) == 1: target = logging.getLogger("kanta")
logger.info(f"{header}{diff_lines[0]}") target.propagate = False
else:
logger.info(header)
for line in diff_lines:
logger.info(line)
if not target.handlers:
def configure_logging() -> None:
"""Configure the database logger to output to stderr without prefix."""
if not logger.handlers:
handler = logging.StreamHandler(sys.stderr) handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(logging.Formatter("%(message)s")) handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler) target.addHandler(handler)
logger.setLevel(logging.INFO) target.setLevel(logging.DEBUG if debug else logging.INFO)
logger.propagate = False
-117
View File
@@ -1,117 +0,0 @@
"""Database schema migration framework.
Migrations are numbered functions discovered automatically via a decorator
or by prefix. Each runs exactly once based on the current version.
"""
from __future__ import annotations
import importlib
import logging
from types import ModuleType
from typing import Any
import msgspec
_logger = logging.getLogger(__name__)
class MigrationCtx(msgspec.Struct, omit_defaults=True):
"""Context passed to each migration function.
Subclass or replace this with your own context type.
"""
pass
class MigrationRegistry:
"""Registry of schema migration functions.
Usage::
registry = MigrationRegistry()
@registry.register
def migrate_v1(d: dict, ctx: MigrationCtx) -> None:
d.setdefault("version", 1)
new_version = registry.apply(state, current_version=0)
Or load from a module::
registry = MigrationRegistry.from_module("myapp.migrations")
new_version = registry.apply(state, current_version=0)
"""
def __init__(self) -> None:
self._migrations: dict[int, Any] = {}
@staticmethod
def _migration_version(fn: Any) -> int:
name = getattr(fn, "__name__", "")
if not name.startswith("migrate_v"):
raise ValueError(f"Invalid migration function name: {name!r}")
suffix = name.removeprefix("migrate_v")
if not suffix.isdigit() or int(suffix) <= 0:
raise ValueError(f"Invalid migration version in function name: {name!r}")
return int(suffix)
def register(self, fn):
"""Decorator to register a migration function."""
version = self._migration_version(fn)
self._migrations[version] = fn
return fn
@classmethod
def from_module(cls, module: str | ModuleType) -> MigrationRegistry:
"""Create a registry by scanning a module for ``migrate_vN`` functions.
Args:
module: A module name (string) or an imported module object.
"""
reg = cls()
if isinstance(module, str):
mod = importlib.import_module(module)
else:
mod = module
for name in dir(mod):
if name.startswith("migrate_v"):
fn = getattr(mod, name)
if callable(fn):
version = reg._migration_version(fn)
reg._migrations[version] = fn
return reg
@property
def dbver(self) -> int:
"""Current schema version (= highest discovered migration, or 0)."""
return max(self._migrations.keys(), default=0)
def apply(
self,
data_dict: dict[str, Any],
current_version: int,
ctx: MigrationCtx | None = None,
*,
silent: bool = False,
) -> int:
"""Apply pending migrations to *data_dict* in place.
Returns the new version after all migrations.
"""
while current_version < self.dbver:
next_version = current_version + 1
fn = self._migrations.get(next_version)
if fn is None:
raise ValueError(
f"Missing migration step migrate_v{next_version} "
f"(highest discovered is v{self.dbver})"
)
fn(data_dict, ctx or MigrationCtx())
current_version = next_version
if not silent:
desc = (fn.__doc__ or fn.__name__).split("\n")[0].rstrip(".")
_logger.info("Applied migration %s: %s", fn.__name__, desc)
return current_version
+196
View File
@@ -0,0 +1,196 @@
"""Database schema migration framework.
Migrations are numbered functions discovered automatically via a decorator
or by prefix. Each runs exactly once based on the current version.
"""
from __future__ import annotations
import copy
import importlib
import inspect
from dataclasses import dataclass
from types import ModuleType
from typing import Any
from kanta.diff import compute_diff
from kanta.exceptions import DatabaseError
# Cache registries by imported module object so that many Kanta instances using
# the same migrations module do not re-scan it each time.
_module_registry_cache: dict[ModuleType, Migrations] = {}
@dataclass
class MigrationInfo:
"""Information about a single migration that ran."""
name: str
description: str
version: int
changed: bool
diff: dict | None = None
before: dict | None = None
@dataclass
class MigrationReport:
"""Report of applying migrations."""
version: int
original: int
applied: list[MigrationInfo]
@property
def migrations(self) -> list[MigrationInfo]:
"""Deprecated alias for :attr:`applied`."""
return self.applied
MigrationResult = MigrationReport # deprecated alias for MigrationReport
class Migrations:
"""Registry of schema migration functions.
Usage::
migrations = Migrations()
@migrations.register
def migrate_v1(d: dict, kanta) -> None:
d.setdefault("version", 1)
kanta.ctx.note = "migrated"
@migrations.register
def migrate_v2(d: dict) -> None:
d.setdefault("version", 2)
report = migrations.apply(state, current_version=0, kanta=kanta)
new_version = report.version
Or load from a module::
migrations = Migrations.from_module("myapp.migrations")
report = migrations.apply(state, current_version=0, kanta=kanta)
"""
def __init__(self) -> None:
self._migrations: dict[int, Any] = {}
@staticmethod
def _migration_version(fn: Any) -> int:
name = getattr(fn, "__name__", "")
if not name.startswith("migrate_v"):
raise ValueError(f"Invalid migration function name: {name!r}")
suffix = name.removeprefix("migrate_v")
if not suffix.isdigit() or int(suffix) <= 0:
raise ValueError(f"Invalid migration version in function name: {name!r}")
return int(suffix)
def register(self, fn):
"""Decorator to register a migration function."""
version = self._migration_version(fn)
self._migrations[version] = fn
return fn
@classmethod
def from_module(cls, module: str | ModuleType) -> Migrations:
"""Create or retrieve a cached registry by scanning a module.
Args:
module: A module name (string) or an imported module object.
"""
if isinstance(module, str):
mod = importlib.import_module(module)
else:
mod = module
try:
return _module_registry_cache[mod]
except KeyError:
pass
reg = cls()
for name in dir(mod):
if name.startswith("migrate_v"):
fn = getattr(mod, name)
if callable(fn):
version = reg._migration_version(fn)
reg._migrations[version] = fn
_module_registry_cache[mod] = reg
return reg
@property
def dbver(self) -> int:
"""Current schema version (= highest discovered migration, or 0)."""
return max(self._migrations.keys(), default=0)
@property
def minver(self) -> int:
"""Minimum supported current version (first migration minus 1, or 0)."""
return min(self._migrations.keys(), default=1) - 1
@staticmethod
def _call_migration(fn: Any, data_dict: dict[str, Any], kanta: Any) -> None:
"""Call *fn* with the data dict and, if accepted, the Kanta instance."""
try:
inspect.signature(fn).bind(data_dict, kanta)
except TypeError:
fn(data_dict)
else:
fn(data_dict, kanta)
def apply(
self,
data_dict: dict[str, Any],
current_version: int,
kanta: Any,
) -> MigrationReport:
"""Apply pending migrations to *data_dict* in place.
Missing intermediate migration steps are silently skipped.
Raises:
DatabaseError: If the database version is newer than the highest
supported version or older than the minimum supported version.
Returns a :class:`MigrationReport` describing the original and new
versions and every migration that ran.
"""
if current_version > self.dbver:
raise DatabaseError(
f"Database version v{current_version} is newer than the "
f"highest supported version v{self.dbver}"
)
if current_version < self.minver:
raise DatabaseError(
f"Database version v{current_version} is older than the "
f"minimum supported version v{self.minver}"
)
migrations: list[MigrationInfo] = []
original = current_version
for version in sorted(self._migrations.keys()):
if version <= current_version:
continue
fn = self._migrations[version]
before = copy.deepcopy(data_dict)
self._call_migration(fn, data_dict, kanta)
current_version = version
changed = before != data_dict
diff = compute_diff(before, data_dict) if changed else None
desc = (fn.__doc__ or f"v{version}").split("\n")[0].rstrip(".")
migrations.append(
MigrationInfo(
name=fn.__name__,
description=desc,
version=version,
changed=changed,
diff=diff,
before=before,
)
)
return MigrationReport(
version=current_version, original=original, applied=migrations
)
+132 -48
View File
@@ -4,18 +4,19 @@ from __future__ import annotations
import asyncio import asyncio
import copy import copy
import inspect
import logging import logging
import threading
from collections import deque from collections import deque
from collections.abc import Callable from collections.abc import Callable
from datetime import datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from kanta.callbacks import CallbackRegistry, InjectionContext
from kanta.diff import compute_diff from kanta.diff import compute_diff
from kanta.exceptions import DatabaseError, DataIntegrityError from kanta.exceptions import DatabaseError, DataIntegrityError
from kanta.filelock import LockedFile from kanta.filelock import LockedFile
from kanta.kanta.structs import ChangeRecord from kanta.structs import ChangeRecord
from kanta.serialization import JsonSerializer, Serializer from kanta.serialization import JsonSerializer, Serializer
from kanta.serialization.framing import Framer from kanta.serialization.framing import Framer
from kanta.snapshot import SnapshotState from kanta.snapshot import SnapshotState
@@ -31,41 +32,73 @@ class PersistenceMixin:
flush_failed: bool flush_failed: bool
statedict: dict[str, Any] statedict: dict[str, Any]
pending_changes: deque[ChangeRecord] pending_changes: deque[ChangeRecord]
pending_lock: threading.Lock
snapshot: SnapshotState snapshot: SnapshotState
serializer: Serializer serializer: Serializer
framer: Framer framer: Framer
background_task: asyncio.Task | None background_task: asyncio.Task | None
fatal_error: Callable[[DatabaseError], None] | None callback_registry: CallbackRegistry
background_error: DatabaseError | None background_error: DatabaseError | None
flush_interval: float flush_interval: float
version: int version: int
opened: bool opened: bool
readonly: bool
mtime: datetime | None
clock: Callable[[], datetime] | None
def __init__(self, **kwargs: Any) -> None: def __init__(self, **kwargs: Any) -> None:
"""Initialize persistence-owned state used by mixin methods.""" """Initialize persistence-owned state used by mixin methods."""
filename = kwargs.pop("filename") filename = kwargs.pop("filename")
flush_interval = kwargs.pop("flush_interval", 0.1) flush_interval = kwargs.pop("flush_interval", 0.1)
serializer = kwargs.pop("serializer", None) serializer = kwargs.pop("serializer", None)
fatal_error = kwargs.pop("fatal_error", None)
super().__init__(**kwargs) super().__init__(**kwargs)
self.filename = Path(filename) self.filename = Path(filename)
self.file = LockedFile() self.file = LockedFile()
self.flush_failed = False self.flush_failed = False
self.statedict = {} self.statedict = {}
self.pending_changes = deque() self.pending_changes = deque()
self.pending_lock = threading.Lock()
self.serializer = serializer or JsonSerializer() self.serializer = serializer or JsonSerializer()
self.framer = self.serializer.framer_cls() self.framer = self.serializer.framer_cls()
self.snapshot = SnapshotState(serializer=self.serializer, framer=self.framer) self.snapshot = SnapshotState(serializer=self.serializer, framer=self.framer)
self.background_task = None self.background_task = None
self.fatal_error = fatal_error self.callback_registry = CallbackRegistry()
self.background_error = None self.background_error = None
self.flush_interval = flush_interval self.flush_interval = flush_interval
self.version = 0 self.version = 0
self.mtime: datetime | None = None
self.clock: Callable[[], datetime] | None = None
def add_clock(self, callback) -> None:
"""Register a clock callback ``() -> datetime`` replacing the UTC clock."""
if not callable(callback):
raise TypeError("clock callback must be callable")
for param in inspect.signature(callback).parameters.values():
if param.default is inspect.Parameter.empty and param.kind in (
param.POSITIONAL_ONLY,
param.POSITIONAL_OR_KEYWORD,
param.KEYWORD_ONLY,
):
raise TypeError("clock callback must not require arguments")
self.clock = callback
def now(self) -> datetime:
"""Current time from the registered clock (default: UTC now)."""
if self.clock is None:
return datetime.now(UTC)
ts = self.clock()
if not isinstance(ts, datetime):
raise TypeError(
f"clock callback must return a datetime, got {type(ts).__name__}"
)
return ts
def add_fatal_error(self, callback) -> None:
"""Register one fatal error callback in call order."""
self.callback_registry.register("fatal_error", callback)
async def _background_loop(self) -> None: async def _background_loop(self) -> None:
"""Background task that periodically flushes changes to disk.""" """Background task that periodically flushes changes to disk."""
if self.readonly:
return
while True: while True:
try: try:
await asyncio.sleep(self.flush_interval) await asyncio.sleep(self.flush_interval)
@@ -77,42 +110,85 @@ class PersistenceMixin:
break break
except DatabaseError as e: except DatabaseError as e:
self.background_error = e self.background_error = e
if self.fatal_error is not None:
try: def _log_callback_error(callback_error, callback):
self.fatal_error(e) _logger.exception(
except Exception as callback_error: "Background error callback %r failed: %s",
_logger.exception( callback,
"Background error callback failed: %s", callback_error callback_error,
) )
await self.callback_registry.invoke(
"fatal_error",
InjectionContext(error=e, kanta=self._kanta),
on_error=_log_callback_error,
)
_logger.error("Background flush loop stopped: %s", e) _logger.error("Background flush loop stopped: %s", e)
break break
def maybe_snapshot(self) -> None: def maybe_snapshot(self) -> None:
"""Evaluate and possibly write a snapshot from current state.""" """Evaluate and possibly write a snapshot from current state."""
self.snapshot.maybe_write(self.file, self.version, self.statedict) self.snapshot.maybe_write(
self.file, self.version, self.statedict, m=self.mtime, now=self.now
)
def queue_change( def queue_change(
self, self,
action: str, action: str,
current: dict, current: dict,
*,
user: str | None = None, user: str | None = None,
m: datetime | None = None, mtime: bool | datetime = True,
) -> None: force: bool = False,
"""Queue a change record internally (thread-safe).""" ) -> ChangeRecord | None:
"""Queue a change record internally (thread-safe).
Args:
action: Action label stored in the change record.
current: New serialized state after the change.
user: Optional actor identifier.
mtime: Controls the modification timestamp. ``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.
force: If ``True``, queue the record even when the diff is empty.
Returns:
The queued :class:`ChangeRecord`, or ``None`` if the diff was empty
and *force* is ``False``.
"""
diff = compute_diff(self.statedict, current) diff = compute_diff(self.statedict, current)
if not diff: if not diff:
return if not force:
with self.pending_lock: return None
self.pending_changes.append( diff = {}
ChangeRecord(
a=action, # The clock is only read when a record is actually queued.
v=self.version, now = self.now()
u=user,
m=m, if mtime is True:
diff=diff, m = now
) elif mtime is False:
) m = None
elif isinstance(mtime, datetime):
m = mtime
else:
raise TypeError("mtime must be True, False, or a datetime")
record = ChangeRecord(
ts=now,
a=action,
v=self.version,
u=user,
m=m,
diff=diff,
)
self.pending_changes.append(record)
self.statedict = copy.deepcopy(current) self.statedict = copy.deepcopy(current)
if m is not None:
self.mtime = m
return record
def flush_sync(self) -> None: def flush_sync(self) -> None:
"""Synchronously flush all pending changes to disk.""" """Synchronously flush all pending changes to disk."""
@@ -123,13 +199,19 @@ class PersistenceMixin:
action="flush_sync", action="flush_sync",
) )
if self.readonly:
raise DataIntegrityError(
"Cannot flush in read-only mode",
db_path=self.filename,
action="flush_sync",
)
if self.flush_failed: if self.flush_failed:
return return
with self.pending_lock: if not self.pending_changes:
if not self.pending_changes: return
return changes_to_write = list(self.pending_changes)
changes_to_write = list(self.pending_changes)
if not self.file.is_open: if not self.file.is_open:
self.file.open(self.filename, create=True) self.file.open(self.filename, create=True)
@@ -146,15 +228,13 @@ class PersistenceMixin:
records.append(framed) records.append(framed)
running_size += len(framed) running_size += len(framed)
if not records: if not records:
with self.pending_lock: self.pending_changes.clear()
self.pending_changes.clear()
return return
self.file.write(b"".join(records)) self.file.write(b"".join(records))
self.snapshot.record_changes(len(records)) self.snapshot.record_changes(len(records))
with self.pending_lock: for _ in changes_to_write:
for _ in changes_to_write: self.pending_changes.popleft()
self.pending_changes.popleft()
except OSError as e: except OSError as e:
_logger.error("Failed to flush database: %s", e) _logger.error("Failed to flush database: %s", e)
self.flush_failed = True self.flush_failed = True
@@ -173,13 +253,19 @@ class PersistenceMixin:
action="flush", action="flush",
) )
if self.readonly:
raise DataIntegrityError(
"Cannot flush in read-only mode",
db_path=self.filename,
action="flush",
)
if self.flush_failed: if self.flush_failed:
return return
with self.pending_lock: if not self.pending_changes:
if not self.pending_changes: return
return changes_to_write = list(self.pending_changes)
changes_to_write = list(self.pending_changes)
if not self.file.is_open: if not self.file.is_open:
await asyncio.to_thread(self.file.open, self.filename, create=True) await asyncio.to_thread(self.file.open, self.filename, create=True)
@@ -196,15 +282,13 @@ class PersistenceMixin:
records.append(framed) records.append(framed)
running_size += len(framed) running_size += len(framed)
if not records: if not records:
with self.pending_lock: self.pending_changes.clear()
self.pending_changes.clear()
return return
await asyncio.to_thread(self.file.write, b"".join(records)) await asyncio.to_thread(self.file.write, b"".join(records))
self.snapshot.record_changes(len(records)) self.snapshot.record_changes(len(records))
with self.pending_lock: for _ in changes_to_write:
for _ in changes_to_write: self.pending_changes.popleft()
self.pending_changes.popleft()
except OSError as e: except OSError as e:
_logger.error("Failed to flush database: %s", e) _logger.error("Failed to flush database: %s", e)
self.flush_failed = True self.flush_failed = True
+378
View File
@@ -0,0 +1,378 @@
"""Line-oriented replay and range selection for kantadb files.
Support machinery for the ``python -m kanta`` CLI: decoding a file into
positioned events, resolving ``-r`` range specifications to line numbers,
replaying state over a line range, and building change log events. Internal
for now; not part of the public API.
"""
from __future__ import annotations
import copy
import dataclasses
from collections.abc import Iterator
from typing import TYPE_CHECKING, Any, Union
import msgspec
from kanta.callbacks import InjectionContext
from kanta.diff import patch_state
from kanta.exceptions import ReplayError
from kanta.logging import _USER_PATH, LogEvent, transaction_logger
from kanta.structs import ChangeRecord, Snapshot
if TYPE_CHECKING:
from kanta import Kanta
@dataclasses.dataclass
class SnapshotEvent:
"""A snapshot record positioned in the file."""
line_number: int
byte_pos: int
record_index: int
snap: Snapshot
@property
def version(self) -> int:
return self.snap.v
@dataclasses.dataclass
class ChangeEvent:
"""A change record positioned in the file."""
line_number: int
byte_pos: int
record_index: int
record: ChangeRecord
@property
def version(self) -> int:
return self.record.v
Event = Union[SnapshotEvent, ChangeEvent]
class RangeNotFoundError(Exception):
"""A single-item range specification that does not exist in the file.
The message is fully formatted for display, including the offending
input and how many items of that kind the file contains.
"""
@dataclasses.dataclass
class Selection:
"""A resolved range specification.
Either a ``[start_line, end_line)`` line range, or a single snapshot
(``snapshot`` set), used to show the snapshot state without replaying
further records.
"""
start_line: int
end_line: int
snapshot: SnapshotEvent | None = None
def record_label(line_number: int, record_index: int) -> str:
"""Return a padded record label based on line number, falling back to record index."""
number = line_number if line_number else record_index
return f"l{number:<3}"
def scan_events(content: bytes, kanta: Kanta[Any]) -> tuple[list[Event], int]:
"""Decode all records, validating snapshot consistency.
Uses the Kanta instance's serializer and framer. Returns the events in
file order and the number of change records. Raises :class:`ReplayError`
with a located, display-ready message on decode failures or when a
snapshot does not match the replayed state.
"""
impl = kanta._impl
state: dict[str, Any] = {}
events: list[Event] = []
change_count = 0
for is_snapshot, payload, line_number, byte_pos in impl.framer.iter_records(
content, 0
):
record_index = len(events) + 1
label = record_label(line_number, record_index)
try:
if is_snapshot:
snap = impl.serializer.decode(payload, type=Snapshot)
if record_index > 1 and state != snap.state:
raise ReplayError(
f"Snapshot mismatch at {label}: replayed state"
" does not equal the snapshot state.",
line_number=line_number,
byte_pos=byte_pos,
record_type="snapshot",
)
state = snap.state
events.append(SnapshotEvent(line_number, byte_pos, record_index, snap))
else:
record = impl.serializer.decode(payload, type=ChangeRecord)
state = patch_state(state, record.diff)
events.append(ChangeEvent(line_number, byte_pos, record_index, record))
change_count += 1
except msgspec.DecodeError as exc:
raise ReplayError(
f"Parse error at {label}: {exc}",
line_number=line_number,
byte_pos=byte_pos,
) from exc
return events, change_count
def replay_events(
events: list[Event], end_line: int
) -> Iterator[tuple[Event, dict[str, Any] | None, dict[str, Any]]]:
"""Replay events with line numbers below ``end_line``.
Yields ``(event, previous, state)`` per event: ``previous`` is the state
before a change (``None`` for snapshots) and ``state`` the state after
the event.
"""
state: dict[str, Any] = {}
for event in events:
if event.line_number >= end_line:
break
if isinstance(event, SnapshotEvent):
state = event.snap.state
yield event, None, state
else:
previous = copy.deepcopy(state)
state = patch_state(state, event.record.diff)
yield event, previous, state
def record_change_event(
record: ChangeRecord,
previous: dict[str, Any],
current: dict[str, Any],
kanta: Kanta[Any],
) -> LogEvent:
"""Build a change :class:`LogEvent` for a replayed record.
The Kanta instance's logfmt callbacks are used for value formatting and
for resolving the user/actor name; the event can then be dispatched with
:func:`kanta.logging.emit_event` and the instance's logemit handlers.
"""
registry = kanta._impl.callback_registry
logfmt = registry.build_logfmt(
InjectionContext(
kanta=kanta,
previous_state=previous,
current_state=current,
)
)
user = record.u
if user is not None:
resolved = logfmt(user, _USER_PATH)
if resolved is not None:
user = resolved
return LogEvent(
kind="change",
logger=transaction_logger,
kanta=kanta,
action=record.a,
user=user,
diff=record.diff,
previous=previous,
logfmt=logfmt,
)
def _plural(count: int, word: str) -> str:
"""Return e.g. ``1 snapshot`` or ``2 snapshots``."""
return f"{count} {word}{'' if count == 1 else 's'}"
def end_of_file(events: list[Event]) -> int:
"""Return the sentinel line number just past the last line of the file."""
return events[-1].line_number + 1 if events else 0
def _change_lines(events: list[Event]) -> list[int]:
"""Return the line numbers of all change records, in file order."""
return [e.line_number for e in events if isinstance(e, ChangeEvent)]
def _snapshot_lines(events: list[Event]) -> list[int]:
"""Return the line numbers addressed by s0, s1, ...
If the file begins with a snapshot, s0 is that snapshot (l1) and s1 is
the next snapshot. Otherwise the file begins with change records (empty
initial state): s0 is l0, the position before the start of the file, and
s1 is the first snapshot.
"""
lines = [e.line_number for e in events if isinstance(e, SnapshotEvent)]
if events and isinstance(events[0], SnapshotEvent):
return lines
return [0, *lines]
def _version_lines(events: list[Event]) -> dict[int, int]:
"""Map each version to the line where it first appears; v0 is l0."""
lines: dict[int, int] = {0: 0}
for event in events:
lines.setdefault(event.version, event.line_number)
return lines
def _event_at_line(events: list[Event], line: int) -> Event | None:
"""Return the event whose file line number exactly matches ``line``."""
for event in events:
if event.line_number == line:
return event
return None
def _parse_bound(bound_str: str) -> tuple[str, int | None]:
"""Parse a range bound with optional unit prefix (l, s, v) or change index."""
if not bound_str:
return "change", None
unit_map = {"l": "line", "s": "snapshot", "v": "version"}
if bound_str[0] in unit_map:
unit = unit_map[bound_str[0]]
rest = bound_str[1:]
if not rest:
raise ValueError(f"empty value in {bound_str!r}")
return unit, int(rest)
return "change", int(bound_str)
def _bound_to_line(
unit: str,
value: int | None,
events: list[Event],
total: int,
is_start: bool,
) -> int:
"""Convert a range bound to a line number.
Out-of-range values are truncated to l0 (before the first line) or to
the line just past the end of the file rather than erroring; a missing
bound means the corresponding file end.
"""
eof = end_of_file(events)
if value is None:
return 0 if is_start else eof
if unit == "change":
lines = _change_lines(events)
if value < 0:
value = total + value
value = max(0, min(value, total))
return lines[value] if value < total else eof
if unit == "line":
if value < 0:
raise ValueError("line numbers do not support negative indexing")
return value
if unit == "snapshot":
lines = _snapshot_lines(events)
idx = len(lines) + value if value < 0 else value
if idx < 0:
return 0
return lines[idx] if idx < len(lines) else eof
if unit == "version":
if value < 0:
raise ValueError("version numbers do not support negative indexing")
return _version_lines(events).get(value, eof)
raise ValueError(f"unknown range unit: {unit}")
def _resolve_range(range_str: str, events: list[Event], total: int) -> tuple[int, int]:
"""Parse a range string into a [start_line, end_line) line range."""
sep = ".." if ".." in range_str else ":"
start_str, end_str = range_str.split(sep, 1)
start_unit, start_val = _parse_bound(start_str)
end_unit, end_val = _parse_bound(end_str)
start_line = _bound_to_line(start_unit, start_val, events, total, is_start=True)
end_line = _bound_to_line(end_unit, end_val, events, total, is_start=False)
# ``..`` makes the end bound inclusive.
if sep == ".." and end_val is not None:
end_line += 1
return min(start_line, end_line), end_line
def _negative_check(unit: str, value: int) -> None:
if value < 0:
raise ValueError(f"{unit} numbers do not support negative indexing")
def select(spec: str, events: list[Event], total: int) -> Selection:
"""Resolve a range specification against the scanned events.
``total`` is the number of change records. Returns a :class:`Selection`:
a line range, or a single snapshot for snapshot selections (``sN``, or
``lN`` pointing at a snapshot). Ranges truncate out-of-bounds values;
a single index must exist and raises :class:`RangeNotFoundError`
otherwise. Syntax errors raise :class:`ValueError`.
"""
if ":" in spec or ".." in spec:
start_line, end_line = _resolve_range(spec, events, total)
return Selection(start_line, end_line)
# A single index must exist; out-of-bounds is an error.
unit, value = _parse_bound(spec)
if value is None:
raise ValueError("single bound must not be empty")
if unit == "snapshot":
lines = _snapshot_lines(events)
idx = len(lines) + value if value < 0 else value
n_snapshots = sum(isinstance(e, SnapshotEvent) for e in events)
count = _plural(n_snapshots, "snapshot")
if not 0 <= idx < len(lines):
raise RangeNotFoundError(f"Snapshot {spec!r} not found in file ({count})")
event = _event_at_line(events, lines[idx])
if event is None:
# s0 with an empty initial state (l0): not a real record, so it
# cannot be selected as a single item.
raise RangeNotFoundError(
f"Snapshot {spec!r} not found in file: the file starts"
f" with an empty initial state ({count})"
)
assert isinstance(event, SnapshotEvent)
return Selection(event.line_number, event.line_number + 1, event)
if unit == "line":
_negative_check(unit, value)
event = _event_at_line(events, value)
if event is None:
n_lines = events[-1].line_number if events else 0
raise RangeNotFoundError(
f"Line {spec!r} not found in file ({_plural(n_lines, 'line')})"
)
if isinstance(event, SnapshotEvent):
return Selection(value, value + 1, event)
return Selection(value, value + 1)
if unit == "version":
_negative_check(unit, value)
lines = _version_lines(events)
if value not in lines:
raise RangeNotFoundError(
f"Version {spec!r} not found in file ({_plural(len(lines), 'version')})"
)
start_line = lines[value]
later = [line for line in lines.values() if line > start_line]
return Selection(start_line, min(later) if later else end_of_file(events))
lines = _change_lines(events)
idx = total + value if value < 0 else value
if not 0 <= idx < total:
raise RangeNotFoundError(
f"Change index {spec!r} not found in file ({_plural(total, 'change')})"
)
end_line = lines[idx + 1] if idx + 1 < total else end_of_file(events)
return Selection(lines[idx], end_line)
+1 -6
View File
@@ -9,7 +9,7 @@ from typing import Any, Protocol, TypeVar
import msgspec import msgspec
from kanta.exceptions import ReplayError from kanta.exceptions import ReplayError
from kanta.kanta.structs import ChangeRecord, Snapshot from kanta.structs import ChangeRecord, Snapshot
from kanta.serialization.framing import Framer from kanta.serialization.framing import Framer
T = TypeVar("T") T = TypeVar("T")
@@ -23,14 +23,12 @@ class ReplayResult:
state: dict[str, Any], state: dict[str, Any],
version: int = 0, version: int = 0,
has_migration: bool = False, has_migration: bool = False,
last_patch_mtime: float | None = None,
last_snapshot_mtime: float | None = None, last_snapshot_mtime: float | None = None,
m: datetime | None = None, m: datetime | None = None,
): ):
self.state = state self.state = state
self.version = version self.version = version
self.has_migration = has_migration self.has_migration = has_migration
self.last_patch_mtime = last_patch_mtime
self.last_snapshot_mtime = last_snapshot_mtime self.last_snapshot_mtime = last_snapshot_mtime
self.m = m self.m = m
@@ -63,7 +61,6 @@ def replay(
last_snapshot_mtime: float | None = None last_snapshot_mtime: float | None = None
m: datetime | None = None m: datetime | None = None
has_migration = False has_migration = False
last_patch_mtime: float | None = None
if snap_payload is not None: if snap_payload is not None:
try: try:
@@ -112,14 +109,12 @@ def replay(
has_migration = True has_migration = True
if change.m is not None: if change.m is not None:
m = change.m m = change.m
last_patch_mtime = change.ts.timestamp()
version = change.v version = change.v
state = _patch_state(state, change.diff) state = _patch_state(state, change.diff)
return ReplayResult( return ReplayResult(
state=state, state=state,
version=version, version=version,
has_migration=has_migration, has_migration=has_migration,
last_patch_mtime=last_patch_mtime,
last_snapshot_mtime=last_snapshot_mtime, last_snapshot_mtime=last_snapshot_mtime,
m=m, m=m,
) )
+2 -2
View File
@@ -6,7 +6,7 @@ from typing import Any, TypeVar
import msgspec import msgspec
from kanta.serialization.framing import LineFramer from kanta.serialization.framing import Framer, LineFramer
T = TypeVar("T") T = TypeVar("T")
@@ -14,7 +14,7 @@ T = TypeVar("T")
class JsonSerializer: class JsonSerializer:
"""Line-based JSON serializer.""" """Line-based JSON serializer."""
framer_cls = LineFramer framer_cls: type[Framer] = LineFramer
def encode(self, obj: Any) -> bytes: def encode(self, obj: Any) -> bytes:
return msgspec.json.encode(obj) return msgspec.json.encode(obj)
+2 -2
View File
@@ -6,7 +6,7 @@ from typing import Any, TypeVar
import msgspec import msgspec
from kanta.serialization.framing import BinFramer from kanta.serialization.framing import BinFramer, Framer
T = TypeVar("T") T = TypeVar("T")
@@ -14,7 +14,7 @@ T = TypeVar("T")
class MsgPackSerializer: class MsgPackSerializer:
"""Binary serializer using MessagePack format.""" """Binary serializer using MessagePack format."""
framer_cls = BinFramer framer_cls: type[Framer] = BinFramer
def encode(self, obj: Any) -> bytes: def encode(self, obj: Any) -> bytes:
return msgspec.msgpack.encode(obj) return msgspec.msgpack.encode(obj)
+24 -12
View File
@@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from kanta.kanta.structs import Snapshot from kanta.structs import Snapshot
from kanta.serialization import JsonSerializer, Serializer from kanta.serialization import JsonSerializer, Serializer
from kanta.serialization.framing import Framer, LineFramer from kanta.serialization.framing import Framer, LineFramer
@@ -37,28 +38,39 @@ class SnapshotState:
def record_changes(self, count: int) -> None: def record_changes(self, count: int) -> None:
self.changes += count self.changes += count
def maybe_write(self, file, version: int, state: dict) -> None: def maybe_write(
self,
file,
version: int,
state: dict,
m: datetime | None = None,
now: Callable[[], datetime] | None = None,
) -> None:
"""Write snapshot when thresholds/time policy allows it.""" """Write snapshot when thresholds/time policy allows it."""
if self.changes < self._min_diffs:
return
force = self._force_pending force = self._force_pending
now = datetime.now(UTC) if not force and self.changes < self._min_diffs:
if not force and now.weekday() != 6: # 6 = Sunday
return
sunday_midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
if not force and self.ts is not None and self.ts >= sunday_midnight:
return return
# The clock is only read when a snapshot may actually be written.
ts = now() if now is not None else datetime.now(UTC)
if not force:
if ts.weekday() != 6: # 6 = Sunday
return
sunday_midnight = ts.replace(hour=0, minute=0, second=0, microsecond=0)
if self.ts is not None and self.ts >= sunday_midnight:
return
if not file.is_open: if not file.is_open:
return return
try: try:
self._write(file, version, state, now) self._write(file, version, state, ts, m=m)
self._force_pending = False self._force_pending = False
except Exception as exc: except Exception as exc:
_logger.error("snapshot: failed to write snapshot: %r", exc) _logger.error("snapshot: failed to write snapshot: %r", exc)
def _write(self, file, version: int, state: dict, now: datetime) -> None: def _write(
self, file, version: int, state: dict, now: datetime, m: datetime | None = None
) -> None:
"""Write a snapshot and update internal state.""" """Write a snapshot and update internal state."""
payload = self._serializer.encode(Snapshot(ts=now, v=version, state=state)) payload = self._serializer.encode(Snapshot(ts=now, v=version, state=state, m=m))
record_offset = file.size() if hasattr(file, "size") else 0 record_offset = file.size() if hasattr(file, "size") else 0
file.write(self._framer.frame_snapshot(payload, record_offset=record_offset)) file.write(self._framer.frame_snapshot(payload, record_offset=record_offset))
self.changes = 0 self.changes = 0
+1 -1
View File
@@ -23,7 +23,7 @@ class ChangeRecord(msgspec.Struct, omit_defaults=True, kw_only=True):
v: int = 0 v: int = 0
u: str | None = None u: str | None = None
m: datetime | None = None m: datetime | None = None
diff: dict diff: dict = {}
class Snapshot(msgspec.Struct, omit_defaults=True): class Snapshot(msgspec.Struct, omit_defaults=True):
+75 -8
View File
@@ -4,26 +4,56 @@ from __future__ import annotations
import logging import logging
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime
from typing import Any from typing import Any
from kanta.diff import compute_diff from kanta.diff import compute_diff
from kanta.exceptions import DataIntegrityError from kanta.exceptions import DataIntegrityError
from kanta.logging import log_change from kanta.callbacks import InjectionContext
from kanta.logging import _USER_PATH, LogEvent, emit_event, transaction_logger
from kanta.serialization import restore_data_in_place, struct_to_dict from kanta.serialization import restore_data_in_place, struct_to_dict
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
def _build_logfmt(impl, previous: dict, current: dict):
"""Build the logfmt chain for a state transition."""
return impl.callback_registry.build_logfmt(
InjectionContext(
previous_state=previous,
current_state=current,
kanta=impl._kanta,
)
)
def _resolve_user(logfmt, user: str | None) -> str | None:
"""Resolve *user* for display via the logfmt chain (raw as fallback)."""
if user is None:
return None
resolved = logfmt(user, _USER_PATH)
return resolved if resolved is not None else user
@contextmanager @contextmanager
def transaction( def transaction(
impl, impl,
action: str, action: str,
*, *,
user: str | None = None, user: str | None = None,
user_display: str | None = None, extra: Any = None,
resolver: Any = None, mtime: bool | datetime = 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:
raise DataIntegrityError(
"Cannot start transaction in read-only mode",
db_path=impl.filename,
action=action,
)
if impl.in_transaction: if impl.in_transaction:
raise RuntimeError( raise RuntimeError(
"Nested or simultaneous transactions are not supported " "Nested or simultaneous transactions are not supported "
@@ -58,11 +88,48 @@ def transaction(
new_dict = struct_to_dict(impl.data, serializer=impl.serializer) new_dict = struct_to_dict(impl.data, serializer=impl.serializer)
diff = compute_diff(impl.statedict, new_dict) diff = compute_diff(impl.statedict, new_dict)
if diff: if diff:
impl.queue_change(action, new_dict, user=user) previous = impl.statedict
log_change(action, diff, user_display, impl.statedict, resolver) record = impl.queue_change(action, new_dict, user=user, mtime=mtime)
impl.statedict = new_dict if record is not None:
except Exception: if log is not False:
_logger.warning("Transaction '%s' failed, rolling back changes", action) logfmt = _build_logfmt(impl, previous, new_dict)
logger = (
log if isinstance(log, logging.Logger) else transaction_logger
)
emit_event(
LogEvent(
kind="change",
logger=logger,
kanta=impl._kanta,
action=action,
user=_resolve_user(logfmt, user),
extra=extra,
diff=record.diff,
previous=previous,
current=new_dict,
logfmt=logfmt,
show_diff=logdiff,
),
impl.callback_registry.logemit_handlers,
)
except Exception as exc:
resolved_user = None
if user is not None:
logfmt = _build_logfmt(impl, impl.statedict, impl.statedict)
resolved_user = _resolve_user(logfmt, user)
emit_event(
LogEvent(
kind="aborted",
logger=transaction_logger,
level=logging.WARNING,
kanta=impl._kanta,
action=action,
user=resolved_user,
extra=extra,
error=exc,
),
impl.callback_registry.logemit_handlers,
)
if impl.transaction_snapshot is not None: if impl.transaction_snapshot is not None:
impl.data = restore_data_in_place( impl.data = restore_data_in_place(
impl.data, impl.data,
+183
View File
@@ -0,0 +1,183 @@
"""Terminal string building: ANSI colors, display widths, and a line builder.
Colors are stored as bare SGR parameter strings (e.g. ``"1;34"``) without
the ``\\x1b[`` prefix and ``m`` suffix. The :class:`Line` builder understands
how SGR parameters stack: ``0`` clears everything, other parameters apply
sequentially and the last one of each class wins. This lets it emit minimal
escape sequences, folding a needed reset into the same sequence as the next
color instead of emitting a separate one.
"""
from __future__ import annotations
import re
import unicodedata
from typing import Any
ESC = "\x1b["
# Matches a full ANSI escape sequence (color codes, cursor movement, ...).
ANSI_RE = re.compile(r"\x1b\[[0-9;:]*[A-Za-z]")
def strip_ansi(text: str) -> str:
"""Remove ANSI escape sequences from *text*."""
return ANSI_RE.sub("", text)
def displaywidth(text: str) -> int:
"""Return the terminal column width of *text*, ignoring ANSI sequences.
Wide characters (CJK, most emoji) count as two columns; combining and
zero-width characters count as zero.
"""
return sum(
2
if unicodedata.east_asian_width(c) in "WF"
else 0
if unicodedata.category(c) in ("Mn", "Me", "Cf")
else 1
for c in strip_ansi(text)
)
def pad(text: str, width: int, align: str = "left") -> str:
"""Pad *text* to *width* columns by display width.
*align* is ``"left"`` (padding after), ``"right"`` (padding before), or
``"center"``. Text already at or above *width* is returned unchanged.
"""
missing = width - displaywidth(text)
if missing <= 0:
return text
if align == "right":
return " " * missing + text
if align == "center":
left = missing // 2
return " " * left + text + " " * (missing - left)
return text + " " * missing
class Colors:
"""Kanta's log color palette: bare SGR parameter strings.
Attributes are looked up when a line is rendered, so assignments such as
``colors.action = "36"`` or additions like ``colors.session = "38;5;226"``
take effect immediately, no matter how the object was imported. Added
colors become available on :class:`Line` under the same name.
"""
action = "1;34" # Bold blue for the action name
user = "34" # Blue for the user display
target = "38;5;250" # White for the extra/target display
snapshot = "97" # Bright white for snapshot indicator text
sep = "38;5;242" # Dark grey for separators
path_prefix = "38;5;242" # Dark grey for the leading part of a dotted path
path_final = "38;5;250" # White for the final path element
add = "32" # Green for additions
delete = "1;31" # Bold red for deletions
ellipsis = "38;5;242" # Dark grey for the truncation ellipsis
colors = Colors()
# SGR attribute classes that carry no class siblings (each clears/sets itself).
_ATTR_CLASSES = frozenset({"1", "2", "3", "4", "7", "9"})
def _parse_sgr(spec: str) -> dict[str, str]:
"""Parse a bare SGR parameter string into a ``{class: group}`` state.
Applies the stacking rules: ``0`` clears everything, other parameters
apply sequentially and the last one of each class wins.
"""
state: dict[str, str] = {}
tokens = spec.split(";")
i = 0
while i < len(tokens):
token = tokens[i]
if token == "0":
state.clear()
elif token in ("38", "48"):
cls = "fg" if token == "38" else "bg"
if i + 1 < len(tokens) and tokens[i + 1] == "5":
state[cls] = ";".join(tokens[i : i + 3])
i += 3
continue
if i + 1 < len(tokens) and tokens[i + 1] == "2":
state[cls] = ";".join(tokens[i : i + 4])
i += 4
continue
state[cls] = token
elif token.isdigit() and (30 <= int(token) <= 37 or 90 <= int(token) <= 97):
state["fg"] = token
elif token.isdigit() and (40 <= int(token) <= 47 or 100 <= int(token) <= 107):
state["bg"] = token
elif token in _ATTR_CLASSES:
state[token] = token
else:
state[f"other:{token}"] = token
i += 1
return state
def _sgr_transition(current: dict[str, str], new: dict[str, str]) -> str:
"""Return the minimal escape sequence moving from *current* to *new*."""
if current == new:
return ""
if not new:
return f"{ESC}0m" if current else ""
if not current:
return f"{ESC}{';'.join(new.values())}m"
if current.keys() - new.keys():
# Some attribute must be cleared; fold the reset into one sequence.
return f"{ESC}0;{';'.join(new.values())}m"
changed = [group for cls, group in new.items() if current.get(cls) != group]
return f"{ESC}{';'.join(changed)}m" if changed else ""
class Line:
"""Build a terminal string part by part with colors, width and alignment.
Calling the builder appends content (arguments are converted to ``str``).
Attribute access with a color name arms that palette color for the next
call; the color is reset automatically when that call ends, so a color
always applies to exactly one call::
str(Line().user("Alice")(" by ")) # "Alice" blue, " by " plain
``width`` and ``align`` keyword arguments pad the content of a call by
display width. ``str(line)`` finishes the line, restoring default
colors if any are active.
"""
def __init__(self, palette: Colors | None = None) -> None:
self._palette = palette if palette is not None else colors
self._parts: list[str] = []
self._active: dict[str, str] = {}
self._pending: dict[str, str] = {}
def __getattr__(self, name: str) -> Line:
if name.startswith("_"):
raise AttributeError(name)
spec = getattr(self._palette, name, None)
if spec is None:
raise AttributeError(f"unknown color: {name!r}")
self._pending = _parse_sgr(spec)
return self
def __call__(self, *args: Any, width: int = 0, align: str = "left") -> Line:
text = "".join(str(arg) for arg in args)
if width:
text = pad(text, width, align)
if self._pending != self._active:
self._parts.append(_sgr_transition(self._active, self._pending))
self._active = self._pending
self._parts.append(text)
self._pending = {}
return self
def __str__(self) -> str:
if self._active:
return "".join(self._parts) + f"{ESC}0m"
return "".join(self._parts)
-6
View File
@@ -1,6 +0,0 @@
def main():
print("Hello from kanta!")
if __name__ == "__main__":
main()
+3
View File
@@ -21,6 +21,9 @@ dependencies = [
"msgspec>=0.20.0", "msgspec>=0.20.0",
] ]
[project.scripts]
kanta = "kanta.__main__:main"
[project.optional-dependencies] [project.optional-dependencies]
bin = [ bin = [
"blake3>=1.0.8", "blake3>=1.0.8",
+1 -1
View File
@@ -1,6 +1,6 @@
import pytest import pytest
from kanta import JsonSerializer, MsgPackSerializer from kanta.serialization import JsonSerializer, MsgPackSerializer
@pytest.fixture( @pytest.fixture(
+25 -1
View File
@@ -6,7 +6,8 @@ from uuid import UUID
import msgspec import msgspec
from kanta import ChangeRecord, Kanta from kanta.kanta import Kanta
from kanta.structs import ChangeRecord, Snapshot
class User(msgspec.Struct): class User(msgspec.Struct):
@@ -69,6 +70,18 @@ def change_actions(path: Path, format_config) -> list[str]:
return actions return actions
def read_changes(path: Path, format_config) -> list[ChangeRecord]:
_, serializer_cls = format_config
serializer = serializer_cls()
framer = serializer.framer_cls()
records: list[ChangeRecord] = []
for is_snapshot, payload, _, _ in framer.iter_records(path.read_bytes(), 0):
if is_snapshot:
continue
records.append(serializer.decode(payload, type=ChangeRecord))
return records
def make_migrations_module(name: str, fn_name: str, fn): def make_migrations_module(name: str, fn_name: str, fn):
mod = ModuleType(name) mod = ModuleType(name)
mod.__dict__[fn_name] = fn mod.__dict__[fn_name] = fn
@@ -76,6 +89,17 @@ def make_migrations_module(name: str, fn_name: str, fn):
return mod return mod
def read_last_snapshot(path: Path, format_config) -> Snapshot | None:
_, serializer_cls = format_config
serializer = serializer_cls()
framer = serializer.framer_cls()
data = path.read_bytes()
payload, _, _ = framer.scan_last_snapshot(data)
if payload is None:
return None
return serializer.decode(payload, type=Snapshot)
def fixed_change(action: str, diff: dict, *, version: int = 0) -> ChangeRecord: def fixed_change(action: str, diff: dict, *, version: int = 0) -> ChangeRecord:
return ChangeRecord( return ChangeRecord(
ts=datetime(2026, 1, 1, tzinfo=UTC), a=action, v=version, diff=diff ts=datetime(2026, 1, 1, tzinfo=UTC), a=action, v=version, diff=diff
+451
View File
@@ -0,0 +1,451 @@
from typing import Any, Optional, Union
import pytest
from kanta import DictPrev, DictState, Kanta
from kanta.callbacks import DictPost, DictPre, LogFmt
from kanta.exceptions import DatabaseError
from .support import Data, User, make_kanta
def test_bootstrap_rejects_unannotated_param(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
with pytest.raises(TypeError, match="without an annotation or default"):
@kanta.bootstrap
def seed(data):
data.counter = 1
def test_bootstrap_accepts_unknown_with_default(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
@kanta.bootstrap
def seed(data: Data, extra: int = 0) -> None:
data.counter = extra + 1
# Should register without error.
def test_bootstrap_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.bootstrap
def seed(data: int):
pass
def test_logfmt_requires_value_annotation(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
with pytest.raises(TypeError, match="value parameter.*must be annotated"):
@kanta.logfmt
def resolve_names(previous: DictPre, current: DictPost) -> str | None:
return None
def test_logfmt_allows_missing_return_annotation(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
@kanta.logfmt
def resolve_names(value: str, current: DictPost):
return None
def test_logfmt_class_allows_missing_return_annotation(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
@kanta.logfmt
class UserLogFmt(LogFmt):
def resolve(self, value: str, path: str):
return None
# fmt: off
def test_logfmt_accepts_optional_return_typing_forms(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
@kanta.logfmt
def resolve_optional(value: str) -> Optional[str]: # noqa: UP007
return value
@kanta.logfmt
def resolve_union(value: str) -> Union[str, None]: # noqa: UP007
return value
@kanta.logfmt
def resolve_pipe(value: "str") -> "str | None":
return value
def test_logfmt_class_accepts_optional_return_typing_forms(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
@kanta.logfmt
class OptionalStyle(LogFmt):
def resolve(self, value: str, path: str) -> Optional[str]: # noqa: UP007
return value
@kanta.logfmt
class UnionStyle(LogFmt):
def resolve(self, value: str, path: str) -> Union[str, None]: # noqa: UP007
return value
@kanta.logfmt
class StringStyle(LogFmt):
def resolve(self, value: "str", path: "str") -> "str | None":
return value
# fmt: on
def test_logfmt_rejects_async_callback(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
with pytest.raises(TypeError, match="must not be async"):
@kanta.logfmt
async def resolve_names(value: str, current: DictPost) -> str | None:
return None
@pytest.mark.asyncio
async def test_bootstrap_injects_data_by_type(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.bootstrap
def seed(data: Data) -> None:
data.counter = 7
await kanta.open()
assert kanta.data.counter == 7
await kanta.close()
@pytest.mark.asyncio
async def test_bootstrap_injects_kanta(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
seen: list[Kanta] = []
@kanta.bootstrap
def seed(data: Data, kanta_ref: Kanta) -> None:
seen.append(kanta_ref)
data.counter = 8
await kanta.open()
assert seen == [kanta]
assert kanta.data.counter == 8
await kanta.close()
@pytest.mark.asyncio
async def test_logfmt_injects_states(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.transaction")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt
def resolve_users(value: str, current: DictPost) -> str | None:
return current.get("users", {}).get(value, {}).get("name")
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["uuid-1"] = User(name="Alice")
await kanta.close()
assert "Alice" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_injects_states_by_name(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.transaction")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt
def resolve_users(value: str, prev, state: dict | None) -> str | None:
assert prev == {}
assert state is not None
return state.get("users", {}).get(value, {}).get("name")
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["uuid-9"] = User(name="Carol")
await kanta.close()
assert "Carol" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_state_name_ignores_annotation(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.transaction")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
# Matching by name does not check the annotation.
@kanta.logfmt
def resolve_users(value: str, state: int) -> str | None:
return state.get("users", {}).get(value, {}).get("name")
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["uuid-10"] = User(name="Dave")
await kanta.close()
assert "Dave" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_tag_takes_precedence_over_name(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.transaction")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt
def check_prev(value: str, anything: DictPrev) -> str | None:
assert anything == {}
return None
@kanta.logfmt
def resolve_users(value: str, prev: DictState) -> str | None:
# The tag wins: prev receives the current state despite its name.
return prev.get("users", {}).get(value, {}).get("name")
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["uuid-11"] = User(name="Erin")
await kanta.close()
assert "Erin" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_class_state_attribute(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.transaction")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt
class UserLogFmt(LogFmt):
def resolve(self, value: str, path: str) -> str | None:
if not isinstance(value, str):
return None
return self.state.get("users", {}).get(value, {}).get("name")
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["uuid-12"] = User(name="Fred")
await kanta.close()
assert "Fred" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_class_injection(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.transaction")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@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()
with kanta.transaction(action="create_user") as data:
data.users["uuid-2"] = User(name="Bob")
await kanta.close()
assert "Bob" in caplog.text
@pytest.mark.asyncio
async def test_multiple_logfmt_chain(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.transaction")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt
def resolve_a(value: str) -> str | None:
return "A" if value == "a" else None
@kanta.logfmt
def resolve_b(value: str) -> str | None:
return "B" if value == "b" else None
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["a"] = User(name="first")
data.users["b"] = User(name="second")
await kanta.close()
assert "A" in caplog.text
assert "B" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_path_context(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.transaction")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt(path="users.uuid-1")
def resolve_user_key(value: str) -> str | None:
if value == "uuid-1":
return "user-alice"
return None
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["uuid-1"] = User(name="Alice")
await kanta.close()
assert "user-alice" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_decorator_path_filters_calls(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.transaction")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt(path="counter")
def fmt_counter(value: Any) -> str | None:
if value == 1:
return "one"
return None
await kanta.open()
with kanta.transaction(action="create_user") as data:
data.users["uuid-1"] = User(name="Alice")
data.counter = 1
await kanta.close()
assert "one" in caplog.text
assert "uuid-1" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_user_path_replaces_user_display(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.transaction")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt(path="$user")
def resolve_user(value: str, current: DictPost) -> str | None:
return current.get("users", {}).get(value, {}).get("name")
await kanta.open()
with kanta.transaction(action="create_user", user="uuid-1") as data:
data.users["uuid-1"] = User(name="Alice")
await kanta.close()
assert "by Alice" in caplog.text
@pytest.mark.asyncio
async def test_logfmt_non_string_value(tmp_path, format_config, caplog):
import logging
caplog.set_level(logging.INFO, logger="kanta.transaction")
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt
def fmt_count(value: Any, path: str) -> str | None:
if path == "counter" and value == 1:
return "one"
return None
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
await kanta.close()
assert "one" in caplog.text
@pytest.mark.asyncio
async def test_fatal_error_injects_kanta_and_error(
tmp_path, format_config, monkeypatch
):
import asyncio
path = tmp_path / "test.db"
errors: list[DatabaseError] = []
kantas: list[Kanta] = []
signaled = asyncio.Event()
kanta = make_kanta(path, Data, format_config, flush_interval=0.01)
@kanta.fatal_error
def on_fatal(error: DatabaseError, kanta_ref: Kanta) -> None:
errors.append(error)
kantas.append(kanta_ref)
signaled.set()
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
def fail_write(_data: bytes) -> None:
raise OSError("simulated background write failure")
monkeypatch.setattr(kanta._impl.file, "write", fail_write)
await asyncio.wait_for(signaled.wait(), timeout=1.0)
assert errors
assert kantas == [kanta]
await kanta.close()
+129
View File
@@ -0,0 +1,129 @@
"""Tests for the ``python -m kanta`` CLI output formatting."""
import sys
from datetime import UTC, datetime
from kanta.__main__ import (
_extra_import_paths,
_format_ts,
_import_dotted,
_import_kanta_object,
main,
)
from kanta.serialization import JsonSerializer
from kanta.serialization.framing import LineFramer
from kanta.structs import ChangeRecord, Snapshot
def test_format_ts_strips_microseconds():
"""Timestamps are rendered without microsecond precision."""
dt = datetime(2026, 8, 12, 10, 6, 52, 375398, tzinfo=UTC)
assert _format_ts(dt) == "2026-08-12 10:06:52"
def test_extra_import_paths_are_temporary(tmp_path, monkeypatch):
"""CWD and nearby venv site-packages are added only for the import block."""
parent_dir = tmp_path / "parent"
cwd = parent_dir / "child"
venv_site = (
cwd
/ ".venv"
/ "lib"
/ f"python{sys.version_info.major}.{sys.version_info.minor}"
/ "site-packages"
)
venv_site.mkdir(parents=True)
parent_venv_site = (
parent_dir
/ ".venv"
/ "lib"
/ f"python{sys.version_info.major}.{sys.version_info.minor}"
/ "site-packages"
)
parent_venv_site.mkdir(parents=True)
monkeypatch.chdir(cwd)
cwd_str = str(cwd)
venv = str(venv_site)
parent_venv = str(parent_venv_site)
before = sys.path.copy()
with _extra_import_paths():
during = sys.path.copy()
assert cwd_str in during
assert venv in during
assert parent_venv in during
assert during.index(cwd_str) < during.index(venv) < during.index(parent_venv)
assert sys.path == before
def test_extra_import_paths_ignores_other_python_versions(tmp_path, monkeypatch):
"""Only the site-packages for the running Python version is picked up."""
current_site = (
tmp_path
/ ".venv"
/ "lib"
/ f"python{sys.version_info.major}.{sys.version_info.minor}"
/ "site-packages"
)
other_site = tmp_path / ".venv" / "lib" / "python9.9" / "site-packages"
current_site.mkdir(parents=True)
other_site.mkdir(parents=True)
monkeypatch.chdir(tmp_path)
with _extra_import_paths():
assert str(current_site) in sys.path
assert str(other_site) not in sys.path
def test_cli_snapshot_line_format(tmp_path, capsys):
"""Snapshot lines are timestamped and colored with metadata."""
path = tmp_path / "test.kantadb"
ts = datetime(2026, 8, 12, 10, 6, 52, 375398, tzinfo=UTC)
mtime = datetime(2026, 8, 12, 9, 0, 0, tzinfo=UTC)
serializer = JsonSerializer()
framer = LineFramer()
snapshot = Snapshot(ts=ts, v=1, m=mtime, state={"counter": 5})
change = ChangeRecord(ts=ts, a="inc", v=1, u="user1", diff={"counter": 6})
data = framer.frame_snapshot(
serializer.encode(snapshot), record_offset=0
) + framer.frame_change(serializer.encode(change), record_offset=0)
path.write_bytes(data)
code = main([str(path)])
assert code == 0
err = capsys.readouterr().err
# No microsecond precision anywhere.
assert "10:06:52" in err
assert "10:06:52.375398" not in err
# Snapshot line: bright white snapshot/sN, white version/mtime, dark size.
assert "\x1b[97msnapshot s0" in err
assert "\x1b[38;5;250m v1 2026-08-12 09:00:00" in err
assert "\x1b[38;5;242m 13 B" in err
def test_import_dotted_from_file_path(tmp_path):
"""--data can be a filesystem path with an optional colon-separated symbol."""
module = tmp_path / "models.py"
module.write_text("class Data:\n pass\n")
result = _import_dotted(f"{module}:Data")
assert result.__name__ == "Data"
def test_import_kanta_object_from_file_path(tmp_path):
"""--kanta can be a filesystem path; default symbol is ``kanta``."""
module = tmp_path / "database.py"
module.write_text("class Kanta:\n pass\nkanta = Kanta()\n")
result = _import_kanta_object(str(module))
assert type(result).__name__ == "Kanta"
def test_import_kanta_object_from_file_path_with_symbol(tmp_path):
"""--kanta can be a filesystem path with an explicit colon-separated symbol."""
module = tmp_path / "database.py"
module.write_text("class CustomKanta:\n pass\nmy_kanta = CustomKanta()\n")
result = _import_kanta_object(f"{module}:my_kanta")
assert type(result).__name__ == "CustomKanta"
+135
View File
@@ -0,0 +1,135 @@
from datetime import UTC, datetime, timedelta
import pytest
from .support import (
Data,
make_kanta,
make_migrations_module,
read_changes,
read_last_snapshot,
)
T0 = datetime(2026, 1, 1, 12, 0, tzinfo=UTC)
def test_clock_rejects_non_callable(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
with pytest.raises(TypeError, match="must be callable"):
kanta.clock(42)
def test_clock_rejects_required_argument(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
with pytest.raises(TypeError, match="must not require arguments"):
@kanta.clock
def fake_now(tz) -> datetime:
return T0
@pytest.mark.asyncio
async def test_clock_rejects_non_datetime_result(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
@kanta.clock
def fake_now() -> datetime:
return "noon"
with pytest.raises(TypeError, match="must return a datetime"):
await kanta.open(log=False)
@pytest.mark.asyncio
async def test_clock_controls_record_timestamps(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
current = T0
@kanta.clock
def fake_now() -> datetime:
return current
await kanta.open(log=False)
current = T0 + timedelta(hours=1)
with kanta.transaction(action="update") as data:
data.counter = 1
current = T0 + timedelta(hours=2)
with kanta.transaction(action="repair", mtime=False) as data:
data.counter = 2
await kanta.close()
bootstrap, update, repair = read_changes(path, format_config)
assert bootstrap.ts == T0
assert bootstrap.m == T0
assert update.ts == T0 + timedelta(hours=1)
assert update.m == T0 + timedelta(hours=1)
# System operation: stamped by the clock, but m is not updated.
assert repair.ts == T0 + timedelta(hours=2)
assert repair.m is None
assert kanta.mtime == T0 + timedelta(hours=1)
@pytest.mark.asyncio
async def test_clock_not_read_without_changes(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
reads = 0
@kanta.clock
def fake_now() -> datetime:
nonlocal reads
reads += 1
return T0
await kanta.open(log=False) # bootstrap record: one read
reads = 0
with kanta.transaction(action="noop"):
pass # no changes, no record, no clock read
await kanta.close() # no snapshot written, no clock read
assert reads == 0
@pytest.mark.asyncio
async def test_clock_controls_migration_and_snapshot_timestamps(
tmp_path, format_config
):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.clock
def fake_now() -> datetime:
return T0
await kanta.open(log=False)
await kanta.close()
def migrate_v1(d):
"""Bump counter"""
d["counter"] = 1
migrations = make_migrations_module("clock_migrations", "migrate_v1", migrate_v1)
t1 = T0 + timedelta(days=1)
kanta2 = make_kanta(path, Data, format_config, migrations=migrations)
@kanta2.clock
def fake_now2() -> datetime:
return t1
await kanta2.open(log=False)
await kanta2.close()
migrate_records = [
r for r in read_changes(path, format_config) if r.a.startswith("migrate:")
]
assert migrate_records
assert all(r.ts == t1 for r in migrate_records)
snapshot = read_last_snapshot(path, format_config)
assert snapshot is not None
assert snapshot.ts == t1
# mtime is carried forward from the last real modification.
assert snapshot.m == T0
+1 -1
View File
@@ -1,4 +1,4 @@
from kanta import compute_diff from kanta.diff import compute_diff
def test_no_diff(): def test_no_diff():
+53 -3
View File
@@ -1,4 +1,8 @@
from kanta import format_diff from kanta.logging import format_diff
from kanta.tty import ESC, colors
_ADD = f"{ESC}{colors.add}m"
_DELETE = f"{ESC}{colors.delete}m"
def test_add(): def test_add():
@@ -6,6 +10,28 @@ def test_add():
assert any("name" in line for line in lines) assert any("name" in line for line in lines)
def test_add_path_is_green():
lines = format_diff({"name": "Alice"}, previous={})
assert any(_ADD in line for line in lines)
def test_nested_add_path_final_element_is_green():
lines = format_diff({"users": {"alice": 1}}, previous={"users": {}})
assert any(_ADD in line and "alice" in line for line in lines)
def test_update_path_not_colored_as_add():
lines = format_diff({"name": "Bob"}, previous={"name": "Alice"})
assert lines
assert all(_ADD not in line for line in lines)
def test_delete_path_not_colored_as_add():
lines = format_diff({"$delete": ["old_key"]}, previous={"old_key": 1})
assert any(_DELETE in line for line in lines)
assert all(_ADD not in line for line in lines)
def test_update(): def test_update():
lines = format_diff({"name": "Bob"}, previous={"name": "Alice"}) lines = format_diff({"name": "Bob"}, previous={"name": "Alice"})
assert any("Bob" in line for line in lines) assert any("Bob" in line for line in lines)
@@ -16,10 +42,34 @@ def test_delete():
assert any("old_key" in line for line in lines) assert any("old_key" in line for line in lines)
def test_resolver(): def test_logfmt():
lines = format_diff( lines = format_diff(
{"users": {"uuid-1": {"name": "Alice"}}}, {"users": {"uuid-1": {"name": "Alice"}}},
previous={}, previous={},
resolver=lambda x: "Alice" if x == "uuid-1" else x, logfmt=lambda value, path: "Alice" if value == "uuid-1" else None,
) )
assert any("Alice" in line for line in lines) assert any("Alice" in line for line in lines)
def test_logfmt_uses_path_context():
lines = format_diff(
{
"users": {"uuid-1": {"name": "Alice"}},
"groups": {"uuid-1": {"name": "Admins"}},
},
previous={},
logfmt=lambda value, path: (
"User Alice" if path.startswith("users.") and value == "uuid-1" else None
),
)
assert any("User Alice" in line for line in lines)
assert any("uuid-1" in line for line in lines)
def test_logfmt_formats_non_string_value():
lines = format_diff(
{"count": 42},
previous={},
logfmt=lambda value, path: "forty-two" if value == 42 else None,
)
assert any("forty-two" in line for line in lines)
+582 -7
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import logging
import sys import sys
from datetime import UTC, datetime from datetime import UTC, datetime
from uuid import uuid4 from uuid import uuid4
@@ -6,6 +7,7 @@ from uuid import uuid4
import pytest import pytest
from kanta.exceptions import DatabaseError, DataIntegrityError, FileLockError from kanta.exceptions import DatabaseError, DataIntegrityError, FileLockError
from kanta.migrations import MigrationResult
from kanta.serialization import struct_to_dict from kanta.serialization import struct_to_dict
from .support import ( from .support import (
@@ -17,6 +19,9 @@ from .support import (
change_actions, change_actions,
fixed_change, fixed_change,
make_kanta, make_kanta,
make_migrations_module,
read_changes,
read_last_snapshot,
seed_single_change, seed_single_change,
) )
@@ -30,6 +35,63 @@ async def test_load_empty(tmp_path, format_config):
await kanta.close() await kanta.close()
@pytest.mark.asyncio
async def test_new_file_writes_bootstrap_record_without_handlers(
tmp_path, format_config
):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
await kanta.close()
records = read_changes(path, format_config)
assert len(records) == 1
assert records[0].a == "bootstrap"
assert records[0].diff == {"$replace": {"users": {}, "counter": 0}}
@pytest.mark.asyncio
async def test_new_file_persists_initial_state_for_roundtrip(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(
path, Data(counter=5, users={"alice": User(name="Alice")}), format_config
)
await kanta.open()
await kanta.close()
records = read_changes(path, format_config)
assert len(records) == 1
assert records[0].a == "bootstrap"
assert records[0].diff == {
"$replace": {"users": {"alice": {"name": "Alice", "age": 0}}, "counter": 5}
}
kanta2 = make_kanta(path, Data, format_config)
await kanta2.open()
assert kanta2.data.counter == 5
assert kanta2.data.users["alice"].name == "Alice"
await kanta2.close()
@pytest.mark.asyncio
async def test_reopen_without_changes_does_not_force_snapshot(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data(counter=5), format_config)
await kanta.open()
await kanta.close()
# No snapshot should exist after the initial bootstrap and close.
assert read_last_snapshot(path, format_config) is None
kanta2 = make_kanta(path, Data, format_config)
await kanta2.open()
assert kanta2.data.counter == 5
await kanta2.close()
# Re-opening without migrations or normalization changes must not force one.
assert read_last_snapshot(path, format_config) is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_open_overwrites_caller_owned_root_data(tmp_path, format_config): async def test_open_overwrites_caller_owned_root_data(tmp_path, format_config):
path = tmp_path / "test.db" path = tmp_path / "test.db"
@@ -100,6 +162,202 @@ async def test_bootstrap_creates_file(tmp_path, format_config):
assert path.exists() assert path.exists()
@pytest.mark.asyncio
async def test_bootstrap_decorator_with_args(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.bootstrap(action="seed_init", user="system")
def seed(data: Data):
data.counter = 3
await kanta.open()
await kanta.close()
assert change_actions(path, format_config) == ["seed_init"]
@pytest.mark.asyncio
async def test_bootstrap_decorator_without_args(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.bootstrap
def seed(data: Data):
data.counter = 4
await kanta.open()
await kanta.close()
assert change_actions(path, format_config) == ["bootstrap"]
@pytest.mark.asyncio
async def test_bootstrap_decorator_async(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.bootstrap(action="async_seed")
async def seed(data: Data):
await asyncio.sleep(0)
data.counter = 5
await kanta.open()
await kanta.close()
assert change_actions(path, format_config) == ["async_seed"]
@pytest.mark.asyncio
async def test_bootstrap_decorator_multiple_handlers_in_order(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.bootstrap(action="boot_1")
def seed_one(data: Data):
data.counter = 1
@kanta.bootstrap(action="boot_2")
async def seed_two(data: Data):
await asyncio.sleep(0)
data.counter = 2
await kanta.open()
await kanta.close()
assert change_actions(path, format_config) == ["boot_2"]
@pytest.mark.asyncio
async def test_bootstrap_failure_removes_database_file(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.bootstrap(action="boot_fail")
def seed_fail(data: Data):
data.counter = 10
raise RuntimeError("bootstrap failed")
with pytest.raises(RuntimeError, match="bootstrap failed"):
await kanta.open()
assert not path.exists()
@pytest.mark.asyncio
async def test_bootstrap_async_failure_removes_database_file(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.bootstrap(action="boot_fail_async")
async def seed_fail(data: Data):
await asyncio.sleep(0)
data.counter = 10
raise RuntimeError("bootstrap async failed")
with pytest.raises(RuntimeError, match="bootstrap async failed"):
await kanta.open()
assert not path.exists()
@pytest.mark.asyncio
async def test_open_create_false_missing_file_fails(tmp_path, format_config):
path = tmp_path / "missing.db"
kanta = make_kanta(path, Data, format_config)
with pytest.raises(FileLockError):
await kanta.open(create=False)
@pytest.mark.asyncio
async def test_open_create_false_empty_file_fails(tmp_path, format_config):
path = tmp_path / "empty.db"
path.touch()
kanta = make_kanta(path, Data, format_config)
with pytest.raises(DataIntegrityError, match="empty"):
await kanta.open(create=False)
@pytest.mark.asyncio
async def test_background_write_failure_notifies_decorator_callback(
tmp_path, format_config, monkeypatch
):
path = tmp_path / "test.db"
errors: list[DatabaseError] = []
signaled = asyncio.Event()
kanta = make_kanta(
path,
Data,
format_config,
flush_interval=0.01,
)
@kanta.fatal_error
async def on_fatal_error(err: DatabaseError) -> None:
errors.append(err)
signaled.set()
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
def fail_write(_data: bytes) -> None:
raise OSError("simulated background write failure")
monkeypatch.setattr(kanta._impl.file, "write", fail_write)
await asyncio.wait_for(signaled.wait(), timeout=1.0)
assert errors
assert "Failed to flush database" in str(errors[0])
await kanta.close()
@pytest.mark.asyncio
async def test_background_write_failure_notifies_multiple_callbacks_in_order(
tmp_path, format_config, monkeypatch
):
path = tmp_path / "test.db"
calls: list[str] = []
signaled = asyncio.Event()
kanta = make_kanta(
path,
Data,
format_config,
flush_interval=0.01,
)
@kanta.fatal_error
def on_fatal_error_sync(err: DatabaseError) -> None:
calls.append("sync")
@kanta.fatal_error
async def on_fatal_error_async(err: DatabaseError) -> None:
await asyncio.sleep(0)
calls.append("async")
signaled.set()
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
def fail_write(_data: bytes) -> None:
raise OSError("simulated background write failure")
monkeypatch.setattr(kanta._impl.file, "write", fail_write)
await asyncio.wait_for(signaled.wait(), timeout=1.0)
assert calls == ["sync", "async"]
await kanta.close()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_snapshot(tmp_path, format_config): async def test_snapshot(tmp_path, format_config):
path = tmp_path / "test.db" path = tmp_path / "test.db"
@@ -208,7 +466,7 @@ async def test_migrations_from_module(tmp_path, format_config):
mod = type(sys)("test_migrations") mod = type(sys)("test_migrations")
def migrate_v1(d, ctx): def migrate_v1(d, kanta):
d["version"] = 1 d["version"] = 1
mod.__dict__["migrate_v1"] = migrate_v1 mod.__dict__["migrate_v1"] = migrate_v1
@@ -238,6 +496,322 @@ async def test_msgspec_normalization_logs_migration(tmp_path, format_config):
assert "migrate:msgspec" in change_actions(path, format_config) assert "migrate:msgspec" in change_actions(path, format_config)
@pytest.mark.asyncio
async def test_empty_migration_writes_snapshot_and_is_not_reapplied(
tmp_path, format_config
):
path = tmp_path / "test.db"
seed_single_change(
path, fixed_change("init", {"counter": 0, "users": {}}), format_config
)
def migrate_v1(d, kanta):
"""No-op migration that only bumps the schema version."""
pass
mod = make_migrations_module("empty_migration_mod", "migrate_v1", migrate_v1)
try:
kanta = make_kanta(path, Data, format_config, migrations=mod)
await kanta.open()
assert kanta.version == 1
await kanta.close()
# Empty migrations must not produce empty change records.
records = read_changes(path, format_config)
migration_records = [r for r in records if r.a.startswith("migrate")]
assert not migration_records
# The version bump is persisted via a snapshot instead.
snap = read_last_snapshot(path, format_config)
assert snap is not None
assert snap.v == 1
assert snap.state == {"counter": 0, "users": {}}
kanta2 = make_kanta(path, Data, format_config, migrations=mod)
await kanta2.open()
assert kanta2.version == 1
await kanta2.close()
# Re-opening must not create additional migration records or snapshots.
records2 = read_changes(path, format_config)
assert not [r for r in records2 if r.a.startswith("migrate")]
finally:
sys.modules.pop("empty_migration_mod", None)
@pytest.mark.asyncio
async def test_migration_with_changes_records_diff_and_snapshot(
tmp_path, format_config
):
path = tmp_path / "test.db"
seed_single_change(path, fixed_change("init", {"counter": 0}), format_config)
mod = type(sys)("test_migrations_changes")
def migrate_v1(d, kanta):
d["counter"] = 2
mod.__dict__["migrate_v1"] = migrate_v1
kanta = make_kanta(path, Data, format_config, migrations=mod)
await kanta.open()
assert kanta.version == 1
assert kanta.data.counter == 2
await kanta.close()
records = read_changes(path, format_config)
migration_records = [r for r in records if r.a.startswith("migrate")]
# The version migration and the msgspec normalization that follows it are
# grouped into a single migrate:vN record.
assert len(migration_records) == 1
assert migration_records[0].a == "migrate:v1"
assert migration_records[0].v == 1
assert migration_records[0].diff == {"counter": 2, "users": {}}
snap = read_last_snapshot(path, format_config)
assert snap is not None
assert snap.v == 1
assert snap.state == {"counter": 2, "users": {}}
@pytest.mark.asyncio
async def test_migration_summary_log_includes_filename(tmp_path, format_config, caplog):
path = tmp_path / "test.db"
seed_single_change(path, fixed_change("init", {"counter": 0}), format_config)
mod = type(sys)("test_migrations_log")
def migrate_v1(d, kanta):
"""Bump counter."""
d["counter"] = 2
mod.__dict__["migrate_v1"] = migrate_v1
with caplog.at_level(logging.INFO, logger="kanta.migration"):
kanta = make_kanta(path, Data, format_config, migrations=mod)
await kanta.open()
assert kanta.version == 1
await kanta.close()
info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO]
assert len(info_messages) == 1
assert str(path) in info_messages[0]
assert "v0 -> v1" in info_messages[0]
assert "migrate_v1 (Bump counter)" in info_messages[0]
@pytest.mark.asyncio
async def test_open_log_false_suppresses_migration_log(tmp_path, format_config, caplog):
path = tmp_path / "test.db"
seed_single_change(path, fixed_change("init", {"counter": 0}), format_config)
mod = type(sys)("test_migrations_silent")
def migrate_v1(d, kanta):
d["counter"] = 2
mod.__dict__["migrate_v1"] = migrate_v1
with caplog.at_level(logging.INFO, logger="kanta.migration"):
kanta = make_kanta(path, Data, format_config, migrations=mod)
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_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.bootstrap"):
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.bootstrap"):
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_open_existing_database_logs_using_on_debug(
tmp_path, format_config, caplog
):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
await kanta.close()
kanta2 = make_kanta(path, Data, format_config)
with caplog.at_level(logging.DEBUG, logger="kanta.bootstrap"):
await kanta2.open()
await kanta2.close()
debug_messages = [r.message for r in caplog.records if r.levelno == logging.DEBUG]
assert any("opened" in m and str(path.resolve()) in m for m in debug_messages)
@pytest.mark.asyncio
async def test_logmigr_callback_replaces_default_logging(
tmp_path, format_config, caplog
):
path = tmp_path / "test.db"
seed_single_change(path, fixed_change("init", {"counter": 0}), format_config)
mod = type(sys)("test_migrations_callback")
def migrate_v1(d, kanta):
"""Bump counter."""
d["counter"] = 2
mod.__dict__["migrate_v1"] = migrate_v1
summaries = []
kanta = make_kanta(path, Data, format_config, migrations=mod)
@kanta.logmigr
def collect(summary: MigrationResult):
summaries.append(summary)
with caplog.at_level(logging.INFO, logger="kanta.migration"):
await kanta.open()
await kanta.close()
assert len(summaries) == 1
assert summaries[0].version == 1
assert summaries[0].migrations[0].name == "migrate_v1"
info_messages = [r for r in caplog.records if r.levelno == logging.INFO]
assert not info_messages
@pytest.mark.asyncio
async def test_logmigr_callback_report(tmp_path, format_config, caplog):
import logging
from kanta import MigrationReport
path = tmp_path / "test.db"
seed_single_change(path, fixed_change("init", {"counter": 0}), format_config)
mod = type(sys)("test_migrations_report")
def migrate_v1(d):
"""Bump counter."""
d["counter"] = 2
mod.__dict__["migrate_v1"] = migrate_v1
reports = []
kanta = make_kanta(path, Data, format_config, migrations=mod)
@kanta.logmigr
def collect(report: MigrationReport):
reports.append(report)
with caplog.at_level(logging.INFO, logger="kanta.migration"):
await kanta.open()
await kanta.close()
assert len(reports) == 1
assert reports[0].original == 0
assert reports[0].version == 1
assert [m.name for m in reports[0].applied] == ["migrate_v1"]
@pytest.mark.asyncio
async def test_transaction_log_false_suppresses_log(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", log=False) as data:
data.counter = 1
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_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
async def test_transaction_log_custom_logger(tmp_path, format_config, caplog):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
custom_logger = logging.getLogger("custom.transaction")
custom_logger.setLevel(logging.INFO)
with caplog.at_level(logging.INFO, logger="custom.transaction"):
with kanta.transaction(action="inc", log=custom_logger) as data:
data.counter = 1
await kanta.close()
info_messages = [r for r in caplog.records if r.levelno == logging.INFO]
assert len(info_messages) >= 1
assert "inc" in info_messages[0].message
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_open_locked_file_raises_filelock_error(tmp_path, format_config): async def test_open_locked_file_raises_filelock_error(tmp_path, format_config):
path = tmp_path / "test.db" path = tmp_path / "test.db"
@@ -281,17 +855,18 @@ async def test_background_write_failure_notifies_callback(
errors: list[DatabaseError] = [] errors: list[DatabaseError] = []
signaled = asyncio.Event() signaled = asyncio.Event()
def on_fatal_error(err: DatabaseError) -> None:
errors.append(err)
signaled.set()
kanta = make_kanta( kanta = make_kanta(
path, path,
Data, Data,
format_config, format_config,
flush_interval=0.01, flush_interval=0.01,
fatal_error=on_fatal_error,
) )
@kanta.fatal_error
def on_fatal_error(err: DatabaseError) -> None:
errors.append(err)
signaled.set()
await kanta.open() await kanta.open()
with kanta.transaction(action="inc") as data: with kanta.transaction(action="inc") as data:
@@ -317,7 +892,7 @@ async def test_migrations_from_module_path(tmp_path, format_config):
module_name = "test_migrations_path" module_name = "test_migrations_path"
mod = type(sys)(module_name) mod = type(sys)(module_name)
def migrate_v1(d, ctx): def migrate_v1(d, kanta):
d["counter"] = 2 d["counter"] = 2
mod.__dict__["migrate_v1"] = migrate_v1 mod.__dict__["migrate_v1"] = migrate_v1
+362
View File
@@ -0,0 +1,362 @@
import logging
import sys
import pytest
from kanta.logging import (
LogEvent,
bootstrap_logger,
configure_logging,
emit_event,
log_change,
migration_logger,
transaction_logger,
)
from kanta.migrations import MigrationResult
from tests.support import (
Data,
fixed_change,
make_kanta,
seed_single_change,
)
@pytest.fixture(autouse=True)
def _reset_kanta_loggers():
yield
for name in (
"kanta",
"kanta.transaction",
"kanta.transaction.diff",
"kanta.bootstrap",
"kanta.migration",
):
logger = logging.getLogger(name)
logger.setLevel(logging.NOTSET)
logger.propagate = True
logger.disabled = False
logger.handlers.clear()
def _change_event(**kwargs) -> LogEvent:
return LogEvent(kind="change", logger=transaction_logger, action="update", **kwargs)
def test_emit_event_falsy_return_stops_chain(capsys):
logging.getLogger("kanta").handlers.clear()
configure_logging()
calls = []
def first(ev):
calls.append("first")
return None
def second(ev):
calls.append("second")
emit_event(_change_event(), [first, second])
assert calls == ["first"]
assert capsys.readouterr().err == ""
def test_emit_event_truthy_return_falls_back_to_default(capsys):
logging.getLogger("kanta").handlers.clear()
configure_logging()
emit_event(_change_event(), [lambda ev: True])
assert "update" in capsys.readouterr().err
def test_emit_event_mutation_reaches_later_handlers_and_default(capsys):
logging.getLogger("kanta").handlers.clear()
configure_logging()
calls = []
def first(ev):
calls.append("first")
ev.extra = "tgt"
return True
def second(ev):
calls.append(("second", ev.extra))
return True
emit_event(_change_event(), [first, second])
assert calls == ["first", ("second", "tgt")]
assert "tgt" in capsys.readouterr().err
def test_emit_event_handler_error_falls_back_to_default(capsys):
logging.getLogger("kanta").handlers.clear()
configure_logging()
def boom(ev):
raise RuntimeError("broken")
emit_event(_change_event(), [boom])
assert "update" in capsys.readouterr().err
def test_diff_lines_built_lazily(monkeypatch):
def _boom(*args, **kwargs):
raise AssertionError("format_diff should not be called")
monkeypatch.setattr("kanta.logging.format_diff", _boom)
ev = _change_event(diff={"counter": 1})
emit_event(ev, [lambda ev: None]) # handled without touching the diff
monkeypatch.undo()
assert len(ev.diff_lines) == 1
assert "counter" in ev.diff_lines[0]
def test_default_emit_created_and_migrated(capsys):
logging.getLogger("kanta").handlers.clear()
configure_logging()
emit_event(LogEvent(kind="created", logger=bootstrap_logger, filename="x.kantadb"))
emit_event(
LogEvent(
kind="migrated",
logger=migration_logger,
filename="x.kantadb",
from_version=0,
to_version=1,
migrations=["migrate_v1 (rename)"],
)
)
err = capsys.readouterr().err
assert "🛢️ x.kantadb created" in err
assert "🛢️ x.kantadb migrated v0 -> v1: migrate_v1 (rename)" in err
@pytest.mark.asyncio
async def test_logemit_receives_transaction_events(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
events = []
kanta.logemit(lambda ev: events.append(ev) or True)
await kanta.open()
with kanta.transaction(action="inc", user="u1", extra="x") as data:
data.counter = 1
await kanta.close()
change = events[-1]
assert change.kind == "change"
assert change.action == "inc"
assert change.user == "u1"
assert change.extra == "x"
assert change.diff == {"counter": 1}
assert change.logger.name == "kanta.transaction"
def test_logemit_rejects_classes_and_async(tmp_path, format_config):
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
class NotAFunction:
pass
with pytest.raises(TypeError):
kanta.logemit(NotAFunction)
async def ahandler(ev):
return None
with pytest.raises(TypeError):
kanta.logemit(ahandler)
def _raise(*args, **kwargs):
raise RuntimeError("formatting broken")
def test_log_change_never_raises(monkeypatch):
monkeypatch.setattr("kanta.logging.format_action_header", _raise)
log_change("update", {"counter": 1}, previous={}) # must not raise
@pytest.mark.asyncio
async def test_logging_failure_does_not_break_transaction(
tmp_path, format_config, monkeypatch
):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
kanta.logemit(_raise)
monkeypatch.setattr("kanta.logging.format_action_header", _raise)
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
await kanta.close()
kanta2 = make_kanta(path, Data, format_config)
kanta2.logemit(_raise)
monkeypatch.setattr("kanta.logging.format_action_header", _raise)
await kanta2.open()
assert kanta2.data.counter == 1
await kanta2.close()
@pytest.mark.asyncio
async def test_logfmt_failure_falls_back_to_default(tmp_path, format_config, caplog):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt
def bad(value: str, path: str) -> str | None:
raise RuntimeError("broken")
await kanta.open()
with caplog.at_level(logging.INFO, logger="kanta.transaction"):
with kanta.transaction(action="inc", user="alice") as data:
data.counter = 1
await kanta.close()
assert kanta.data.counter == 1
assert "alice" in caplog.text # raw rendering used despite the failure
assert "counter" in caplog.text
@pytest.mark.asyncio
async def test_logmigr_failure_does_not_break_open(tmp_path, format_config):
path = tmp_path / "test.db"
seed_single_change(path, fixed_change("init", {"counter": 0}), format_config)
mod = type(sys)("test_migrations_broken_logmigr")
def migrate_v1(d, kanta):
"""Bump counter."""
d["counter"] = 2
mod.__dict__["migrate_v1"] = migrate_v1
kanta = make_kanta(path, Data, format_config, migrations=mod)
@kanta.logmigr
def bad(summary: MigrationResult) -> None:
raise RuntimeError("broken")
await kanta.open()
assert kanta.data.counter == 2
await kanta.close()
@pytest.mark.asyncio
async def test_aborted_transaction_emits_event(tmp_path, format_config, caplog):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
events = []
kanta.logemit(lambda ev: events.append(ev) or True)
await kanta.open()
with caplog.at_level(logging.WARNING, logger="kanta.transaction"):
with pytest.raises(ValueError):
with kanta.transaction(action="reset") as data:
data.counter = 99
raise ValueError("simulated failure")
await kanta.close()
aborted = events[-1]
assert aborted.kind == "aborted"
assert aborted.action == "reset"
assert aborted.level == logging.WARNING
assert isinstance(aborted.error, ValueError)
messages = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
assert any("\x1b[1;34mreset" in m for m in messages) # action color, no quotes
assert any(" transaction aborted: simulated failure" in m for m in messages)
assert kanta.data.counter == 0 # rolled back
@pytest.mark.asyncio
async def test_aborted_transaction_includes_resolved_user(
tmp_path, format_config, caplog
):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
@kanta.logfmt
def resolve(value: str, path: str) -> str | None:
return "Alice" if value == "u1" else None
await kanta.open()
with caplog.at_level(logging.WARNING, logger="kanta.transaction"):
with pytest.raises(ValueError):
with kanta.transaction(action="reset", user="u1", extra="exp") as data:
data.counter = 99
raise ValueError("boom")
await kanta.close()
messages = [r.getMessage() for r in caplog.records if r.levelno == logging.WARNING]
assert any("exp" in m for m in messages)
assert any(" by " in m and "Alice" in m for m in messages)
assert any(" transaction aborted: boom" in m for m in messages)
def test_event_header_covers_all_kinds():
created = LogEvent(kind="created", logger=transaction_logger, filename="x.db")
assert created.header == "🛢️ x.db created"
migrated = LogEvent(
kind="migrated",
logger=transaction_logger,
filename="x.db",
from_version=0,
to_version=1,
migrations=["migrate_v1 (rename)"],
)
assert migrated.header == "🛢️ x.db migrated v0 -> v1: migrate_v1 (rename)"
aborted = LogEvent(
kind="aborted",
logger=transaction_logger,
action="reset",
user="alice",
error=ValueError("boom"),
)
assert "transaction aborted: boom" in aborted.header
assert "alice" in aborted.header
@pytest.mark.asyncio
async def test_event_carries_kanta_instance(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
events = []
kanta.logemit(lambda ev: events.append(ev) or True)
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
await kanta.close()
assert events
assert all(ev.kanta is kanta for ev in events)
def test_header_is_settable_and_used_by_default_emit(capsys):
logging.getLogger("kanta").handlers.clear()
configure_logging()
def restyle(ev):
ev.header = f"CUSTOM {ev.action}"
return True
emit_event(_change_event(diff={"counter": 1}, previous={}), [restyle])
err = capsys.readouterr().err
assert "CUSTOM update" in err
assert "counter" in err # default diff routing still applies
@pytest.mark.asyncio
async def test_ctx_reachable_from_event(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
kanta.ctx.connection_id = 7
seen = []
kanta.logemit(lambda ev: seen.append(ev.kanta.ctx.connection_id) or True)
await kanta.open()
with kanta.transaction(action="inc") as data:
data.counter = 1
await kanta.close()
assert seen and all(connection_id == 7 for connection_id in seen)
+104 -5
View File
@@ -1,17 +1,116 @@
import logging import logging
from kanta import configure_logging, log_change import pytest
from kanta.logging import logger
from kanta.logging import (
configure_logging,
format_action_header,
log_change,
)
from kanta.tty import ESC
def test_configure_logging(): def test_format_action_header():
header = format_action_header("update", "alice", "tgt")
assert header == (
f"{ESC}1;34mupdate{ESC}0m {ESC}38;5;250mtgt{ESC}0m by {ESC}34malice{ESC}0m"
)
def test_format_action_header_action_only():
assert format_action_header("update") == f"{ESC}1;34mupdate{ESC}0m"
@pytest.fixture(autouse=True)
def _reset_kanta_loggers():
yield
for name in (
"kanta",
"kanta.transaction",
"kanta.transaction.diff",
"kanta.bootstrap",
"kanta.migration",
):
logger = logging.getLogger(name)
logger.setLevel(logging.NOTSET)
logger.propagate = True
logger.disabled = False
logger.handlers.clear()
def test_configure_logging_defaults():
kanta_logger = logging.getLogger("kanta")
configure_logging() configure_logging()
assert logger.level == logging.INFO assert kanta_logger.level == logging.INFO
assert not kanta_logger.propagate
assert kanta_logger.handlers
def test_configure_logging_disables_specific_loggers():
configure_logging(bootstrap=False, migration=False, transaction=False)
assert not logging.getLogger("kanta.bootstrap").propagate
assert not logging.getLogger("kanta.migration").propagate
assert not logging.getLogger("kanta.transaction").propagate
def test_configure_logging_skiproot_false_leaves_kanta_propagation():
kanta_logger = logging.getLogger("kanta")
kanta_logger.handlers.clear()
configure_logging(bootstrap=False, skiproot=False)
assert kanta_logger.propagate
assert not kanta_logger.handlers
assert not logging.getLogger("kanta.bootstrap").propagate
def test_log_change_no_diff(capsys): def test_log_change_no_diff(capsys):
logger.handlers.clear() kanta_logger = logging.getLogger("kanta")
kanta_logger.handlers.clear()
configure_logging() configure_logging()
log_change("test", {}) log_change("test", {})
captured = capsys.readouterr() captured = capsys.readouterr()
assert "test" in captured.err assert "test" in captured.err
def test_log_change_appends_extra_string(capsys):
kanta_logger = logging.getLogger("kanta")
kanta_logger.handlers.clear()
configure_logging()
log_change("export", {}, extra="mydb.db")
captured = capsys.readouterr()
assert "export" in captured.err
assert f"{ESC}38;5;250mmydb.db{ESC}0m" in captured.err
def test_log_change_log_diff_false(capsys, monkeypatch):
kanta_logger = logging.getLogger("kanta")
kanta_logger.handlers.clear()
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)
captured = capsys.readouterr()
assert "update" in captured.err
assert "counter" not in captured.err
def test_configure_logging_diff_false(capsys):
kanta_logger = logging.getLogger("kanta")
kanta_logger.handlers.clear()
configure_logging(diff=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()
assert "counter" in captured.err
+190 -16
View File
@@ -1,53 +1,227 @@
from types import ModuleType from types import ModuleType, SimpleNamespace
from kanta.migrate import MigrationRegistry import pytest
from kanta.exceptions import DatabaseError
from kanta.migrations import Migrations
class _DummyKanta:
def __init__(self):
self.ctx = SimpleNamespace()
def test_register_and_apply(): def test_register_and_apply():
reg = MigrationRegistry() reg = Migrations()
kanta = _DummyKanta()
@reg.register @reg.register
def migrate_v1(d, ctx): def migrate_v1(d, kanta):
d["version"] = 1 d["version"] = 1
@reg.register @reg.register
def migrate_v2(d, ctx): def migrate_v2(d, kanta):
d["version"] = 2 d["version"] = 2
state = {} state = {}
new_ver = reg.apply(state, current_version=0, silent=True) result = reg.apply(state, current_version=0, kanta=kanta)
assert new_ver == 2 assert result.version == 2
assert state["version"] == 2 assert state["version"] == 2
def test_no_migrations_needed(): def test_no_migrations_needed():
reg = MigrationRegistry() reg = Migrations()
kanta = _DummyKanta()
@reg.register @reg.register
def migrate_v1(d, ctx): def migrate_v1(d, kanta):
d["x"] = 1 d["x"] = 1
state = {"x": 1} state = {"x": 1}
new_ver = reg.apply(state, current_version=1, silent=True) result = reg.apply(state, current_version=1, kanta=kanta)
assert new_ver == 1 assert result.version == 1
def test_from_module(): def test_from_module():
mod = ModuleType("fake_migrations") mod = ModuleType("fake_migrations")
kanta = _DummyKanta()
def migrate_v1(d, ctx): def migrate_v1(d, kanta):
d["v"] = 1 d["v"] = 1
def migrate_v2(d, ctx): def migrate_v2(d, kanta):
d["v"] = 2 d["v"] = 2
mod.__dict__["migrate_v1"] = migrate_v1 mod.__dict__["migrate_v1"] = migrate_v1
mod.__dict__["migrate_v2"] = migrate_v2 mod.__dict__["migrate_v2"] = migrate_v2
reg = MigrationRegistry.from_module(mod) reg = Migrations.from_module(mod)
assert reg.dbver == 2 assert reg.dbver == 2
state = {} state = {}
new_ver = reg.apply(state, current_version=0, silent=True) result = reg.apply(state, current_version=0, kanta=kanta)
assert new_ver == 2 assert result.version == 2
assert state["v"] == 2 assert state["v"] == 2
def test_migrations_can_use_kanta_ctx():
reg = Migrations()
kanta = _DummyKanta()
@reg.register
def migrate_v1(d, kanta):
kanta.ctx.source = "migration"
d["source"] = kanta.ctx.source
state = {}
result = reg.apply(state, current_version=0, kanta=kanta)
assert result.version == 1
assert state["source"] == "migration"
assert kanta.ctx.source == "migration"
def test_migration_can_omit_kanta_argument():
reg = Migrations()
kanta = _DummyKanta()
@reg.register
def migrate_v1(d):
d["x"] = 1
state = {}
result = reg.apply(state, current_version=0, kanta=kanta)
assert result.version == 1
assert state["x"] == 1
def test_version_too_new():
reg = Migrations()
kanta = _DummyKanta()
@reg.register
def migrate_v1(d):
d["x"] = 1
with pytest.raises(
DatabaseError,
match="Database version v2 is newer than the highest supported version v1",
):
reg.apply({}, current_version=2, kanta=kanta)
def test_version_too_old():
reg = Migrations()
kanta = _DummyKanta()
@reg.register
def migrate_v3(d):
d["x"] = 3
with pytest.raises(
DatabaseError,
match="Database version v1 is older than the minimum supported version v2",
):
reg.apply({}, current_version=1, kanta=kanta)
def test_missing_middle_migration_is_skipped():
reg = Migrations()
kanta = _DummyKanta()
@reg.register
def migrate_v1(d):
d["x"] = 1
@reg.register
def migrate_v3(d):
d["y"] = 3
state = {"x": 1}
result = reg.apply(state, current_version=1, kanta=kanta)
assert result.version == 3
assert state["x"] == 1
assert state["y"] == 3
def test_old_migrations_deleted_current_supported():
reg = Migrations()
kanta = _DummyKanta()
@reg.register
def migrate_v3(d):
d["x"] = 3
state = {"x": 2}
result = reg.apply(state, current_version=2, kanta=kanta)
assert result.version == 3
assert state["x"] == 3
def test_apply_returns_change_information():
reg = Migrations()
kanta = _DummyKanta()
@reg.register
def migrate_v1(d):
"""Set x."""
d["x"] = 1
@reg.register
def migrate_v2(d):
"""No-op."""
pass
@reg.register
def migrate_v3(d):
"""Set y."""
d["y"] = 3
result = reg.apply({}, current_version=0, kanta=kanta)
assert result.version == 3
assert len(result.migrations) == 3
assert result.migrations[0].name == "migrate_v1"
assert result.migrations[0].description == "Set x"
assert result.migrations[0].changed is True
assert result.migrations[0].diff == {"$replace": {"x": 1}}
assert result.migrations[1].name == "migrate_v2"
assert result.migrations[1].description == "No-op"
assert result.migrations[1].changed is False
assert result.migrations[1].diff is None
assert result.migrations[2].name == "migrate_v3"
assert result.migrations[2].description == "Set y"
assert result.migrations[2].changed is True
assert result.migrations[2].diff == {"y": 3}
def test_description_defaults_to_version_when_no_docstring():
reg = Migrations()
kanta = _DummyKanta()
@reg.register
def migrate_v1(d):
d["x"] = 1
result = reg.apply({}, current_version=0, kanta=kanta)
assert result.migrations[0].description == "v1"
def test_report_fields():
from kanta import MigrationReport
reg = Migrations()
kanta = _DummyKanta()
@reg.register
def migrate_v1(d):
d["x"] = 1
report = reg.apply({"x": 0}, current_version=0, kanta=kanta)
assert isinstance(report, MigrationReport)
assert report.original == 0
assert report.version == 1
assert [m.name for m in report.applied] == ["migrate_v1"]
# Deprecated alias still works.
assert report.migrations is report.applied
+144
View File
@@ -0,0 +1,144 @@
"""Tests for mtime handling and the public ``kanta.mtime`` property."""
from datetime import UTC, datetime
import pytest
from kanta.structs import ChangeRecord
from .support import Data, make_kanta, seed_single_change
def _read_last_change(path, format_config):
name, serializer_cls = format_config
serializer = serializer_cls()
framer = serializer.framer_cls()
last = None
for is_snapshot, payload, _, _ in framer.iter_records(path.read_bytes(), 0):
if is_snapshot:
continue
last = serializer.decode(payload, type=ChangeRecord)
assert last is not None
return last
@pytest.mark.asyncio
async def test_default_transaction_updates_mtime(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
before = datetime.now(UTC)
with kanta.transaction(action="inc") as data:
data.counter = 1
await kanta.flush()
await kanta.close()
rec = _read_last_change(path, format_config)
assert rec.ts == rec.m
assert before <= rec.m <= datetime.now(UTC)
assert kanta.mtime == rec.m
@pytest.mark.asyncio
async def test_transaction_custom_mtime(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
custom_m = datetime(2026, 1, 1, 8, 0, tzinfo=UTC)
with kanta.transaction(action="inc", mtime=custom_m) as data:
data.counter = 1
await kanta.flush()
await kanta.close()
rec = _read_last_change(path, format_config)
assert rec.m == custom_m
assert kanta.mtime == custom_m
@pytest.mark.asyncio
async def test_transaction_mtime_false_preserves_mtime(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
first_m = datetime(2026, 1, 1, 10, 0, tzinfo=UTC)
with kanta.transaction(action="first", mtime=first_m) as data:
data.counter = 1
with kanta.transaction(action="second", mtime=False) as data:
data.counter = 2
await kanta.flush()
await kanta.close()
records = []
name, serializer_cls = format_config
serializer = serializer_cls()
framer = serializer.framer_cls()
for is_snapshot, payload, _, _ in framer.iter_records(path.read_bytes(), 0):
if is_snapshot:
continue
records.append(serializer.decode(payload, type=ChangeRecord))
assert records[0].a == "bootstrap"
assert records[1].m == first_m
assert records[2].m is None
assert kanta.mtime == first_m
@pytest.mark.asyncio
async def test_migration_does_not_update_mtime(tmp_path, format_config):
path = tmp_path / "test.db"
seed_m = datetime(2025, 12, 31, 23, 0, tzinfo=UTC)
seed_single_change(
path,
ChangeRecord(
ts=seed_m,
m=seed_m,
a="seed",
v=0,
diff={"counter": 0},
),
format_config,
)
kanta = make_kanta(path, Data, format_config)
await kanta.open()
assert kanta.mtime == seed_m
new_m = datetime(2026, 1, 5, 10, 0, tzinfo=UTC)
with kanta.transaction(action="inc", mtime=new_m) as data:
data.counter = 5
await kanta.flush()
assert kanta.mtime == new_m
await kanta.close()
@pytest.mark.asyncio
async def test_rollback_does_not_update_mtime(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
await kanta.open()
seed_m = datetime(2026, 1, 1, 10, 0, tzinfo=UTC)
with kanta.transaction(action="seed", mtime=seed_m) as data:
data.counter = 1
before = kanta.mtime
try:
with kanta.transaction(
action="boom", mtime=datetime(2099, 1, 1, tzinfo=UTC)
) as data:
data.counter = 99
raise RuntimeError("fail")
except RuntimeError:
pass
assert kanta.data.counter == 1
assert kanta.mtime == before
await kanta.close()
+153
View File
@@ -0,0 +1,153 @@
"""Tests for Kanta read-only mode."""
import pytest
from kanta.exceptions import DataIntegrityError, FileLockError
from kanta.serialization import struct_to_dict
from .support import (
Data,
EvolvableDataV2,
fixed_change,
make_kanta,
make_migrations_module,
seed_single_change,
)
@pytest.mark.asyncio
async def test_readonly_opens_existing_database(tmp_path, format_config):
path = tmp_path / "test.db"
seed_single_change(path, fixed_change("seed", {"counter": 7}), format_config)
kanta = make_kanta(path, Data, format_config)
await kanta.open(readonly=True)
assert isinstance(kanta.data, Data)
assert kanta.data.counter == 7
assert kanta._impl.readonly is True
assert kanta._impl.background_task is None
await kanta.close()
@pytest.mark.asyncio
async def test_readonly_missing_file_fails(tmp_path, format_config):
path = tmp_path / "missing.db"
kanta = make_kanta(path, Data, format_config)
with pytest.raises(FileLockError):
await kanta.open(readonly=True)
assert not path.exists()
@pytest.mark.asyncio
async def test_readonly_empty_file_fails(tmp_path, format_config):
path = tmp_path / "empty.db"
path.touch()
kanta = make_kanta(path, Data, format_config)
with pytest.raises(DataIntegrityError, match="empty"):
await kanta.open(readonly=True)
@pytest.mark.asyncio
async def test_readonly_transaction_fails(tmp_path, format_config):
path = tmp_path / "test.db"
seed_single_change(path, fixed_change("seed", {"counter": 1}), format_config)
kanta = make_kanta(path, Data, format_config)
await kanta.open(readonly=True)
with pytest.raises(DataIntegrityError, match="read-only"):
with kanta.transaction(action="inc") as data:
data.counter = 2
# In-memory state must remain unchanged.
assert kanta.data.counter == 1
await kanta.close()
@pytest.mark.asyncio
async def test_readonly_flush_fails(tmp_path, format_config):
path = tmp_path / "test.db"
seed_single_change(path, fixed_change("seed", {"counter": 1}), format_config)
kanta = make_kanta(path, Data, format_config)
await kanta.open(readonly=True)
with pytest.raises(DataIntegrityError, match="read-only"):
await kanta.flush()
await kanta.close()
@pytest.mark.asyncio
async def test_readonly_create_true_does_not_create_file(tmp_path, format_config):
path = tmp_path / "test.db"
kanta = make_kanta(path, Data, format_config)
with pytest.raises(FileLockError):
await kanta.open(create=True, readonly=True)
assert not path.exists()
@pytest.mark.asyncio
async def test_readonly_does_not_persist_changes(tmp_path, format_config):
path = tmp_path / "test.db"
seed_single_change(path, fixed_change("seed", {"counter": 1}), format_config)
original_content = path.read_bytes()
kanta = make_kanta(path, Data, format_config)
await kanta.open(readonly=True)
await kanta.close()
assert path.read_bytes() == original_content
@pytest.mark.asyncio
async def test_readonly_runs_migrations(tmp_path, format_config):
path = tmp_path / "test.db"
seed_single_change(
path,
fixed_change("seed", {"counter": 1}, version=0),
format_config,
)
def migrate_v1(data, kanta):
data.setdefault("enabled", True)
migrations = make_migrations_module("readonly_migrations", "migrate_v1", migrate_v1)
kanta = make_kanta(path, EvolvableDataV2, format_config, migrations=migrations)
await kanta.open(readonly=True)
assert kanta.data.counter == 1
# Migration ran in memory even though no change was persisted.
assert struct_to_dict(kanta.data, serializer=kanta._impl.serializer) == {
"counter": 1,
"enabled": True,
}
assert not kanta._impl.pending_changes
await kanta.close()
@pytest.mark.asyncio
async def test_readwrite_and_readonly_can_open_together(tmp_path, format_config):
path = tmp_path / "test.db"
seed_single_change(path, fixed_change("seed", {"counter": 1}), format_config)
rw = make_kanta(path, Data, format_config)
await rw.open()
ro = make_kanta(path, Data, format_config)
await ro.open(readonly=True)
assert rw.data.counter == 1
assert ro.data.counter == 1
await ro.close()
await rw.close()
+2 -1
View File
@@ -1,6 +1,7 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from kanta import ChangeRecord, Snapshot, replay from kanta.diff import replay_jsonl as replay
from kanta.structs import ChangeRecord, Snapshot
from kanta.serialization.framing import LineFramer from kanta.serialization.framing import LineFramer
+17
View File
@@ -32,3 +32,20 @@ def test_force_writes():
f = FakeFile() f = FakeFile()
ss.maybe_write(f, 1, {"x": 1}) ss.maybe_write(f, 1, {"x": 1})
assert len(f.written) == 1 assert len(f.written) == 1
def test_force_bypasses_min_diffs():
class FakeFile:
def __init__(self):
self.written = []
self.is_open = True
def write(self, data: bytes):
self.written.append(data)
ss = SnapshotState(min_diffs=100)
ss.record_changes(5)
ss.request_force()
f = FakeFile()
ss.maybe_write(f, 1, {"x": 1})
assert len(f.written) == 1
+74
View File
@@ -0,0 +1,74 @@
import pytest
from kanta.tty import ESC, Colors, Line, colors, displaywidth, pad, strip_ansi
def test_strip_ansi():
assert strip_ansi(f"{ESC}1;34mhello{ESC}0m") == "hello"
def test_displaywidth_plain_and_ansi():
assert displaywidth("hello") == 5
assert displaywidth(f"{ESC}38;5;226mhi{ESC}0m") == 2
def test_displaywidth_wide_and_combining_chars():
assert displaywidth("你好") == 4
assert displaywidth("🚀") == 2
assert displaywidth("") == 1
def test_pad():
assert pad("ab", 4) == "ab "
assert pad("ab", 4, align="right") == " ab"
assert pad("ab", 5, align="center") == " ab "
assert pad("abcdef", 4) == "abcdef"
assert pad("你好", 6) == "你好 "
def test_line_plain_and_str_conversion():
assert str(Line()("n=", 42)) == "n=42"
def test_line_color_auto_resets_on_next_call():
assert str(Line().user("Alice")(" by ")) == f"{ESC}34mAlice{ESC}0m by "
def test_line_str_restores_active_color():
assert str(Line().user("Alice")) == f"{ESC}34mAlice{ESC}0m"
def test_line_same_color_not_reemitted():
assert str(Line().user("a").user("b")) == f"{ESC}34mab{ESC}0m"
def test_line_transition_folds_reset_into_one_sequence():
# bold blue -> plain blue: the bold clear rides in the same sequence
assert str(Line().action("a").user("b")) == f"{ESC}1;34ma{ESC}0;34mb{ESC}0m"
def test_line_unknown_color_raises():
with pytest.raises(AttributeError, match="unknown color"):
Line().nosuchcolor("x")
def test_line_palette_addition(monkeypatch):
monkeypatch.setattr(colors, "session", "38;5;226", raising=False)
assert str(Line().session("3")) == f"{ESC}38;5;226m3{ESC}0m"
def test_line_palette_override_takes_effect(monkeypatch):
monkeypatch.setattr(colors, "user", "36")
assert str(Line().user("x")) == f"{ESC}36mx{ESC}0m"
def test_line_custom_palette():
palette = Colors()
palette.brand = "35"
assert str(Line(palette).brand("x")) == f"{ESC}35mx{ESC}0m"
def test_line_width_and_align():
assert str(Line()("ab", width=4)) == "ab "
assert str(Line()("ab", width=4, align="right")) == " ab"
assert str(Line().user("ab", width=4)) == f"{ESC}34mab {ESC}0m"