diff --git a/src/formbridge/bridge/dto.py b/src/formbridge/bridge/dto.py index 44a4949..be02551 100644 --- a/src/formbridge/bridge/dto.py +++ b/src/formbridge/bridge/dto.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass, field, replace from enum import StrEnum from math import isfinite @@ -11,6 +11,7 @@ from metaclass_registry import AutoRegisterMeta from objectstate import DottedFieldPath +from objectstate.field_value import FieldValue, Unset from python_introspect import ( JsonObject, JsonValue, @@ -48,7 +49,6 @@ SelectionModeCarrier, ) from formbridge.services.widget_tree_projection_config import ( - COMPACT_FIELD_PROJECTION_METADATA_KEY, CompactFieldProjection, WidgetNodeIdentity, WidgetTreeProjectionControls, @@ -303,7 +303,7 @@ class RawResolvedFields(UiObjectStateFieldFilter): @classmethod def matches(cls, field: UiObjectStateFieldSummary) -> bool: - return field.raw_value_is_none != field.resolved_value_is_none + return field.inherited_value class SemanticFields(UiObjectStateFieldFilter): @@ -1352,42 +1352,70 @@ class UiWidgetRect: height: int -@dataclass(frozen=True, slots=True) -class UiObjectStateValuePreview: - """Bounded display preview for an ObjectState field value.""" +@dataclass(frozen=True) +class UiObjectStateValuePreview(ABC): + """A preview renders itself; absence never impersonates a null datum.""" + + @abstractmethod + def render(self, value: FieldValue, format_value: Callable[[object], str]) -> str: + """Render this preview or request a display projection of the value.""" + + +@dataclass(frozen=True) +class UiObjectStateNoPreview(UiObjectStateValuePreview): + def render(self, value: FieldValue, format_value: Callable[[object], str]) -> str: + return value.project(format_value) + - type_name: str - is_none: bool +@dataclass(frozen=True) +class UiObjectStateTextPreview(UiObjectStateValuePreview): text: str truncated: bool = False + def render(self, value: FieldValue, format_value: Callable[[object], str]) -> str: + return self.text + + +@dataclass(frozen=True, slots=True) +class UiObjectStateFieldDeclaration: + """The declaring Python symbol, independent of a field's current datum. + + Remote documentation needs this address even for absent or null values. + Import resolution belongs here, not in consumers parsing display strings. + """ + + module: str + qualname: str + + @classmethod + def of(cls, owner) -> UiObjectStateFieldDeclaration: + return cls(module=owner.__module__, qualname=owner.__qualname__) + + @property + def import_path(self) -> str: + return f"{self.module}.{self.qualname}" + + @property + def display_name(self) -> str: + return self.qualname.rsplit(".", 1)[-1] + + def resolve(self): + from importlib import import_module + + symbol = import_module(self.module) + for name in self.qualname.split("."): + symbol = vars(symbol)[name] + if not callable(symbol): + raise TypeError(f"Declaration is not a class or callable: {self.import_path!r}") + return symbol + @dataclass(frozen=True, kw_only=True) class UiObjectStateFieldValuePreviewCarrier: """Shared raw/resolved value previews for ObjectState-aware UI DTOs.""" - raw_value_is_none: bool = False - resolved_value_is_none: bool = False - raw_value_preview: UiObjectStateValuePreview | None = field( - default=None, - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=lambda owner, field_value: ( - field_value is not None or owner.raw_value_is_none - ) - ) - }, - ) - resolved_value_preview: UiObjectStateValuePreview | None = field( - default=None, - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=lambda owner, field_value: ( - field_value is not None or owner.resolved_value_is_none - ) - ) - }, - ) + raw_value_preview: UiObjectStateValuePreview = field(default_factory=UiObjectStateNoPreview) + resolved_value_preview: UiObjectStateValuePreview = field(default_factory=UiObjectStateNoPreview) @dataclass(frozen=True, kw_only=True) @@ -1398,8 +1426,8 @@ class UiObjectStateFieldSemanticCarrier(UiObjectStateFieldValuePreviewCarrier): signature_diff: bool = False last_changed: bool = False semantic_markers: tuple[str, ...] = () - raw_value: JsonValue | None = None - resolved_value: JsonValue | None = None + raw_value: FieldValue = field(default_factory=Unset) + resolved_value: FieldValue = field(default_factory=Unset) inherited_value: bool = False provenance: UiObjectStateFieldProvenance | None = None @@ -1443,49 +1471,49 @@ class UiWidgetTreeNode(WidgetNodeIdentity): class UiWidgetActionSummary(UiWidgetActionSemanticCarrier, WidgetNodeIdentity): label: str | None = field( metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( + CompactFieldProjection: CompactFieldProjection( includes=always_project_compact_field ) } ) visible: bool = field( metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( + CompactFieldProjection: CompactFieldProjection( includes=always_project_compact_field ) } ) enabled: bool = field( metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( + CompactFieldProjection: CompactFieldProjection( includes=always_project_compact_field ) } ) geometry: UiWidgetRect = field( metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( + CompactFieldProjection: CompactFieldProjection( includes=always_project_compact_field ) } ) global_geometry: UiWidgetRect = field( metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( + CompactFieldProjection: CompactFieldProjection( includes=always_project_compact_field ) } ) action_kinds: tuple[str, ...] = field( metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( + CompactFieldProjection: CompactFieldProjection( includes=always_project_compact_field ) } ) clickable: bool = field( metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( + CompactFieldProjection: CompactFieldProjection( includes=always_project_compact_field ) } @@ -1493,7 +1521,7 @@ class UiWidgetActionSummary(UiWidgetActionSemanticCarrier, WidgetNodeIdentity): checkable: bool | None checked: bool | None = field( metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( + CompactFieldProjection: CompactFieldProjection( includes=lambda owner, field_value: ( isinstance(field_value, bool) and (field_value or owner.checkable is True) ) @@ -1506,35 +1534,15 @@ class UiWidgetActionSummary(UiWidgetActionSemanticCarrier, WidgetNodeIdentity): tool_tip: str item_texts: tuple[str, ...] = () context_label: str | None = None - raw_value: JsonValue | None = field( - default=None, - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=lambda owner, field_value: ( - owner.raw_value_preview is None - and (field_value is not None or owner.raw_value_is_none) - ) - ) - }, - ) - resolved_value: JsonValue | None = field( - default=None, - metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( - includes=lambda owner, field_value: ( - owner.resolved_value_preview is None - and (field_value is not None or owner.resolved_value_is_none) - ) - ) - }, - ) - def compact_projection(self) -> JsonObject: """Return the field-declaration-owned compact action projection.""" + # Serialize through declared field types before selecting compact keys; + # serializing detached values would erase their member-family tags. + payload = to_jsonable(self) return { - field_name: to_jsonable(field_value) - for field_name, field_value in compact_dataclass_projection(self).items() + field_name: payload[field_name] + for field_name in compact_dataclass_projection(self) } @@ -1684,7 +1692,6 @@ class UiObjectStateFieldProvenance: """Resolved source address for an inherited ObjectState field.""" source_scope_id: str | None - source_type: str | None source_field_path: str | None = None @@ -1696,9 +1703,7 @@ class UiObjectStateFieldSummary(UiObjectStateFieldSemanticCarrier): address: UiSemanticAddress field_name: str container_path: str - object_state_path_type: str - raw_value_type: str - resolved_value_type: str | None + declared_by: UiObjectStateFieldDeclaration parameter_description: str | None = None @@ -1791,8 +1796,6 @@ class UiObjectStateFieldHelpResult(AgentResultEnvelope): address: UiSemanticAddress field: UiObjectStateFieldSummary | None = None - object_type: str | None = None - help_target_type: str | None = None parameter_name: str | None = None target_summary: str | None = None target_description: str | None = None @@ -1869,7 +1872,6 @@ class UiObjectStateFieldMutationResult(AgentResultEnvelope): class UiObjectStateScopeSummary(UiFieldCatalogPageCarrier): schema_version: str identity: UiObjectStateScopeIdentity - object_type: str parameter_count: int dirty_field_count: int signature_diff_field_count: int @@ -1989,9 +1991,7 @@ class UiObjectStateFieldProjection(UiObjectStateFieldSemanticCarrier): field_path: str field_name: str container_path: str - object_state_path_type: str - raw_value_type: str - resolved_value_type: str | None + declared_by: UiObjectStateFieldDeclaration @dataclass(frozen=True, slots=True) @@ -1999,7 +1999,6 @@ class UiObjectStateFieldScopeProjection: """Compact ObjectState scope row for field-list query results.""" scope_id: str - object_type: str dirty_field_count: int signature_diff_field_count: int has_unsaved_changes: bool diff --git a/src/formbridge/bridge/field_projection.py b/src/formbridge/bridge/field_projection.py index 96ac085..52dddc4 100644 --- a/src/formbridge/bridge/field_projection.py +++ b/src/formbridge/bridge/field_projection.py @@ -5,6 +5,7 @@ from collections.abc import Mapping from python_introspect import JsonValue, project_dataclass +from objectstate.field_value import FieldValue from formbridge.bridge.dto import ( SemanticFields, @@ -77,7 +78,6 @@ def project_catalog( matched_scopes.append( UiObjectStateFieldScopeProjection( scope_id=scope_id, - object_type=scope.object_type, dirty_field_count=scope.dirty_field_count, signature_diff_field_count=scope.signature_diff_field_count, has_unsaved_changes=scope.has_unsaved_changes, @@ -128,11 +128,15 @@ def project_field( resolved_value=cls.compact_value(query, field.resolved_value), ) + @classmethod + def compact_value(cls, query: UiObjectStateFieldListQuery, member: FieldValue) -> FieldValue: + return FieldValue.of(member.map(lambda datum: cls._compact_datum(query, datum))) + @staticmethod - def compact_value( + def _compact_datum( query: UiObjectStateFieldListQuery, - value: JsonValue | None, - ) -> JsonValue | None: + value: JsonValue, + ) -> JsonValue: if isinstance(value, str) and len(value) > query.max_value_chars: return { "type": "str", diff --git a/src/formbridge/bridge/object_state.py b/src/formbridge/bridge/object_state.py index b68062d..2c8d876 100644 --- a/src/formbridge/bridge/object_state.py +++ b/src/formbridge/bridge/object_state.py @@ -5,15 +5,16 @@ import hashlib import logging from collections.abc import Callable, Mapping, Sequence, Set -from dataclasses import dataclass, is_dataclass +from dataclasses import dataclass from enum import Enum from typing import ClassVar from objectstate import ObjectState, ObjectStateRegistry -from objectstate.inspection import FieldInspection, type_name, type_qualname +from objectstate.inspection import FieldInspection +from objectstate.field_value import FieldValue, Unset +from objectstate.field_access import DottedFieldPath, FieldAccessError from python_introspect import ( JsonValue, - UnifiedParameterAnalyzer, project_dataclass, to_jsonable, ) @@ -45,6 +46,8 @@ UiObjectStateFieldProvenance, UiObjectStateFieldSemanticCarrier, UiObjectStateFieldSummary, + UiObjectStateFieldDeclaration, + UiObjectStateTextPreview, UiObjectStateScopeCatalog, UiObjectStateScopeIdentity, UiObjectStateScopeListRequest, @@ -162,25 +165,13 @@ def provider_identity(self, title: str) -> UiCodeDocumentProviderIdentity: class ObjectStateFieldSemanticProjection(FieldInspection): """One inspected ObjectState field projected onto the bridge's DTOs.""" - @property - def raw_json_value(self) -> JsonValue: - return self.json_value(self.raw_value) - - @property - def resolved_json_value(self) -> JsonValue | None: - if not self.resolved_value_available: - return None - return self.json_value(self.resolved_value) - @property def raw_value_preview(self) -> UiObjectStateValuePreview: - return self.value_preview(self.raw_value) + return self.raw.project(self.value_preview) @property - def resolved_value_preview(self) -> UiObjectStateValuePreview | None: - if not self.resolved_value_available: - return None - return self.value_preview(self.resolved_value) + def resolved_value_preview(self) -> UiObjectStateValuePreview: + return self.resolved.project(self.value_preview) def semantic_address(self, *, window_id: str | None = None) -> UiSemanticAddress: return UiSemanticAddress( @@ -191,7 +182,7 @@ def semantic_address(self, *, window_id: str | None = None) -> UiSemanticAddress @property def agent_scope_id(self) -> str: - return agent_object_state_scope_id(self.state.scope_id) or self.state.scope_id + return agent_object_state_scope_id(self.field.state.scope_id) or self.field.state.scope_id def to_semantic_carrier( self, @@ -205,16 +196,19 @@ def to_semantic_carrier( signature_diff=self.signature_diff, last_changed=self.last_changed, semantic_markers=self.semantic_markers, - raw_value=self.raw_json_value if include_values else None, - resolved_value=self.resolved_json_value if include_values else None, + raw_value=self._payload_member(self.raw, include_values), + resolved_value=self._payload_member(self.resolved, include_values), raw_value_preview=self.raw_value_preview, resolved_value_preview=self.resolved_value_preview, - raw_value_is_none=self.raw_value_is_none, - resolved_value_is_none=self.resolved_value_is_none, inherited_value=self.inherited_value, provenance=self.provenance_dto, ) + def _payload_member(self, member: FieldValue, include_values: bool) -> FieldValue: + # An excluded datum is explicitly absent from this projection. Keep + # directives intact; never pretend omitted data is literal null. + return FieldValue.of(member.map(self.json_value if include_values else lambda value: Unset())) + def to_field_summary( self, *, @@ -223,21 +217,19 @@ def to_field_summary( ) -> UiObjectStateFieldSummary: parameter_description = None if include_description: - parameter_description = self.state.parameter_descriptions.get(self.field_path) + parameter_description = self.field.description return project_dataclass( UiObjectStateFieldSummary, self.to_semantic_carrier(include_values=include_values), schema_version=SCHEMA_VERSION, address=self.semantic_address( window_id=UiStableWindowIdentityDeclaration.window_id_for_manager_scope( - self.state.scope_id + self.field.state.scope_id ), ), field_name=self.field_name, container_path=self.container_path, - object_state_path_type=self.declaring_type, - raw_value_type=self.raw_value_type, - resolved_value_type=self.resolved_value_type, + declared_by=UiObjectStateFieldDeclaration.of(self.field.declaring_type), parameter_description=parameter_description, ) @@ -247,7 +239,6 @@ def provenance_dto(self) -> UiObjectStateFieldProvenance | None: return None return UiObjectStateFieldProvenance( source_scope_id=agent_object_state_scope_id(self.provenance.source_scope_id), - source_type=type_qualname(self.provenance.source_type), source_field_path=self.provenance.source_field_path, ) @@ -264,9 +255,7 @@ def json_value(value) -> JsonValue: @classmethod def value_preview(cls, value) -> UiObjectStateValuePreview: text, truncated = cls.preview_text(value) - return UiObjectStateValuePreview( - type_name=type_name(value), - is_none=value is None, + return UiObjectStateTextPreview( text=text, truncated=truncated, ) @@ -300,8 +289,6 @@ def _preview_text_unbounded(cls, value) -> str: ): items = tuple(cls._preview_item_text(item) for item in cls._first_items(value)) return f"{type(value).__name__}(len={len(value)}, items={items!r})" - if is_dataclass(value) and not isinstance(value, type): - return repr(value) return repr(value) @staticmethod @@ -368,8 +355,7 @@ def summary( identity=UiObjectStateScopeIdentity( object_state_scope_id=agent_object_state_scope_id(state.scope_id) or state.scope_id, ), - object_type=type(state.object_instance).__name__, - parameter_count=len(state.parameters), + parameter_count=len(state.fields), dirty_field_count=len(state.dirty_fields), signature_diff_field_count=len(state.signature_diff_fields), has_unsaved_changes=bool(state.dirty_fields), @@ -387,20 +373,17 @@ def _field_page( if not request.include_fields: return (), None + available = tuple(state.fields) if request.field_paths: - all_field_paths = tuple( - field_path for field_path in request.field_paths if field_path in state.parameters - ) + all_field_paths = tuple(item for item in available if item.address.value in request.field_paths) else: - all_field_paths = tuple(sorted(state.parameters.keys())) + all_field_paths = tuple(sorted(available, key=lambda item: item.address.value)) if request.field_filter is not AllFields: all_field_paths = tuple( field_path for field_path in all_field_paths if request.field_filter.matches( - self._field_summary( - state, - field_path, + ObjectStateFieldSemanticProjection.of(field_path).to_field_summary( include_values=False, include_description=False, ), @@ -423,9 +406,7 @@ def _field_page( next_offset=next_page_offset, ) fields = tuple( - self._field_summary( - state, - field_path, + ObjectStateFieldSemanticProjection.of(field_path).to_field_summary( include_values=request.include_field_values, include_description=request.include_field_descriptions, ) @@ -433,21 +414,7 @@ def _field_page( ) return fields, page - def _field_summary( - self, - state: ObjectState, - field_path: str, - *, - include_values: bool, - include_description: bool, - ) -> UiObjectStateFieldSummary: - return ObjectStateFieldSemanticProjection.of( - state, - field_path, - ).to_field_summary( - include_values=include_values, - include_description=include_description, - ) + def object_state_for_agent_scope_id(scope_id: str) -> ObjectState | None: @@ -486,7 +453,9 @@ def mutate( ), ), ) - if request.field_path not in state.parameters: + try: + field = DottedFieldPath(request.field_path).bind(state) + except FieldAccessError: return self._error_result( request, AgentError( @@ -497,18 +466,15 @@ def mutate( ), ) - before = self._field_summary(state, request) + before = ObjectStateFieldSemanticProjection.of(field).to_field_summary(include_values=request.include_field_values) try: if self._before_mutation is not None: self._before_mutation(request.object_state_scope_id) if request.reset: - state.reset_parameter(request.field_path) + field.reset() else: - state.update_parameter( - request.field_path, - self._converted_value(state, request), - ) - after = self._field_summary(state, request) + field.write(self._converted_value(field, request.value)) + after = ObjectStateFieldSemanticProjection.of(field).to_field_summary(include_values=request.include_field_values) return UiObjectStateFieldMutationResult( schema_version=SCHEMA_VERSION, address=after.address, @@ -525,33 +491,15 @@ def mutate( before=before, ) - @staticmethod - def _field_summary( - state: ObjectState, - request: UiObjectStateFieldMutationRequest, - ) -> UiObjectStateFieldSummary: - return ObjectStateFieldSemanticProjection.of( - state, - request.field_path, - ).to_field_summary(include_values=request.include_field_values) + @staticmethod - def _converted_value( - state: ObjectState, - request: UiObjectStateFieldMutationRequest, - ) -> object: - help_target = state.type_for_path(request.field_path) - parameter_name = request.field_path.rsplit(".", 1)[-1] - parameter_info = UnifiedParameterAnalyzer.analyze(help_target).get(parameter_name) - if parameter_info is None: - return request.value + def _converted_value(field, value): return ParameterFormService().convert_value_to_type( - request.value, - parameter_info.param_type, - parameter_name, - help_target, + value, field.annotation, field.name, field.declaring_type, ) + @staticmethod def _error_result( request: UiObjectStateFieldMutationRequest, diff --git a/src/formbridge/bridge/server.py b/src/formbridge/bridge/server.py index c91189d..495788a 100644 --- a/src/formbridge/bridge/server.py +++ b/src/formbridge/bridge/server.py @@ -2,6 +2,8 @@ from __future__ import annotations +from objectstate.ui_visibility import UIParameterVisibilityPolicy + import json import os import pickle @@ -73,12 +75,12 @@ class UiBridgeServerConfig(EndpointConnectionSpec): """Exact descriptor path; overrides ``descriptor_directory_path``.""" bridge_instance_id: NonBlankString | None = field( default=None, - metadata={"ui_hidden": True}, + metadata=UIParameterVisibilityPolicy.field_metadata(True), ) """Stable bridge instance identifier; None generates a fresh ui-prefixed one at startup.""" auth_token: NonBlankString | None = field( default=None, - metadata={"ui_hidden": True}, + metadata=UIParameterVisibilityPolicy.field_metadata(True), ) """Authentication token required by bridge clients. None generates a random token at startup.""" poll_timeout_ms: PositiveInteger = 100 diff --git a/src/formbridge/forms/parameter_form_service.py b/src/formbridge/forms/parameter_form_service.py index 37468b9..805e2a7 100644 --- a/src/formbridge/forms/parameter_form_service.py +++ b/src/formbridge/forms/parameter_form_service.py @@ -7,6 +7,9 @@ Uses React-style discriminated unions for type-safe parameter handling. """ +from objectstate.field_value import Unset +from objectstate.declarations import ValueDeclaration +from objectstate.construction_binding import admit_target import dataclasses from collections.abc import ( @@ -25,6 +28,7 @@ DataclassFieldAccess, LazyDefaultPlaceholderService, UIParameterVisibilityRequest, + UIParameterVisibilityPolicy, has_lazy_resolution, should_hide_ui_parameter, ) @@ -34,6 +38,7 @@ declared_annotation_choices, is_union_type, resolve_annotated, + resolve_optional, ) from formbridge.forms.parameter_form_constants import CONSTANTS @@ -92,7 +97,9 @@ class FormStructure: field_id: str parameters: list[ParameterInfo] nested_forms: dict[str, "FormStructure"] - has_optional_dataclasses: bool = False + @property + def has_optional_dataclasses(self) -> bool: + return any(info.optional_parameters() for info in self.parameters) def get_parameter_info(self, param_name: str) -> ParameterInfo | None: """ @@ -130,98 +137,18 @@ def __init__(self): self._type_utils = ParameterTypeUtils() def analyze_parameters(self, input: ParameterAnalysisInput) -> FormStructure: - """ - Analyze parameters and create form structure. - - This method analyzes the parameters and their types to create a complete - form structure that can be used by any UI framework. - - Args: - input: Type-safe parameter analysis input (field names match UnifiedParameterInfo) - - Returns: - Complete form structure information - """ - debug_param( - "analyze_parameters", - f"field_id={input.field_id}, parameter_count={len(input.default_value)}", - ) - - import logging - - logger = logging.getLogger(__name__) - logger.debug( - "analyze_parameters: field_id=%s param_type.keys()=%s", - input.field_id, - list(input.param_type.keys()), - ) - - param_infos = [] + parameters = [] nested_forms = {} - has_optional_dataclasses = False - - for param_name, parameter_type in input.param_type.items(): - current_value = input.default_value.get(param_name) - - # Check if this parameter should be hidden from UI - if self._should_hide_from_ui(input.parent_obj_type, param_name, parameter_type): - debug_param( - "analyze_parameters", f"Hiding parameter {param_name} from UI (ui_hidden=True)" - ) + for name, annotation in input.param_type.items(): + if self._should_hide_from_ui(input.parent_obj_type, name, annotation): continue - - # CRITICAL FIX: Build full dotted path for description lookup - # When nested_field_id is set (e.g., "well_filter_config"), pass the full - # dotted path to _create_parameter_info so it builds the key that - # state._parameter_descriptions uses (dotted paths). - full_param_path = f"{input.field_id}.{param_name}" if input.field_id else param_name - - # Create parameter info with full dotted path - param_info = self._create_parameter_info( - param_name, parameter_type, current_value, input.description, full_param_path + full_path = f"{input.field_id}.{name}" if input.field_id else name + info = self._create_parameter_info( + name, annotation, input.default_value.get(name, Unset()), input.description, full_path, ) - param_infos.append(param_info) - - # Check for nested dataclasses using isinstance (type-safe!) - from formbridge.forms.parameter_info_types import ( - DirectDataclassInfo, - OptionalDataclassInfo, - ) - - if isinstance(param_info, (OptionalDataclassInfo, DirectDataclassInfo)): - # Get actual field path from FieldPathDetector (no artificial "nested_" prefix) - # Unwrap Optional types to get the actual dataclass type for field path detection - unwrapped_param_type = self._unwrap_optional_dataclass_type(parameter_type) - - # For function parameters (no parent dataclass), use parameter name directly - if input.parent_obj_type is None: - nested_field_id = param_name - else: - nested_field_id = self.resolve_declared_field_path( - input.parent_obj_type, - param_name, - unwrapped_param_type, - ) - - nested_structure = self._analyze_nested_dataclass( - param_name, - parameter_type, - current_value, - nested_field_id, - input.parent_obj_type, - ) - nested_forms[param_name] = nested_structure - - # Check for optional dataclasses using isinstance (type-safe!) - if isinstance(param_info, OptionalDataclassInfo): - has_optional_dataclasses = True - - return FormStructure( - field_id=input.field_id, - parameters=param_infos, - nested_forms=nested_forms, - has_optional_dataclasses=has_optional_dataclasses, - ) + parameters.append(info) + nested_forms.update(info.nested_forms(self, input)) + return FormStructure(field_id=input.field_id, parameters=parameters, nested_forms=nested_forms) def _should_hide_from_ui( self, @@ -244,7 +171,7 @@ def _should_hide_from_ui( UIParameterVisibilityRequest( owner_type=parent_obj_type, field_name=param_name, - field_type_candidate=self._unwrap_optional_dataclass_type(param_type), + field_type_candidate=resolve_optional(resolve_annotated(param_type)), field_metadata_hidden=self._field_metadata_declares_hidden( parent_obj_type, param_name, @@ -252,31 +179,22 @@ def _should_hide_from_ui( ) ) - def _unwrap_optional_dataclass_type(self, param_type: type) -> type: - """Return the dataclass type behind Optional[T], or the type itself.""" - if self._type_utils.is_optional_dataclass(param_type): - return self._type_utils.get_optional_inner_type(param_type) - return param_type - def _field_metadata_declares_hidden( - self, - parent_obj_type: type | None, - param_name: str, - ) -> bool: - """Return True when a dataclass field carries UI-hidden metadata.""" - if parent_obj_type is None: - return False - if not dataclasses.is_dataclass(parent_obj_type): - return False - - field_by_name = {field.name: field for field in dataclasses.fields(parent_obj_type)} - field_obj = field_by_name.get(param_name) - if field_obj is None: - return False - return field_obj.metadata.get("ui_hidden", False) + def _field_metadata_declares_hidden(self, parent_obj_type, param_name) -> bool: + return any(item.name == param_name and UIParameterVisibilityPolicy.field_hidden(item) + for owner in ValueDeclaration.of(parent_obj_type).configuration_owners() + for item in owner.declared_fields()) def convert_value_to_type( self, value: ParameterValue, param_type: type, param_name: str, obj_type: type = None + ) -> ParameterValue: + """Convert domain input through its annotation; retain field directives.""" + return ValueDeclaration.of(param_type).admit(value).map( + lambda raw: self._convert_domain_value(raw, param_type, param_name, obj_type) + ) + + def _convert_domain_value( + self, value: ParameterValue, param_type: type, param_name: str, obj_type: type = None ) -> ParameterValue | None: """ Convert a value to the appropriate type for a parameter. @@ -302,10 +220,6 @@ def convert_value_to_type( if value is None: return None - # Handle string "None" literal - if isinstance(value, str) and value == CONSTANTS.NONE_STRING_LITERAL: - return None - choices = declared_annotation_choices(param_type) if choices is not None: return self._convert_choice_value(value, choices, param_name) @@ -337,7 +251,7 @@ def convert_value_to_type( return self._type_utils.convert_string_to_bool(value) if resolved_type in (int, float) and isinstance(value, str): if value == CONSTANTS.EMPTY_STRING: - return None + return Unset() try: return resolved_type(value) except (ValueError, TypeError) as exc: @@ -346,15 +260,6 @@ def convert_value_to_type( f"{param_name!r}: {value!r}" ) from exc - # Handle empty strings in lazy context - convert to None for all parameter types - # This is critical for lazy dataclass behavior where None triggers placeholder resolution - if isinstance(value, str) and value == CONSTANTS.EMPTY_STRING: - return None - - # Handle string types - also convert empty strings to None for consistency - if resolved_type is str and isinstance(value, str) and value == CONSTANTS.EMPTY_STRING: - return None - return value def _convert_value_by_annotation( @@ -384,18 +289,16 @@ def _convert_value_by_annotation( return value return coerce_enum_member(param_type, value) - if dataclasses.is_dataclass(param_type): - return self._convert_dataclass_value(value, param_type, param_name) + return ValueDeclaration.of(param_type).convert_structure(self, value, param_name) + def convert_leaf_structure(self, value, param_type, param_name): + origin = get_origin(param_type) if origin in (tuple, SequenceABC): return self._convert_tuple_value(value, param_type, param_name) - if origin is list: return self._convert_list_value(value, param_type, param_name) - if origin in (dict, MappingABC): return self._convert_dict_value(value, param_type, param_name) - return _NO_CONVERSION @staticmethod @@ -457,41 +360,6 @@ def _convert_union_value( f"expected {_type_label(param_type)}, got {type(value).__name__}." ) - def _convert_dataclass_value( - self, - value: ParameterValue, - dataclass_type: type, - param_name: str, - ) -> ParameterValue | object: - if isinstance(value, dataclass_type): - return value - if not isinstance(value, dict): - return _NO_CONVERSION - - try: - type_hints = get_type_hints(dataclass_type, include_extras=True) - except Exception: - type_hints = {} - - kwargs = {} - for field in dataclasses.fields(dataclass_type): - if field.name not in value: - continue - field_value = value[field.name] - field_type = type_hints.get(field.name, field.type) - converted = self._convert_value_by_annotation( - field_value, - field_type, - field.name, - ) - kwargs[field.name] = field_value if converted is _NO_CONVERSION else converted - - try: - return dataclass_type(**kwargs) - except Exception as exc: - raise ValueError( - f"Invalid dataclass value for parameter {param_name!r}: {value!r}" - ) from exc def _convert_tuple_value( self, @@ -620,34 +488,6 @@ def get_parameter_display_info( "tooltip": f"{text.display_name} ({_type_label(param_type)})", } - def resolve_declared_field_path( - self, - parent_type: type, - param_name: str, - param_type: type, - ) -> str: - """Validate and return the already-declared nested field name.""" - if not param_name: - raise ValueError("Nested form field name cannot be empty.") - - if not dataclasses.is_dataclass(parent_type): - return param_name - - try: - field_types = get_type_hints(parent_type) - except Exception as exc: - raise ValueError(f"Cannot resolve declared fields for {parent_type!r}.") from exc - - if param_name not in field_types: - raise ValueError(f"{parent_type.__name__} has no declared field {param_name!r}.") - - declared_type = self._unwrap_optional_dataclass_type(field_types[param_name]) - if declared_type is not param_type: - raise ValueError( - f"{parent_type.__name__}.{param_name} declares " - f"{_type_label(declared_type)}, not {_type_label(param_type)}." - ) - return param_name def generate_field_ids_direct(self, base_field_id: str, param_name: str) -> dict[str, str]: """Generate field IDs directly without artificial complexity.""" @@ -659,74 +499,18 @@ def generate_field_ids_direct(self, base_field_id: str, param_name: str) -> dict "optional_checkbox_id": f"{base_field_id}_{param_name}_enabled", } - def should_use_concrete_values( - self, current_value: ParameterValue | None, is_global_editing: bool = False - ) -> bool: - """ - Determine whether to use concrete values for a dataclass parameter. - - Args: - current_value: The current parameter value - is_global_editing: Whether in global configuration editing mode - - Returns: - True if concrete values should be used - """ - if current_value is None: - return False - - if is_global_editing: - return True - - # If current_value is a concrete dataclass instance, use its values - if self._type_utils.is_concrete_dataclass(current_value): - return True - - # For lazy dataclasses, return True so we can extract raw values from them - if self._type_utils.is_lazy_dataclass(current_value): - return True - - return False - - def extract_nested_parameters( - self, - dataclass_instance: FormObject | None, - obj_type: type, - parent_obj_type: type | None = None, - ) -> tuple[ParameterDefaultsByName, ParameterTypesByName]: - """ - Extract parameters and types from a dataclass instance. - - This method always preserves concrete field values when a dataclass instance exists, - regardless of parent context. Placeholder behavior is handled at the widget level, - not by discarding concrete values during parameter extraction. - """ - if not dataclasses.is_dataclass(obj_type): - return ParameterDefaultsByName(), ParameterTypesByName() + def extract_nested_parameters(self, dataclass_instance, obj_type, parent_obj_type=None): + source = admit_target(dataclass_instance) + declaration = ValueDeclaration.of(obj_type) parameters = ParameterDefaultsByName() parameter_types = ParameterTypesByName() - - for field in dataclasses.fields(obj_type): - # Always extract actual field values when dataclass instance exists - # This preserves concrete user-entered values in nested lazy dataclass forms - if dataclass_instance is not None: - current_value = self._get_field_value(dataclass_instance, field) - else: - current_value = None # Only use None when no instance exists - - parameters[field.name] = current_value - parameter_types[field.name] = field.type - + annotations = declaration.field_annotations() + for name in source.declaration.parameters: + parameters[name] = source.read_raw(name).stored + parameter_types[name] = annotations[name] return parameters, parameter_types - def _get_field_value( - self, dataclass_instance: FormObject | None, field: dataclasses.Field - ) -> ParameterValue: - """Extract a single field value from a dataclass instance.""" - if dataclass_instance is None: - return field.default - return DataclassFieldAccess.raw_value(dataclass_instance, field.name) def _create_parameter_info( self, @@ -769,10 +553,7 @@ def _analyze_nested_dataclass( ) -> FormStructure: """Analyze a nested dataclass parameter.""" # Get the actual dataclass type - if self._type_utils.is_optional_dataclass(param_type): - obj_type = self._type_utils.get_optional_inner_type(param_type) - else: - obj_type = param_type + obj_type = resolve_optional(resolve_annotated(param_type)) # Extract nested parameters using parent context nested_params, nested_types = self.extract_nested_parameters( @@ -848,102 +629,3 @@ def reset_nested_managers( for nested_manager in nested_managers.values(): # All nested managers must have reset_all_parameters method nested_manager.reset_all_parameters() - - def get_reset_value_for_parameter( - self, - param_name: str, - param_type: type, - obj_type: type, - is_global_config_editing: bool | None = None, - ) -> ParameterValue | None: - """ - Get the reset value for one parameter. - - Args: - param_name: Name of the parameter to reset - param_type: Type of the parameter (int, str, bool, etc.) - obj_type: The specific dataclass type - is_global_config_editing: Whether global config is being edited (detected if None) - - Returns: - - For global config editing: Actual default values - - For lazy config editing: None to show placeholder text - """ - # Context-driven behavior: Use the editing context to determine reset behavior - # This follows the architectural principle that behavior is determined by context - # of usage rather than intrinsic properties of the dataclass. - - # Context-driven behavior: Use explicit context when provided - # Auto-detect editing mode if not explicitly provided - if is_global_config_editing is None: - is_global_config_editing = not has_lazy_resolution(obj_type) - - # Context-driven behavior: Reset behavior depends on editing context - if is_global_config_editing: - # Global config editing: Reset to actual default values - # Users expect to see concrete defaults when editing global configuration - return self._get_actual_dataclass_field_default(param_name, obj_type) - else: - # CRITICAL FIX: For lazy config editing, always return None - # This ensures reset shows inheritance chain values (like compiler resolution) - # instead of concrete values from thread-local context - return None - - def _get_actual_dataclass_field_default( - self, param_name: str, obj_type: type - ) -> ParameterValue | None: - """ - Get the actual default value for a parameter. - - Works uniformly for dataclasses, functions, and any other object type. - Always returns None for non-existent fields (fail-soft for dynamic properties). - - Returns: - - If class attribute is None → return None (show placeholder) - - If class attribute has concrete value → return that value - - If field(default_factory) → call default_factory and return result - - If field doesn't exist → return None (dynamic property) - """ - import inspect - from dataclasses import MISSING, fields, is_dataclass - - # For pure functions: get default from signature - if callable(obj_type) and not is_dataclass(obj_type) and not inspect.isclass(obj_type): - sig = inspect.signature(obj_type) - if param_name in sig.parameters: - default = sig.parameters[param_name].default - return None if default is inspect.Parameter.empty else default - return None # Dynamic property, not in signature - - # For all other types (dataclasses, ABCs, classes): check class attribute first - sentinel = object() - class_value = inspect.getattr_static(obj_type, param_name, sentinel) - if class_value is not sentinel: - return class_value - - # For dataclasses: check if it's a field(default_factory=...) field - if is_dataclass(obj_type): - dataclass_fields = {f.name: f for f in fields(obj_type)} - if param_name not in dataclass_fields: - return None # Dynamic property, not a dataclass field - - field_info = dataclass_fields[param_name] - - # Handle field(default_factory=...) case - if field_info.default_factory is not MISSING: - try: - return field_info.default_factory() - except Exception as e: - raise ValueError( - f"Failed to call default_factory for field '{param_name}': {e}" - ) from e - - # Handle field with explicit default - if field_info.default is not MISSING: - return field_info.default - - # Field has no default (should not happen in practice) - return None - - # For non-dataclass types: return None (dynamic property) - return None diff --git a/src/formbridge/forms/parameter_info_types.py b/src/formbridge/forms/parameter_info_types.py index 6ac6081..71e902e 100644 --- a/src/formbridge/forms/parameter_info_types.py +++ b/src/formbridge/forms/parameter_info_types.py @@ -1,359 +1,131 @@ -""" -Discriminated union types for parameter information. - -This module implements React-style discriminated unions for type-safe parameter handling. -Instead of using boolean flags (is_optional, is_nested), we use polymorphic types that -are automatically selected based on type annotations. - -Key features: -1. Metaclass auto-registration - all ParameterInfo subclasses auto-register -2. Type-driven factory - create_parameter_info() uses type introspection -3. Zero boilerplate - just define new dataclass with matches() predicate -4. Type-safe dispatch - services use class name for automatic dispatch - -Pattern (React-style): - Instead of: - @dataclass - class ParameterInfo: - is_optional: bool - is_nested: bool - - if info.is_optional and info.is_nested: - # handle optional dataclass - - Use: - @dataclass - class OptionalDataclassInfo(metaclass=ParameterInfoMeta): - @staticmethod - def matches(param_type): ... - - if isinstance(info, OptionalDataclassInfo): - # Type checker knows info is OptionalDataclassInfo! - -Architecture: - - ParameterInfoMeta: Metaclass that auto-registers all subclasses - - ParameterInfoBase: Base class for all parameter info types - - OptionalDataclassInfo: Optional[Dataclass] parameters (checkbox-controlled) - - DirectDataclassInfo: Direct Dataclass parameters (always exists) - - GenericInfo: Generic parameters (simple widgets) - - create_parameter_info(): Factory that auto-selects correct type -""" - -import logging -from abc import ABC, ABCMeta +"""Parameter declarations select their UI behavior through admitted values.""" + +from abc import ABC from collections.abc import Callable -from dataclasses import dataclass, is_dataclass +from dataclasses import dataclass -from objectstate.lazy_factory import get_base_type_for_lazy -from python_introspect import optional_member_type +from objectstate.field_value import FieldValue, Unset +from objectstate.declarations import ValueDeclaration +from python_introspect import optional_member_type, resolve_annotated, resolve_declared_type from formbridge.forms.parameter_value_contracts import ParameterValue -logger = logging.getLogger(__name__) - - InlineDataclassWidgetFactory = Callable class _InlineDataclassWidgetCatalog: - """Private registry authority for dataclasses with structural inline editors.""" + """Authority for registered structural editors, not configuration kinds.""" _factories: dict[type, InlineDataclassWidgetFactory] = {} @classmethod - def register( - cls, - dataclass_type: type, - widget_factory: InlineDataclassWidgetFactory, - ) -> None: - if not is_dataclass(dataclass_type): - raise TypeError( - "Inline dataclass widgets can only be registered for dataclass types; " - f"got {dataclass_type!r}." - ) + def register(cls, dataclass_type: type, widget_factory: InlineDataclassWidgetFactory) -> None: + ValueDeclaration.of(dataclass_type).require_config_type() + dataclass_type = resolve_declared_type(dataclass_type) registered = cls._factories.get(dataclass_type) if registered is not None and registered is not widget_factory: - raise ValueError( - f"{dataclass_type.__qualname__} already has an inline widget factory " - f"({registered!r}); it cannot also use {widget_factory!r}." - ) + raise ValueError(f"{dataclass_type.__qualname__} already has an inline widget factory ({registered!r}); it cannot also use {widget_factory!r}.") cls._factories[dataclass_type] = widget_factory @classmethod def factory_for(cls, dataclass_type: type) -> InlineDataclassWidgetFactory | None: - for candidate_type in cls._candidate_types(dataclass_type): - factory = cls._factories.get(candidate_type) - if factory is not None: - return factory + for candidate in resolve_declared_type(dataclass_type).__mro__: + if candidate in cls._factories: + return cls._factories[candidate] return None @classmethod - def has_factory_for(cls, dataclass_type: type) -> bool: - return cls.factory_for(dataclass_type) is not None + def declaration(cls, declared_type, **arguments): + factory = cls.factory_for(declared_type) + return DirectDataclassInfo(**arguments) if factory is None else InlineDataclassWidgetInfo(**arguments) - @classmethod - def _candidate_types(cls, dataclass_type: type) -> tuple[type, ...]: - """Return exact, lazy-base, and inherited dataclass types for lookup.""" - - base_type = get_base_type_for_lazy(dataclass_type) - root_types = ( - (dataclass_type,) - if base_type is None or base_type is dataclass_type - else (dataclass_type, base_type) - ) - candidates: list[type] = [] - seen: set[type] = set() - for root_type in root_types: - for candidate_type in root_type.__mro__: - if candidate_type is object or candidate_type in seen: - continue - candidates.append(candidate_type) - seen.add(candidate_type) - return tuple(candidates) - - -def register_inline_dataclass_widget( - dataclass_type: type, - widget_factory: InlineDataclassWidgetFactory, -) -> None: - """Register a full-row editor for a dataclass type before nested fallback.""" + +def register_inline_dataclass_widget(dataclass_type: type, widget_factory: InlineDataclassWidgetFactory) -> None: _InlineDataclassWidgetCatalog.register(dataclass_type, widget_factory) @dataclass class ParameterInfoBase(ABC): - """ABC for parameter information objects - enforces explicit interface.""" - name: str type: type - current_value: ParameterValue | None + current_value: ParameterValue description: str | None = None + default_value: ParameterValue = Unset() + is_required: bool = True + def creation_type(self, kinds): + return kinds.REGULAR - # Widget creation type - subclasses override. Imported lazily to avoid circular imports. - widget_creation_type: str = "REGULAR" # Default, overridden by subclasses - - -class ParameterInfoMeta(ABCMeta): - """ - Metaclass for auto-registration of ParameterInfo types. - - All classes with a matches() method are automatically registered - in the global registry for use by the factory function. - - This eliminates manual registration and enables zero-boilerplate - addition of new parameter types. - """ - - _registry: list[type] = [] + @classmethod + def scalar_declaration(cls, declared_type, *, nullable=False, **arguments): + return GenericInfo(**arguments) - def __new__(mcs, name, bases, namespace): - cls = super().__new__(mcs, name, bases, namespace) + @classmethod + def config_declaration(cls, declared_type, *, nullable=False, **arguments): + if nullable: + return OptionalDataclassInfo(**arguments) + return _InlineDataclassWidgetCatalog.declaration(declared_type, **arguments) - # Auto-register if it has a matches() predicate - if "matches" in namespace and callable(namespace["matches"]): - mcs._registry.append(cls) - logger.debug(f"Auto-registered ParameterInfo type: {name}") + def nested_forms(self, service, analysis_input): + return {} - return cls - - @classmethod - def get_registry(cls) -> list[type]: - """Get all registered ParameterInfo types.""" - return cls._registry.copy() + def optional_parameters(self): + return () @dataclass -class OptionalDataclassInfo(ParameterInfoBase, metaclass=ParameterInfoMeta): - """ - Parameter info for Optional[Dataclass] types. - - These parameters: - - Have a checkbox to enable/disable - - Have a nested form that appears when enabled - - Can be None when checkbox is unchecked - - Support lazy inheritance from parent configs - - Examples: - def process(config: Optional[ProcessingConfig]): ... - def analyze(settings: Optional[AnalysisSettings]): ... - """ - - default_value: ParameterValue | None = None - is_required: bool = True - widget_creation_type: str = "OPTIONAL_NESTED" +class DirectDataclassInfo(ParameterInfoBase): + def creation_type(self, kinds): + return kinds.NESTED - @staticmethod - def matches(param_type: type) -> bool: - """ - Predicate: Does this type annotation match Optional[Dataclass]? - - Returns True if: - - Type is Union[T, None] (i.e., Optional[T]) - - T is a dataclass - """ - inner_type = optional_member_type(param_type) - return inner_type is not None and is_dataclass(inner_type) + def nested_forms(self, service, analysis_input): + return {self.name: service._analyze_nested_dataclass( + self.name, self.type, self.current_value, self.name, analysis_input.parent_obj_type, + )} @dataclass -class InlineDataclassWidgetInfo(ParameterInfoBase, metaclass=ParameterInfoMeta): - """ - Parameter info for dataclasses with a registered structural inline editor. +class OptionalDataclassInfo(DirectDataclassInfo): + def creation_type(self, kinds): + return kinds.OPTIONAL_NESTED - These parameters: - - Are still regular dataclass-backed values - - Use a registered widget instead of the default nested field form - - Fall back to DirectDataclassInfo when no inline widget is registered - """ + def optional_parameters(self): + return (self,) - default_value: ParameterValue | None = None - is_required: bool = True - widget_creation_type: str = "INLINE_DATACLASS" - @staticmethod - def matches(param_type: type) -> bool: - return is_dataclass(param_type) and _InlineDataclassWidgetCatalog.has_factory_for( - param_type - ) +@dataclass +class InlineDataclassWidgetInfo(ParameterInfoBase): + def creation_type(self, kinds): + return kinds.INLINE_DATACLASS def widget_factory(self) -> InlineDataclassWidgetFactory: - factory = _InlineDataclassWidgetCatalog.factory_for(self.type) + factory = _InlineDataclassWidgetCatalog.factory_for(resolve_annotated(self.type)) if factory is None: - raise ValueError( - "No inline dataclass widget registered for " - f"{self.type!r}; DirectDataclassInfo should have handled it." - ) + raise ValueError(f"No inline dataclass widget registered for {self.type!r}.") return factory @dataclass -class DirectDataclassInfo(ParameterInfoBase, metaclass=ParameterInfoMeta): - """ - Parameter info for direct Dataclass types (non-optional). - - These parameters: - - Always exist (never None) - - Have a nested form that's always visible - - Preserve object identity during reset - - Don't have a checkbox - - Examples: - def process(config: ProcessingConfig): ... - def analyze(settings: AnalysisSettings): ... - """ - - default_value: ParameterValue | None = None - is_required: bool = True - widget_creation_type: str = "NESTED" - - @staticmethod - def matches(param_type: type) -> bool: - """ - Predicate: Does this type annotation match a direct Dataclass? - - Returns True if: - - Type is a dataclass - - Type is NOT Optional - """ - return is_dataclass(param_type) - - -@dataclass -class GenericInfo(ParameterInfoBase, metaclass=ParameterInfoMeta): - """ - Parameter info for generic types (int, str, Path, etc.). - - These parameters: - - Use simple widgets (QLineEdit, QSpinBox, etc.) - - Don't have nested forms - - Support lazy inheritance via placeholders - - Are the most common parameter type - - Examples: - def process(threshold: int): ... - def analyze(input_path: Path): ... - def filter(sigma: float): ... - """ - - default_value: ParameterValue | None = None - is_required: bool = True - - @staticmethod - def matches(param_type: type) -> bool: - """ - Predicate: Fallback - matches everything. - - This should be registered LAST in the registry so it acts - as a catch-all for any types not matched by other predicates. - """ - return True +class GenericInfo(ParameterInfoBase): + pass -# Union type for type hints (React-style) -ParameterInfo = ( - OptionalDataclassInfo | InlineDataclassWidgetInfo | DirectDataclassInfo | GenericInfo -) +ParameterInfo = OptionalDataclassInfo | InlineDataclassWidgetInfo | DirectDataclassInfo | GenericInfo def create_parameter_info( name: str, param_type: type, - current_value: ParameterValue | None, - default_value: ParameterValue | None = None, + current_value: ParameterValue, + default_value: ParameterValue = Unset(), description: str | None = None, is_required: bool = True, ) -> ParameterInfo: - """ - Factory function that auto-selects the correct ParameterInfo subclass. - - Uses type introspection to determine which ParameterInfo type to create. - This eliminates manual if-elif-else chains and enables type-safe dispatch. - - Args: - name: Parameter name - param_type: Parameter type annotation - current_value: Current parameter value - default_value: Default parameter value - description: Parameter description - is_required: Whether parameter is required - - Returns: - Correct ParameterInfo subclass instance - - Raises: - ValueError: If no matching ParameterInfo type found - - Examples: - >>> from typing import Optional - >>> @dataclass - ... class Config: pass - - >>> info1 = create_parameter_info('config', Optional[Config], None) - >>> type(info1).__name__ - 'OptionalDataclassInfo' - - >>> info2 = create_parameter_info('config', Config, Config()) - >>> type(info2).__name__ - 'DirectDataclassInfo' - - >>> info3 = create_parameter_info('value', int, 42) - >>> type(info3).__name__ - 'GenericInfo' - """ - # Iterate through registered types and find first match - for info_class in ParameterInfoMeta.get_registry(): - if info_class.matches(param_type): - return info_class( - name=name, - type=param_type, - current_value=current_value, - default_value=default_value, - description=description, - is_required=is_required, - ) - - # Should never reach here due to GenericInfo fallback - raise ValueError( - f"No matching ParameterInfo type for {param_type}. " - f"This should never happen - GenericInfo should match everything." + annotation = resolve_annotated(param_type) + inner = optional_member_type(annotation) + declaration = ValueDeclaration.of(annotation if inner is None else resolve_annotated(inner)) + return declaration.choose_declaration( + ParameterInfoBase, + nullable=inner is not None, + name=name, type=param_type, current_value=current_value, + default_value=default_value, description=description, is_required=is_required, ) diff --git a/src/formbridge/forms/parameter_type_utils.py b/src/formbridge/forms/parameter_type_utils.py index 26d5fb0..c3d37e8 100644 --- a/src/formbridge/forms/parameter_type_utils.py +++ b/src/formbridge/forms/parameter_type_utils.py @@ -5,10 +5,10 @@ code duplication between PyQt and Textual parameter form implementations. """ -import dataclasses from enum import Enum from objectstate.lazy_factory import LazyDataclass +from objectstate.field_access import DataclassFieldAccess from python_introspect import ( get_enum_from_list, optional_member_type, @@ -55,31 +55,6 @@ def is_optional(param_type: type) -> bool: """ return optional_member_type(param_type) is not None - @staticmethod - def is_optional_dataclass(param_type: type) -> bool: - """ - Check if parameter type is Optional[dataclass]. - - This method determines whether a type annotation represents an optional - dataclass parameter (Union[DataclassType, None]). - - Args: - param_type: The type to check - - Returns: - True if the type is Optional[dataclass], False otherwise - - Example: - >>> from typing import Optional - >>> @dataclass - ... class Config: pass - >>> ParameterTypeUtils.is_optional_dataclass(Optional[Config]) - True - >>> ParameterTypeUtils.is_optional_dataclass(Config) - False - """ - optional_type = optional_member_type(param_type) - return optional_type is not None and dataclasses.is_dataclass(optional_type) @staticmethod def get_optional_inner_type(param_type: type) -> type: @@ -146,74 +121,10 @@ def get_enum_from_list_type(param_type: type) -> type | None: """ return get_enum_from_list(param_type) - @staticmethod - def has_dataclass_fields(obj: any) -> bool: - """ - Check if an object has dataclass fields. - - Args: - obj: The object to check - - Returns: - True if the object has __dataclass_fields__ attribute - """ - return dataclasses.is_dataclass(obj) - - @staticmethod - def has_resolve_field_value(obj: any) -> bool: - """ - Check if an object has the _resolve_field_value method (lazy dataclass). - - Args: - obj: The object to check - - Returns: - True if the object has _resolve_field_value attribute - """ - return isinstance(obj, LazyDataclass) - - @staticmethod - def is_concrete_dataclass(obj: any) -> bool: - """ - Check if an object is a concrete (non-lazy) dataclass. - - Args: - obj: The object to check - - Returns: - True if the object is a concrete dataclass - """ - return ParameterTypeUtils.has_dataclass_fields( - obj - ) and not ParameterTypeUtils.has_resolve_field_value(obj) - - @staticmethod - def is_lazy_dataclass(obj: any) -> bool: - """ - Check if an object is a lazy dataclass. - Args: - obj: The object to check - Returns: - True if the object is a lazy dataclass - """ - return ParameterTypeUtils.has_resolve_field_value(obj) - @staticmethod - def extract_value_attribute(obj: any) -> any: - """ - Extract the value attribute from an object if it exists. - - This is commonly used for enum values and other wrapped types. - - Args: - obj: The object to extract value from - Returns: - The value attribute if it exists, otherwise the original object - """ - return obj.value if isinstance(obj, Enum) else obj @staticmethod def convert_string_to_bool(value: str) -> bool: diff --git a/src/formbridge/forms/raw_display_value.py b/src/formbridge/forms/raw_display_value.py index 083043c..194a04c 100644 --- a/src/formbridge/forms/raw_display_value.py +++ b/src/formbridge/forms/raw_display_value.py @@ -2,17 +2,19 @@ from __future__ import annotations -from dataclasses import dataclass, fields +from dataclasses import dataclass from objectstate import DataclassFieldAccess -from objectstate.lazy_factory import get_base_type_for_lazy, replace_raw +from objectstate.lazy_factory import replace_raw +from objectstate.declarations import ValueDeclaration +from python_introspect import resolve_declared_type @dataclass(frozen=True, slots=True) class RawDisplayValue: """One dataclass value as edited (``raw``) and as shown (``display``). - ``raw`` holds what this owner declares: an unset field is ``None`` there. + ``raw`` preserves literal null, inheritance and absence as distinct meanings. ``display`` is the same value with inherited fields filled in. Keeping the two in one record keeps the question "did this owner supply the field, or was it inherited" answerable for every field (:meth:`shows_inherited`). @@ -38,7 +40,7 @@ def shown_as_edited(cls, value_type: type, raw: object) -> RawDisplayValue: return cls(value_type, raw, raw) def _declared_type(self, value: object) -> type: - declared = get_base_type_for_lazy(type(value)) or type(value) + declared = resolve_declared_type(type(value)) if not issubclass(declared, self.value_type): raise TypeError( f"Expected {self.value_type.__name__}, got {type(value).__name__}." @@ -64,19 +66,15 @@ def display_field(self, field_name: str) -> object: def shows_inherited(self, field_name: str) -> bool: """Whether the shown field was inherited rather than declared here.""" - return ( - self.raw_field(field_name) is None - and self.display_field(field_name) is not None + declaration = ValueDeclaration.of(self.declared_type) + return declaration.read_raw(self.raw, field_name).inherited_from( + declaration.read_raw(self.display, field_name) ) def field_default(self, field_name: str) -> object: """The declared default of one field of the concrete dataclass.""" - return next( - item.default - for item in fields(self.declared_type) - if item.name == field_name - ) + return ValueDeclaration.of(self.declared_type).own_default(field_name).stored def replacing(self, **changes: object) -> RawDisplayValue: """The value after an edit: the fields change in both ``raw`` and ``display``.""" diff --git a/src/formbridge/managers/preview_formatting.py b/src/formbridge/managers/preview_formatting.py index 6155e37..c1d1aac 100644 --- a/src/formbridge/managers/preview_formatting.py +++ b/src/formbridge/managers/preview_formatting.py @@ -1,6 +1,7 @@ """Preview field formatting for manager list items.""" -from dataclasses import is_dataclass +from objectstate.declarations import ValueDeclaration +from formbridge.protocols.preview_formatter import PreviewFormatterRegistry from formbridge.utils.preview_formatters import ( PreviewFieldFormatRequest, @@ -15,12 +16,12 @@ class ManagerPreviewFieldFormatter: def format_field(self, request: PreviewFieldFormatRequest) -> str | None: """Format a value using metadata from its actual declaring type.""" + declaration = ValueDeclaration.of(request.field_owner).owner_scope.field_types[request.field_name] + return declaration.admit(request.value).render_preview(self, request) + + def scalar_preview(self, request): if request.value is None: return None - - if is_dataclass(request.value) and not isinstance(request.value, type): - return self._format_dataclass_value(request) - formatted = request.value_formatter(request.value) if formatted is None: return None @@ -31,9 +32,7 @@ def format_field(self, request: PreviewFieldFormatRequest) -> str | None: abbrev = abbreviation.abbreviation if abbreviation is not None else request.field_name return f"{abbrev}:{formatted}" - def _format_dataclass_value(self, request: PreviewFieldFormatRequest) -> str | None: - from formbridge.protocols.preview_formatter import PreviewFormatterRegistry - + def configuration_preview(self, request: PreviewFieldFormatRequest) -> str | None: formatted = PreviewFormatterRegistry.format_field(request.value, request.field_name) if formatted is None: formatted = self._preview_label_for_config(request.value) diff --git a/src/formbridge/services/code_editor_form_updater.py b/src/formbridge/services/code_editor_form_updater.py index f052896..6f945da 100644 --- a/src/formbridge/services/code_editor_form_updater.py +++ b/src/formbridge/services/code_editor_form_updater.py @@ -1,236 +1,19 @@ -"""Shared utility for updating forms from code editor changes. - -This module provides utilities for parsing edited code and updating form -managers with only the explicitly set fields, preserving ``None`` values for -unspecified fields. -""" +"""Apply code-editor fields through admitted handles and the existing mutation owner.""" from __future__ import annotations -import logging -from dataclasses import fields, is_dataclass -from typing import Any, get_args, get_origin - -from objectstate import semantic_values_equal +from typing import Any -logger = logging.getLogger(__name__) +from objectstate.construction_binding import admit_target class CodeEditorFormUpdater: - """Utility for updating forms from code editor changes. - - The code editor always produces a *concrete* instance (dataclass or - regular object). For lazy configs, we rely on the pattern that raw - attribute access (``object.__getattribute__``) returns ``None`` for - inherited / unset fields. The form layer then interprets ``None`` as - "show placeholder", while concrete values override placeholders. - """ - @staticmethod - def update_form_from_instance( - form_manager, - new_instance: Any, - broadcast_callback=None, - ) -> None: - """Update a form manager with values from a new instance. - - Args: - form_manager: ParameterFormManager instance. - new_instance: New object/dataclass instance with updated values. - broadcast_callback: Optional callback to broadcast the new - instance (e.g., to an event bus). - """ - - is_instance_dataclass = is_dataclass(new_instance) - if is_instance_dataclass: - instance_fields = [field.name for field in fields(new_instance)] - else: - # Fallback: drive off whatever the form currently knows about. - instance_fields = list(form_manager.parameters.keys()) - - for field_name in instance_fields: - if field_name not in form_manager.parameters: - logger.debug( - "CodeEditorFormUpdater: field %s missing from form_manager %s", - field_name, - getattr(form_manager, "field_id", ""), - ) - continue - - if is_instance_dataclass: - new_value = CodeEditorFormUpdater._get_raw_field_value(new_instance, field_name) - else: - new_value = getattr(new_instance, field_name, None) - - if is_dataclass(new_value) and not isinstance(new_value, type): - CodeEditorFormUpdater._update_nested_dataclass(form_manager, field_name, new_value) - else: - logger.debug("CodeEditorFormUpdater: updating %s to %r", field_name, new_value) - CodeEditorFormUpdater._update_parameter_if_changed( - form_manager, - field_name, - new_value, - ) - - # NOTE: - # Cross-window propagation and placeholder refresh are handled by - # ParameterFormManager.update_parameter together with the - # FieldChangeDispatcher / LiveContextService stack. Callers usually - # do NOT need to trigger a global refresh after using this helper; - # only special flows (e.g., global-config snapshot restore/cancel) - # should call ParameterFormManager.trigger_global_cross_window_refresh(). - - if broadcast_callback: + def update_form_from_instance(form_manager, new_instance: Any, broadcast_callback=None) -> None: + source = admit_target(new_instance) + for field in form_manager.fields.children: + member = source.read_raw(field.name) + if member != field.raw_member: + form_manager.update_parameter(field.name, member.stored) + if broadcast_callback is not None: broadcast_callback(new_instance) - - # ------------------------------------------------------------------ - # Nested dataclass handling - # ------------------------------------------------------------------ - - @staticmethod - def _update_nested_dataclass( - form_manager, - field_name: str, - new_value: Any, - ) -> None: - """Recursively update a nested dataclass field and all its children. - - ``new_value`` is a concrete dataclass instance coming from the code - editor. We walk its fields and drive the *nested* manager entirely via - ``update_parameter`` so that FieldChangeDispatcher and - ValueCollectionService can do the right thing (parent reconstruction, - _user_set_fields tracking, cross-window propagation, etc.). - """ - - nested_manager = form_manager.nested_managers.get(field_name) - if not nested_manager: - # No dedicated nested manager – treat as a regular field on the - # parent. This is still routed through FieldChangeDispatcher. - CodeEditorFormUpdater._update_parameter_if_changed( - form_manager, - field_name, - new_value, - ) - return - - for field in fields(new_value): - nested_field_value = CodeEditorFormUpdater._get_raw_field_value(new_value, field.name) - if field.name not in nested_manager.parameters: - continue - - if is_dataclass(nested_field_value) and not isinstance(nested_field_value, type): - CodeEditorFormUpdater._update_nested_dataclass_in_manager( - nested_manager, field.name, nested_field_value - ) - else: - CodeEditorFormUpdater._update_parameter_if_changed( - nested_manager, - field.name, - nested_field_value, - ) - - @staticmethod - def _update_nested_dataclass_in_manager( - manager, - field_name: str, - new_value: Any, - ) -> None: - """Helper to update nested dataclass within a specific manager.""" - - nested_manager = manager.nested_managers.get(field_name) - if not nested_manager: - CodeEditorFormUpdater._update_parameter_if_changed( - manager, - field_name, - new_value, - ) - return - - for field in fields(new_value): - nested_field_value = CodeEditorFormUpdater._get_raw_field_value(new_value, field.name) - if field.name not in nested_manager.parameters: - continue - - if is_dataclass(nested_field_value) and not isinstance(nested_field_value, type): - CodeEditorFormUpdater._update_nested_dataclass_in_manager( - nested_manager, field.name, nested_field_value - ) - else: - CodeEditorFormUpdater._update_parameter_if_changed( - nested_manager, - field.name, - nested_field_value, - ) - - @staticmethod - def _update_parameter_if_changed( - form_manager, - field_name: str, - new_value: Any, - ) -> bool: - """Dispatch one code-mode field only when its raw value changed. - - ``ObjectState`` already rejects identical assignments before cache - invalidation, but ``ParameterFormManager.update_parameter`` performs - widget synchronization and emits root/cross-window signals before the - caller can benefit from that no-op. Code documents reconstruct a whole - object, so filtering against the manager's raw, path-scoped parameters - avoids turning a one-field edit into one dispatch per dataclass field. - - Returns: - ``True`` when the field was sent through the normal form dispatcher. - """ - - current_parameters = form_manager.parameters - if field_name not in current_parameters: - return False - if semantic_values_equal( - current_parameters[field_name], - new_value, - ): - logger.debug( - "CodeEditorFormUpdater: skipping unchanged field %s", - field_name, - ) - return False - - form_manager.update_parameter(field_name, new_value) - return True - - @staticmethod - def _get_raw_field_value(obj: Any, field_name: str): - """Fetch field without triggering lazy ``__getattr__`` logic.""" - - try: - return object.__getattribute__(obj, field_name) - except AttributeError: - return getattr(obj, field_name, None) - - @staticmethod - def _is_dataclass(field_type: Any) -> bool: - """Check if a field type represents (or wraps) a dataclass.""" - - origin = get_origin(field_type) - if origin is not None: - return any( - CodeEditorFormUpdater._is_dataclass(arg) - for arg in get_args(field_type) - if arg is not type(None) - ) - try: - return is_dataclass(field_type) - except TypeError: - return False - - @staticmethod - def _get_dataclass_field_value(instance: Any, field_obj) -> Any: - """Get a field value from a dataclass, preserving raw ``None``. - - For nested dataclasses we want to avoid triggering any lazy resolution - logic, so we go through ``_get_raw_field_value``. For primitives and - non-dataclass fields we fall back to normal ``getattr``. - """ - - if CodeEditorFormUpdater._is_dataclass(field_obj.type): - return CodeEditorFormUpdater._get_raw_field_value(instance, field_obj.name) - return getattr(instance, field_obj.name, None) diff --git a/src/formbridge/services/function_pattern_code_document.py b/src/formbridge/services/function_pattern_code_document.py index 4879a54..2bc032f 100644 --- a/src/formbridge/services/function_pattern_code_document.py +++ b/src/formbridge/services/function_pattern_code_document.py @@ -13,13 +13,7 @@ from dataclasses import dataclass from typing import Any, ClassVar, get_type_hints -from objectstate import ( - ObjectState, - ObjectStateRegistry, - ScopeKey, - patch_lazy_constructors, - semantic_values_equal, -) +from objectstate import ObjectState, ObjectStateRegistry, ScopeKey, semantic_values_equal from python_introspect import ( callable_declaration_kwargs, parameter_exclusions, @@ -336,8 +330,7 @@ def pattern_from_source(self, source: str) -> PatternSourceValue: ) namespace = {} - with patch_lazy_constructors(): - exec(source, namespace) + exec(source, namespace) if self.pattern_assignment_name not in namespace: raise FunctionPatternRoundTripError( diff --git a/src/formbridge/services/parameter_help_service.py b/src/formbridge/services/parameter_help_service.py index b6b6116..cfbbc4d 100644 --- a/src/formbridge/services/parameter_help_service.py +++ b/src/formbridge/services/parameter_help_service.py @@ -7,12 +7,15 @@ import logging import re from collections.abc import Callable -from dataclasses import dataclass, fields, is_dataclass +from dataclasses import dataclass, fields +from objectstate.declarations import ValueDeclaration +from python_introspect import resolve_annotated from enum import Enum from types import UnionType from typing import Annotated, Union, get_args, get_origin -from objectstate.lazy_factory import get_base_type_for_lazy, is_lazy_dataclass +from objectstate.lazy_factory import is_lazy_dataclass +from python_introspect import resolve_declared_type from python_introspect import ( DocstringExtractor, DocstringInfo, @@ -93,33 +96,22 @@ def format_body(self, text: str) -> str: def dataclass_type_for_target(target: Callable | type | None) -> type | None: """Return the dataclass type represented by a help target.""" - if target is None: - return None - if inspect.isclass(target) and is_dataclass(target): - return target - target_type = type(target) - if is_dataclass(target_type): - return target_type - return None + owners = ValueDeclaration.of(target).configuration_owners() + return owners[0].declaring_type if owners else None def source_dataclass_type(dataclass_type: type) -> type: """Return the source-authored dataclass for generated lazy dataclass types.""" + base_type = resolve_declared_type(dataclass_type) if is_lazy_dataclass(dataclass_type): - base_type = get_base_type_for_lazy(dataclass_type) - if base_type is None: + if base_type is dataclass_type: raise RuntimeError( f"Lazy dataclass {dataclass_type.__qualname__} is missing its " "registered source dataclass type." ) - if not is_dataclass(base_type): - raise RuntimeError( - f"Lazy dataclass {dataclass_type.__qualname__} resolves to " - f"non-dataclass source type {base_type!r}." - ) return base_type - return dataclass_type + return base_type def source_class_docstring_resolution(dataclass_type: type) -> DataclassDocstringResolution: @@ -194,23 +186,10 @@ def split_docstring_summary(docstring: str | None, fallback: str) -> tuple[str, def dataclass_type_from_annotation(annotation) -> type | None: """Return a dataclass type represented by a field annotation.""" - if inspect.isclass(annotation) and is_dataclass(annotation): - return annotation - - origin = get_origin(annotation) - if origin is Annotated: - annotated_args = get_args(annotation) - if not annotated_args: - return None - return dataclass_type_from_annotation(annotated_args[0]) - - if origin in (Union, UnionType): - dataclass_args = tuple( - arg for arg in get_args(annotation) if inspect.isclass(arg) and is_dataclass(arg) - ) - if len(dataclass_args) == 1: - return dataclass_args[0] - return None + annotation = resolve_annotated(annotation) + arguments = get_args(annotation) if get_origin(annotation) in (Union, UnionType) else (annotation,) + owners = tuple(owner for argument in arguments for owner in ValueDeclaration.of(argument).configuration_owners()) + return owners[0].declaring_type if len(owners) == 1 else None def dataclass_field_description(description: str | None, annotation) -> str | None: diff --git a/src/formbridge/services/widget_tree_projection_config.py b/src/formbridge/services/widget_tree_projection_config.py index e7844da..84e24a6 100644 --- a/src/formbridge/services/widget_tree_projection_config.py +++ b/src/formbridge/services/widget_tree_projection_config.py @@ -3,7 +3,8 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import MISSING, dataclass, field, fields, is_dataclass +from dataclasses import MISSING, dataclass, field +from objectstate.declarations import admit_root_value from typing import Any, TypeAlias DEFAULT_MAXIMUM_WIDGET_TEXT_LENGTH = 4096 @@ -11,7 +12,6 @@ DEFAULT_TEXT_TRUNCATION_SUFFIX = "..." WidgetPath: TypeAlias = tuple[int, ...] CompactFieldPredicate: TypeAlias = Callable[[object, object], bool] -COMPACT_FIELD_PROJECTION_METADATA_KEY = "compact_field_projection" @dataclass(frozen=True, slots=True) @@ -40,29 +40,26 @@ def _compact_value_carries_information(value: object) -> bool: def compact_dataclass_projection(value: object) -> dict[str, Any]: """Project one dataclass through its field-owned compactness declarations.""" - if isinstance(value, type) or not is_dataclass(value): - raise TypeError(f"Compact projection requires a dataclass instance, got {value!r}.") projected: dict[str, Any] = {} - for declared_field in fields(value): - field_value = getattr(value, declared_field.name) - policy = declared_field.metadata.get(COMPACT_FIELD_PROJECTION_METADATA_KEY) - if policy is not None and not isinstance(policy, CompactFieldProjection): - raise TypeError( - f"{type(value).__name__}.{declared_field.name} declares an invalid " - "compact field projection." - ) + node = admit_root_value(value) + for declared_field, member in node.projection_fields(): + field_value = member.stored required = declared_field.default is MISSING and declared_field.default_factory is MISSING - if required: - includes = True - elif isinstance(policy, CompactFieldProjection): - includes = policy.includes(value, field_value) + if CompactFieldProjection in declared_field.metadata: + policy = declared_field.metadata[CompactFieldProjection] + if not isinstance(policy, CompactFieldProjection): + raise TypeError( + f"{type(value).__name__}.{declared_field.name} declares an invalid " + "compact field projection." + ) + includes = required or policy.includes(value, field_value) else: # Empty values may only disappear when the declaration restores # that exact value. Do not evaluate factories during serialization. - includes = _compact_value_carries_information(field_value) or not ( + includes = required or _compact_value_carries_information(field_value) or not ( declared_field.default is not MISSING and type(field_value) is type(declared_field.default) - and field_value == declared_field.default + and member.source_equals(node.declaration.field_types[declared_field.name].admit(declared_field.default)) ) if includes: projected[declared_field.name] = field_value @@ -83,28 +80,28 @@ class WidgetNodeIdentity: path: WidgetPath = field( metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( + CompactFieldProjection: CompactFieldProjection( includes=always_project_compact_field ) } ) path_id: str = field( metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( + CompactFieldProjection: CompactFieldProjection( includes=always_project_compact_field ) } ) child_index: int | None = field( metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( + CompactFieldProjection: CompactFieldProjection( includes=always_project_compact_field ) } ) class_name: str = field( metadata={ - COMPACT_FIELD_PROJECTION_METADATA_KEY: CompactFieldProjection( + CompactFieldProjection: CompactFieldProjection( includes=always_project_compact_field ) } diff --git a/src/formbridge/strategies/preview_formatting.py b/src/formbridge/strategies/preview_formatting.py index 07da0f4..09a0253 100644 --- a/src/formbridge/strategies/preview_formatting.py +++ b/src/formbridge/strategies/preview_formatting.py @@ -2,6 +2,8 @@ from __future__ import annotations +from objectstate.ui_visibility import UIParameterVisibilityPolicy + from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from typing import TYPE_CHECKING, TypeAlias @@ -52,17 +54,17 @@ class FormattingConfig: max_value_length: int = 96 """Maximum characters per value, with an ellipsis for longer values; zero shows all text.""" - group_separator: str = field(default=" | ", metadata={"ui_hidden": True}) + group_separator: str = field(default=" | ", metadata=UIParameterVisibilityPolicy.field_metadata(True)) """Text before each labelled configuration group after the first group.""" - field_separator: str = field(default=", ", metadata={"ui_hidden": True}) + field_separator: str = field(default=", ", metadata=UIParameterVisibilityPolicy.field_metadata(True)) """Text between successive field values within one configuration group.""" - closing_brace_separator: str = field(default="", metadata={"ui_hidden": True}) + closing_brace_separator: str = field(default="", metadata=UIParameterVisibilityPolicy.field_metadata(True)) """Text before the closing brace of each labelled configuration group.""" container_abbr_func: Callable[[type], str] = field( - default=get_group_abbreviation, metadata={"ui_hidden": True} + default=get_group_abbreviation, metadata=UIParameterVisibilityPolicy.field_metadata(True) ) """Function mapping a configuration declaration type to its preview heading. diff --git a/src/formbridge/utils/preview_formatters.py b/src/formbridge/utils/preview_formatters.py index b0a49b4..614fd18 100644 --- a/src/formbridge/utils/preview_formatters.py +++ b/src/formbridge/utils/preview_formatters.py @@ -28,9 +28,9 @@ class PreviewFieldAbbreviationResolution: def canonical_declaration_mro(declaration_type: type) -> tuple[type, ...]: """Return the semantic declaration MRO, excluding generated lazy wrappers.""" - from objectstate.lazy_factory import get_base_type_for_lazy + from python_introspect import resolve_declared_type - canonical_type = get_base_type_for_lazy(declaration_type) or declaration_type + canonical_type = resolve_declared_type(declaration_type) return canonical_type.__mro__ diff --git a/tests/test_code_editor_form_updater.py b/tests/test_code_editor_form_updater.py index e3c5567..d17a16a 100644 --- a/tests/test_code_editor_form_updater.py +++ b/tests/test_code_editor_form_updater.py @@ -98,7 +98,7 @@ def test_update_form_from_instance_skips_fully_unchanged_document() -> None: assert nested_manager.updated == [] -def test_update_form_from_instance_preserves_explicit_reset_to_none() -> None: +def test_update_form_from_instance_preserves_literal_null() -> None: root_manager = _RecordingFormManager( { "name": "override", diff --git a/tests/test_field_inspection_projection.py b/tests/test_field_inspection_projection.py new file mode 100644 index 0000000..9caa619 --- /dev/null +++ b/tests/test_field_inspection_projection.py @@ -0,0 +1,121 @@ +"""Bridge projections receive the resolved member, never absence disguised as null.""" +from dataclasses import dataclass +import pytest +from objectstate import Inherit, ObjectState, Unset, set_base_config_type +from objectstate.field_value import FieldValue, Value +from formbridge.bridge.object_state import ObjectStateFieldSemanticProjection + + +@dataclass +class Config: + text: str | None = None + + +@pytest.mark.parametrize('datum, text', [(None, 'None'), (Unset(), 'Unset()'), ('None', "'None'"), ('Unset()', "'Unset()'"), ('', "''")]) +def test_resolved_projection_distinguishes_null_absence_and_literal_text(datum, text): + set_base_config_type(Config) + state = ObjectState(Config(text=datum)) + projected = ObjectStateFieldSemanticProjection.of(state.fields.text) + preview = projected.resolved_value_preview + carrier = projected.to_semantic_carrier(include_values=True) + assert carrier.raw_value == FieldValue.of(datum) + assert carrier.resolved_value == FieldValue.of(datum) + assert preview.text == text + + +@pytest.mark.parametrize('member', [Value(None), Value(False), Value(0), Value(''), Value('None'), Inherit(), Unset()]) +def test_semantic_carrier_roundtrip_preserves_members(member): + import json + from python_introspect import dataclass_from_mapping, to_jsonable + from formbridge.bridge.dto import UiObjectStateFieldSemanticCarrier + original = UiObjectStateFieldSemanticCarrier(raw_value=member, resolved_value=member) + decoded = dataclass_from_mapping( + UiObjectStateFieldSemanticCarrier, json.loads(json.dumps(to_jsonable(original))) + ) + assert decoded == original + assert decoded.raw_value == member + assert decoded.resolved_value == member + + +def test_excluding_payload_does_not_turn_it_into_null_or_erase_an_inheritance_request(): + from objectstate import LazyDataclassFactory, set_global_config + set_base_config_type(Config) + set_global_config(Config, Config(text='global')) + lazy = LazyDataclassFactory.make_lazy_simple(Config) + state = ObjectState(lazy(text=Inherit())) + inspected = ObjectStateFieldSemanticProjection.of(state.fields.text) + excluded = inspected.to_semantic_carrier(include_values=False) + assert excluded.raw_value == Inherit() + assert excluded.resolved_value == Unset() + assert excluded.raw_value_preview.text == 'Inherit()' + assert excluded.resolved_value_preview.text == "'global'" + included = inspected.to_semantic_carrier(include_values=True) + assert included.raw_value == Inherit() + assert included.resolved_value == Value('global') + + +@pytest.mark.parametrize('member', [Value(None), Value(False), Value(0), Value(''), Inherit(), Unset()]) +def test_compact_action_retains_member_tags_and_decodes(member): + from python_introspect import dataclass_from_mapping + from formbridge.bridge.dto import UiWidgetActionSummary, UiWidgetRect + rect = UiWidgetRect(0, 0, 10, 10) + action = UiWidgetActionSummary( + path=(), path_id='root', child_index=None, class_name='QLineEdit', + object_name='', accessible_name='', accessible_description='', + label=None, visible=True, enabled=True, geometry=rect, global_geometry=rect, + action_kinds=('text',), clickable=False, checkable=None, checked=None, + current_index=None, current_text=None, item_count=None, tool_tip='', + raw_value=member, resolved_value=member, + ) + wire = action.compact_projection() + decoded = dataclass_from_mapping(UiWidgetActionSummary, wire) + assert decoded.raw_value == member + assert decoded.resolved_value == member + assert decoded == action + + +def test_field_list_compacts_concrete_data_without_converting_directives_or_null(): + from formbridge.bridge.dto import UiObjectStateFieldListQuery + from formbridge.bridge.field_projection import ObjectStateFieldListProjector + query = UiObjectStateFieldListQuery(max_value_chars=3) + assert ObjectStateFieldListProjector.compact_value(query, Value(None)) == Value(None) + assert ObjectStateFieldListProjector.compact_value(query, Inherit()) == Inherit() + assert ObjectStateFieldListProjector.compact_value(query, Unset()) == Unset() + assert ObjectStateFieldListProjector.compact_value(query, Value('abcdef')) == Value({ + 'type': 'str', 'length': 6, 'truncated': True, 'preview': 'abc', + }) + + +def test_missing_preview_and_a_preview_of_literal_null_are_distinct_members(): + from python_introspect import dataclass_from_mapping, to_jsonable + from formbridge.bridge.dto import ( + UiObjectStateFieldSemanticCarrier, UiObjectStateNoPreview, + UiObjectStateTextPreview, + ) + absent = UiObjectStateFieldSemanticCarrier(raw_value=Value(None)) + present = UiObjectStateFieldSemanticCarrier( + raw_value=Value(None), raw_value_preview=UiObjectStateTextPreview('None'), + ) + assert absent.raw_value_preview == UiObjectStateNoPreview() + assert absent.raw_value_preview.render(Value(None), repr) == 'None' + assert present.raw_value_preview.render(Value('different'), repr) == 'None' + assert to_jsonable(absent)['raw_value_preview'] == {'UiObjectStateNoPreview': {}} + assert to_jsonable(present)['raw_value_preview'] == { + 'UiObjectStateTextPreview': {'text': 'None', 'truncated': False}, + } + for original in (absent, present): + assert dataclass_from_mapping( + UiObjectStateFieldSemanticCarrier, to_jsonable(original), + ) == original + + +def test_declaring_symbol_owns_nested_import_resolution_and_roundtrip(): + from python_introspect import dataclass_from_mapping, to_jsonable + from formbridge.bridge.dto import UiObjectStateFieldDeclaration + declaration = UiObjectStateFieldDeclaration.of(Config) + assert declaration.resolve() is Config + assert declaration.import_path == f'{Config.__module__}.{Config.__qualname__}' + assert declaration.display_name == 'Config' + assert dataclass_from_mapping( + UiObjectStateFieldDeclaration, to_jsonable(declaration), + ) == declaration diff --git a/tests/test_value_node_deletion_gates.py b/tests/test_value_node_deletion_gates.py new file mode 100644 index 0000000..a007829 --- /dev/null +++ b/tests/test_value_node_deletion_gates.py @@ -0,0 +1,45 @@ +"""Form operations consume admitted values, not duplicate classifiers or type names.""" + +import ast +from pathlib import Path + +import pytest + + +SOURCE = Path(__file__).parents[1] / "src" / "formbridge" +DELETED_CLASSIFIERS = {"is_container", "is_config_type", "is_dataclass"} +# MIME metadata is a protocol fact, not a Python class-name mirror. +DELETED_TYPE_FIELDS = ( + "source_type", "object_state_path_type", "raw_value_type", + "resolved_value_type", "object_type", "help_target_type", +) + + +def test_operations_do_not_reclassify_values(): + violations = [] + for path in SOURCE.rglob("*.py"): + for node in ast.walk(ast.parse(path.read_text(), filename=str(path))): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + name = node.name + elif isinstance(node, ast.Call): + name = node.func.id if isinstance(node.func, ast.Name) else ( + node.func.attr if isinstance(node.func, ast.Attribute) else "" + ) + else: + continue + if name in DELETED_CLASSIFIERS: + violations.append(f"{path.relative_to(SOURCE)}:{node.lineno}: {name}") + assert not violations, "\n" + "\n".join(sorted(violations)) + + +@pytest.mark.parametrize("name", DELETED_TYPE_FIELDS) +def test_dto_does_not_copy_python_type_names(name): + path = SOURCE / "bridge" / "dto.py" + violations = [ + f"{path.name}:{node.lineno}: {name}" + for node in ast.walk(ast.parse(path.read_text(), filename=str(path))) + if isinstance(node, ast.AnnAssign) + and isinstance(node.target, ast.Name) + and node.target.id == name + ] + assert not violations, "\n" + "\n".join(violations)