From 0e04650b5b336c323535e585a726cace77522daf Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 28 Oct 2026 11:30:30 -0400 Subject: [PATCH] Own declared form fields, pages and bounds for every UI backend Toad's Textual forms carried the field semantics (label, help, default, validation, edit, search text, typed field paths, Multiline/Placeholder kinds, the widget-family registry) in toad/forms, where a second backend such as pyqt-reactive would have to restate them. They now live in formbridge.forms.declared_fields, plus two new facts read from the declaration: the declared bounds (annotated_types Ge/Gt/Le/Lt/Interval, merged by Interval field name) and FormPage, the tab a top-level group is shown on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RnH6dR85kyFKcc2tQAJLhK --- README.md | 5 + pyproject.toml | 1 + src/formbridge/forms/declared_fields.py | 313 ++++++++++++++++++++++++ tests/test_declared_fields.py | 73 ++++++ 4 files changed, 392 insertions(+) create mode 100644 src/formbridge/forms/declared_fields.py create mode 100644 tests/test_declared_fields.py diff --git a/README.md b/README.md index 3aeafda..5917778 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,11 @@ UI it talks to. - **Forms** (`formbridge.forms`): the parameter model behind generated forms: parameter info, type analysis, value contracts and the form service. + `formbridge.forms.declared_fields` is what every backend reads a field + through: `DeclaredField` (label, help, default, bounds, search text, + validation and edit), `FieldPath`, the `Multiline` and `Placeholder` kinds, + `FormPage` (which tab a top-level group is shown on) and `DeclaredViews`, + the registry a backend's widget family uses to pick a widget per kind. - **Services** (`formbridge.services`): help documents and parameter help, function-pattern code documents, scope tokens, the pattern data manager, path cache, log discovery and streaming, system metrics, ZMQ server scanning, diff --git a/pyproject.toml b/pyproject.toml index dc777a7..199c711 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ keywords = ["forms", "reactive", "dataclass", "ui-bridge", "headless"] dependencies = [ + "annotated-types>=0.7.0", "docutils>=0.20", "metaclass-registry>=0.4.0", "objectstate>=5.0.0,<6", diff --git a/src/formbridge/forms/declared_fields.py b/src/formbridge/forms/declared_fields.py new file mode 100644 index 0000000..aae3d2e --- /dev/null +++ b/src/formbridge/forms/declared_fields.py @@ -0,0 +1,313 @@ +"""Fields of an ObjectState as their declarations describe them, for every UI backend. + +A backend draws a form by walking `DeclaredField`s and choosing one of its own +widget classes for each through a `DeclaredViews` family. Everything a field +says about itself (label, help, default, kind, bounds, choices, page) is read +here from the dataclass declaration, so no backend restates it. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Iterator +from dataclasses import dataclass, fields, is_dataclass +from functools import cached_property +from inspect import cleandoc +from typing import Annotated, ClassVar, Optional, Self, TypeVar, get_args, get_origin + +from annotated_types import Interval +from objectstate import DottedFieldPath, ObjectState +from python_introspect import ( + ParameterInfo, + UnifiedParameterAnalyzer, + declared_field_label, + optional_member_type, + resolve_annotated, + resolve_optional, +) +from python_introspect.validation import validate_annotation_value + +from formbridge.forms.ui_utils import FieldDisplayText + +Metadata = TypeVar("Metadata") + +BOUND_SYMBOLS = (("gt", ">"), ("ge", ">="), ("lt", "<"), ("le", "<=")) +"""How each `annotated_types.Interval` bound reads.""" + + +@dataclass(frozen=True) +class Placeholder: + """Text for an empty field, not an offered or persisted value.""" + + text: str + + +@dataclass(frozen=True) +class Multiline: + """Text of several lines: `Annotated[str, Multiline()]`.""" + + +@dataclass(frozen=True, eq=False) +class FormPage: + """One page (tab) of a form, named on the top-level fields shown on it. + + `Annotated[Appearance, INTERFACE]` puts that group on the page `INTERFACE`. + A page is one object declared once; the pages of a form and their order + follow from the fields that name them. + """ + + title: str + + +class DeclaredViews: + """A family of widget classes, each registered under what it shows. + + A class directly below this one starts a family. A member names the + declarations it shows in its own body (`shows = (int,)`), so adding a kind + of field is adding a class: nothing lists the members and nothing chooses + between them by testing types. A backend's field widgets and editors are + such families. + """ + + shows: ClassVar[tuple[object, ...]] = () + shown_by: ClassVar[dict[object, Callable]] + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + if DeclaredViews in cls.__bases__: + cls.shown_by = {} + for declaration in vars(cls).get("shows", ()): + cls.shown_by[declaration] = cls + + @classmethod + def showing(cls, declarations: Iterable[object]) -> Callable[..., Self]: + """The member registered under the first of these declarations that has one.""" + declarations = tuple(declarations) + for declaration in declarations: + if declaration in cls.shown_by: + return cls.shown_by[declaration] + raise LookupError(f"No {cls.__name__} shows any of {declarations!r}") + + +def declarations(annotation: object) -> Iterator[object]: + """What an annotation declares, most specific first. + + `Annotated` metadata comes before the type it annotates, `T | None` is + `Optional`, and a class is followed by its bases. + """ + if get_origin(annotation) is Annotated: + base, *metadata = get_args(annotation) + for item in metadata: + yield from type(item).__mro__[:-1] + yield from declarations(base) + elif optional_member_type(annotation) is not None: + yield Optional + else: + declared = get_origin(annotation) or annotation + yield from declared.__mro__[:-1] if isinstance(declared, type) else (declared,) + + +@dataclass(frozen=True) +class DeclaredField: + """One field of an ObjectState: where its value lives and how it was declared.""" + + state: ObjectState + path: DottedFieldPath + + @property + def declaration(self) -> ParameterInfo: + return self.state.parameter_structure.declarations[self.path.value] + + @property + def annotation(self) -> object: + return self.declaration.param_type + + @property + def owner_type(self) -> type: + """The class of the resolved record containing this field. + + ObjectState's declaration owner may instead be a function. Record + serialization needs the containing value's actual class, including + the delegate returned by to_object at the root. + """ + parent = self.path.parent.value + owner = self.state.get_resolved_value(parent) if parent else self.state.to_object() + return type(owner) + + def metadata(self, kind: type[Metadata]) -> Metadata | None: + """Metadata of the bound field, including an optional member's metadata.""" + return next(self.all_metadata(kind), None) + + def all_metadata(self, kind: type[Metadata]) -> Iterator[Metadata]: + """Every item of this kind the field and its optional member declare, outermost first.""" + for annotation in (self.annotation, resolve_optional(resolve_annotated(self.annotation))): + if get_origin(annotation) is Annotated: + for item in get_args(annotation)[1:]: + if isinstance(item, kind): + yield item + + @property + def interval(self) -> Interval: + """The declared bounds (`Ge`, `Gt`, `Le`, `Lt`, `Interval`) merged into one interval. + + Each bound is read by its `Interval` field name, so every annotated_types + bound class and `Interval` itself contribute without a list of classes. + """ + bounds = {field.name for field in fields(Interval)} + return Interval(**{ + field.name: getattr(item, field.name) + for item in self.all_metadata(object) if is_dataclass(item) + for field in fields(item) if field.name in bounds + }) + + @property + def bounds_text(self) -> str: + """The declared bounds as a reader would say them: `>= 1, <= 10`; empty when unbounded.""" + interval = self.interval + return ", ".join( + f"{symbol} {getattr(interval, name)}" + for name, symbol in BOUND_SYMBOLS if getattr(interval, name) is not None + ) + + @property + def value(self) -> object: + return self.state.get_resolved_value(self.path.value) + + @property + def default(self) -> object: + return self.declaration.default_value + + @property + def has_default(self) -> bool: + return not self.declaration.is_required + + @property + def placeholder(self) -> str: + hint = self.metadata(Placeholder) + return hint.text if hint is not None else "" + + @property + def label(self) -> str: + return ( + declared_field_label(self.annotation) + or FieldDisplayText.from_field_name(self.declaration.name).display_name + ) + + @property + def help(self) -> str: + return cleandoc(self.declaration.description or "") + + @cached_property + def search_text(self) -> str: + """What a search over the form matches: this label and every enclosing group's.""" + return " ".join( + DeclaredField(self.state, step).label + for step in self.state.parameter_structure.ancestors(self.path) + ).lower() + + @property + def member(self) -> DeclaredField: + """The same field as declared without `None`.""" + return OptionalMemberField(self.state, self.path) + + def validate(self, value: object) -> None: + """Require this field's declared type and constraints before edit or save.""" + # ObjectState takes any value for a field of the root object and only + # fails later, when the object is rebuilt (ObjectState issue #47). + validate_annotation_value(self.annotation, value, path=self.label) + + def edit(self, value: object) -> None: + """Give the field a value accepted by its declaration.""" + self.validate(value) + self.state.update_parameter(self.path.value, value) + + +class OptionalMemberField(DeclaredField): + """The member editor reads a narrower annotation, not a copied declaration.""" + + @property + def annotation(self) -> object: + return optional_member_type(resolve_annotated(super().annotation)) + + +def form_pages( + state: ObjectState, shown: Iterable[DottedFieldPath] +) -> dict[FormPage, tuple[DottedFieldPath, ...]]: + """The pages of a form and the top-level fields on each, in declaration order. + + Every shown top-level field of a paged form names its page; a form whose + fields name none is one page with no title. + """ + pages: dict[FormPage | None, list[DottedFieldPath]] = {} + for path in shown: + page = DeclaredField(state, path).metadata(FormPage) + pages.setdefault(page, []).append(path) + if None in pages and len(pages) > 1: + unpaged = ", ".join(path.value for path in pages[None]) + raise TypeError(f"Top-level fields {unpaged} name no FormPage beside fields that do") + return {page or FormPage(""): tuple(paths) for page, paths in pages.items()} + + +class FieldPath: + """One field of a dataclass tree: `FieldPath(Settings).sidebar.hide`. + + Each step is checked against the dataclass fields when the path is built, + so a path that exists names a declared field. `str(path)` is the dotted + name ObjectState keeps the field under. + """ + + __slots__ = ("_root", "_names") + + def __init__(self, root: type, names: tuple[str, ...] = ()) -> None: + self._root, self._names = root, names + + def __getattr__(self, name: str) -> FieldPath: + owner = declared_type(self) + if not is_dataclass(owner) or name not in {field.name for field in fields(owner)}: + raise AttributeError(f"{owner!r} declares no field {name!r}") + return FieldPath(self._root, (*self._names, name)) + + def __str__(self) -> str: + return ".".join(self._names) + + def __repr__(self) -> str: + return f"FieldPath({self._root.__name__}, {str(self)!r})" + + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, FieldPath) + and (self._root, self._names) == (other._root, other._names) + ) + + def __hash__(self) -> int: + return hash((self._root, self._names)) + + def bind(self, state: ObjectState) -> DeclaredField: + """Bind this declaration to the field ObjectState declares at this path.""" + if not issubclass(state.type_for_path(""), self._root): + raise TypeError(f"{self!r} does not name a field of this ObjectState") + return DeclaredField(state, state.parameter_structure.path(str(self))) + + def matches(self, field: DeclaredField) -> bool: + """Whether this declaration names the bound field, including its root type.""" + return ( + issubclass(field.state.type_for_path(""), self._root) + and field.path.parts == self._names + ) + + +def parent(path: FieldPath) -> FieldPath: + return FieldPath(path._root, path._names[:-1]) + + +def declared_annotation(path: FieldPath) -> object: + """The field's annotation as written, `Annotated` metadata included.""" + if not path._names: + return path._root + return UnifiedParameterAnalyzer.analyze(declared_type(parent(path)))[ + path._names[-1] + ].param_type + + +def declared_type(path: FieldPath) -> object: + """The type a value of the field has, without `Annotated` and `Optional`.""" + return resolve_annotated(resolve_optional(resolve_annotated(declared_annotation(path)))) diff --git a/tests/test_declared_fields.py b/tests/test_declared_fields.py new file mode 100644 index 0000000..40e174a --- /dev/null +++ b/tests/test_declared_fields.py @@ -0,0 +1,73 @@ +"""A declared field reads its bounds, search text and page from the dataclass declaration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Annotated + +import pytest +from annotated_types import Ge, Interval, Le +from objectstate import ObjectState, set_base_config_type +from python_introspect import FieldLabel + +from formbridge.forms.declared_fields import FieldPath, FormPage, form_pages +from formbridge.forms.parameter_form_service import ParameterFormService + +LOOK = FormPage("Look") +WORK = FormPage("Work") + + +@dataclass(frozen=True) +class Inner: + rows: Annotated[int, FieldLabel("Rows shown"), Ge(1), Le(10)] = 2 + ratio: Annotated[float, Interval(gt=0)] = 1.0 + name: str = "plain" + + +@dataclass(frozen=True) +class Paged: + look: Annotated[Inner, FieldLabel("Appearance"), LOOK] = field(default_factory=Inner) + work: Annotated[Inner, WORK] = field(default_factory=Inner) + more: Annotated[Inner, LOOK] = field(default_factory=Inner) + + +@dataclass(frozen=True) +class HalfPaged: + look: Annotated[Inner, LOOK] = field(default_factory=Inner) + loose: Inner = field(default_factory=Inner) + + +def state_of(value: object) -> ObjectState: + set_base_config_type(type(value)) + return ObjectState(value) + + +def shown(state: ObjectState): + return [path for path, _ in ParameterFormService().parameters_for_state(state, "")] + + +def test_bounds_merge_every_declared_bound(): + state = state_of(Paged()) + rows = FieldPath(Paged).look.rows.bind(state) + assert (rows.interval.ge, rows.interval.le) == (1, 10) + assert rows.bounds_text == ">= 1, <= 10" + assert FieldPath(Paged).look.ratio.bind(state).bounds_text == "> 0" + assert FieldPath(Paged).look.name.bind(state).bounds_text == "" + + +def test_search_text_holds_every_enclosing_label(): + state = state_of(Paged()) + assert FieldPath(Paged).look.rows.bind(state).search_text == "appearance rows shown" + + +def test_pages_follow_the_fields_that_name_them_in_declaration_order(): + state = state_of(Paged()) + pages = form_pages(state, shown(state)) + assert list(pages) == [LOOK, WORK] + assert [path.value for path in pages[LOOK]] == ["look", "more"] + + +def test_a_paged_form_refuses_a_top_level_field_without_a_page(): + state = state_of(HalfPaged()) + with pytest.raises(TypeError, match="loose"): + form_pages(state, shown(state))