From 61882f1a6f6d0043414f5bbb385ff2110959727e Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:42:56 +0000 Subject: [PATCH 1/2] fix: ops functions write through the wrapper's own __setitem__/__delitem__ (#3) The free functions in cosmodol.ops now perform the plain mapping write on the caller's store, with a one-shot ContextVar redirect (cosmodol.routing) telling the leaf how to execute the single write that reaches it. Every layer's write logic runs: write guards, preset hooks, write-through caches, and filt_iter scopes on keys an operation creates. Swallowed/deferred/multiplied writes raise RedirectError. batch_items patch on a wrapped or subclassed store needs unguarded_patch=True. Closes #3 Co-Authored-By: Claude Opus 5.5 --- cosmodol/ops.py | 258 ++++++++++++++++++++---------- cosmodol/routing.py | 92 +++++++++++ cosmodol/stores.py | 5 + cosmodol/tests/test_ops.py | 15 +- cosmodol/tests/test_ops_layers.py | 204 +++++++++++++++++++++++ cosmodol/trees.py | 9 ++ misc/docs/architecture.md | 2 + 7 files changed, 499 insertions(+), 86 deletions(-) create mode 100644 cosmodol/routing.py create mode 100644 cosmodol/tests/test_ops_layers.py diff --git a/cosmodol/ops.py b/cosmodol/ops.py index 44bcbe3..8cdddf3 100644 --- a/cosmodol/ops.py +++ b/cosmodol/ops.py @@ -23,26 +23,36 @@ destructive ones become free functions). The removed methods now raise an ``AttributeError`` that names their replacement here. -What a function can and cannot honour, stated rather than hidden (ADR-0011 D3a): - -- **Key mapping** — honoured. Keys are resolved with ``dol.inner_most_key``, i.e. each - wrapping layer's ``_id_of_key`` (``wrap_kvs``, ``KeyCodecs``, ``Pipe``, class- and - instance-wraps alike). A hand-rolled ``__getattr__`` passthrough layer can make it - apply a transform twice. -- **Scope** — honoured for operations on *existing* resources. On a wrapped store, - ``replace_item``, ``batch_items``'s read/replace/patch/delete, and the forced - ``delete_container`` / ``delete_database`` first check ``k in store`` through the - outer store, so a ``filt_iter`` scope (or any wrapper whose ``__contains__`` says no) - stops them. That is one extra point read per key. Operations that *create* (batch - ``create``/``upsert``, ``add_container``, ``add_database``) cannot ask a filter, since - the key does not exist yet: they honour the key mapping only. -- **Values** — ``replace_item`` and ``batch_items`` pass values through each layer's - ``_data_of_obj`` (what ``wrap_kvs(data_of_obj=...)`` and ``ValueCodecs`` install). A - key-aware ``preset=`` write hook is **not** followed (it only runs inside - ``__setitem__``), and returned documents are in stored (leaf) form. -- **Guards and caches** — not seen. A layer that refuses writes in its own - ``__setitem__``/``__delitem__``, or caches values, is bypassed by any function, - exactly as by the old methods. Pass such a store only if you mean to bypass it. +How a function honours the wrapper (i2mint/cosmodol#3): every write goes through the +caller's store itself. A function performs the plain mapping write (``store[k] = v`` or +``del store[k]``) on the outer store, after telling the cosmodol leaf, through +``cosmodol.routing``, how to execute the one write that reaches it (a conditional replace, +a batch entry, a cascade, a create). So each layer runs exactly as it does for that +mapping write: + +- **Key and value mapping**, ``preset=`` hooks, ``ValueCodecs``: applied by the layers + themselves, class- and instance-wraps alike. +- **Write guards**: a layer whose ``__setitem__``/``__delitem__`` refuses (a read-only + wrapper, ``dol.disable_setitem``) refuses the operation too. +- **Scope**: a ``filt_iter`` scope refuses out-of-scope keys in its ``__setitem__`` / + ``__delitem__``, now including keys an operation *creates* (batch ``create``/``upsert``, + ``add_container``, ``add_database``). Operations on *existing* resources also check + ``k in store`` through the outer store first (one point read), for wrappers that scope + only by ``__contains__``. +- **Write-through caches** see the value they would see for ``store[k] = v``. A cache + that only fills on reads (``dol.cache_vals``) is stale after any write, mapping or not. +- **A layer that swallows, defers or multiplies the write** makes the function raise + ``cosmodol.routing.RedirectError`` rather than guess. + +What remains outside a function's reach, stated rather than hidden (s3dol ADR-0011 D3a): + +- ``batch_items`` collects its writes through the layers first and commits them as one + transaction afterwards: a layer that acts after the inner write returns (a write-through + cache, an audit log) has acted even if the transaction then fails. +- ``batch_items``'s ``patch`` operation has no mapping-write equivalent, so no layer's + write logic can run for it; on a wrapped (or subclassed) store it is refused unless + ``unguarded_patch=True``. Its key is still mapped and scope-checked. +- Returned documents (``replace_item``, batch results) are in stored (leaf) form. """ from __future__ import annotations @@ -54,9 +64,9 @@ from azure.cosmos import PartitionKey from azure.cosmos.partition_key import NonePartitionKeyValue from dol import inner_most_key -from dol.dig import inner_most from cosmodol.base import batch as _batch, point_replace +from cosmodol.routing import RedirectError, redirect from cosmodol.errors import ( ContainerNotFoundError, DatabaseNotFoundError, @@ -100,19 +110,38 @@ def _leaf_key(store, leaf, k): return inner_most_key(store, k) -def _existing_leaf_key(store, leaf, k, not_found: type[KeyError]): - """``_leaf_key``, after checking ``k in store`` through every wrapping layer, so a - scope filter around the store is honoured. Unwrapped stores skip the round-trip +def _require_member(store, leaf, k, not_found: type[KeyError]) -> None: + """Check ``k in store`` through every wrapping layer, so a scope that a wrapper + expresses only in ``__contains__`` is honoured. Unwrapped stores skip the round-trip (the operation itself fails on a missing resource).""" if store is not leaf and k not in store: raise not_found(k) - return _leaf_key(store, leaf, k) -def _leaf_value(store, leaf, v): - if store is leaf: - return v - return inner_most(store, v, "_data_of_obj", default=v) +def _routed(store, leaf, method: str, handler: Callable, k, *v, what: str): + """``store[k] = v`` (or ``del store[k]`` when no ``v``) through every wrapping layer, + with the one resulting ``leaf.`` call handed to ``handler`` (which receives the + leaf's arguments: ``(k, v)`` or ``(k,)``, fully mapped). Returns what it returned.""" + with redirect(leaf, method, handler) as r: + if v: + store[k] = v[0] + else: + del store[k] + if not r.results: + raise RedirectError( + f"{what}: the write of {k!r} did not reach the cosmodol store; a wrapping " + "layer swallowed or deferred it, so it was not performed as a " + what + "." + ) + return r.results[0] + + +def _write_logic_above(store, leaf, base: type) -> bool: + """Whether anything between the caller and ``base``'s own write methods could act + on a write: a wrapping layer, or a subclass overriding ``__setitem__``/``__delitem__``.""" + return store is not leaf or any( + getattr(type(leaf), m) is not getattr(base, m) + for m in ("__setitem__", "__delitem__") + ) # --------------------------------------------------------------------------- @@ -127,8 +156,9 @@ def replace_item(store, k, v, *, etag: Optional[str] = None) -> dict: does not exist, and with a precondition error if ``etag`` no longer matches. Args: - store: A ``CosmosItems``, or any ``dol`` wrapper around one. ``k`` and ``v`` are in - the *caller's* key and value space; they are mapped down to the leaf. + store: A ``CosmosItems``, or any ``dol`` wrapper around one. The write goes through + ``store[k] = v``, so every layer maps, checks or refuses it as usual; only the + leaf's upsert becomes a replace. k: The key, as ``store[k]`` would take it. v: The value, as ``store[k] = v`` would take it. etag: If given, only replace when the stored item's ``_etag`` still equals it. @@ -138,13 +168,17 @@ def replace_item(store, k, v, *, etag: Optional[str] = None) -> dict: leaf strips them). """ leaf = _leaf_of(store, CosmosItems, fn="replace_item") - k = _existing_leaf_key(store, leaf, k, ItemNotFoundError) - body = leaf._prepare_body(k, _leaf_value(store, leaf, v)) - item, hdrs = point_replace( - leaf.container, k, body, leaf.partition_key_value, etag=etag - ) - leaf._observe("replace", hdrs) - return leaf._maybe_strip(item) + _require_member(store, leaf, k, ItemNotFoundError) + + def replace(leaf_k, leaf_v): + body = leaf._prepare_body(leaf_k, leaf_v) + item, hdrs = point_replace( + leaf.container, leaf_k, body, leaf.partition_key_value, etag=etag + ) + leaf._observe("replace", hdrs) + return leaf._maybe_strip(item) + + return _routed(store, leaf, "__setitem__", replace, k, v, what="replace_item") #: Operations ``batch_items`` accepts, and whether each carries a value. @@ -158,7 +192,9 @@ def replace_item(store, k, v, *, etag: Optional[str] = None) -> dict: } -def batch_items(store, operations: Iterable[tuple]) -> list[dict]: +def batch_items( + store, operations: Iterable[tuple], *, unguarded_patch: bool = False +) -> list[dict]: """Transactional batch within the store's partition, in the caller's key space. Each operation is ``(op, key)`` or ``(op, key, value)`` (``op`` one of @@ -167,16 +203,29 @@ def batch_items(store, operations: Iterable[tuple]) -> list[dict]: [("upsert", "a", {"v": 1}), ("replace", "b", {"v": 2}), ("delete", "c")] - Keys (and values, except ``patch`` operation lists) are mapped down to the leaf exactly - as ``replace_item`` maps them, and bodies get the leaf's usual ``id`` / - partition-key injection. On a wrapped store, every key a ``read``, ``replace``, - ``patch`` or ``delete`` targets must be in the store (one point read each), unless an - earlier ``create``/``upsert`` of the same batch makes it. The SDK's raw + Each ``create``/``upsert``/``replace`` is collected through ``store[k] = v`` and each + ``delete`` through ``del store[k]``, so every layer maps, checks or refuses it as + usual; nothing is sent until all operations are collected, then they commit as one + transaction. Bodies get the leaf's usual ``id`` / partition-key injection. On a + wrapped store, every key a ``read``, ``replace``, ``patch`` or ``delete`` targets must + be in the store (one point read each), unless an earlier ``create``/``upsert`` of the + same batch makes it. ``read`` and ``patch`` keys are mapped with each layer's + ``_id_of_key``; ``patch`` operations are refused on a wrapped or subclassed store + unless ``unguarded_patch=True`` (no layer's write logic can run for a patch). The SDK's raw ``(op, args, kwargs)`` triples are not accepted here, because the ids inside them are opaque to any key mapping; use ``cosmodol.batch(container, triples, partition_key)``. """ leaf = _leaf_of(store, CosmosItems, fn="batch_items") triples, created = [], set() + + def collect_write(leaf_k, leaf_v): + return leaf_k, leaf._prepare_body(leaf_k, leaf_v) + + def collect_key(leaf_k): + if leaf.strict_keys: + validate_cosmos_id(leaf_k) + return leaf_k + for operation in operations: op, k, *rest = operation if op not in BATCH_OPS: @@ -198,20 +247,29 @@ def batch_items(store, operations: Iterable[tuple]) -> list[dict]: raise ValueError( f"Batch op {op!r} takes {shape}[, kwargs]; got {operation!r}." ) + if op == "patch" and not unguarded_patch and _write_logic_above( + store, leaf, CosmosItems + ): + raise TypeError( + "batch_items refused a 'patch': this store is wrapped (or subclassed), " + "and no wrapping layer's write logic (guards, hooks, caches) can run for " + "a patch. Pass unguarded_patch=True to send it anyway." + ) if op in ("create", "upsert"): created.add(k) - k = _leaf_key(store, leaf, k) - elif k in created: - k = _leaf_key(store, leaf, k) - else: - k = _existing_leaf_key(store, leaf, k, ItemNotFoundError) + elif k not in created: + _require_member(store, leaf, k, ItemNotFoundError) + what = f"batch_items {op!r}" if op in ("create", "upsert", "replace"): - body = leaf._prepare_body(k, _leaf_value(store, leaf, rest[0])) - args = (body,) if op != "replace" else (k, body) + leaf_k, body = _routed( + store, leaf, "__setitem__", collect_write, k, rest[0], what=what + ) + args = (body,) if op != "replace" else (leaf_k, body) + elif op == "delete": + args = (_routed(store, leaf, "__delitem__", collect_key, k, what=what),) else: - if leaf.strict_keys: - validate_cosmos_id(k) - args = (k, rest[0]) if op == "patch" else (k,) + leaf_k = collect_key(_leaf_key(store, leaf, k)) + args = (leaf_k, rest[0]) if op == "patch" else (leaf_k,) triples.append((op, args, kwargs)) return _batch(leaf.container, triples, leaf.partition_key_value) @@ -273,6 +331,10 @@ def add_container( ): """Create a container named ``name`` (in the caller's key space) in ``db_store``. + Goes through ``db_store[name] = ``, so every layer maps, scopes or + refuses the name as usual; the leaf creates the container from the arguments given + here (the value a layer passes down is ignored). + Args: db_store: A ``CosmosDatabase``, or a ``dol`` wrapper around one. name: Container name, as ``db_store[name]`` would take it. @@ -288,8 +350,7 @@ def add_container( The SDK's ``ContainerProxy``. """ leaf = _leaf_of(db_store, CosmosDatabase, fn="add_container") - return leaf.database.create_container( - id=_leaf_key(db_store, leaf, name), + settings = dict( partition_key=PartitionKey(path=partition_key_path), offer_throughput=throughput, indexing_policy=indexing_policy, @@ -299,6 +360,13 @@ def add_container( **extra, ) + def create(leaf_name, _settings): + return leaf.database.create_container(id=leaf_name, **settings) + + return _routed( + db_store, leaf, "__setitem__", create, name, settings, what="add_container" + ) + def _partition_key_expr(container) -> Optional[str]: """Cosmos SQL expression for the container's partition key: ``c["tenant"]``, or an @@ -318,28 +386,35 @@ def delete_container(db_store, name: str, *, force: bool = False) -> None: Without ``force`` this is ``del db_store[name]``, which refuses a non-empty container. With ``force=True`` every item is deleted, one point delete each, and then the - container is dropped. Not recoverable without a Cosmos backup. + container is dropped, through ``del db_store[name]`` so every layer maps, scopes or + refuses it as usual. Not recoverable without a Cosmos backup. """ leaf = _leaf_of(db_store, CosmosDatabase, fn="delete_container") if not force: del db_store[name] return - k = _existing_leaf_key(db_store, leaf, name, ContainerNotFoundError) - try: - c = leaf.database.get_container_client(k) - pk_expr = _partition_key_expr(c) - except Exception as e: - raise ContainerNotFoundError(k) from e - select = ( - "SELECT c.id FROM c" - if pk_expr is None - else (f"SELECT c.id, {pk_expr} AS pk FROM c") + _require_member(db_store, leaf, name, ContainerNotFoundError) + + def cascade(k): + try: + c = leaf.database.get_container_client(k) + pk_expr = _partition_key_expr(c) + except Exception as e: + raise ContainerNotFoundError(k) from e + select = ( + "SELECT c.id FROM c" + if pk_expr is None + else (f"SELECT c.id, {pk_expr} AS pk FROM c") + ) + for item in c.query_items(query=select, enable_cross_partition_query=True): + # A projection omits an undefined value: the item has no partition-key property. + pk = item["pk"] if "pk" in item else NonePartitionKeyValue + c.delete_item(item=item["id"], partition_key=pk) + leaf.database.delete_container(k) + + _routed( + db_store, leaf, "__delitem__", cascade, name, what="delete_container(force=True)" ) - for item in c.query_items(query=select, enable_cross_partition_query=True): - # A projection omits an undefined value: the item has no partition-key property. - pk = item["pk"] if "pk" in item else NonePartitionKeyValue - c.delete_item(item=item["id"], partition_key=pk) - leaf.database.delete_container(k) def add_database( @@ -347,13 +422,19 @@ def add_database( ): """Create database ``name`` (in the caller's key space). + Goes through ``account_store[name] = `` (see ``add_container``). Returns the leaf's value for it (its ``database_factory`` output, a ``CosmosDatabase`` by default), not ``account_store[name]``: a wrapper that does not expose the new name would otherwise raise after the database was created. """ leaf = _leaf_of(account_store, CosmosAccount, fn="add_database") - db = leaf.client.create_database( - id=_leaf_key(account_store, leaf, name), offer_throughput=throughput, **extra + settings = dict(offer_throughput=throughput, **extra) + + def create(leaf_name, _settings): + return leaf.client.create_database(id=leaf_name, **settings) + + db = _routed( + account_store, leaf, "__setitem__", create, name, settings, what="add_database" ) return leaf.database_factory(db) @@ -368,15 +449,26 @@ def delete_database(account_store, name: str, *, force: bool = False) -> None: if not force: del account_store[name] return - k = _existing_leaf_key(account_store, leaf, name, DatabaseNotFoundError) - try: - db = leaf.client.get_database_client(k) - db.read() - except Exception as e: - raise DatabaseNotFoundError(k) from e - for c in db.list_containers(): - db.delete_container(c["id"]) - leaf.client.delete_database(k) + _require_member(account_store, leaf, name, DatabaseNotFoundError) + + def cascade(k): + try: + db = leaf.client.get_database_client(k) + db.read() + except Exception as e: + raise DatabaseNotFoundError(k) from e + for c in db.list_containers(): + db.delete_container(c["id"]) + leaf.client.delete_database(k) + + _routed( + account_store, + leaf, + "__delitem__", + cascade, + name, + what="delete_database(force=True)", + ) __all__ = [ diff --git a/cosmodol/routing.py b/cosmodol/routing.py new file mode 100644 index 0000000..c8e614d --- /dev/null +++ b/cosmodol/routing.py @@ -0,0 +1,92 @@ +"""Route an operation through a wrapped store's own ``__setitem__``/``__delitem__``. + +The functions in ``cosmodol.ops`` need Cosmos semantics a mapping write does not have (a +conditional replace, a transactional batch, a cascading delete, a parameterised create). +Calling the leaf directly skips every wrapping layer's own write logic: write guards, +write-through caches, ``preset=`` hooks, scope filters on new keys (i2mint/cosmodol#3). +Instead, an operation *redirects* the leaf: it installs a one-shot handler for one leaf +method, then performs the plain mapping write (``store[k] = v`` / ``del store[k]``) on the +caller's outer store. Every layer runs exactly as for that write; when the call reaches the +leaf, the leaf hands its (fully mapped) arguments to the handler instead of doing its usual +upsert or delete. + +>>> class Leaf(dict): +... def __setitem__(self, k, v): +... if (handler := claim(self, "__setitem__")) is not None: +... return handler(k, v) +... super().__setitem__(k, v) +>>> leaf = Leaf() +>>> with redirect(leaf, "__setitem__", lambda k, v: f"handled {k}={v}") as r: +... leaf["a"] = 1 +>>> r.results, dict(leaf) +(['handled a=1'], {}) + +The redirect is held in a ``ContextVar``, so it is invisible to other threads and tasks, and +it only applies to the leaf object it names. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from typing import Any, Callable, Iterator, Optional + + +class RedirectError(RuntimeError): + """A wrapping layer did not turn one mapping write into exactly one leaf write.""" + + +@dataclass +class Redirect: + """One pending redirect of ``method`` on ``leaf`` to ``handler``. + + ``results`` holds what the handler returned, one entry per leaf call (so after a + redirect, ``len(results)`` says whether the write reached the leaf). + """ + + leaf: Any + method: str + handler: Callable + results: list = field(default_factory=list) + + +_active: ContextVar[Optional[Redirect]] = ContextVar("cosmodol_redirect", default=None) + + +@contextmanager +def redirect(leaf, method: str, handler: Callable) -> Iterator[Redirect]: + """Within the block, the first call of ``leaf.`` goes to ``handler``. + + A second call of the same method on the same leaf within the block raises + ``RedirectError``: one mapping write was expected to reach the leaf once. + """ + r = Redirect(leaf, method, handler) + token = _active.set(r) + try: + yield r + finally: + _active.reset(token) + + +def claim(leaf, method: str) -> Optional[Callable]: + """The handler redirecting ``leaf.`` right now, or ``None``. + + Called by a leaf at the top of the method. The returned callable runs the handler and + records its result. + """ + r = _active.get() + if r is None or r.leaf is not leaf or r.method != method: + return None + if r.results: + raise RedirectError( + f"A wrapping layer turned one write into several {method} calls on the " + "cosmodol store; refusing the extra one. The first was already performed." + ) + + def run(*args): + r.results.append(None) # claimed, even if the handler raises + r.results[-1] = r.handler(*args) + return r.results[-1] + + return run diff --git a/cosmodol/stores.py b/cosmodol/stores.py index b10c975..0943804 100644 --- a/cosmodol/stores.py +++ b/cosmodol/stores.py @@ -28,6 +28,7 @@ batch as _batch, ) from cosmodol.connection import CosmosConnection +from cosmodol.routing import claim from cosmodol.errors import ( KeyMismatchError, MovedToFunction, @@ -181,11 +182,15 @@ def __getitem__(self, k: str) -> dict: return self._maybe_strip(item) def __setitem__(self, k: str, v) -> None: + if (handler := claim(self, "__setitem__")) is not None: + return handler(k, v) # an ops function, routed through every wrapper body = self._prepare_body(k, v) _item, hdrs = point_upsert(self.container, body) self._observe("set", hdrs) def __delitem__(self, k: str) -> None: + if (handler := claim(self, "__delitem__")) is not None: + return handler(k) if self.strict_keys: validate_cosmos_id(k) hdrs = point_delete(self.container, k, self.partition_key_value) diff --git a/cosmodol/tests/test_ops.py b/cosmodol/tests/test_ops.py index 14b3d96..8411069 100644 --- a/cosmodol/tests/test_ops.py +++ b/cosmodol/tests/test_ops.py @@ -468,13 +468,22 @@ def test_scope_filter_stops_forced_container_delete(): assert sorted(db.containers) == ["app_events", "events"] -def test_add_database_does_not_raise_after_creating(): +def test_add_database_does_not_read_back_through_the_wrapper(): + acct, client = _account(True) + db = add_database(acct, "extra") + assert db.name == "app_extra" and "app_extra" in client.dbs + + +def test_add_database_refuses_a_name_outside_the_scope(): + # Was test_add_database_does_not_raise_after_creating: before #3 the out-of-scope + # database was created; now the scope's own __setitem__ refuses it first. from dol import filt_iter acct, client = _account(True) hidden = filt_iter(acct, filt=lambda k: k.startswith("x")) - db = add_database(hidden, "extra") - assert db.name == "app_extra" and "app_extra" in client.dbs + with pytest.raises(KeyError): + add_database(hidden, "extra") + assert "app_extra" not in client.dbs def test_forced_delete_handles_hierarchical_and_missing_partition_keys(): diff --git a/cosmodol/tests/test_ops_layers.py b/cosmodol/tests/test_ops_layers.py new file mode 100644 index 0000000..d57857a --- /dev/null +++ b/cosmodol/tests/test_ops_layers.py @@ -0,0 +1,204 @@ +"""Ops functions run every wrapping layer's own write logic (i2mint/cosmodol#3). + +Write guards, write-through caches, ``preset=`` hooks and scope filters on new keys all +live in a layer's ``__setitem__``/``__delitem__``; the functions in ``cosmodol.ops`` now +perform their writes through those methods (see ``cosmodol.routing``). +""" + +import pytest +from dol import KeyCodecs, Store, filt_iter, wrap_kvs +from dol.trans import mk_read_only + +from cosmodol import ( + CosmosDatabase, + CosmosItems, + add_container, + add_database, + batch_items, + delete_container, + delete_database, + replace_item, +) +from cosmodol.routing import RedirectError +from cosmodol.tests.test_ops import PK, _account, _container, _db, _state + + +def _leaf(c): + return CosmosItems(c, partition_key_value=PK, partition_key_path="/_pk") + + +class _ReadOnly(Store): + def __setitem__(self, k, v): + raise PermissionError("read-only") + + def __delitem__(self, k): + raise PermissionError("read-only") + + +class _WriteThrough(Store): + def __init__(self, store): + super().__init__(store) + self.cache = {} + + def __setitem__(self, k, v): + super().__setitem__(k, v) + self.cache[k] = v + + +class _Swallow(Store): + def __setitem__(self, k, v): + pass + + +class _Twice(Store): + def __setitem__(self, k, v): + super().__setitem__(k, v) + super().__setitem__(k + "_copy", v) + + +# --------------------------------------------------------------------------- +# Write guards +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "guarded", + [ + lambda c: (_ReadOnly(_leaf(c)), "app_doc1"), + lambda c: (KeyCodecs.prefixed("app_")(_ReadOnly(_leaf(c))), "doc1"), + lambda c: ( + mk_read_only(type("RO", (CosmosItems,), {}))( + c, partition_key_value=PK, partition_key_path="/_pk" + ), + "app_doc1", + ), + ], + ids=["layer", "layer-under-codec", "read-only-subclass"], +) +def test_write_guard_refuses_item_ops(guarded): + c = _container() + before = _state(c) + store, k = guarded(c) + with pytest.raises((PermissionError, ValueError)): + replace_item(store, k, {"payload": "X"}) + with pytest.raises((PermissionError, ValueError)): + batch_items(store, [("upsert", "new", {"payload": "N"})]) + with pytest.raises((PermissionError, ValueError)): + batch_items(store, [("delete", k)]) + assert _state(c) == before + + +def test_write_guard_refuses_tree_ops(): + raw = _db() + db = _ReadOnly(CosmosDatabase(raw)) + with pytest.raises(PermissionError): + add_container(db, "logs") + with pytest.raises(PermissionError): + delete_container(db, "events", force=True) + assert sorted(raw.containers) == ["app_events", "events"] and not raw.created + + acct, client = _account(False) + ro = _ReadOnly(acct) + with pytest.raises(PermissionError): + add_database(ro, "extra") + with pytest.raises(PermissionError): + delete_database(ro, "main", force=True) + assert sorted(client.dbs) == ["app_main", "main"] + + +# --------------------------------------------------------------------------- +# Hooks and caches +# --------------------------------------------------------------------------- + + +def test_preset_hook_runs_for_replace_and_batch(): + c = _container() + stamped = wrap_kvs(_leaf(c), preset=lambda k, v: {**v, "payload": f"{k}:{v['payload']}"}) + replace_item(stamped, "doc1", {"payload": "R"}) + batch_items(stamped, [("upsert", "new", {"payload": "N"})]) + assert _state(c)["doc1"] == "doc1:R" and _state(c)["new"] == "new:N" + + +def test_write_through_cache_sees_the_replace_and_not_a_failed_one(): + c = _container() + store = _WriteThrough(_leaf(c)) + replace_item(store, "doc1", {"payload": "R"}) + assert store.cache == {"doc1": {"payload": "R"}} + with pytest.raises(Exception): + replace_item(store, "doc1", {"payload": "stale"}, etag="not-the-etag") + assert store.cache == {"doc1": {"payload": "R"}} and _state(c)["doc1"] == "R" + + +# --------------------------------------------------------------------------- +# Scope on creation +# --------------------------------------------------------------------------- + + +def test_scope_filter_stops_creating_ops(): + c = _container() + scoped = filt_iter(_leaf(c), filt=lambda k: k.startswith("app_")) + before = _state(c) + with pytest.raises(KeyError): + batch_items( + scoped, + [("upsert", "app_ok", {"payload": "ok"}), ("create", "other", {"payload": "x"})], + ) + assert _state(c) == before # nothing was committed: the batch never left + + raw = _db() + db = filt_iter(CosmosDatabase(raw), filt=lambda k: k.startswith("app_")) + with pytest.raises(KeyError): + add_container(db, "logs") + add_container(db, "app_logs") + assert raw.created == ["app_logs"] + + +# --------------------------------------------------------------------------- +# Layers that do not turn one write into one leaf write +# --------------------------------------------------------------------------- + + +def test_a_swallowed_write_is_loud(): + c = _container() + before = _state(c) + with pytest.raises(RedirectError, match="did not reach"): + replace_item(_Swallow(_leaf(c)), "doc1", {"payload": "X"}) + with pytest.raises(RedirectError, match="did not reach"): + batch_items(_Swallow(_leaf(c)), [("upsert", "doc1", {"payload": "X"})]) + assert _state(c) == before + + +def test_a_multiplied_write_is_loud_and_a_batch_sends_nothing(): + c = _container() + before = _state(c) + with pytest.raises(RedirectError, match="several"): + batch_items(_Twice(_leaf(c)), [("upsert", "doc1", {"payload": "X"})]) + assert _state(c) == before + + +def test_no_redirect_outlives_its_operation(): + c = _container() + leaf = _leaf(c) + store = _WriteThrough(leaf) + with pytest.raises(Exception): + replace_item(store, "absent", {"payload": "X"}) + store["plain"] = {"payload": "P"} # an ordinary upsert again + del store["doc1"] + assert _state(c)["plain"] == "P" and "doc1" not in _state(c) + + +# --------------------------------------------------------------------------- +# Patch has no mapping-write equivalent +# --------------------------------------------------------------------------- + + +def test_patch_on_a_wrapped_store_needs_an_explicit_opt_in(): + c = _container() + store = KeyCodecs.prefixed("app_")(_leaf(c)) + ops = [("patch", "doc1", [{"op": "set", "path": "/payload", "value": "P"}])] + with pytest.raises(TypeError, match="unguarded_patch"): + batch_items(store, ops) + with pytest.raises(NotImplementedError, match="patch"): # reached the (fake) SDK + batch_items(store, ops, unguarded_patch=True) + with pytest.raises(NotImplementedError, match="patch"): # unwrapped: no opt-in needed + batch_items(_leaf(c), [("patch", "app_doc1", ops[0][2])]) diff --git a/cosmodol/trees.py b/cosmodol/trees.py index d2cc9c7..c1c21d2 100644 --- a/cosmodol/trees.py +++ b/cosmodol/trees.py @@ -18,6 +18,7 @@ from dol import KvReader from cosmodol.connection import CosmosConnection +from cosmodol.routing import claim from cosmodol.errors import ( ContainerNotEmptyError, ContainerNotFoundError, @@ -98,12 +99,16 @@ def __getitem__(self, k: str): return self.store_factory(c) def __setitem__(self, k, v): + if (handler := claim(self, "__setitem__")) is not None: + return handler(k, v) # cosmodol.add_container / add_database raise TypeError( "Container creation does not go through __setitem__ (too parameter-rich). " "Use `cosmodol.add_container(db_store, name, partition_key_path=..., ...)` instead." ) def __delitem__(self, k: str) -> None: + if (handler := claim(self, "__delitem__")) is not None: + return handler(k) # a forced cosmodol.delete_container / delete_database try: c = self.database.get_container_client(k) c.read() @@ -194,12 +199,16 @@ def __getitem__(self, k: str) -> CosmosDatabase: return self.database_factory(db) def __setitem__(self, k, v): + if (handler := claim(self, "__setitem__")) is not None: + return handler(k, v) # cosmodol.add_container / add_database raise TypeError( "Database creation does not go through __setitem__ (too parameter-rich). " "Use `cosmodol.add_database(account_store, name, throughput=...)` instead." ) def __delitem__(self, k: str) -> None: + if (handler := claim(self, "__delitem__")) is not None: + return handler(k) # a forced cosmodol.delete_container / delete_database try: db = self._client.get_database_client(k) db.read() diff --git a/misc/docs/architecture.md b/misc/docs/architecture.md index db066aa..fcf6090 100644 --- a/misc/docs/architecture.md +++ b/misc/docs/architecture.md @@ -235,6 +235,8 @@ items = CosmosItems( `dol` wraps by delegation, so a *method* of a wrapped store runs bound to the leaf and receives the caller's unmapped key ([#1](https://github.com/i2mint/cosmodol/issues/1), [dol#83](https://github.com/i2mint/dol/issues/83)). So the stores carry no public method that takes a key: `replace_item`, `batch_items`, `partition`, `add_container`, `delete_container`, `add_database` and `delete_database` live in `cosmodol.ops` (re-exported from `cosmodol`) and take the store first, resolving the key through every wrapping layer. The old method names raise an `AttributeError` naming their replacement. `query` stays a method: it takes SQL, and speaks raw Cosmos ids and documents on both ends. A conformance test (`tests/test_ops.py`) inventories every public method and fails on a new one. Same design as [s3dol ADR-0011](https://github.com/i2mint/s3dol/blob/master/misc/docs/decisions/0011-keyed-capability-surface.md). +Each function writes through the caller's store rather than to the leaf ([#3](https://github.com/i2mint/cosmodol/issues/3)): it performs the plain mapping write (`store[k] = v` or `del store[k]`) after installing, in `cosmodol.routing`, a one-shot handler that tells the leaf how to execute the one write reaching it (a conditional replace, a batch entry, a cascade, a create). So every layer's own write logic runs: write guards refuse, `preset=` hooks and write-through caches see the value, and a `filt_iter` scope refuses keys an operation would create. A layer that swallows, defers or multiplies the write makes the function raise `RedirectError`. Two limits remain, both documented in `cosmodol/ops.py`: `batch_items` collects its writes before committing them as one transaction, so a layer that acts after the inner write returns has acted even if the commit fails; and a batch `patch` has no mapping-write equivalent, so on a wrapped or subclassed store it needs `unguarded_patch=True`. + We do **not** overload `s[prefix]` for narrowing — Cosmos keys are flat strings and the prefix convention has no native server-side support. From a1e77565d036b947d7e3b22d9c9a39a55dc4405f Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:47:11 +0000 Subject: [PATCH 2/2] fix: pin each redirect to its expected leaf key; re-raise swallowed errors Adversarial review of #3: a layer that writes another key first (companion container, audit record) received the operation's semantics; now the leaf refuses any write but the expected (method, key). Errors a layer catches are re-raised; subclasses scoping by __contains__ are checked; messages no longer claim a deferred write was not performed. Co-Authored-By: Claude Opus 5.5 --- cosmodol/ops.py | 50 +++++++++++++------- cosmodol/routing.py | 76 +++++++++++++++++++++---------- cosmodol/stores.py | 4 +- cosmodol/tests/test_ops_layers.py | 66 ++++++++++++++++++++++++++- cosmodol/trees.py | 8 ++-- 5 files changed, 155 insertions(+), 49 deletions(-) diff --git a/cosmodol/ops.py b/cosmodol/ops.py index 8cdddf3..19d84f8 100644 --- a/cosmodol/ops.py +++ b/cosmodol/ops.py @@ -41,11 +41,17 @@ only by ``__contains__``. - **Write-through caches** see the value they would see for ``store[k] = v``. A cache that only fills on reads (``dol.cache_vals``) is stale after any write, mapping or not. -- **A layer that swallows, defers or multiplies the write** makes the function raise - ``cosmodol.routing.RedirectError`` rather than guess. +- **A layer that writes another key first** (an index, an audit record), **writes twice, + or swallows the write** makes the function raise ``cosmodol.routing.RedirectError``; + the leaf refuses any write but the expected one, before acting. An error a layer + catches is re-raised to the caller. What remains outside a function's reach, stated rather than hidden (s3dol ADR-0011 D3a): +- A layer that performs the write in another thread or later (a write-behind buffer) + escapes the redirect: that write is the leaf's ordinary upsert or delete, without the + operation's semantics (no ETag check, outside a batch's transaction). The function + raises ``RedirectError``, but cannot undo it. - ``batch_items`` collects its writes through the layers first and commits them as one transaction afterwards: a layer that acts after the inner write returns (a write-through cache, an audit log) has acted even if the transaction then fails. @@ -110,29 +116,39 @@ def _leaf_key(store, leaf, k): return inner_most_key(store, k) -def _require_member(store, leaf, k, not_found: type[KeyError]) -> None: - """Check ``k in store`` through every wrapping layer, so a scope that a wrapper - expresses only in ``__contains__`` is honoured. Unwrapped stores skip the round-trip - (the operation itself fails on a missing resource).""" - if store is not leaf and k not in store: +def _require_member(store, leaf, k, not_found: type[KeyError], base: type) -> None: + """Check ``k in store`` through every wrapping layer, so a scope that a wrapper (or + a subclass of ``base``) expresses only in ``__contains__`` is honoured. A plain + ``base`` instance skips the round-trip (the operation itself fails on a missing + resource).""" + if store is leaf and type(leaf).__contains__ is base.__contains__: + return + if k not in store: raise not_found(k) def _routed(store, leaf, method: str, handler: Callable, k, *v, what: str): """``store[k] = v`` (or ``del store[k]`` when no ``v``) through every wrapping layer, with the one resulting ``leaf.`` call handed to ``handler`` (which receives the - leaf's arguments: ``(k, v)`` or ``(k,)``, fully mapped). Returns what it returned.""" - with redirect(leaf, method, handler) as r: + leaf's arguments: ``(k, v)`` or ``(k,)``, fully mapped). Returns what it returned. + + The redirect is pinned to ``k``'s leaf key, so a layer's write of any other key during + the operation is refused rather than given this operation's semantics.""" + with redirect(leaf, method, _leaf_key(store, leaf, k), handler) as r: if v: store[k] = v[0] else: del store[k] - if not r.results: + if not r.claimed: raise RedirectError( - f"{what}: the write of {k!r} did not reach the cosmodol store; a wrapping " - "layer swallowed or deferred it, so it was not performed as a " + what + "." + f"{what}: the write of {k!r} did not reach the cosmodol store during the " + "operation. A wrapping layer swallowed it, or performs it elsewhere (another " + "thread, a write-behind buffer): then it may have been, or may later be, " + "performed as an ordinary upsert or delete, without this operation's checks." ) - return r.results[0] + if r.error is not None: # a layer caught it; the caller must still see it + raise r.error + return r.result def _write_logic_above(store, leaf, base: type) -> bool: @@ -168,7 +184,7 @@ def replace_item(store, k, v, *, etag: Optional[str] = None) -> dict: leaf strips them). """ leaf = _leaf_of(store, CosmosItems, fn="replace_item") - _require_member(store, leaf, k, ItemNotFoundError) + _require_member(store, leaf, k, ItemNotFoundError, CosmosItems) def replace(leaf_k, leaf_v): body = leaf._prepare_body(leaf_k, leaf_v) @@ -258,7 +274,7 @@ def collect_key(leaf_k): if op in ("create", "upsert"): created.add(k) elif k not in created: - _require_member(store, leaf, k, ItemNotFoundError) + _require_member(store, leaf, k, ItemNotFoundError, CosmosItems) what = f"batch_items {op!r}" if op in ("create", "upsert", "replace"): leaf_k, body = _routed( @@ -393,7 +409,7 @@ def delete_container(db_store, name: str, *, force: bool = False) -> None: if not force: del db_store[name] return - _require_member(db_store, leaf, name, ContainerNotFoundError) + _require_member(db_store, leaf, name, ContainerNotFoundError, CosmosDatabase) def cascade(k): try: @@ -449,7 +465,7 @@ def delete_database(account_store, name: str, *, force: bool = False) -> None: if not force: del account_store[name] return - _require_member(account_store, leaf, name, DatabaseNotFoundError) + _require_member(account_store, leaf, name, DatabaseNotFoundError, CosmosAccount) def cascade(k): try: diff --git a/cosmodol/routing.py b/cosmodol/routing.py index c8e614d..aa4bf7f 100644 --- a/cosmodol/routing.py +++ b/cosmodol/routing.py @@ -12,17 +12,23 @@ >>> class Leaf(dict): ... def __setitem__(self, k, v): -... if (handler := claim(self, "__setitem__")) is not None: +... if (handler := claim(self, "__setitem__", k)) is not None: ... return handler(k, v) ... super().__setitem__(k, v) >>> leaf = Leaf() ->>> with redirect(leaf, "__setitem__", lambda k, v: f"handled {k}={v}") as r: +>>> with redirect(leaf, "__setitem__", "a", lambda k, v: f"handled {k}={v}") as r: ... leaf["a"] = 1 ->>> r.results, dict(leaf) -(['handled a=1'], {}) +>>> r.result, dict(leaf) +('handled a=1', {}) + +The redirect is pinned to the leaf key the operation computed (``dol.inner_most_key``), so a +layer that writes some *other* key first (an index, an audit record) cannot have the +operation's semantics applied to it: that write is refused instead. The redirect is held in a ``ContextVar``, so it is invisible to other threads and tasks, and -it only applies to the leaf object it names. +it only applies to the leaf object it names. The flip side: a layer that performs the write +in another thread, or later (a write-behind buffer), escapes it, and that write happens as +the leaf's ordinary upsert or delete. """ from __future__ import annotations @@ -37,31 +43,41 @@ class RedirectError(RuntimeError): """A wrapping layer did not turn one mapping write into exactly one leaf write.""" +#: The leaf methods a redirect guards: while one is pending on a leaf, any of them called +#: with anything but the expected method and key is refused. +WRITE_METHODS = ("__setitem__", "__delitem__") + + @dataclass class Redirect: - """One pending redirect of ``method`` on ``leaf`` to ``handler``. + """One pending redirect of ``leaf.(key, ...)`` to ``handler``. - ``results`` holds what the handler returned, one entry per leaf call (so after a - redirect, ``len(results)`` says whether the write reached the leaf). + After the block, ``claimed`` says whether the write reached the leaf, ``result`` is + what the handler returned, and ``error`` what it raised (re-raised by the caller + even if a layer swallowed it). """ leaf: Any method: str + key: Any handler: Callable - results: list = field(default_factory=list) + claimed: bool = False + result: Any = None + error: Optional[BaseException] = None _active: ContextVar[Optional[Redirect]] = ContextVar("cosmodol_redirect", default=None) @contextmanager -def redirect(leaf, method: str, handler: Callable) -> Iterator[Redirect]: - """Within the block, the first call of ``leaf.`` goes to ``handler``. +def redirect(leaf, method: str, key, handler: Callable) -> Iterator[Redirect]: + """Within the block, the call ``leaf.(key, ...)`` goes to ``handler``. - A second call of the same method on the same leaf within the block raises - ``RedirectError``: one mapping write was expected to reach the leaf once. + Any other write call on ``leaf`` within the block (another method, another key, or + the same call a second time) raises ``RedirectError`` before touching anything: one + mapping write was expected to reach the leaf once, as ``key``. """ - r = Redirect(leaf, method, handler) + r = Redirect(leaf, method, key, handler) token = _active.set(r) try: yield r @@ -69,24 +85,34 @@ def redirect(leaf, method: str, handler: Callable) -> Iterator[Redirect]: _active.reset(token) -def claim(leaf, method: str) -> Optional[Callable]: - """The handler redirecting ``leaf.`` right now, or ``None``. +def claim(leaf, method: str, key) -> Optional[Callable]: + """The handler redirecting ``leaf.(key, ...)`` right now, or ``None``. - Called by a leaf at the top of the method. The returned callable runs the handler and - records its result. + Called by a leaf at the top of each of its ``WRITE_METHODS``. The returned callable + runs the handler and records its result or error. """ r = _active.get() - if r is None or r.leaf is not leaf or r.method != method: + if r is None or r.leaf is not leaf: return None - if r.results: + if r.claimed: + outcome = "failed" if r.error is not None else "was performed" + raise RedirectError( + f"A wrapping layer made a second {method} call ({key!r}) on the cosmodol " + f"store during one operation; refused. The first call ({r.key!r}) {outcome}." + ) + if (method, key) != (r.method, r.key): raise RedirectError( - f"A wrapping layer turned one write into several {method} calls on the " - "cosmodol store; refusing the extra one. The first was already performed." + f"A wrapping layer called {method}({key!r}) on the cosmodol store where " + f"the operation expected {r.method}({r.key!r}); refused before acting." ) def run(*args): - r.results.append(None) # claimed, even if the handler raises - r.results[-1] = r.handler(*args) - return r.results[-1] + r.claimed = True + try: + r.result = r.handler(*args) + except BaseException as e: + r.error = e + raise + return r.result return run diff --git a/cosmodol/stores.py b/cosmodol/stores.py index 0943804..323ae6e 100644 --- a/cosmodol/stores.py +++ b/cosmodol/stores.py @@ -182,14 +182,14 @@ def __getitem__(self, k: str) -> dict: return self._maybe_strip(item) def __setitem__(self, k: str, v) -> None: - if (handler := claim(self, "__setitem__")) is not None: + if (handler := claim(self, "__setitem__", k)) is not None: return handler(k, v) # an ops function, routed through every wrapper body = self._prepare_body(k, v) _item, hdrs = point_upsert(self.container, body) self._observe("set", hdrs) def __delitem__(self, k: str) -> None: - if (handler := claim(self, "__delitem__")) is not None: + if (handler := claim(self, "__delitem__", k)) is not None: return handler(k) if self.strict_keys: validate_cosmos_id(k) diff --git a/cosmodol/tests/test_ops_layers.py b/cosmodol/tests/test_ops_layers.py index d57857a..df8ac1f 100644 --- a/cosmodol/tests/test_ops_layers.py +++ b/cosmodol/tests/test_ops_layers.py @@ -171,7 +171,7 @@ def test_a_swallowed_write_is_loud(): def test_a_multiplied_write_is_loud_and_a_batch_sends_nothing(): c = _container() before = _state(c) - with pytest.raises(RedirectError, match="several"): + with pytest.raises(RedirectError, match="second"): batch_items(_Twice(_leaf(c)), [("upsert", "doc1", {"payload": "X"})]) assert _state(c) == before @@ -202,3 +202,67 @@ def test_patch_on_a_wrapped_store_needs_an_explicit_opt_in(): batch_items(store, ops, unguarded_patch=True) with pytest.raises(NotImplementedError, match="patch"): # unwrapped: no opt-in needed batch_items(_leaf(c), [("patch", "app_doc1", ops[0][2])]) + + +# --------------------------------------------------------------------------- +# Adversarial-review regressions: the redirect is pinned to the expected leaf key +# --------------------------------------------------------------------------- + + +class _Companion(Store): + """Drops a companion resource before the requested one.""" + + def __delitem__(self, k): + del self.store["events"] + del self.store[k] + + +class _Audit(Store): + """Writes an audit record before the requested item.""" + + def __setitem__(self, k, v): + self.store["app_log"] = {"payload": f"set {k}"} + self.store[k] = v + + +class _Forgiving(Store): + def __setitem__(self, k, v): + try: + super().__setitem__(k, v) + except Exception: + pass + + +def test_a_forced_delete_never_cascades_into_a_companion_resource(): + raw = _db() + with pytest.raises(RedirectError, match="expected __delitem__"): + delete_container(_Companion(CosmosDatabase(raw)), "app_events", force=True) + assert [i["id"] for i in raw.containers["events"].items] == ["prod-0", "prod-1", "prod-2"] + assert sorted(raw.containers) == ["app_events", "events"] + + +def test_replace_semantics_never_land_on_a_layers_other_key(): + c = _container() + before = _state(c) + with pytest.raises(RedirectError, match="expected __setitem__"): + replace_item(_Audit(_leaf(c)), "app_doc1", {"payload": "X"}) + assert _state(c) == before + + +def test_an_error_a_layer_swallows_still_reaches_the_caller(): + c = _container() + with pytest.raises(Exception, match="(?i)precondition|etag"): + replace_item(_Forgiving(_leaf(c)), "app_doc1", {"payload": "X"}, etag="stale") + assert _state(c)["app_doc1"] == "MINE" + + +def test_a_subclass_scoping_by_contains_is_honoured(): + class Scoped(CosmosItems): + def __contains__(self, k): + return k.startswith("app_") and super().__contains__(k) + + c = _container() + store = Scoped(c, partition_key_value=PK, partition_key_path="/_pk") + with pytest.raises(KeyError): + replace_item(store, "doc1", {"payload": "X"}) + assert _state(c)["doc1"] == "SOMEONE ELSE'S" diff --git a/cosmodol/trees.py b/cosmodol/trees.py index c1c21d2..a0d520d 100644 --- a/cosmodol/trees.py +++ b/cosmodol/trees.py @@ -99,7 +99,7 @@ def __getitem__(self, k: str): return self.store_factory(c) def __setitem__(self, k, v): - if (handler := claim(self, "__setitem__")) is not None: + if (handler := claim(self, "__setitem__", k)) is not None: return handler(k, v) # cosmodol.add_container / add_database raise TypeError( "Container creation does not go through __setitem__ (too parameter-rich). " @@ -107,7 +107,7 @@ def __setitem__(self, k, v): ) def __delitem__(self, k: str) -> None: - if (handler := claim(self, "__delitem__")) is not None: + if (handler := claim(self, "__delitem__", k)) is not None: return handler(k) # a forced cosmodol.delete_container / delete_database try: c = self.database.get_container_client(k) @@ -199,7 +199,7 @@ def __getitem__(self, k: str) -> CosmosDatabase: return self.database_factory(db) def __setitem__(self, k, v): - if (handler := claim(self, "__setitem__")) is not None: + if (handler := claim(self, "__setitem__", k)) is not None: return handler(k, v) # cosmodol.add_container / add_database raise TypeError( "Database creation does not go through __setitem__ (too parameter-rich). " @@ -207,7 +207,7 @@ def __setitem__(self, k, v): ) def __delitem__(self, k: str) -> None: - if (handler := claim(self, "__delitem__")) is not None: + if (handler := claim(self, "__delitem__", k)) is not None: return handler(k) # a forced cosmodol.delete_container / delete_database try: db = self._client.get_database_client(k)