From a495d2b884143742c5617303d87a8b5807f3bf98 Mon Sep 17 00:00:00 2001 From: GeneAI Date: Tue, 25 Aug 2026 12:31:26 -0400 Subject: [PATCH] feat(bridge): resolve form_to_widget_html from the bridge namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two surface renderers were split across submodules — bridge.form_to_askuserquestion and widget.form_to_widget_html — so a caller reaching for one and then the other by submodule path got an ImportError that reads as "this renderer does not exist". Hit live while following attune-ai CLAUDE.md D21, which names form_to_widget_html without naming its module. Served via a module __getattr__, not a top-level import: attune_forms.widget imports is_fully_inferred from bridge, so hoisting it would be a circular import AND would invert the layering (the renderer builds on the bridge, not the reverse). A drift guard asserts the bridge module body has no attune_forms.widget import, since "simplifying" the accessor away is the obvious future regression. Both renderers stay exported from the package root, which remains the preferred import path. 814 tests pass. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 13 ++++++++++ src/attune_forms/bridge.py | 21 ++++++++++++++++ tests/test_bridge.py | 50 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74188cb..dbd045a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ follow [SemVer](https://semver.org/). ## [Unreleased] +### Added + +- `attune_forms.bridge.form_to_widget_html` — the widget renderer now + resolves from the bridge namespace too, so a caller reaching for + `form_to_askuserquestion` and then its sibling renderer finds both in + one place. Served through a module `__getattr__` rather than a + top-level import, deliberately: `attune_forms.widget` imports + `is_fully_inferred` from `bridge`, so hoisting the import would be a + circular import and would invert the layering (the renderer builds on + the bridge, not the reverse). A drift guard pins the accessor lazy. + Both renderers remain exported from the package root, which stays the + preferred import path. + ## [0.8.0] — 2026-08-24 Per-stage form telemetry: the lifecycle is now measurable end to end diff --git a/src/attune_forms/bridge.py b/src/attune_forms/bridge.py index 6bf2222..4f87a2d 100644 --- a/src/attune_forms/bridge.py +++ b/src/attune_forms/bridge.py @@ -1961,3 +1961,24 @@ def collect_form_response( raise FormValidationError(problems) return FormResponse(template_id=template_id, responses=responses) + + +def __getattr__(name: str) -> Any: + """Serve the widget renderer from the bridge namespace. + + :func:`~attune_forms.widget.form_to_widget_html` is the sibling of + :func:`form_to_askuserquestion` — one surface each — but it cannot be + imported at module scope here: :mod:`attune_forms.widget` imports + :func:`is_fully_inferred` from this module, so a top-level re-export + would be a circular import (and would invert the layering, since the + renderer builds on the bridge, not the other way round). + + Resolving it lazily keeps that layering intact while letting callers + who reach for one renderer find the other in the same place. Both are + also exported from the package root, which is the preferred import. + """ + if name == "form_to_widget_html": + from attune_forms.widget import form_to_widget_html + + return form_to_widget_html + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tests/test_bridge.py b/tests/test_bridge.py index b7e2733..3b2b98a 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -458,3 +458,53 @@ def test_documented_aliases_stay_accepted(self): } ) assert form.questions[0].text == "Q" + + +class TestWidgetRendererReachableFromBridge: + """The two surface renderers resolve from one namespace. + + ``form_to_askuserquestion`` lives in ``bridge`` and + ``form_to_widget_html`` in ``widget``; a caller reaching for one and + then the other by submodule path used to get an ImportError, which + reads as "this renderer does not exist". + """ + + def test_widget_renderer_resolves_from_bridge(self): + from attune_forms import bridge, widget + + assert bridge.form_to_widget_html is widget.form_to_widget_html + + def test_both_renderers_resolve_from_package_root(self): + import attune_forms + + assert hasattr(attune_forms, "form_to_askuserquestion") + assert hasattr(attune_forms, "form_to_widget_html") + + def test_bridge_still_raises_on_unknown_attribute(self): + import pytest + + from attune_forms import bridge + + with pytest.raises(AttributeError): + _ = bridge.NoSuchRenderer + + def test_bridge_has_no_module_level_widget_import(self): + """The accessor must stay lazy — a top-level import would be circular. + + ``attune_forms.widget`` imports ``is_fully_inferred`` from this + module, so hoisting the import out of ``__getattr__`` (an easy + "simplification") reintroduces the cycle this indirection exists + to avoid. + """ + import ast + import pathlib + + import attune_forms.bridge as bridge_mod + + tree = ast.parse(pathlib.Path(bridge_mod.__file__).read_text()) + module_level_imports = [ + node + for node in tree.body + if isinstance(node, ast.ImportFrom) and (node.module or "").startswith("attune_forms.widget") + ] + assert module_level_imports == []