diff --git a/docs/profile-spec-pattern.md b/docs/profile-spec-pattern.md index 8dcb1a4..2e6c8de 100644 --- a/docs/profile-spec-pattern.md +++ b/docs/profile-spec-pattern.md @@ -173,24 +173,29 @@ The pattern is not free. Compared to a plain subclass: For a single application-specific settings class, none of this is worth it. For a library or any code where you manage more than two or three similar profiles, the structural guarantees pay for themselves quickly. -## Extending emission: `register_adapter` +## Extending emission -`emit()` targets are any `Hashable`, so other domains can add their own. To -register an `emit()` adapter for a target on a profile class after definition: +`emit()` targets are any `Hashable`, so other domains can add their own. The +sanctioned mechanism is the **inline** per-target adapter map declared on the +profile class: ```python -from mountainash_settings.profiles import emit_adapter +class PasswordAuthProfile(Profile): + __spec__ = ... + __adapters__ = {MyTarget.POSTGRES: _postgres} + -@emit_adapter(PasswordAuthProfile, MyTarget.POSTGRES) def _postgres(auth, base): return {**base, "user": auth.USERNAME, "password": auth.PASSWORD.get_secret_value()} ``` -`Profile.register_adapter(target, adapter, *, overwrite=False)` is the -non-decorator form. It is copy-on-write-safe (never mutates a parent/shared -adapter map), idempotent for the same adapter object, and raises on a conflicting -re-registration. Register on the **concrete** class you mean (registering on a -base does not propagate to a child that already registered). Use a -package-namespaced target type (an `Enum` / frozen dataclass), never bare strings. -`registered_adapters()` returns a read-only copy for introspection. +Each adapter has the 2-arg compose signature `(profile, merged) -> dict`; +`instance.emit(target, base=...)` routes through the entry for `target`. Use a +package-namespaced target type (an `Enum` / frozen dataclass), never bare +strings. + +A consumer that needs to extend emission for a profile it does not own builds a +**consumer-owned** dispatch table (`(provider_type, auth_class) -> fn`) and reads +the credential's data directly, rather than mutating a class it imports — see +`mountainash-auth-client/a.architecture/credentials-are-rendered-by-the-consumer.md`. diff --git a/docs/superpowers/plans/2026-06-27-profile-register-adapter.md b/docs/superpowers/plans/2026-06-27-profile-register-adapter.md index e1a69d2..df54abb 100644 --- a/docs/superpowers/plans/2026-06-27-profile-register-adapter.md +++ b/docs/superpowers/plans/2026-06-27-profile-register-adapter.md @@ -1,5 +1,7 @@ # Profile.register_adapter Implementation Plan +> **SUPERSEDED (2026-06-29):** The `register_adapter` registry this plan built was removed as built-but-unused and architecturally overkill (zero callers). See `mountainash-central/01.principles/mountainash-settings/h.backlog/remove-register-adapter-registry.md` and the removal PR. Retained for history. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a generic, copy-on-write-safe `Profile.register_adapter` primitive to mountainash-settings so downstream packages can register an `emit()` adapter for a `Hashable` target on an existing `Profile` subclass after class definition. diff --git a/docs/superpowers/specs/2026-06-27-profile-register-adapter-design.md b/docs/superpowers/specs/2026-06-27-profile-register-adapter-design.md index 8bf3c7f..79dd0db 100644 --- a/docs/superpowers/specs/2026-06-27-profile-register-adapter-design.md +++ b/docs/superpowers/specs/2026-06-27-profile-register-adapter-design.md @@ -1,7 +1,7 @@ # Design Spec: `Profile.register_adapter` — post-hoc emit-adapter registration **Date:** 2026-06-27 -**Status:** Draft — for review +**Status:** SUPERSEDED (2026-06-29) — the `register_adapter` registry was removed as built-but-unused and architecturally overkill (zero callers; consumers use a consumer-owned dispatch table instead). See `mountainash-central/01.principles/mountainash-settings/h.backlog/remove-register-adapter-registry.md` and the removal PR. The original draft is retained below for history. **Repo:** mountainash-settings **Author:** Nathaniel Ramm (with Claude) diff --git a/src/mountainash_settings/profiles/__init__.py b/src/mountainash_settings/profiles/__init__.py index a584d92..896ddd0 100644 --- a/src/mountainash_settings/profiles/__init__.py +++ b/src/mountainash_settings/profiles/__init__.py @@ -8,13 +8,12 @@ from .lookup import lookup_class_var from .invariants import spec_invariants_for -from .profile import Adapter, Profile, emit_adapter +from .profile import Adapter, Profile from .registry import Registry from .spec import MISSING, Missing, ParameterSpec, ProfileSpec __all__ = [ "Adapter", - "emit_adapter", "MISSING", "Missing", "ParameterSpec", diff --git a/src/mountainash_settings/profiles/profile.py b/src/mountainash_settings/profiles/profile.py index f676cb3..1b6a0b1 100644 --- a/src/mountainash_settings/profiles/profile.py +++ b/src/mountainash_settings/profiles/profile.py @@ -13,8 +13,6 @@ from __future__ import annotations -import inspect -import threading import typing as t import warnings @@ -26,7 +24,7 @@ from .lookup import lookup_class_var from .spec import MISSING, ProfileSpec -__all__ = ["Adapter", "Profile", "emit_adapter"] +__all__ = ["Adapter", "Profile"] # A target adapter composes credential/config kwargs: it receives the profile # and the already-merged (base + driver_key renames) dict, and returns the final @@ -38,29 +36,6 @@ # target, so ``emit()`` can fail closed on target-scoped profiles. _UNSET: t.Any = object() -# Serializes register_adapter's copy-on-write + conflict-check + insert. Registration -# is import-time (already serialized by the import lock); this is defence-in-depth. -_REGISTER_LOCK = threading.RLock() - - -def _check_two_positional(adapter: t.Callable[..., t.Any]) -> None: - """Raise ``TypeError`` unless ``adapter`` can be called with two positional args. - - For C callables / builtins where ``inspect.signature`` is unavailable, accept - after the caller's ``callable()`` check rather than guess. - """ - try: - sig = inspect.signature(adapter) - except (ValueError, TypeError): - return # cannot introspect (C callable) — accept - try: - sig.bind(_UNSET, _UNSET) - except TypeError as exc: - raise TypeError( - "adapter must accept two positional args (profile, merged); " - f"{getattr(adapter, '__name__', adapter)!r} does not: {exc}" - ) from None - def _resolve_spec(cls: type) -> ProfileSpec | None: """Resolve a class's bound spec from __spec__ (new) or __descriptor__ (old). @@ -120,77 +95,6 @@ class Profile(MountainAshBaseSettings): ] = None __adapters__: t.ClassVar[dict[t.Hashable, "Adapter"]] = {} - @classmethod - def register_adapter( - cls, - target: t.Hashable, - adapter: "Adapter", - *, - overwrite: bool = False, - ) -> None: - """Register a per-target ``emit()`` adapter on this Profile subclass. - - ``adapter`` has the 2-arg compose signature ``(profile, merged) -> dict`` - (the same shape as inline ``__adapters__`` entries). After registration, - ``instance.emit(target, base=...)`` routes through it. - - Safe to call at import time from a downstream package: copies - ``__adapters__`` onto ``cls`` first if ``cls`` is still inheriting an - ancestor's map, so registration never mutates a shared/parent dict. - - Idempotent by identity: re-registering the *same* adapter object is a - no-op; a *different* adapter for an existing target raises unless - ``overwrite=True``. - - Raises: - TypeError: if called on ``Profile`` itself, if ``adapter`` is not a - two-positional-arg callable, or if ``target`` is unhashable. - ValueError: if ``target`` is already registered to a different - adapter and ``overwrite`` is False. - """ - if cls is Profile: - raise TypeError( - "register_adapter must be called on a concrete Profile subclass, " - "not Profile itself (would mutate the shared default adapter map)." - ) - if not callable(adapter): - raise TypeError( - f"adapter must be callable, got {type(adapter).__name__}" - ) - _check_two_positional(adapter) - try: - hash(target) - except TypeError as exc: - raise TypeError(f"target must be hashable, got {target!r}") from exc - - with _REGISTER_LOCK: - # Build the new adapter map in full, then rebind in a single atomic - # assignment (last statement). Copy-on-write off the inherited/own map - # snapshots existing entries; the conflict check and insert run on the - # *copy*, so a lock-free emit() — whether it reads __adapters__.get() or - # iterates it in _known_targets() — only ever observes the old complete - # dict or the new complete dict, never a partially mutated one, even on - # re-registration. This also never touches Profile's shared default or a - # parent's map. - new_map = dict(cls.__adapters__) - existing = new_map.get(target, _UNSET) - if existing is not _UNSET and existing is not adapter and not overwrite: - raise ValueError( - f"{cls.__name__} already has an adapter for target {target!r}; " - f"pass overwrite=True to replace it." - ) - new_map[target] = adapter - cls.__adapters__ = new_map - - @classmethod - def registered_adapters(cls) -> dict[t.Hashable, "Adapter"]: - """Return a copy of the effective ``__adapters__`` map for ``cls``. - - Read-only snapshot (own or inherited entries); mutating it does not - affect the class. - """ - return dict(cls.__adapters__) - @classmethod def __pydantic_init_subclass__(cls, **kwargs: t.Any) -> None: """Install fields described by ``__spec__`` on the subclass.""" @@ -397,21 +301,3 @@ def emit( return type(self).__adapter__(self) # legacy 1-arg owns-pipeline return merged - -def emit_adapter( - profile_cls: type["Profile"], - target: t.Hashable, - *, - overwrite: bool = False, -) -> t.Callable[["Adapter"], "Adapter"]: - """Decorator form of :meth:`Profile.register_adapter`. - - Registers the decorated 2-arg adapter on ``profile_cls`` for ``target`` and - returns it unchanged. - """ - def _wrap(fn: "Adapter") -> "Adapter": - profile_cls.register_adapter(target, fn, overwrite=overwrite) - return fn - - return _wrap - diff --git a/tests/unit/profiles/test_register_adapter.py b/tests/unit/profiles/test_register_adapter.py deleted file mode 100644 index 8be724e..0000000 --- a/tests/unit/profiles/test_register_adapter.py +++ /dev/null @@ -1,241 +0,0 @@ -# tests/unit/profiles/test_register_adapter.py -"""Unit tests for Profile.register_adapter / registered_adapters / emit_adapter.""" - -from __future__ import annotations - -import functools - -import pytest - -from mountainash_settings.profiles import ParameterSpec, ProfileSpec, emit_adapter -from mountainash_settings.profiles.profile import Profile - -SPEC = ProfileSpec( - name="reg", - provider_type="reg", - parameters=[ParameterSpec(name="HOST", type=str, tier="core", driver_key="host")], -) - - -def _adapter(profile, merged): - """A valid 2-arg compose adapter.""" - return {**merged, "marked": True} - - -def _make_cls(): - """A fresh Profile subclass with no own __adapters__ (inherits the default).""" - class _RegProfile(Profile): - __spec__ = SPEC - - return _RegProfile - - -@pytest.mark.unit -class TestRegisterAdapter: - def test_registers_and_owns_a_fresh_dict(self): - cls = _make_cls() - assert "__adapters__" not in cls.__dict__ # inheriting the default - cls.register_adapter("t1", _adapter) - assert "__adapters__" in cls.__dict__ # copy-on-write created own dict - assert cls.__adapters__["t1"] is _adapter - - def test_copy_on_write_does_not_pollute_profile_or_siblings(self): - sibling = _make_cls() - cls = _make_cls() - before = dict(Profile.__adapters__) - cls.register_adapter("t1", _adapter) - assert Profile.__adapters__ == before # shared default untouched - assert "__adapters__" not in sibling.__dict__ # sibling unaffected - assert dict(sibling.__adapters__) == before - - def test_root_registration_rejected(self): - before = dict(Profile.__adapters__) - with pytest.raises(TypeError, match="concrete Profile subclass"): - Profile.register_adapter("t1", _adapter) - assert Profile.__adapters__ == before - - def test_non_callable_rejected(self): - cls = _make_cls() - with pytest.raises(TypeError, match="callable"): - cls.register_adapter("t1", 123) - - def test_one_arg_callable_rejected(self): - cls = _make_cls() - with pytest.raises(TypeError, match="two positional"): - cls.register_adapter("t1", lambda profile: {}) - - def test_star_args_callable_accepted(self): - cls = _make_cls() - cls.register_adapter("t1", lambda *a: {}) - assert "t1" in cls.__adapters__ - - def test_idempotent_same_object(self): - cls = _make_cls() - cls.register_adapter("t1", _adapter) - cls.register_adapter("t1", _adapter) # no raise - assert cls.__adapters__["t1"] is _adapter - - def test_conflict_different_object_raises(self): - cls = _make_cls() - cls.register_adapter("t1", _adapter) - with pytest.raises(ValueError, match="already has an adapter"): - cls.register_adapter("t1", lambda p, m: m) - - def test_conflict_overwrite_replaces(self): - cls = _make_cls() - cls.register_adapter("t1", _adapter) - - def other(p, m): - return m - - cls.register_adapter("t1", other, overwrite=True) - assert cls.__adapters__["t1"] is other - - def test_partial_identity_caveat(self): - cls = _make_cls() - a = functools.partial(lambda p, m, x: m, x=1) - b = functools.partial(lambda p, m, x: m, x=1) - cls.register_adapter("t1", a) - with pytest.raises(ValueError, match="already has an adapter"): - cls.register_adapter("t1", b) # distinct partial objects - - def test_uninspectable_callable_accepted(self, monkeypatch): - # C/builtin callables where inspect.signature raises ValueError must be - # accepted after the callable() check (spec §3.2 step 1 fallback). - cls = _make_cls() - - def boom(_obj): - raise ValueError("no signature for C callables") - - monkeypatch.setattr( - "mountainash_settings.profiles.profile.inspect.signature", boom - ) - cls.register_adapter("t1", _adapter) # would reject if the fallback were missing - assert cls.__adapters__["t1"] is _adapter - - def test_concurrent_conflicting_registration_serialized(self): - # Asserts the observable registration contract under contention: when two - # threads race different adapters onto the same target, exactly one wins and - # the other sees the conflict (never zero or two errors). This locks in the - # contract; it does not by itself prove _REGISTER_LOCK is load-bearing — - # under CPython's GIL this short critical section usually serializes even - # unlocked. The lock's necessity is argued by inspection (atomic - # read-check-write across the copy-and-rebind), and is defence-in-depth for - # any future non-CPython / free-threaded runtime. - import threading - - cls = _make_cls() - barrier = threading.Barrier(2) - errors: list[ValueError] = [] - - def a(p, m): - return m - - def b(p, m): - return m - - def worker(adapter): - barrier.wait() # maximize contention - try: - cls.register_adapter("t1", adapter) - except ValueError as exc: - errors.append(exc) - - threads = [ - threading.Thread(target=worker, args=(a,)), - threading.Thread(target=worker, args=(b,)), - ] - for th in threads: - th.start() - for th in threads: - th.join() - - assert len(errors) == 1 # exactly one conflict - assert cls.__adapters__["t1"] in (a, b) # one winner recorded - - -@pytest.mark.unit -class TestRegisteredAdapters: - def test_returns_copy_not_live_dict(self): - cls = _make_cls() - cls.register_adapter("t1", _adapter) - snapshot = cls.registered_adapters() - snapshot["t2"] = _adapter # mutate the returned copy - assert "t2" not in cls.__adapters__ # class map unaffected - - def test_reflects_inherited_entries(self): - cls = _make_cls() - # No own registration yet → reflects the inherited (empty) default. - assert cls.registered_adapters() == dict(Profile.__adapters__) - - -@pytest.mark.unit -class TestEmitAdapterDecorator: - def test_decorator_registers_and_returns_fn(self): - cls = _make_cls() - - @emit_adapter(cls, "t1") - def my_adapter(profile, merged): - return {**merged, "via": "decorator"} - - assert cls.__adapters__["t1"] is my_adapter - assert my_adapter.__name__ == "my_adapter" # returned unchanged - - def test_decorator_honors_overwrite(self): - cls = _make_cls() - cls.register_adapter("t1", _adapter) - - @emit_adapter(cls, "t1", overwrite=True) - def replacement(profile, merged): - return merged - - assert cls.__adapters__["t1"] is replacement - - -@pytest.mark.unit -class TestEmitIntegration: - def test_registered_adapter_routes_through_emit(self): - cls = _make_cls() - - def layer(profile, merged): - return {**merged, "token": "abc"} - - cls.register_adapter("dbx", layer) - p = cls(HOST="h") - # emit merges driver_key (_default_kwargs) then runs the adapter. - assert p.emit("dbx", base={"timeout": 5}) == { - "timeout": 5, "host": "h", "token": "abc", - } - - def test_unregistered_target_fails_closed(self): - cls = _make_cls() - cls.register_adapter("dbx", _adapter) # makes the profile target-scoped - p = cls(HOST="h") - with pytest.raises(ValueError): - p.emit("not-registered") - - def test_child_first_severs_parent_propagation(self): - # Documents the F-4 ordering semantics: a child that registers first owns - # its own dict and does NOT see a target the parent registers later. - class Parent(Profile): - __spec__ = SPEC - - class Child(Parent): - pass - - Child.register_adapter("c", _adapter) # child snapshots its own dict - Parent.register_adapter("p", _adapter) # later parent registration - assert "c" in Child.registered_adapters() - assert "p" not in Child.registered_adapters() # severed - assert "p" in Parent.registered_adapters() - - def test_parent_first_propagates_to_unregistered_child(self): - class Parent(Profile): - __spec__ = SPEC - - class Child(Parent): - pass - - Parent.register_adapter("p", _adapter) # child has no own dict yet - assert "__adapters__" not in Child.__dict__ # Child never copied — pure MRO - assert "p" in Child.registered_adapters() # inherits live through Parent