Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dc2f0648e |
@@ -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.
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -133,7 +133,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=...)`
|
||||||
@@ -146,6 +150,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.
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -129,7 +129,9 @@ class LockedFile:
|
|||||||
else:
|
else:
|
||||||
self._open_unix(path, create, readonly)
|
self._open_unix(path, create, readonly)
|
||||||
|
|
||||||
def open_and_read(self, path: Path, create: bool = False, readonly: bool = False) -> bytes:
|
def open_and_read(
|
||||||
|
self, path: Path, create: bool = False, readonly: bool = False
|
||||||
|
) -> bytes:
|
||||||
"""Open *path* and read all content.
|
"""Open *path* and read all content.
|
||||||
|
|
||||||
Combined operation for efficient use with asyncio.to_thread().
|
Combined operation for efficient use with asyncio.to_thread().
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType, SimpleNamespace
|
from types import ModuleType, SimpleNamespace
|
||||||
from typing import Any, Generic, TypeVar
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
from kanta.kantaimpl import KantaImpl
|
from kanta.kantaimpl import KantaImpl
|
||||||
from kanta.serialization import JsonSerializer, Serializer
|
from kanta.serialization import JsonSerializer, Serializer
|
||||||
|
|||||||
+8
-5
@@ -172,19 +172,22 @@ class KantaImpl(PersistenceMixin, Generic[T]):
|
|||||||
db_path=self.filename,
|
db_path=self.filename,
|
||||||
action="open",
|
action="open",
|
||||||
)
|
)
|
||||||
elif self.callback_registry.has("bootstrap"):
|
else:
|
||||||
try:
|
try:
|
||||||
await self.callback_registry.invoke(
|
if self.callback_registry.has("bootstrap"):
|
||||||
"bootstrap",
|
await self.callback_registry.invoke(
|
||||||
InjectionContext(data=self.data, kanta=self._kanta),
|
"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(
|
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,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.file.close()
|
self.file.close()
|
||||||
|
|||||||
@@ -109,6 +109,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).
|
||||||
|
|
||||||
@@ -121,9 +122,11 @@ 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)
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
@@ -137,7 +140,7 @@ class PersistenceMixin:
|
|||||||
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)
|
diff = compute_diff(self.statedict, current)
|
||||||
if not diff:
|
if not diff and not force:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
record = ChangeRecord(
|
record = ChangeRecord(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .support import (
|
|||||||
change_actions,
|
change_actions,
|
||||||
fixed_change,
|
fixed_change,
|
||||||
make_kanta,
|
make_kanta,
|
||||||
|
read_changes,
|
||||||
seed_single_change,
|
seed_single_change,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,6 +31,44 @@ 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
|
@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"
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user