From e82a226f91ef4f6692bfd44627ebb84db9f862a0 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Fri, 24 Jul 2026 15:31:06 +0800 Subject: [PATCH] core/tools: serialize domain objects on PersistentDataView commit --- src/plyngent/tools/view.py | 111 +++++++++++++++++++++------------- tests/test_tools/test_view.py | 21 ++++++- 2 files changed, 89 insertions(+), 43 deletions(-) diff --git a/src/plyngent/tools/view.py b/src/plyngent/tools/view.py index a8e814d..b2cd86a 100644 --- a/src/plyngent/tools/view.py +++ b/src/plyngent/tools/view.py @@ -94,23 +94,6 @@ class PersistentDataView[T](AbstractAsyncContextManager["PersistentDataView[T]"] return None return current - def _ensure_parent(self, root: object) -> object: - """Ensure dict parents along path exist; return parent container for last key.""" - if not self._path: - return root - current: object = root - for key in self._path[:-1]: - if not isinstance(current, dict): - msg = f"cannot navigate non-dict parent at {key!r}" - raise TypeError(msg) - mapping = cast("dict[object, object]", current) - child: object | None = mapping.get(key) - if child is None: - child = cast("object", {}) - mapping[key] = child - current = child - return current - def __getitem__(self, key: str | int) -> PersistentDataView[Any]: child_path = (*self._path, key) cached = self._child_cache.get(child_path) @@ -142,24 +125,70 @@ class PersistentDataView[T](AbstractAsyncContextManager["PersistentDataView[T]"] def store(self, value: T) -> None: txn = self._require_txn() self._domain[self._path] = value - if not self._path: - txn.root = value - else: - parent = self._ensure_parent(txn.root) - last = self._path[-1] - if isinstance(parent, dict): - cast("dict[object, object]", parent)[last] = value - elif isinstance(parent, list) and isinstance(last, int): - lst = cast("list[object]", parent) - while len(lst) <= last: - lst.append(None) - lst[last] = value - else: - msg = f"cannot store at path {self._path!r}" - raise TypeError(msg) + self._write_at_path(txn, self._path, value) txn.dirty = True + def _write_at_path(self, txn: _TxnState, path: tuple[str | int, ...], value: object) -> None: + """Write *value* into the txn buffer at *path* (may be a live domain object).""" + if not path: + txn.root = value + return + parent = self._ensure_parent_for(txn.root, path) + last = path[-1] + if isinstance(parent, dict): + cast("dict[object, object]", parent)[last] = value + elif isinstance(parent, list) and isinstance(last, int): + lst = cast("list[object]", parent) + while len(lst) <= last: + lst.append(None) + lst[last] = value + else: + msg = f"cannot store at path {path!r}" + raise TypeError(msg) + + def _ensure_parent_for(self, root: object, path: tuple[str | int, ...]) -> object: + """Ensure dict parents along *path* exist; return parent container for last key.""" + if not path: + return root + current: object = root + for key in path[:-1]: + if not isinstance(current, dict): + msg = f"cannot navigate non-dict parent at {key!r}" + raise TypeError(msg) + mapping = cast("dict[object, object]", current) + child: object | None = mapping.get(key) + if child is None: + child = cast("object", {}) + mapping[key] = child + current = child + return current + + @staticmethod + def _serialize_value(value: object) -> object: + """Prefer domain ``to_raw()`` so stores receive JSON-ish trees, not live objects.""" + to_raw = getattr(value, "to_raw", None) + if callable(to_raw): + return to_raw() + return value + + def _flush_domain(self, txn: _TxnState) -> None: + """Rewrite domain objects into the buffer in serializable form before store.""" + for path, value in self._domain.items(): + self._write_at_path(txn, path, self._serialize_value(value)) + def _materialize_domain(self, typ: type[Any], current: object) -> object: + if current is not None and isinstance(current, typ): + self._domain[self._path] = current + return current + + # Hybrid domain objects (e.g. TodoStack): reconstruct from durable raw. + from_raw = getattr(typ, "from_raw", None) + if current is not None and callable(from_raw): + converted: object = from_raw(current) + self._domain[self._path] = converted + self.store(cast("T", converted)) + return converted + ctor = cast("Any", typ) if current is None: try: @@ -170,15 +199,13 @@ class PersistentDataView[T](AbstractAsyncContextManager["PersistentDataView[T]"] if created is not None: self.store(cast("T", created)) return created - if not isinstance(current, typ): - try: - converted: object = ctor(current) - except TypeError: - converted = current - self._domain[self._path] = converted - self.store(cast("T", converted)) - return converted - return current + try: + converted = ctor(current) + except TypeError: + converted = current + self._domain[self._path] = converted + self.store(cast("T", converted)) + return converted @overload def typed(self, typ: None = None) -> T: ... @@ -211,6 +238,7 @@ class PersistentDataView[T](AbstractAsyncContextManager["PersistentDataView[T]"] """Flush the root buffer to the store without closing the txn.""" txn = self._require_txn() if txn.dirty: + self._flush_domain(txn) await self._store.store(txn.root) txn.dirty = False @@ -249,6 +277,7 @@ class PersistentDataView[T](AbstractAsyncContextManager["PersistentDataView[T]"] # Root exit try: if exc_type is None and txn.dirty: + self._flush_domain(txn) await self._store.store(txn.root) finally: self._txn = None diff --git a/tests/test_tools/test_view.py b/tests/test_tools/test_view.py index 17b109a..c92989e 100644 --- a/tests/test_tools/test_view.py +++ b/tests/test_tools/test_view.py @@ -29,7 +29,7 @@ async def test_view_rollback_on_error() -> None: assert await store.load() == {"keep": 1} -async def test_typed_todo_stack() -> None: +async def test_typed_todo_stack_commits_raw() -> None: store = MemoryViewStore({}) root: PersistentDataView[dict[str, object]] = PersistentDataView(store, bound_type=dict) async with root as data: @@ -38,7 +38,24 @@ async def test_typed_todo_stack() -> None: assert todo.depth == 1 loaded = await store.load() assert isinstance(loaded, dict) - assert isinstance(loaded.get("todo"), TodoStack) + raw_todo = loaded.get("todo") + assert isinstance(raw_todo, dict) + assert "groups" in raw_todo + restored = TodoStack.from_raw(raw_todo) + assert restored.depth == 1 + assert [i.title for i in restored.groups[0].items] == ["A", "B"] + + +async def test_typed_todo_stack_roundtrip_from_raw() -> None: + store = MemoryViewStore({"todo": {"groups": [], "next_id": 1}}) + root: PersistentDataView[dict[str, object]] = PersistentDataView(store, bound_type=dict) + async with root as data: + todo = data["todo"].typed(TodoStack) + _ = todo.push_group(["only"]) + loaded = await store.load() + assert isinstance(loaded, dict) + again = TodoStack.from_raw(loaded["todo"]) + assert [i.title for i in again.all_items()] == ["only"] async def test_mutate_outside_txn_errors() -> None: