Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6839b48f6d | ||
|
|
955fdd8e1c | ||
|
|
20694576c7 | ||
|
|
8be44bd490 | ||
|
|
f8a0a85158 | ||
|
|
8e436295aa | ||
|
|
0b8c1d2da9 | ||
|
|
33a8c07043 | ||
|
|
be3acaed3e | ||
|
|
f489216c2a | ||
|
|
08f3c44f1f | ||
|
|
e0046ae9d9 | ||
|
|
c101f187d8 | ||
|
|
3a56bfbb10 | ||
|
|
55fa475a13 | ||
|
|
753b7eba86 | ||
|
|
42789e6619 | ||
|
|
c4726e6728 | ||
|
|
66e92739ab | ||
|
|
4dc2f0648e | ||
|
|
bec4635460 | ||
|
|
c04a245366 |
@@ -53,10 +53,13 @@ asyncio.run(main())
|
|||||||
|
|
||||||
## Bootstrap and Open Modes
|
## Bootstrap and Open Modes
|
||||||
|
|
||||||
Kanta supports open-time bootstrap callbacks for initializing a brand-new
|
When `open()` creates a brand-new database, it always writes a single bootstrap
|
||||||
database before `open()` returns.
|
change record from the initial data object you passed to `Kanta(...)`. The
|
||||||
|
simplest bootstrap is therefore the object itself — no extra code is required.
|
||||||
|
|
||||||
Register bootstrap handlers with a decorator:
|
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
|
```python
|
||||||
kanta = Kanta("data.kantadb", Data())
|
kanta = Kanta("data.kantadb", Data())
|
||||||
@@ -76,9 +79,10 @@ async def bootstrap_async(data) -> None:
|
|||||||
data.counter = 1
|
data.counter = 1
|
||||||
```
|
```
|
||||||
|
|
||||||
When multiple bootstrap handlers are registered:
|
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,
|
- they run in registration order,
|
||||||
- exactly one bootstrap change record is queued,
|
|
||||||
- bootstrap metadata (`action`, `user`, `mtime`) is taken from the last
|
- bootstrap metadata (`action`, `user`, `mtime`) is taken from the last
|
||||||
registration.
|
registration.
|
||||||
|
|
||||||
@@ -94,6 +98,18 @@ await kanta.open(create=False)
|
|||||||
With `create=False`, open fails if the database file does not exist or is
|
With `create=False`, open fails if the database file does not exist or is
|
||||||
empty.
|
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 Error Handlers
|
||||||
|
|
||||||
Fatal background write errors can be observed with a decorator:
|
Fatal background write errors can be observed with a decorator:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
demo.kantadb
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
#!/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
|
||||||
|
from kanta.callbacks import DictPre
|
||||||
|
from kanta.logging import 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, previous: DictPre) -> str | None:
|
||||||
|
"""Resolve user ids to names from the database state itself."""
|
||||||
|
if path != "$user" and not path.startswith("users."):
|
||||||
|
return None
|
||||||
|
return previous.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())
|
||||||
+112
-3
@@ -76,8 +76,9 @@ 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
|
||||||
|
|
||||||
@@ -118,6 +119,12 @@ reloads, while system operations such as migrations leave it unchanged.
|
|||||||
|
|
||||||
- `await kanta.open()` (default) creates the database file if missing.
|
- `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(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
|
### Callbacks
|
||||||
|
|
||||||
@@ -127,7 +134,11 @@ when they have a default value.
|
|||||||
|
|
||||||
#### Bootstrap Callbacks
|
#### Bootstrap Callbacks
|
||||||
|
|
||||||
- Bootstrap callbacks run during `open()` when the database is empty.
|
- 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:
|
- Register callbacks via:
|
||||||
- `@kanta.bootstrap`
|
- `@kanta.bootstrap`
|
||||||
- `@kanta.bootstrap(action=..., user=..., mtime=...)`
|
- `@kanta.bootstrap(action=..., user=..., mtime=...)`
|
||||||
@@ -140,6 +151,8 @@ when they have a default value.
|
|||||||
- exactly one bootstrap `ChangeRecord` is queued,
|
- exactly one bootstrap `ChangeRecord` is queued,
|
||||||
- bootstrap metadata (`action`, `user`, `mtime`) is taken from the last
|
- bootstrap metadata (`action`, `user`, `mtime`) is taken from the last
|
||||||
callback registration.
|
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,
|
- If any bootstrap callback raises, Kanta closes and removes the database file,
|
||||||
then re-raises the exception.
|
then re-raises the exception.
|
||||||
|
|
||||||
@@ -151,6 +164,17 @@ when they have a default value.
|
|||||||
- Multiple handlers are supported and invoked in registration order. A failing
|
- Multiple handlers are supported and invoked in registration order. A failing
|
||||||
handler is logged and does not prevent subsequent handlers from running.
|
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
|
#### Transaction Log Formatting
|
||||||
|
|
||||||
- Logfmt callbacks prettify identifiers in the change log and are registered with
|
- Logfmt callbacks prettify identifiers in the change log and are registered with
|
||||||
@@ -185,6 +209,91 @@ def resolve_user_key(value: str) -> str | None:
|
|||||||
return names_by_id.get(value)
|
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=`.
|
||||||
|
|||||||
@@ -0,0 +1,601 @@
|
|||||||
|
"""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, migration_result=result),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception("logmigr callback failed")
|
||||||
|
return
|
||||||
|
if quiet:
|
||||||
|
return
|
||||||
|
descriptions = [
|
||||||
|
f"{m.name} ({m.description})" for m in result.migrations 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())
|
||||||
+64
-28
@@ -7,20 +7,29 @@ default value.
|
|||||||
Log formatters are a special case: they are called per value being rendered
|
Log formatters are a special case: they are called per value being rendered
|
||||||
and receive the value plus an optional ``path`` string. They return
|
and receive the value plus an optional ``path`` string. They return
|
||||||
``str | None``; ``None`` means "fall through to the next formatter".
|
``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
|
from __future__ import annotations
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
|
import logging
|
||||||
|
import types
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Annotated, Any, Union, get_args, get_origin
|
from typing import Annotated, Any, Union, get_args, get_origin
|
||||||
|
|
||||||
from kanta.exceptions import DatabaseError
|
from kanta.exceptions import DatabaseError
|
||||||
|
from kanta.migrations import MigrationResult
|
||||||
|
|
||||||
DictPre = Annotated[dict, "pre"]
|
DictPre = Annotated[dict, "pre"]
|
||||||
DictPost = Annotated[dict, "post"]
|
DictPost = Annotated[dict, "post"]
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class LogFmt:
|
class LogFmt:
|
||||||
"""Base class for stateful logfmt callbacks.
|
"""Base class for stateful logfmt callbacks.
|
||||||
@@ -58,6 +67,7 @@ class InjectionContext:
|
|||||||
error: DatabaseError | None = None
|
error: DatabaseError | None = None
|
||||||
previous_state: dict | None = None
|
previous_state: dict | None = None
|
||||||
current_state: dict | None = None
|
current_state: dict | None = None
|
||||||
|
migration_result: MigrationResult | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -97,8 +107,10 @@ class CallbackRegistry:
|
|||||||
self._callbacks: dict[str, list[_CallbackRegistration]] = {
|
self._callbacks: dict[str, list[_CallbackRegistration]] = {
|
||||||
"bootstrap": [],
|
"bootstrap": [],
|
||||||
"fatal_error": [],
|
"fatal_error": [],
|
||||||
|
"logmigr": [],
|
||||||
}
|
}
|
||||||
self._logfmt_callbacks: list[_LogFmtFunctionSpec | _LogFmtClassSpec] = []
|
self._logfmt_callbacks: list[_LogFmtFunctionSpec | _LogFmtClassSpec] = []
|
||||||
|
self._logemit_callbacks: list[Callable[..., Any]] = []
|
||||||
|
|
||||||
def register(
|
def register(
|
||||||
self,
|
self,
|
||||||
@@ -119,6 +131,14 @@ class CallbackRegistry:
|
|||||||
)
|
)
|
||||||
return callback
|
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:
|
if kind not in self._callbacks:
|
||||||
raise ValueError(f"unknown callback kind: {kind}")
|
raise ValueError(f"unknown callback kind: {kind}")
|
||||||
|
|
||||||
@@ -171,8 +191,15 @@ class CallbackRegistry:
|
|||||||
"""Return True if any callback of *kind* is registered."""
|
"""Return True if any callback of *kind* is registered."""
|
||||||
if kind == "logfmt":
|
if kind == "logfmt":
|
||||||
return bool(self._logfmt_callbacks)
|
return bool(self._logfmt_callbacks)
|
||||||
|
if kind == "logemit":
|
||||||
|
return bool(self._logemit_callbacks)
|
||||||
return bool(self._callbacks[kind])
|
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]:
|
def build_logfmt(self, ctx: InjectionContext) -> Callable[[Any, str], str | None]:
|
||||||
"""Build a chained formatter from registered logfmt callbacks."""
|
"""Build a chained formatter from registered logfmt callbacks."""
|
||||||
formatters: list[tuple[Callable[[Any, str], str | None], str | None]] = []
|
formatters: list[tuple[Callable[[Any, str], str | None], str | None]] = []
|
||||||
@@ -206,7 +233,13 @@ class CallbackRegistry:
|
|||||||
for fn, pattern in formatters:
|
for fn, pattern in formatters:
|
||||||
if pattern is not None and path != pattern:
|
if pattern is not None and path != pattern:
|
||||||
continue
|
continue
|
||||||
resolved = fn(value, path)
|
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:
|
if resolved is not None:
|
||||||
return resolved
|
return resolved
|
||||||
return None
|
return None
|
||||||
@@ -317,17 +350,13 @@ class CallbackRegistry:
|
|||||||
f"Allowed: str path, {self._allowed_message('logfmt')}"
|
f"Allowed: str path, {self._allowed_message('logfmt')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if sig.return_annotation is inspect.Signature.empty:
|
if sig.return_annotation is not inspect.Signature.empty:
|
||||||
raise TypeError(
|
return_ann = self._resolve_raw_annotation(sig.return_annotation, callback)
|
||||||
f"logfmt callback {callback.__name__} must annotate its "
|
if not self._is_optional_str(return_ann):
|
||||||
f"return type as str | None"
|
raise TypeError(
|
||||||
)
|
f"logfmt callback {callback.__name__} must return str | None, "
|
||||||
return_ann = self._resolve_raw_annotation(sig.return_annotation, callback)
|
f"got {return_ann!r}"
|
||||||
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(
|
return _LogFmtFunctionSpec(
|
||||||
callback=callback,
|
callback=callback,
|
||||||
@@ -416,19 +445,15 @@ class CallbackRegistry:
|
|||||||
f"logfmt class {cls.__name__}.resolve must accept a 'path: str' parameter"
|
f"logfmt class {cls.__name__}.resolve must accept a 'path: str' parameter"
|
||||||
)
|
)
|
||||||
|
|
||||||
if resolve_sig.return_annotation is inspect.Signature.empty:
|
if resolve_sig.return_annotation is not inspect.Signature.empty:
|
||||||
raise TypeError(
|
return_ann = self._resolve_raw_annotation(
|
||||||
f"logfmt class {cls.__name__}.resolve must annotate its "
|
resolve_sig.return_annotation, resolve
|
||||||
f"return type as str | None"
|
|
||||||
)
|
|
||||||
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}"
|
|
||||||
)
|
)
|
||||||
|
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)
|
return _LogFmtClassSpec(cls=cls, inject_params=inject_params, path=path)
|
||||||
|
|
||||||
@@ -453,10 +478,17 @@ class CallbackRegistry:
|
|||||||
return kind == "logfmt"
|
return kind == "logfmt"
|
||||||
if bare is DatabaseError:
|
if bare is DatabaseError:
|
||||||
return kind == "fatal_error"
|
return kind == "fatal_error"
|
||||||
|
if bare is MigrationResult:
|
||||||
|
return kind == "logmigr"
|
||||||
if self._data_type is not None and bare is self._data_type:
|
if self._data_type is not None and bare is self._data_type:
|
||||||
return kind == "bootstrap"
|
return kind == "bootstrap"
|
||||||
if self._kanta_class is not None and bare is self._kanta_class:
|
if self._kanta_class is not None and bare is self._kanta_class:
|
||||||
return kind in {"bootstrap", "fatal_error", "logfmt"}
|
return kind in {
|
||||||
|
"bootstrap",
|
||||||
|
"fatal_error",
|
||||||
|
"logfmt",
|
||||||
|
"logmigr",
|
||||||
|
}
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _allowed_message(self, kind: str) -> str:
|
def _allowed_message(self, kind: str) -> str:
|
||||||
@@ -464,11 +496,13 @@ class CallbackRegistry:
|
|||||||
if kind == "bootstrap":
|
if kind == "bootstrap":
|
||||||
if self._data_type is not None:
|
if self._data_type is not None:
|
||||||
parts.append(self._data_type.__name__)
|
parts.append(self._data_type.__name__)
|
||||||
if kind in {"bootstrap", "fatal_error", "logfmt"}:
|
if kind in {"bootstrap", "fatal_error", "logfmt", "logmigr"}:
|
||||||
if self._kanta_class is not None:
|
if self._kanta_class is not None:
|
||||||
parts.append(self._kanta_class.__name__)
|
parts.append(self._kanta_class.__name__)
|
||||||
if kind == "fatal_error":
|
if kind == "fatal_error":
|
||||||
parts.append("DatabaseError")
|
parts.append("DatabaseError")
|
||||||
|
if kind == "logmigr":
|
||||||
|
parts.append("MigrationResult")
|
||||||
if kind == "logfmt":
|
if kind == "logfmt":
|
||||||
parts.append("Annotated[dict, 'pre']")
|
parts.append("Annotated[dict, 'pre']")
|
||||||
parts.append("Annotated[dict, 'post']")
|
parts.append("Annotated[dict, 'post']")
|
||||||
@@ -482,6 +516,8 @@ class CallbackRegistry:
|
|||||||
return ctx.current_state
|
return ctx.current_state
|
||||||
if bare is DatabaseError:
|
if bare is DatabaseError:
|
||||||
return ctx.error
|
return ctx.error
|
||||||
|
if bare is MigrationResult:
|
||||||
|
return ctx.migration_result
|
||||||
if self._data_type is not None and bare is self._data_type:
|
if self._data_type is not None and bare is self._data_type:
|
||||||
return ctx.data
|
return ctx.data
|
||||||
if self._kanta_class is not None and bare is self._kanta_class:
|
if self._kanta_class is not None and bare is self._kanta_class:
|
||||||
@@ -516,7 +552,7 @@ class CallbackRegistry:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _unwrap_optional(ann: Any) -> Any:
|
def _unwrap_optional(ann: Any) -> Any:
|
||||||
origin = get_origin(ann)
|
origin = get_origin(ann)
|
||||||
if origin is not Union:
|
if origin not in (Union, types.UnionType):
|
||||||
return ann
|
return ann
|
||||||
args = [arg for arg in get_args(ann) if arg is not type(None)]
|
args = [arg for arg in get_args(ann) if arg is not type(None)]
|
||||||
return args[0] if len(args) == 1 else ann
|
return args[0] if len(args) == 1 else ann
|
||||||
@@ -524,7 +560,7 @@ class CallbackRegistry:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_optional_str(ann: Any) -> bool:
|
def _is_optional_str(ann: Any) -> bool:
|
||||||
origin = get_origin(ann)
|
origin = get_origin(ann)
|
||||||
if origin is not Union:
|
if origin not in (Union, types.UnionType):
|
||||||
return ann is str
|
return ann is str
|
||||||
args = get_args(ann)
|
args = get_args(ann)
|
||||||
return type(None) in args and any(arg is str for arg in args)
|
return type(None) in args and any(arg is str for arg in args)
|
||||||
|
|||||||
+42
-26
@@ -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,
|
||||||
|
|||||||
+115
-6
@@ -1,9 +1,10 @@
|
|||||||
"""Kanta DB main public API"""
|
"""Kanta DB main public API"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
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.kantaimpl import KantaImpl
|
from kanta.kantaimpl import KantaImpl
|
||||||
@@ -50,7 +51,6 @@ 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,
|
||||||
flush_interval: float = 0.1,
|
flush_interval: float = 0.1,
|
||||||
):
|
):
|
||||||
@@ -61,7 +61,6 @@ 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.
|
||||||
|
|
||||||
@@ -78,7 +77,6 @@ class Kanta(Generic[T]):
|
|||||||
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,
|
kanta=self,
|
||||||
)
|
)
|
||||||
@@ -127,6 +125,18 @@ class Kanta(Generic[T]):
|
|||||||
"""
|
"""
|
||||||
return self._impl.filename
|
return self._impl.filename
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ctx(self) -> SimpleNamespace:
|
||||||
|
"""User-writable context namespace.
|
||||||
|
|
||||||
|
Migration functions receive the ``Kanta`` instance and can read or
|
||||||
|
mutate ``kanta.ctx`` during migrations. 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
|
@property
|
||||||
def mtime(self) -> datetime | None:
|
def mtime(self) -> datetime | None:
|
||||||
"""Last modification time carried forward from change records.
|
"""Last modification time carried forward from change records.
|
||||||
@@ -138,7 +148,13 @@ class Kanta(Generic[T]):
|
|||||||
"""
|
"""
|
||||||
return self._impl.mtime
|
return self._impl.mtime
|
||||||
|
|
||||||
async def open(self, *, create: bool = True) -> None:
|
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
|
||||||
@@ -147,6 +163,16 @@ class Kanta(Generic[T]):
|
|||||||
Args:
|
Args:
|
||||||
create: Whether to create the database file when missing.
|
create: Whether to create the database file when missing.
|
||||||
If False, opening fails when the file does not exist or is empty.
|
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.
|
||||||
|
|
||||||
@@ -154,7 +180,7 @@ class Kanta(Generic[T]):
|
|||||||
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(create=create)
|
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.
|
||||||
@@ -230,6 +256,45 @@ class Kanta(Generic[T]):
|
|||||||
return _register
|
return _register
|
||||||
return _register(fn)
|
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.MigrationResult` and
|
||||||
|
may be sync or async. If registered, it replaces the default migration
|
||||||
|
logger output; the application is responsible for emitting any log
|
||||||
|
messages.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _register(callback):
|
||||||
|
self._impl.add_logmigr(callback)
|
||||||
|
return callback
|
||||||
|
|
||||||
|
if fn is None:
|
||||||
|
return _register
|
||||||
|
return _register(fn)
|
||||||
|
|
||||||
def logfmt(self, fn=None, *, path: str | None = None):
|
def logfmt(self, fn=None, *, path: str | None = None):
|
||||||
"""Register a transaction logfmt callback.
|
"""Register a transaction logfmt callback.
|
||||||
|
|
||||||
@@ -251,12 +316,39 @@ class Kanta(Generic[T]):
|
|||||||
return _register
|
return _register
|
||||||
return _register(fn)
|
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,
|
||||||
|
extra: Any = None,
|
||||||
mtime: bool | datetime = True,
|
mtime: bool | datetime = True,
|
||||||
|
log: bool | logging.Logger = True,
|
||||||
|
logdiff: bool = True,
|
||||||
):
|
):
|
||||||
"""Create a transactional mutation context manager.
|
"""Create a transactional mutation context manager.
|
||||||
|
|
||||||
@@ -265,12 +357,26 @@ class Kanta(Generic[T]):
|
|||||||
user: Optional user identifier stored in metadata and rendered in
|
user: Optional user identifier stored in metadata and rendered in
|
||||||
the log header. Register a ``@kanta.logfmt`` callback to format
|
the log header. Register a ``@kanta.logfmt`` callback to format
|
||||||
the user value; the path ``"$user"`` is passed for this case.
|
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)
|
mtime: Controls the modification time ``m``. ``True`` (default)
|
||||||
sets ``m`` to the current UTC time. ``False`` omits ``m`` so the
|
sets ``m`` to the current UTC time. ``False`` omits ``m`` so the
|
||||||
previous modification time remains in effect; this is used for
|
previous modification time remains in effect; this is used for
|
||||||
system operations that are not considered modifications. A
|
system operations that are not considered modifications. A
|
||||||
:class:`~datetime.datetime` value sets ``m`` to that explicit
|
:class:`~datetime.datetime` value sets ``m`` to that explicit
|
||||||
time.
|
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.
|
||||||
@@ -284,5 +390,8 @@ class Kanta(Generic[T]):
|
|||||||
self._impl,
|
self._impl,
|
||||||
action,
|
action,
|
||||||
user=user,
|
user=user,
|
||||||
|
extra=extra,
|
||||||
mtime=mtime,
|
mtime=mtime,
|
||||||
|
log=log,
|
||||||
|
logdiff=logdiff,
|
||||||
)
|
)
|
||||||
|
|||||||
+233
-35
@@ -7,11 +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.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 MigrationResult, 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
|
||||||
@@ -21,28 +29,34 @@ _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.migration_ctx = kwargs.pop("migration_ctx", None)
|
|
||||||
self._kanta = kwargs.pop("kanta", None)
|
self._kanta = kwargs.pop("kanta", 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_action = "bootstrap"
|
||||||
self.bootstrap_user: str | None = None
|
self.bootstrap_user: str | None = None
|
||||||
self.bootstrap_mtime: bool | datetime = True
|
self.bootstrap_mtime: bool | datetime = True
|
||||||
@@ -53,9 +67,7 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.statedict = struct_to_dict(self.data, serializer=self.serializer)
|
self.statedict = struct_to_dict(self.data, serializer=self.serializer)
|
||||||
self.version = (
|
self.version = self.migrations.dbver if self.migrations is not None else 0
|
||||||
self.migration_registry.dbver if self.migration_registry is not None else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_bootstrap(
|
def add_bootstrap(
|
||||||
self,
|
self,
|
||||||
@@ -75,7 +87,64 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
|||||||
"""Register one transaction logfmt callback."""
|
"""Register one transaction logfmt callback."""
|
||||||
self.callback_registry.register("logfmt", callback, path=path)
|
self.callback_registry.register("logfmt", callback, path=path)
|
||||||
|
|
||||||
async def open(self, *, create: bool = True) -> None:
|
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,
|
||||||
|
migration_result: MigrationResult,
|
||||||
|
previous_version: int,
|
||||||
|
log: bool | logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
"""Route migration logging to callback or default logger."""
|
||||||
|
assert isinstance(migration_result, MigrationResult)
|
||||||
|
|
||||||
|
if self.callback_registry.has("logmigr"):
|
||||||
|
await self.callback_registry.invoke(
|
||||||
|
"logmigr",
|
||||||
|
InjectionContext(
|
||||||
|
kanta=self._kanta,
|
||||||
|
migration_result=migration_result,
|
||||||
|
),
|
||||||
|
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 migration_result.migrations 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=previous_version,
|
||||||
|
to_version=migration_result.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(
|
||||||
@@ -84,12 +153,17 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
|||||||
action="open",
|
action="open",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.readonly = readonly
|
||||||
existed_before_open = self.filename.exists()
|
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=create,
|
create=open_create,
|
||||||
|
readonly=readonly,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not create and (not existed_before_open or not content):
|
if not create and (not existed_before_open or not content):
|
||||||
@@ -105,6 +179,9 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
|||||||
action="open",
|
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(
|
||||||
@@ -134,12 +211,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_result = 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_result = self.migrations.apply(
|
||||||
|
rr.state, rr.version, self._kanta
|
||||||
)
|
)
|
||||||
|
rr.version = migration_result.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,
|
||||||
@@ -148,28 +242,132 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
|||||||
)
|
)
|
||||||
self.version = rr.version
|
self.version = rr.version
|
||||||
self.mtime = rr.m
|
self.mtime = rr.m
|
||||||
normalized = struct_to_dict(self.data, serializer=self.serializer)
|
if log is not False and not migrations_ran:
|
||||||
self.queue_change("migrate:msgspec", normalized, mtime=False)
|
logger = log if isinstance(log, logging.Logger) else bootstrap_logger
|
||||||
self.snapshot.ts = (
|
emit_event(
|
||||||
datetime.fromtimestamp(rr.last_snapshot_mtime, UTC)
|
LogEvent(
|
||||||
if rr.last_snapshot_mtime is not None
|
kind="opened",
|
||||||
else None
|
logger=logger,
|
||||||
)
|
level=logging.DEBUG,
|
||||||
elif self.callback_registry.has("bootstrap"):
|
kanta=self._kanta,
|
||||||
try:
|
filename=str(self.filename.resolve()),
|
||||||
await self.callback_registry.invoke(
|
),
|
||||||
"bootstrap",
|
self.callback_registry.logemit_handlers,
|
||||||
InjectionContext(data=self.data, kanta=self._kanta),
|
|
||||||
)
|
)
|
||||||
|
normalized = struct_to_dict(self.data, serializer=self.serializer)
|
||||||
|
if self.readonly:
|
||||||
|
self.statedict = copy.deepcopy(normalized)
|
||||||
|
else:
|
||||||
|
# One record per open: migration changes and normalization are
|
||||||
|
# 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_result is not None:
|
||||||
|
await self._handle_migration_log(
|
||||||
|
migration_result, previous_version, 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.statedict = {}
|
||||||
current = struct_to_dict(self.data, serializer=self.serializer)
|
current = struct_to_dict(self.data, serializer=self.serializer)
|
||||||
self.queue_change(
|
record = self.queue_change(
|
||||||
self.bootstrap_action,
|
self.bootstrap_action,
|
||||||
current,
|
current,
|
||||||
user=self.bootstrap_user,
|
user=self.bootstrap_user,
|
||||||
mtime=self.bootstrap_mtime,
|
mtime=self.bootstrap_mtime,
|
||||||
|
force=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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:
|
except Exception:
|
||||||
|
self.opened = False
|
||||||
self.file.close()
|
self.file.close()
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(self.filename.unlink, missing_ok=True)
|
await asyncio.to_thread(self.filename.unlink, missing_ok=True)
|
||||||
@@ -177,9 +375,8 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
|||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
self.opened = True
|
if not self.readonly:
|
||||||
|
self.background_task = asyncio.create_task(self._background_loop())
|
||||||
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."""
|
||||||
@@ -196,7 +393,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
|
||||||
|
|||||||
+304
-68
@@ -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,20 +32,157 @@ _UNSAFE_CHARS = re.compile(
|
|||||||
r"]"
|
r"]"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ANSI color codes
|
|
||||||
_RESET = "\033[0m"
|
|
||||||
_SEP = "\033[38;5;242m" # Dark grey for separators
|
|
||||||
_PATH_PREFIX = "\033[38;5;242m" # Dark grey for path prefix
|
|
||||||
_PATH_FINAL = "\033[38;5;250m" # Default for final element
|
|
||||||
_DELETE = "\033[1;31m" # Red for deletions
|
|
||||||
_ADD = "\033[0;32m" # Green for additions
|
|
||||||
_ACTION = "\033[1;34m" # Bold blue for action name
|
|
||||||
_USER = "\033[0;34m" # Blue for user display
|
|
||||||
|
|
||||||
# Metadata path used when formatting the transaction actor.
|
# Metadata path used when formatting the transaction actor.
|
||||||
_USER_PATH = "$user"
|
_USER_PATH = "$user"
|
||||||
|
|
||||||
|
|
||||||
|
class LogEvent(msgspec.Struct, kw_only=True):
|
||||||
|
"""All state describing one loggable event, passed to logemit callbacks.
|
||||||
|
|
||||||
|
``kind`` is ``"change"`` (transaction, bootstrap, or migration diff),
|
||||||
|
``"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:
|
def _join_path(path: str, key: str) -> str:
|
||||||
"""Append *key* to a dot-notation *path*."""
|
"""Append *key* to a dot-notation *path*."""
|
||||||
if not path:
|
if not path:
|
||||||
@@ -42,6 +190,11 @@ def _join_path(path: str, key: str) -> str:
|
|||||||
return f"{path}.{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,
|
value: Any,
|
||||||
path: str,
|
path: str,
|
||||||
@@ -64,7 +217,7 @@ def _format_value(
|
|||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
value = _UNSAFE_CHARS.sub("", value)
|
value = _UNSAFE_CHARS.sub("", value)
|
||||||
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:
|
||||||
@@ -90,7 +243,7 @@ def _format_value(
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -113,17 +266,22 @@ def _format_path_components(
|
|||||||
|
|
||||||
|
|
||||||
def _format_path(
|
def _format_path(
|
||||||
path: list[str], logfmt: Callable[[Any, str], str | None] | None
|
path: list[str],
|
||||||
|
logfmt: Callable[[Any, str], str | None] | None,
|
||||||
|
final_color: str = "path_final",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Format a path as dot notation with prefix in dark grey, final in default."""
|
"""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)
|
components = _format_path_components(path, logfmt)
|
||||||
if not components:
|
if not components:
|
||||||
return ""
|
return ""
|
||||||
if len(components) == 1:
|
line = Line()
|
||||||
return f"{_PATH_FINAL}{components[0]}{_RESET}"
|
if len(components) > 1:
|
||||||
prefix = ".".join(components[:-1])
|
line.path_prefix(".".join(components[:-1]) + ".")
|
||||||
final = components[-1]
|
getattr(line, final_color)(components[-1])
|
||||||
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
|
return str(line)
|
||||||
|
|
||||||
|
|
||||||
def _get_nested(data: dict | None, path: list[str]) -> Any:
|
def _get_nested(data: dict | None, path: list[str]) -> Any:
|
||||||
@@ -199,37 +357,47 @@ def _format_change_lines(
|
|||||||
logfmt: Callable[[Any, str], str | None] | 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."""
|
||||||
path_str = _format_path(path, logfmt=logfmt)
|
|
||||||
|
|
||||||
if change_type == "delete":
|
if change_type == "delete":
|
||||||
components = _format_path_components(path, logfmt)
|
components = _format_path_components(path, logfmt)
|
||||||
if len(components) == 1:
|
line = Line()(" ")
|
||||||
return [f" {_DELETE}{components[0]} ✗{_RESET}"]
|
if len(components) > 1:
|
||||||
prefix = ".".join(components[:-1])
|
line.path_prefix(".".join(components[:-1]) + ".")
|
||||||
final = components[-1]
|
line.delete(components[-1], " ✗")
|
||||||
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final} ✗{_RESET}"]
|
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 = [f" {path_str} {_SEP}={_RESET}"]
|
lines = [str(Line()(" ", path_str, " ").sep("="))]
|
||||||
formatted_items = []
|
|
||||||
base_path = ".".join(path)
|
base_path = ".".join(path)
|
||||||
for k, v in value.items():
|
keys = []
|
||||||
|
for k in value:
|
||||||
key_path = _join_path(base_path, str(k))
|
key_path = _join_path(base_path, str(k))
|
||||||
key_display = _format_value(k, key_path, max_len=30, logfmt=logfmt)
|
keys.append((k, _format_value(k, key_path, max_len=30, logfmt=logfmt)))
|
||||||
v_str = _format_value(v, key_path, max_len=30, logfmt=logfmt)
|
field_width = max(displaywidth(kd) for _, kd in keys)
|
||||||
|
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 = []
|
||||||
|
for (k, key_display), v in zip(keys, value.values()):
|
||||||
|
key_path = _join_path(base_path, str(k))
|
||||||
|
v_str = _format_value(v, key_path, max_len=value_width, logfmt=logfmt)
|
||||||
formatted_items.append((key_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
|
)
|
||||||
|
for k, v in formatted_items
|
||||||
|
]
|
||||||
value_str = _format_value(value, ".".join(path), logfmt=logfmt)
|
value_str = _format_value(value, ".".join(path), logfmt=logfmt)
|
||||||
return [f" {path_str} {_SEP}={_RESET} {value_str}"]
|
return [str(Line()(" ", path_str, " ").sep("=")(" ", value_str))]
|
||||||
|
|
||||||
value_str = _format_value(value, ".".join(path), logfmt=logfmt)
|
value_str = _format_value(value, ".".join(path), logfmt=logfmt)
|
||||||
return [f" {path_str} {_SEP}={_RESET} {value_str}"]
|
path_str = _format_path(path, logfmt=logfmt)
|
||||||
|
return [str(Line()(" ", path_str, " ").sep("=")(" ", value_str))]
|
||||||
|
|
||||||
|
|
||||||
def format_diff(
|
def format_diff(
|
||||||
@@ -259,13 +427,18 @@ def format_diff(
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def format_action_header(action: str, user: 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:
|
extra: Any = None,
|
||||||
user_str = f"{_USER}{user}{_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(
|
||||||
@@ -273,37 +446,100 @@ def log_change(
|
|||||||
diff: dict,
|
diff: dict,
|
||||||
user: str | None = None,
|
user: str | None = None,
|
||||||
previous: dict | None = None,
|
previous: dict | None = None,
|
||||||
|
extra: Any = None,
|
||||||
logfmt: Callable[[Any, str], str | None] | None = 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: Optional already-formatted user name to show in the header.
|
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).
|
||||||
|
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``.
|
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)
|
emit_event(
|
||||||
diff_lines = format_diff(diff, previous, logfmt)
|
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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""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 MigrationResult:
|
||||||
|
"""Result of applying migrations."""
|
||||||
|
|
||||||
|
version: int
|
||||||
|
migrations: list[MigrationInfo]
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
result = migrations.apply(state, current_version=0, kanta=kanta)
|
||||||
|
new_version = result.version
|
||||||
|
|
||||||
|
Or load from a module::
|
||||||
|
|
||||||
|
migrations = Migrations.from_module("myapp.migrations")
|
||||||
|
result = 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,
|
||||||
|
) -> MigrationResult:
|
||||||
|
"""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:`MigrationResult` describing the new version 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] = []
|
||||||
|
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 MigrationResult(version=current_version, migrations=migrations)
|
||||||
+60
-7
@@ -4,8 +4,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
|
import inspect
|
||||||
import logging
|
import logging
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from collections.abc import Callable
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -39,7 +41,9 @@ class PersistenceMixin:
|
|||||||
flush_interval: float
|
flush_interval: float
|
||||||
version: int
|
version: int
|
||||||
opened: bool
|
opened: bool
|
||||||
|
readonly: bool
|
||||||
mtime: datetime | None
|
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."""
|
||||||
@@ -61,6 +65,31 @@ class PersistenceMixin:
|
|||||||
self.flush_interval = flush_interval
|
self.flush_interval = flush_interval
|
||||||
self.version = 0
|
self.version = 0
|
||||||
self.mtime: datetime | None = None
|
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:
|
def add_fatal_error(self, callback) -> None:
|
||||||
"""Register one fatal error callback in call order."""
|
"""Register one fatal error callback in call order."""
|
||||||
@@ -68,6 +97,8 @@ class PersistenceMixin:
|
|||||||
|
|
||||||
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)
|
||||||
@@ -97,7 +128,9 @@ class PersistenceMixin:
|
|||||||
|
|
||||||
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, m=self.mtime)
|
self.snapshot.maybe_write(
|
||||||
|
self.file, self.version, self.statedict, m=self.mtime, now=self.now
|
||||||
|
)
|
||||||
|
|
||||||
def queue_change(
|
def queue_change(
|
||||||
self,
|
self,
|
||||||
@@ -106,6 +139,7 @@ class PersistenceMixin:
|
|||||||
*,
|
*,
|
||||||
user: str | None = None,
|
user: str | None = None,
|
||||||
mtime: bool | datetime = True,
|
mtime: bool | datetime = True,
|
||||||
|
force: bool = False,
|
||||||
) -> ChangeRecord | None:
|
) -> ChangeRecord | None:
|
||||||
"""Queue a change record internally (thread-safe).
|
"""Queue a change record internally (thread-safe).
|
||||||
|
|
||||||
@@ -118,11 +152,20 @@ class PersistenceMixin:
|
|||||||
previous modification time remains in effect; this is used for
|
previous modification time remains in effect; this is used for
|
||||||
system operations that are not considered modifications. A
|
system operations that are not considered modifications. A
|
||||||
:class:`~datetime.datetime` value sets ``m`` to that explicit time.
|
:class:`~datetime.datetime` value sets ``m`` to that explicit time.
|
||||||
|
force: If ``True``, queue the record even when the diff is empty.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The queued :class:`ChangeRecord`, or ``None`` if the diff was empty.
|
The queued :class:`ChangeRecord`, or ``None`` if the diff was empty
|
||||||
|
and *force* is ``False``.
|
||||||
"""
|
"""
|
||||||
now = datetime.now(UTC)
|
diff = compute_diff(self.statedict, current)
|
||||||
|
if not diff:
|
||||||
|
if not force:
|
||||||
|
return None
|
||||||
|
diff = {}
|
||||||
|
|
||||||
|
# The clock is only read when a record is actually queued.
|
||||||
|
now = self.now()
|
||||||
|
|
||||||
if mtime is True:
|
if mtime is True:
|
||||||
m = now
|
m = now
|
||||||
@@ -133,10 +176,6 @@ class PersistenceMixin:
|
|||||||
else:
|
else:
|
||||||
raise TypeError("mtime must be True, False, or a datetime")
|
raise TypeError("mtime must be True, False, or a datetime")
|
||||||
|
|
||||||
diff = compute_diff(self.statedict, current)
|
|
||||||
if not diff:
|
|
||||||
return None
|
|
||||||
|
|
||||||
record = ChangeRecord(
|
record = ChangeRecord(
|
||||||
ts=now,
|
ts=now,
|
||||||
a=action,
|
a=action,
|
||||||
@@ -160,6 +199,13 @@ 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
|
||||||
|
|
||||||
@@ -207,6 +253,13 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""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"
|
||||||
|
f" ({_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"
|
||||||
|
f" ({_plural(total, 'change')})"
|
||||||
|
)
|
||||||
|
end_line = lines[idx + 1] if idx + 1 < total else end_of_file(events)
|
||||||
|
return Selection(lines[idx], end_line)
|
||||||
+17
-9
@@ -3,6 +3,7 @@
|
|||||||
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.structs import Snapshot
|
from kanta.structs import Snapshot
|
||||||
@@ -38,22 +39,29 @@ class SnapshotState:
|
|||||||
self.changes += count
|
self.changes += count
|
||||||
|
|
||||||
def maybe_write(
|
def maybe_write(
|
||||||
self, file, version: int, state: dict, m: datetime | None = None
|
self,
|
||||||
|
file,
|
||||||
|
version: int,
|
||||||
|
state: dict,
|
||||||
|
m: datetime | None = None,
|
||||||
|
now: Callable[[], datetime] | None = 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, m=m)
|
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)
|
||||||
|
|||||||
+1
-1
@@ -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):
|
||||||
|
|||||||
+69
-15
@@ -5,25 +5,55 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
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.callbacks import InjectionContext
|
from kanta.callbacks import InjectionContext
|
||||||
from kanta.logging import _USER_PATH, log_change
|
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,
|
||||||
|
extra: Any = None,
|
||||||
mtime: bool | datetime = True,
|
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 "
|
||||||
@@ -61,21 +91,45 @@ def transaction(
|
|||||||
previous = impl.statedict
|
previous = impl.statedict
|
||||||
record = impl.queue_change(action, new_dict, user=user, mtime=mtime)
|
record = impl.queue_change(action, new_dict, user=user, mtime=mtime)
|
||||||
if record is not None:
|
if record is not None:
|
||||||
logfmt = impl.callback_registry.build_logfmt(
|
if log is not False:
|
||||||
InjectionContext(
|
logfmt = _build_logfmt(impl, previous, new_dict)
|
||||||
previous_state=previous,
|
logger = (
|
||||||
current_state=new_dict,
|
log if isinstance(log, logging.Logger) else transaction_logger
|
||||||
kanta=impl._kanta,
|
|
||||||
)
|
)
|
||||||
)
|
emit_event(
|
||||||
formatted_user = user
|
LogEvent(
|
||||||
if user is not None and logfmt is not None:
|
kind="change",
|
||||||
resolved = logfmt(user, _USER_PATH)
|
logger=logger,
|
||||||
if resolved is not None:
|
kanta=impl._kanta,
|
||||||
formatted_user = resolved
|
action=action,
|
||||||
log_change(action, record.diff, formatted_user, previous, logfmt)
|
user=_resolve_user(logfmt, user),
|
||||||
except Exception:
|
extra=extra,
|
||||||
_logger.warning("Transaction '%s' failed, rolling back changes", action)
|
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
@@ -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)
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
def main():
|
|
||||||
print("Hello from kanta!")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -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",
|
||||||
|
|||||||
+24
-1
@@ -7,7 +7,7 @@ from uuid import UUID
|
|||||||
import msgspec
|
import msgspec
|
||||||
|
|
||||||
from kanta.kanta import Kanta
|
from kanta.kanta import Kanta
|
||||||
from kanta.structs import ChangeRecord
|
from kanta.structs import ChangeRecord, Snapshot
|
||||||
|
|
||||||
|
|
||||||
class User(msgspec.Struct):
|
class User(msgspec.Struct):
|
||||||
@@ -70,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
|
||||||
@@ -77,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
|
||||||
|
|||||||
+58
-12
@@ -1,4 +1,4 @@
|
|||||||
from typing import Any
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -49,16 +49,60 @@ def test_logfmt_requires_value_annotation(tmp_path, format_config):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def test_logfmt_requires_return_annotation(tmp_path, format_config):
|
def test_logfmt_allows_missing_return_annotation(tmp_path, format_config):
|
||||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||||
|
|
||||||
with pytest.raises(TypeError, match="must annotate its return"):
|
@kanta.logfmt
|
||||||
|
def resolve_names(value: str, current: DictPost):
|
||||||
|
return None
|
||||||
|
|
||||||
@kanta.logfmt
|
|
||||||
def resolve_names(value: str, current: DictPost):
|
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
|
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):
|
def test_logfmt_rejects_async_callback(tmp_path, format_config):
|
||||||
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
kanta = make_kanta(tmp_path / "test.db", Data, format_config)
|
||||||
|
|
||||||
@@ -104,7 +148,7 @@ async def test_bootstrap_injects_kanta(tmp_path, format_config):
|
|||||||
async def test_logfmt_injects_states(tmp_path, format_config, caplog):
|
async def test_logfmt_injects_states(tmp_path, format_config, caplog):
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
caplog.set_level(logging.INFO, logger="kanta.changes")
|
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||||
path = tmp_path / "test.db"
|
path = tmp_path / "test.db"
|
||||||
kanta = make_kanta(path, Data, format_config)
|
kanta = make_kanta(path, Data, format_config)
|
||||||
|
|
||||||
@@ -126,13 +170,15 @@ async def test_logfmt_injects_states(tmp_path, format_config, caplog):
|
|||||||
async def test_logfmt_class_injection(tmp_path, format_config, caplog):
|
async def test_logfmt_class_injection(tmp_path, format_config, caplog):
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
caplog.set_level(logging.INFO, logger="kanta.changes")
|
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||||
path = tmp_path / "test.db"
|
path = tmp_path / "test.db"
|
||||||
kanta = make_kanta(path, Data, format_config)
|
kanta = make_kanta(path, Data, format_config)
|
||||||
|
|
||||||
@kanta.logfmt
|
@kanta.logfmt
|
||||||
class UserLogFmt(LogFmt):
|
class UserLogFmt(LogFmt):
|
||||||
def resolve(self, value: str, path: str) -> str | None:
|
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")
|
return self.current_state.get("users", {}).get(value, {}).get("name")
|
||||||
|
|
||||||
await kanta.open()
|
await kanta.open()
|
||||||
@@ -149,7 +195,7 @@ async def test_logfmt_class_injection(tmp_path, format_config, caplog):
|
|||||||
async def test_multiple_logfmt_chain(tmp_path, format_config, caplog):
|
async def test_multiple_logfmt_chain(tmp_path, format_config, caplog):
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
caplog.set_level(logging.INFO, logger="kanta.changes")
|
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||||
path = tmp_path / "test.db"
|
path = tmp_path / "test.db"
|
||||||
kanta = make_kanta(path, Data, format_config)
|
kanta = make_kanta(path, Data, format_config)
|
||||||
|
|
||||||
@@ -177,7 +223,7 @@ async def test_multiple_logfmt_chain(tmp_path, format_config, caplog):
|
|||||||
async def test_logfmt_path_context(tmp_path, format_config, caplog):
|
async def test_logfmt_path_context(tmp_path, format_config, caplog):
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
caplog.set_level(logging.INFO, logger="kanta.changes")
|
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||||
path = tmp_path / "test.db"
|
path = tmp_path / "test.db"
|
||||||
kanta = make_kanta(path, Data, format_config)
|
kanta = make_kanta(path, Data, format_config)
|
||||||
|
|
||||||
@@ -201,7 +247,7 @@ async def test_logfmt_path_context(tmp_path, format_config, caplog):
|
|||||||
async def test_logfmt_decorator_path_filters_calls(tmp_path, format_config, caplog):
|
async def test_logfmt_decorator_path_filters_calls(tmp_path, format_config, caplog):
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
caplog.set_level(logging.INFO, logger="kanta.changes")
|
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||||
path = tmp_path / "test.db"
|
path = tmp_path / "test.db"
|
||||||
kanta = make_kanta(path, Data, format_config)
|
kanta = make_kanta(path, Data, format_config)
|
||||||
|
|
||||||
@@ -227,7 +273,7 @@ async def test_logfmt_decorator_path_filters_calls(tmp_path, format_config, capl
|
|||||||
async def test_logfmt_user_path_replaces_user_display(tmp_path, format_config, caplog):
|
async def test_logfmt_user_path_replaces_user_display(tmp_path, format_config, caplog):
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
caplog.set_level(logging.INFO, logger="kanta.changes")
|
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||||
path = tmp_path / "test.db"
|
path = tmp_path / "test.db"
|
||||||
kanta = make_kanta(path, Data, format_config)
|
kanta = make_kanta(path, Data, format_config)
|
||||||
|
|
||||||
@@ -249,7 +295,7 @@ async def test_logfmt_user_path_replaces_user_display(tmp_path, format_config, c
|
|||||||
async def test_logfmt_non_string_value(tmp_path, format_config, caplog):
|
async def test_logfmt_non_string_value(tmp_path, format_config, caplog):
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
caplog.set_level(logging.INFO, logger="kanta.changes")
|
caplog.set_level(logging.INFO, logger="kanta.transaction")
|
||||||
path = tmp_path / "test.db"
|
path = tmp_path / "test.db"
|
||||||
kanta = make_kanta(path, Data, format_config)
|
kanta = make_kanta(path, Data, format_config)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""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"
|
||||||
@@ -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,4 +1,8 @@
|
|||||||
from kanta.logging 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)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -404,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
|
||||||
@@ -434,6 +496,287 @@ 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_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"
|
||||||
@@ -514,7 +857,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
|
||||||
|
|||||||
@@ -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
@@ -1,17 +1,116 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from kanta.logging 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
|
||||||
|
|||||||
+171
-16
@@ -1,53 +1,208 @@
|
|||||||
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"
|
||||||
|
|||||||
+3
-2
@@ -82,8 +82,9 @@ async def test_transaction_mtime_false_preserves_mtime(tmp_path, format_config):
|
|||||||
continue
|
continue
|
||||||
records.append(serializer.decode(payload, type=ChangeRecord))
|
records.append(serializer.decode(payload, type=ChangeRecord))
|
||||||
|
|
||||||
assert records[0].m == first_m
|
assert records[0].a == "bootstrap"
|
||||||
assert records[1].m is None
|
assert records[1].m == first_m
|
||||||
|
assert records[2].m is None
|
||||||
assert kanta.mtime == first_m
|
assert kanta.mtime == first_m
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user