Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions src/attune_forms/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
50 changes: 50 additions & 0 deletions tests/test_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []
Loading