Demo: plain-text custom header colored by Kanta, rename migration, str extras
This commit is contained in:
+52
-77
@@ -18,11 +18,8 @@ import msgspec
|
||||
|
||||
from kanta import Kanta
|
||||
from kanta.callbacks import DictPost, DictPre
|
||||
from kanta.logging import _ACTION, _ACTOR, _RESET, _SESSION, _TARGET
|
||||
from kanta.logging import configure_logging
|
||||
|
||||
DB = Path(__file__).with_name("demo.kantadb")
|
||||
|
||||
|
||||
class DataV1(msgspec.Struct):
|
||||
"""Original schema (version 0)."""
|
||||
@@ -32,34 +29,31 @@ class DataV1(msgspec.Struct):
|
||||
|
||||
|
||||
class Data(msgspec.Struct):
|
||||
"""Current schema: migration v1 adds the settings section."""
|
||||
"""Current schema: migration v1 renames counter to total."""
|
||||
|
||||
users: dict[str, dict] = {}
|
||||
counter: int = 0
|
||||
settings: dict[str, str] = {}
|
||||
total: int = 0
|
||||
|
||||
|
||||
def migrate_v1(d: dict) -> None:
|
||||
"""Add settings section"""
|
||||
d["settings"] = {"theme": "dark"}
|
||||
"""Rename counter to total"""
|
||||
d["total"] = d.pop("counter")
|
||||
|
||||
|
||||
# Phase 1 instance: default logging, original schema.
|
||||
kanta_v0 = Kanta(DB, DataV1())
|
||||
# Phase 2 instance: migrations (scanned from this script) and a custom header.
|
||||
kanta_v1 = Kanta(DB, Data(), migrations=sys.modules[__name__])
|
||||
filename = Path(__file__).with_name("demo.kantadb")
|
||||
# For demonstration purposes, we use "original v0" and "modified v1" in this same script
|
||||
kanta_v0 = Kanta(filename, DataV1())
|
||||
kanta_v1 = Kanta(filename, Data(), migrations=sys.modules[__name__])
|
||||
|
||||
_now = datetime(2026, 8, 6, tzinfo=UTC)
|
||||
_now = datetime(2027, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
@kanta_v0.clock
|
||||
@kanta_v1.clock
|
||||
def fake_now() -> datetime:
|
||||
"""Deterministic clock: starts at midnight, +1h on every read."""
|
||||
def fake_clock() -> datetime:
|
||||
global _now
|
||||
ts = _now
|
||||
_now += timedelta(hours=1)
|
||||
return ts
|
||||
return _now
|
||||
|
||||
|
||||
@kanta_v0.logfmt
|
||||
@@ -79,12 +73,9 @@ def resolve_user(
|
||||
|
||||
|
||||
@kanta_v1.logheader
|
||||
def header(action: str, user: str | None, extra: dict | None) -> str:
|
||||
"""Aligned rich header: actor, session id, action, target."""
|
||||
actor = f"{_ACTOR}{user or '-':<8}{_RESET}"
|
||||
session = f"{_SESSION}{extra.get('session_id', '-'):>2}{_RESET}"
|
||||
target = f"{_TARGET}{extra['target']}{_RESET}"
|
||||
return f"{actor} {session} {_ACTION}{action}{_RESET} {target}"
|
||||
def header(action: str, user: str, extra: str) -> str:
|
||||
"""Custom header: Kanta colors the parts, we just arrange them."""
|
||||
return f"{user} {action} {extra}"
|
||||
|
||||
|
||||
@kanta_v0.bootstrap
|
||||
@@ -98,69 +89,53 @@ def section(title: str) -> None:
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
DB.unlink(missing_ok=True)
|
||||
filename.unlink(missing_ok=True)
|
||||
|
||||
section("Standard logging: bootstrap, diffs, toggles, rollback")
|
||||
await kanta_v0.open()
|
||||
section("Database creation with v0 schema and basic access")
|
||||
# Open and close automatically; you can also `await kanta.open()` instead
|
||||
async with kanta_v0 as kanta:
|
||||
with kanta.transaction(action="create", user="u2") as data:
|
||||
data.users["u2"] = {"name": "Bob", "role": "user"}
|
||||
|
||||
with kanta_v0.transaction(action="create", user="u2") as data:
|
||||
data.users["u2"] = {"name": "Bob", "role": "user"}
|
||||
with kanta.transaction(action="update", user="u1") as data:
|
||||
data.users["u2"]["role"] = "editor"
|
||||
data.counter = 1
|
||||
|
||||
with kanta_v0.transaction(action="update", user="u1") as data:
|
||||
data.users["u2"]["role"] = "editor"
|
||||
data.counter = 1
|
||||
with kanta.transaction(action="delete", user="u1") as data:
|
||||
del data.users["u2"]
|
||||
|
||||
with kanta_v0.transaction(action="delete", user="u1") as data:
|
||||
del data.users["u2"]
|
||||
# Display-only extra string, appended after the action.
|
||||
with kanta.transaction(action="export", user="u1", extra=filename.name) as data:
|
||||
data.counter = 2
|
||||
|
||||
# Display-only extra string, appended after the action.
|
||||
with kanta_v0.transaction(action="export", user="u1", extra=DB.name) as data:
|
||||
data.counter = 2
|
||||
# Compact logging, diff only: a system fix stamped by the clock, but the
|
||||
# modification time (m) is not updated.
|
||||
with kanta.transaction(
|
||||
action="repair", mtime=False, log={"header": False, "diff": True}
|
||||
) as data:
|
||||
data.users["u3"] = {"name": "Carol", "role": "user"}
|
||||
|
||||
# Compact logging, diff only: a system fix stamped by the clock, but the
|
||||
# modification time (m) is not updated.
|
||||
with kanta_v0.transaction(
|
||||
action="repair", mtime=False, log={"header": False, "diff": True}
|
||||
) as data:
|
||||
data.users["u3"] = {"name": "Carol", "role": "user"}
|
||||
# A failing transaction rolls back and logs a warning.
|
||||
try:
|
||||
with kanta.transaction(action="reset", user="u1") as data:
|
||||
data.counter = 99
|
||||
raise ValueError("simulated failure")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# A failing transaction rolls back and logs a warning.
|
||||
try:
|
||||
with kanta_v0.transaction(action="reset", user="u1") as data:
|
||||
data.counter = 99
|
||||
raise ValueError("simulated failure")
|
||||
except ValueError:
|
||||
pass
|
||||
# Compact logging, header only.
|
||||
with kanta.transaction(
|
||||
action="import", user="u1", log={"header": True, "diff": False}
|
||||
) as data:
|
||||
data.counter = 3
|
||||
|
||||
# Compact logging, header only.
|
||||
with kanta_v0.transaction(
|
||||
action="import", user="u1", log={"header": True, "diff": False}
|
||||
) as data:
|
||||
data.counter = 3
|
||||
section("A later version of our application with new data model and migrations")
|
||||
async with kanta_v1 as kanta:
|
||||
with kanta.transaction(action="update", user="u1", extra=filename.name) as data:
|
||||
data.total = 4
|
||||
|
||||
await kanta_v0.close()
|
||||
|
||||
section("Reopen with migrations and a custom log header")
|
||||
await kanta_v1.open()
|
||||
|
||||
# No target given: defaults to the database filename.
|
||||
with kanta_v1.transaction(
|
||||
action="update", user="u1", extra={"session_id": 3}
|
||||
) as data:
|
||||
data.settings["theme"] = "light"
|
||||
|
||||
with kanta_v1.transaction(
|
||||
action="update",
|
||||
user="u3",
|
||||
extra={"session_id": 7, "target": "settings (demo)"},
|
||||
) as data:
|
||||
data.settings["lang"] = "en"
|
||||
|
||||
await kanta_v1.close()
|
||||
|
||||
# The pretty names only exist in the logs; the database stores raw ids.
|
||||
section("Raw database records (user ids and timestamps, not pretty names)")
|
||||
print(DB.read_text(), end="", flush=True)
|
||||
with kanta.transaction(action="create", user="u3", extra="Dave (u4)") as data:
|
||||
data.users["u4"] = {"name": "Dave", "role": "user"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+14
-19
@@ -212,33 +212,28 @@ def resolve_user_key(value: str) -> str | None:
|
||||
|
||||
- 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 display-only metadata that is
|
||||
used for logging and is never persisted in the `ChangeRecord`:
|
||||
- a string is appended literally after the action in the default header,
|
||||
- a dict is passed to a registered `@kanta.logheader` callback; if it has
|
||||
no `"target"` key, the database filename is inserted as the target.
|
||||
- Register a `@kanta.logheader` callback to replace the entire header line.
|
||||
It may declare `action: str`, `user: str | None` and `extra: dict | None`
|
||||
parameters, and can also have `DictPre`/`DictPost` state dicts and the
|
||||
`Kanta` instance injected. It must be synchronous and return `str | None`.
|
||||
- `kanta.transaction(..., extra="...")` accepts a display-only string that is
|
||||
appended after the action in the default header (colored by Kanta); it is
|
||||
never persisted in the `ChangeRecord`.
|
||||
- Register a `@kanta.logheader` callback to compose a custom header. Declare
|
||||
any of `action: str`, `user: str`, `extra: str`: Kanta passes the parts
|
||||
with its header colors already applied (missing `user`/`extra` as empty
|
||||
strings), so callbacks only arrange text — no color codes, fallbacks, or
|
||||
padding. `DictPre`/`DictPost` state dicts and the `Kanta` instance can
|
||||
also be injected. The callback must be synchronous and return `str | None`.
|
||||
- Multiple logheader callbacks are stacked in registration order; the first
|
||||
callback to return a non-`None` result wins. If all return `None`, Kanta
|
||||
falls back to the default header. The `user` value passed to the callback
|
||||
has already been through the `logfmt` formatters.
|
||||
falls back to the default header. The `user` part has already been through
|
||||
the `logfmt` formatters.
|
||||
- The header and diff parts can be toggled independently per transaction:
|
||||
`kanta.transaction(..., log={"header": True, "diff": False})`.
|
||||
|
||||
```python
|
||||
@kanta.logheader
|
||||
def format_header(action: str, user: str | None, extra: dict | None) -> str:
|
||||
session = extra.get("session_id", "-")
|
||||
return f"{user:<20} {session:>2} {action} {extra['target']}"
|
||||
def format_header(action: str, user: str, extra: str) -> str:
|
||||
return f"{user} {action} {extra}"
|
||||
|
||||
with kanta.transaction(
|
||||
action="update",
|
||||
user="alice",
|
||||
extra={"session_id": 3, "target": "Project Name (abcd1234)"},
|
||||
) as data:
|
||||
with kanta.transaction(action="update", user="alice", extra="Project X") as data:
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user