From a03829144d4297a7438d8519fa1529ecf4c6764e Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:59:32 +0100 Subject: [PATCH] feat(experimental): implement the six #86/Option G design decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - undeclared default 'raise' -> 'exclude' (loud at use, no passthrough habit; 'raise' stays as strict mode) - typed codecs: optional decoded_type/encoded_type tags on Codec + seam validation between adjacent stack layers (untagged = unchecked) - outer-view __eq__ when the spec'd surface covers getitem+iter; __hash__ None (mutable-mapping convention) - MappingInterface built-in spec + kv_interface_wrap facade (wrap_kvs-shaped kwargs over the engine; simple things stay simple) - design doc §11: open questions -> decision record (incl. question 0: split synthesis — codec wrapping compiles to the flat engine, @wrap_kvs class-decoration becomes is-a) Claude-Session: https://claude.ai/code/session_01FLZ8T5a6Y4P3u25yC1JD9R --- dol/_interface_wrap.py | 230 ++++++++++++++++++++++++++++--- dol/tests/test_interface_wrap.py | 90 +++++++++++- misc/docs/dol_issue86_design.md | 51 ++++--- 3 files changed, 332 insertions(+), 39 deletions(-) diff --git a/dol/_interface_wrap.py b/dol/_interface_wrap.py index 5c51245e..133b60c8 100644 --- a/dol/_interface_wrap.py +++ b/dol/_interface_wrap.py @@ -35,18 +35,7 @@ ... KT=Codec(encoder=lambda k: k + '.json', decoder=lambda k: k[:-5]), ... VT=Codec(encoder=str, decoder=int), ... ) - -Loud by default: a public leaf attribute the spec doesn't cover refuses at -wrap time (it would be served with unmapped keys/values — the #83 bug class): - ->>> interface_wrap(d, spec=KvInterface, codecs=codecs) # doctest: +ELLIPSIS -Traceback (most recent call last): -... -dol._interface_wrap.UndeclaredAttributeError: The leaf exposes public... - -Every escape is explicit — extend the spec, forward verbatim, or hide: - ->>> s = interface_wrap(d, spec=KvInterface, codecs=codecs, undeclared='exclude') +>>> s = interface_wrap(d, spec=KvInterface, codecs=codecs) >>> s['a'] 1 >>> s['b'] = 2 @@ -56,6 +45,30 @@ ['a', 'b'] >>> 'a' in s True + +Loudness: under the default ``undeclared='exclude'`` policy, a public leaf +attribute the spec doesn't cover is hidden — USING it refuses with guidance +(it would be served with unmapped keys/values, the #83 bug class): + +>>> s.get('a') # doctest: +ELLIPSIS +Traceback (most recent call last): +... +dol._interface_wrap.UndeclaredAttributeError: 'get' is not in the interface... + +Strict mode refuses at WRAP time instead (``undeclared='raise'``); and +``undeclared='passthrough'`` / ``passthrough={...}`` forward verbatim — every +escape is explicit. + +For the common Mapping-shaped case there is a built-in spec and a +``wrap_kvs``-shaped facade, so simple things stay simple: + +>>> t = kv_interface_wrap({}, id_of_key=lambda k: k + '.txt', +... key_of_id=lambda k: k[:-4]) +>>> t['a'] = 'hello' +>>> list(t) +['a'] +>>> t == {'a': 'hello'} +True """ from collections.abc import Iterable as _IterableABC, Iterator as _IteratorABC @@ -66,9 +79,11 @@ from typing import ( Any, Callable, + Iterator, Mapping, NamedTuple, Optional, + Protocol, TypeVar, Union, get_args, @@ -80,7 +95,9 @@ "Codec", "InterfaceSpec", "InterfaceProxy", + "MappingInterface", "interface_wrap", + "kv_interface_wrap", "InterfaceWrapError", "UnsupportedSpecShape", "UnderAnnotatedSpecError", @@ -119,10 +136,11 @@ class UnderAnnotatedSpecError(InterfaceWrapError, TypeError): class UndeclaredAttributeError(InterfaceWrapError, AttributeError): """A public attribute of the leaf is not covered by the spec. - Raised at wrap time under the default ``undeclared='raise'`` policy — the - census's failure mode is silence-by-omission (s3dol ADR-0011 D5), so an - attribute that would be served with unmapped keys/values must be - explicitly passed through, excluded, or added to the spec. + The census's failure mode is silence-by-omission (s3dol ADR-0011 D5), so + an attribute that would be served with unmapped keys/values must be + explicitly passed through or added to the spec. Under the default + ``undeclared='exclude'`` policy this raises at USE time; under the strict + ``'raise'`` policy, at wrap time. """ @@ -138,10 +156,20 @@ class Codec: ``decoder`` maps inner→outer (the direction of results coming out). Same field semantics as ``dol.trans.Codec``; redefined here to keep this leaf module dependency-free within dol. + + ``decoded_type``/``encoded_type`` are OPTIONAL type tags (design decision + 2026-08-10, question 3): ``decoded_type`` is the outer-facing domain, + ``encoded_type`` the inner-facing (leafward) one. When two adjacent stack + layers both declare the facing types, stack compilation validates the + seam (the outer layer's ``encoded_type`` must be the inner layer's + ``decoded_type``) and refuses loudly on mismatch. ``None`` = untagged = + unchecked, so plain ``Codec(f, g)`` keeps working. """ encoder: Callable[[Any], Any] decoder: Callable[[Any], Any] + decoded_type: Optional[type] = None + encoded_type: Optional[type] = None def __iter__(self): return iter((self.encoder, self.decoder)) @@ -426,6 +454,37 @@ def _fused_role_funcs(stack, *, direction): return out +def _validate_stack_seams(stack): + """Validate typed-codec seams between adjacent layers (per role). + + Layers are innermost-first. For a role present in layers i < j (adjacent + among the layers that carry that role), the OUTER layer's leafward face + (``encoded_type``) meets the INNER layer's outer face (``decoded_type``). + When both are declared and differ, refuse loudly; ``None`` = untagged = + unchecked (design decision 2026-08-10, question 3). + """ + roles = {role for layer in stack for role in layer} + for role in roles: + carriers = [ + (i, layer[role]) for i, layer in enumerate(stack) if role in layer + ] + for (i, inner_c), (j, outer_c) in zip(carriers, carriers[1:]): + inner_face = inner_c.decoded_type + outer_face = outer_c.encoded_type + if ( + inner_face is not None + and outer_face is not None + and inner_face is not outer_face + ): + raise InterfaceWrapError( + f"Typed-codec seam mismatch for role {role!r}: layer {i} " + f"decodes to {inner_face.__name__} but layer {j} encodes " + f"to {outer_face.__name__}. Adjacent codec layers must " + f"agree at their seam (outer encoded_type == inner " + f"decoded_type)." + ) + + # --------------------------------------------------------------------------- # Method-plan compilation @@ -725,6 +784,22 @@ def __iter__(self): ) ns["__iter__"] = __iter__ + if "__getitem__" in method_names and "__iter__" in method_names: + # Design decision (2026-08-10, question 4): when the spec'd surface + # supports Mapping-style traversal, equality compares OUTER views — + # a wrapped store equals a dict holding its outer items. Defining + # __eq__ sets __hash__ to None (mutable-mapping convention), which is + # the decided no-hash policy. + def __eq__(self, other): + if other is self: + return True + try: + other_items = {k: other[k] for k in other} + except (TypeError, KeyError): + return NotImplemented + return {k: self[k] for k in self} == other_items + + ns["__eq__"] = __eq__ ns["__module__"] = __name__ cls_name = class_name or f"{leaf_type.__name__}InterfaceProxy" cls = type(cls_name, (InterfaceProxy,), ns) @@ -742,7 +817,7 @@ def interface_wrap( *, spec, codecs: Optional[Mapping[str, Codec]] = None, - undeclared: str = "raise", + undeclared: str = "exclude", passthrough: _IterableABC = (), _stack: Optional[tuple] = None, ): @@ -755,9 +830,12 @@ def interface_wrap( explicit dict form accepted by ``InterfaceSpec.from_dict``. :param codecs: one codec layer: ``{role_name: Codec(encoder, decoder)}``. :param undeclared: policy for public leaf attributes absent from the - spec: ``'raise'`` (default — loud, at wrap time), ``'passthrough'`` - (forward verbatim; documents itself as unmapped), ``'exclude'`` - (hide: touching them raises at use time). + spec. Default ``'exclude'`` (design decision 2026-08-10, question 2): + the wrap succeeds, and every USE of an undeclared attribute raises + ``UndeclaredAttributeError`` with guidance — refusal at the moment of + danger, with no habit-forming escape. ``'raise'`` is the strict mode + (refuse at wrap time, listing names); ``'passthrough'`` forwards + verbatim (explicitly unmapped keys/values). :param passthrough: explicit names to forward verbatim regardless. """ if undeclared not in ("raise", "passthrough", "exclude"): @@ -824,6 +902,7 @@ def interface_wrap( f"nowhere in the spec (spec roles: {sorted(used_roles)}). " f"A codec that can never apply is almost certainly a mistake." ) + _validate_stack_seams(stack) # --- undeclared-surface policy (wrap time, loud by default) passthrough = frozenset(passthrough) @@ -867,3 +946,114 @@ def interface_wrap( object.__setattr__(proxy, "_self_undeclared", undeclared) object.__setattr__(proxy, "_self_passthrough", passthrough) return proxy + + +# --------------------------------------------------------------------------- +# Built-in Mapping spec and the wrap_kvs-shaped facade +# (design decision 2026-08-10, question 1: simple things stay simple) + + +KT = TypeVar("KT") +VT = TypeVar("VT") + + +class MappingInterface(Protocol[KT, VT]): + """The built-in Mapping-shaped spec: the six methods dol's Store routes. + + The MutableMapping mixin surface (``get``, ``keys``, ``items``, + ``update``, ...) is deliberately NOT declared: each of those needs its + own vocabulary decision (``KeysView[KT]``, ``update(**kw)``, ``get``'s + default), so under the default ``undeclared='exclude'`` policy they are + hidden-and-loud rather than silently unmapped. + """ + + def __getitem__(self, k: KT) -> VT: ... + + def __setitem__(self, k: KT, v: VT) -> None: ... + + def __delitem__(self, k: KT) -> None: ... + + def __iter__(self) -> Iterator[KT]: ... + + def __len__(self) -> int: ... + + def __contains__(self, k: KT) -> bool: ... + + +def _as_codec(codec_or_pair): + """Coerce anything with .encoder/.decoder (or a pair) to our Codec.""" + if isinstance(codec_or_pair, Codec): + return codec_or_pair + if hasattr(codec_or_pair, "encoder") and hasattr(codec_or_pair, "decoder"): + return Codec( + encoder=codec_or_pair.encoder, + decoder=codec_or_pair.decoder, + decoded_type=getattr(codec_or_pair, "decoded_type", None), + encoded_type=getattr(codec_or_pair, "encoded_type", None), + ) + encoder, decoder = codec_or_pair + return Codec(encoder=encoder, decoder=decoder) + + +def kv_interface_wrap( + store, + *, + obj_of_data: Optional[Callable] = None, + data_of_obj: Optional[Callable] = None, + key_of_id: Optional[Callable] = None, + id_of_key: Optional[Callable] = None, + key_codec=None, + value_codec=None, + undeclared: str = "exclude", + passthrough: _IterableABC = (), +): + """``wrap_kvs``-shaped kwargs facade over the interface engine. + + Same transform-naming conventions as ``dol.wrap_kvs`` (``X_of_Y``: + ``id_of_key`` encodes keys going in, ``key_of_id`` decodes keys coming + out, ``data_of_obj`` encodes values going in, ``obj_of_data`` decodes + values coming out), compiled onto a flat proxy with the built-in + ``MappingInterface`` spec. + + Unlike ``wrap_kvs``, transforms here are plain unary callables — there is + no self-convention (``f(self, x)``) inference, because there are no + wrapper layers for a transform to receive. + + >>> import json + >>> s = kv_interface_wrap({}, data_of_obj=json.dumps, obj_of_data=json.loads) + >>> s['a'] = {'x': 1} + >>> s['a'] + {'x': 1} + >>> s.__wrapped__ + {'a': '{"x": 1}'} + """ + if key_codec is not None and (key_of_id is not None or id_of_key is not None): + raise ValueError( + "Pass key_codec OR key_of_id/id_of_key, not both." + ) + if value_codec is not None and ( + obj_of_data is not None or data_of_obj is not None + ): + raise ValueError( + "Pass value_codec OR obj_of_data/data_of_obj, not both." + ) + layer = {} + if key_codec is not None: + layer["KT"] = _as_codec(key_codec) + elif key_of_id is not None or id_of_key is not None: + layer["KT"] = Codec( + encoder=id_of_key or _identity, decoder=key_of_id or _identity + ) + if value_codec is not None: + layer["VT"] = _as_codec(value_codec) + elif obj_of_data is not None or data_of_obj is not None: + layer["VT"] = Codec( + encoder=data_of_obj or _identity, decoder=obj_of_data or _identity + ) + return interface_wrap( + store, + spec=MappingInterface, + codecs=layer or None, + undeclared=undeclared, + passthrough=passthrough, + ) diff --git a/dol/tests/test_interface_wrap.py b/dol/tests/test_interface_wrap.py index c4ebb682..e52cd4c9 100644 --- a/dol/tests/test_interface_wrap.py +++ b/dol/tests/test_interface_wrap.py @@ -291,10 +291,20 @@ class MinimalGet(Protocol[KT, VT]): def __getitem__(self, k: KT) -> VT: ... -def test_undeclared_public_method_raises_at_wrap_time(): +def test_default_exclude_is_loud_at_use_time(): + """Design decision (question 2): default 'exclude' — wrap succeeds, + undeclared use refuses with guidance.""" + s = interface_wrap(Leaky(), spec=MinimalGet, codecs=dict(KT=json_key_codec)) + with pytest.raises(UndeclaredAttributeError) as exc: + _ = s.surprise_delete + assert 'surprise_delete' in str(exc.value) + + +def test_strict_mode_raises_at_wrap_time(): with pytest.raises(UndeclaredAttributeError) as exc: interface_wrap( - Leaky(), spec=MinimalGet, codecs=dict(KT=json_key_codec) + Leaky(), spec=MinimalGet, codecs=dict(KT=json_key_codec), + undeclared='raise', ) assert 'surprise_delete' in str(exc.value) @@ -650,3 +660,79 @@ def bulk(self, ks): codecs=dict(KT=json_key_codec, VT=value_codec), undeclared='exclude') assert s.bulk(['a']) == {'a': 7} + + +# --- decisions round (2026-08-10): typed codecs, eq, facade ------------------ + + +from dol._interface_wrap import MappingInterface, kv_interface_wrap + + +def test_typed_codec_seam_validation_ok_and_mismatch(): + """Question 3: adjacent typed layers must agree at the seam.""" + + class KOnly(Protocol[KT]): + def __getitem__(self, k: KT) -> str: ... + + class L: + def __getitem__(self, k): + return 'v' + + inner = Codec(add_json, strip_json, decoded_type=str, encoded_type=str) + outer_ok = Codec(prefix_x, strip_x, decoded_type=str, encoded_type=str) + s = interface_wrap(L(), spec=KOnly, codecs=dict(KT=inner)) + s2 = interface_wrap(s, spec=KOnly, codecs=dict(KT=outer_ok)) + assert s2._encode_role('KT', 'a') == 'x/a.json' + + outer_bad = Codec(prefix_x, strip_x, decoded_type=str, encoded_type=bytes) + with pytest.raises(InterfaceWrapError, match='seam mismatch'): + interface_wrap(s, spec=KOnly, codecs=dict(KT=outer_bad)) + + # untagged layers stay unchecked (progressive disclosure) + untagged = Codec(prefix_x, strip_x) + assert interface_wrap(s, spec=KOnly, codecs=dict(KT=untagged)) is not None + + +def test_outer_view_equality_and_no_hash(): + """Question 4: eq compares outer views when the spec covers traversal; + hash is absent (defining __eq__ nulls __hash__).""" + s = wrap_bucket() + assert s == {'a': 1, 'b': 2} + assert not (s == {'a': 1}) + t = wrap_bucket(Bucket({'logs/a.json': '1', 'logs/b.json': '2'})) + assert s == t # two proxies, equal outer views + with pytest.raises(TypeError): + hash(s) + # a getitem-only wrap keeps identity eq (no traversal surface) + g = interface_wrap({'a.json': '1'}, + spec={'__getitem__': {0: 'KT', 'return': 'VT'}}, + codecs=dict(KT=json_key_codec, VT=value_codec)) + assert g != {'a': 1} + + +def test_kv_interface_wrap_facade_matches_wrap_kvs_semantics(): + """Question 1: the simple gesture stays simple, with wrap_kvs naming.""" + import json + + s = kv_interface_wrap( + {}, data_of_obj=json.dumps, obj_of_data=json.loads, + id_of_key=add_json, key_of_id=strip_json, + ) + s['a'] = {'x': 1} + assert s.__wrapped__ == {'a.json': '{"x": 1}'} + assert s['a'] == {'x': 1} + assert list(s) == ['a'] + assert len(s) == 1 + assert 'a' in s + assert s == {'a': {'x': 1}} + + +def test_kv_interface_wrap_conflict_raises(): + with pytest.raises(ValueError): + kv_interface_wrap({}, key_codec=json_key_codec, id_of_key=add_json) + + +def test_facade_undeclared_mixin_methods_are_loud(): + s = kv_interface_wrap({}, id_of_key=add_json, key_of_id=strip_json) + with pytest.raises(UndeclaredAttributeError): + s.get('a') diff --git a/misc/docs/dol_issue86_design.md b/misc/docs/dol_issue86_design.md index ba33f067..ea93559b 100644 --- a/misc/docs/dol_issue86_design.md +++ b/misc/docs/dol_issue86_design.md @@ -142,9 +142,12 @@ What this buys, each previously a named open problem — **scoped to pure-codec The census's failure mode is silence-by-omission (ADR-0011 D5), so omission is loud at every level the mechanism can see: -1. **Undeclared public attributes** of the leaf (`undeclared='raise'`, the default): - wrap-time refusal naming the attributes; escapes are explicit - (`'passthrough'` / `'exclude'` / `passthrough={...}` per name). +1. **Undeclared public attributes** of the leaf: under the default + `undeclared='exclude'` (decision 2026-08-10, see §11), the wrap succeeds and + every *use* of an undeclared attribute raises with guidance — refusal at the + moment of danger, no habit-forming escape. `'raise'` is the strict mode + (wrap-time refusal naming the attributes); `'passthrough'` / + `passthrough={...}` forward verbatim, explicitly. 2. **Unannotated parameters inside spec'd methods** (`UnderAnnotatedSpecError`): a spec author who writes `url_for(self, k)` forgot the annotation, and `k` would silently receive outer keys — refused at compile time. (Panel-found hole, closed.) @@ -351,20 +354,34 @@ wrapper, not the backend) **[probe p1]**. The prototype's policy: wrapping a leg scoped to the layers above it. The eventual policy (absorb known layer types? refuse?) belongs with P2's compatibility appendix. -## 11. Open questions for the maintainer - -0. **Who owns the wrap_kvs endgame** — flat engine, is-a, or the split synthesis (§8)? -1. Naming: `interface_wrap`? role-lane spelling (`codecs=dict(KT=…)`)? Should the - built-in Mapping spec ship now, and with which of the mixin methods (each needs a - vocabulary decision: `KeysView[KT]`, `update(**kw)`, `get`'s default)? -2. Loudness defaults: keep `undeclared='raise'` knowing real leaves are noisy and - passthrough becomes habit — or default to `'exclude'` (loud at use, quiet at wrap)? -3. **Typed codecs**: adopt encoded/decoded type tags on `Codec` (the two in-tree - TODOs) so stack composition can check adjacency and the §2.5 laws become - enforceable rather than documentary? -4. The eq/hash/len policy family (§2.4) — decide explicitly, including whether to fix - today's Store eq/hash incoherence in the same breath. -5. `__class__` transparency — co-design with #5, or drop permanently? +## 11. Open questions — DECIDED (maintainer, 2026-08-10) + +0. **Who owns the wrap_kvs endgame?** → **Split synthesis**: codec/instance wrapping + compiles to the flat engine; `@wrap_kvs` class-decoration becomes is-a — each + mechanism serves the population it is uniquely correct for (§8). Consequence: P2 + and the #18 doc's Phase 3 are no longer rivals; the crisp fire-when rule is P2/P3 + design work. +1. **Public surface** → **Private + facade next**: the engine stays private; the + built-in `MappingInterface` spec and the `wrap_kvs`-shaped `kv_interface_wrap` + facade ship (done — this decision round), so the simple gesture never regresses. + Export considered after adapters (P1) validate it. The Mapping *mixin* methods + (`get`, `keys`, `update`, …) stay out of the built-in spec — each needs its own + vocabulary decision — and are hidden-loud under the default policy. +2. **Loudness default** → **`'exclude'`**: wrap succeeds; undeclared *use* raises + with guidance. `'raise'` remains as strict mode. Rationale: wrap-time raise on + real leaves (dict: 11 publics; boto3: 122) drives users to `'passthrough'`, + after which omission is silent again. +3. **Typed codecs** → **Yes, now**: `Codec` carries optional + `decoded_type`/`encoded_type` tags; stack compilation validates adjacent seams + (outer `encoded_type` ≡ inner `decoded_type`) and refuses loudly on mismatch; + untagged stays unchecked (done — this decision round). Tagging `dol.trans.Codec` + is P2 territory (dependents gate). +4. **eq/hash/len** → **Outer-view eq, no hash**: when the spec'd surface covers + `__getitem__`+`__iter__`, `__eq__` compares outer views (a wrap equals a dict + holding its outer items) and `__hash__` is None (done — this decision round). + Store's own eq/hash incoherence is queued for 0.4. +5. **`__class__` transparency** → **Opt-in later, co-designed with #5**. Off today; + nothing lies by default. ## 12. Verification log