From bafab2552c0ec846173d4f613ed660bb4bf38a14 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 01:28:21 -0400 Subject: [PATCH 1/7] WIP: migrate declared field states and remove constructor patch scopes --- openhcs/core/config.py | 14 +++++++------- openhcs/core/source_bindings.py | 16 +++++++++------- .../services/function_step_code_document.py | 5 ++--- openhcs/pyqt_gui/services/ui_agent_bridge.py | 3 +-- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/openhcs/core/config.py b/openhcs/core/config.py index 927b08365b..73eb94a6b7 100644 --- a/openhcs/core/config.py +++ b/openhcs/core/config.py @@ -384,7 +384,7 @@ class NapariDisplayConfig(AxisRoleModeDisplayConfig, NapariDisplaySettings): # Apply the global pipeline config decorator with ui_hidden=True # This config is only inherited by NapariStreamingConfig, so hide it from UI -NapariDisplayConfig = global_pipeline_config(ui_hidden=True, inherit_as_none=False)(NapariDisplayConfig) +NapariDisplayConfig = global_pipeline_config(ui_hidden=True, inherit_fields=False)(NapariDisplayConfig) FijiDimensionMode = FijiSlots.choice_enum("FijiDimensionMode", __name__) @@ -413,7 +413,7 @@ class FijiDisplayConfig(AxisRoleModeDisplayConfig, FijiDisplaySettings): # Apply the global pipeline config decorator with ui_hidden=True # This config is only inherited by FijiStreamingConfig, so hide it from UI -FijiDisplayConfig = global_pipeline_config(ui_hidden=True, inherit_as_none=False)(FijiDisplayConfig) +FijiDisplayConfig = global_pipeline_config(ui_hidden=True, inherit_fields=False)(FijiDisplayConfig) @abbreviation("pfc") @@ -474,7 +474,7 @@ def accepts_partition_filter_provenance(cls, source_type: type | None) -> bool: @abbreviation("zarr") @global_pipeline_config( - inherit_as_none=False, + inherit_fields=False, field_abbreviations={ "compressor": "compressor", "compression_level": "level", @@ -492,7 +492,7 @@ class ZarrConfig(AnnotatedDataclassValidationMixin, _polystore_config.ZarrConfig @abbreviation("tiff") @global_pipeline_config( - inherit_as_none=False, + inherit_fields=False, field_abbreviations={"compression": "codec", "compression_level": "level"}, ) @dataclass(frozen=True) @@ -603,7 +603,7 @@ class ProcessingConfig(AnnotatedDataclassValidationMixin): @global_pipeline_config( - inherit_as_none=False, + inherit_fields=False, preview_label="SRC", abbreviation="src", ) @@ -1239,7 +1239,7 @@ class StreamingConfig(StreamingDefaults, ABC, metaclass=StreamingConfigMeta): Uses multiple inheritance from StepPartitionFilterConfig and StreamingDefaults. Inherited fields (persistent, host, port, transport_mode) are automatically set to None - by @global_pipeline_config(inherit_as_none=True), enabling polymorphic access without + by @global_pipeline_config(inherit_fields=True), enabling polymorphic access without type-specific attribute names. """ @@ -1389,7 +1389,7 @@ class FijiStreamingConfig( @abbreviation("compile_dbg") @global_pipeline_config( - inherit_as_none=False, + inherit_fields=False, always_viewable_fields=["enabled"], ) @dataclass(frozen=True) diff --git a/openhcs/core/source_bindings.py b/openhcs/core/source_bindings.py index 0270462cef..486ac7675b 100644 --- a/openhcs/core/source_bindings.py +++ b/openhcs/core/source_bindings.py @@ -2009,8 +2009,11 @@ class _SourceBindingPlanBase(ABC): """Exact scalar schema for source-literal metadata fields.""" def _normalize_common_fields(self) -> None: - metadata_rules = object.__getattribute__(self, "metadata_rules") - if metadata_rules is not None: + from objectstate.field_value import FieldValue + from objectstate.field_access import DataclassFieldAccess + + metadata_rules = DataclassFieldAccess.raw_value(self, "metadata_rules") + if FieldValue.of(metadata_rules).has_value: metadata_rules = tuple(metadata_rules) for rule in metadata_rules: if not isinstance(rule, MetadataExtractionRule): @@ -2021,16 +2024,15 @@ def _normalize_common_fields(self) -> None: ) object.__setattr__(self, "metadata_rules", metadata_rules) - match_plan = object.__getattribute__(self, "match_plan") - # None is ObjectState's inherited (unset) value on lazy plans. - if not isinstance(match_plan, SourceSetPairing | None): + match_plan = DataclassFieldAccess.raw_value(self, "match_plan") + if FieldValue.of(match_plan).has_value and not isinstance(match_plan, SourceSetPairing | None): raise TypeError( f"{type(self).__name__}.match_plan must be a SourceSetPairing, " f"got {type(match_plan).__name__}." ) - metadata_fields = object.__getattribute__(self, "metadata_fields") - if metadata_fields is not None: + metadata_fields = DataclassFieldAccess.raw_value(self, "metadata_fields") + if FieldValue.of(metadata_fields).has_value: metadata_fields = FieldSpec.merge_exact( (tuple(metadata_fields),), context=f"{type(self).__name__}.metadata_fields", diff --git a/openhcs/pyqt_gui/services/function_step_code_document.py b/openhcs/pyqt_gui/services/function_step_code_document.py index cf8a5b7cba..6659d0f81c 100644 --- a/openhcs/pyqt_gui/services/function_step_code_document.py +++ b/openhcs/pyqt_gui/services/function_step_code_document.py @@ -12,7 +12,7 @@ from openhcs.core.function_step_document import FunctionStepDocumentCodec from openhcs.core.steps.function_step import FunctionStep -from objectstate import patch_lazy_constructors + class FunctionStepCodeDocumentDriver(WindowCodeDocumentDriver): @@ -57,5 +57,4 @@ def apply_source(self, source: str) -> None: @staticmethod def _step_from_source(source: str) -> FunctionStep: - with patch_lazy_constructors(): - return FunctionStepDocumentCodec.from_source(source).step + return FunctionStepDocumentCodec.from_source(source).step diff --git a/openhcs/pyqt_gui/services/ui_agent_bridge.py b/openhcs/pyqt_gui/services/ui_agent_bridge.py index 7b04cfdaf9..b54cb1bc45 100644 --- a/openhcs/pyqt_gui/services/ui_agent_bridge.py +++ b/openhcs/pyqt_gui/services/ui_agent_bridge.py @@ -509,8 +509,7 @@ def validate_source(self, source: str, operations) -> CodeDocumentExecutionResul *projection.key_bindings.items(), ) } - with operations.patch_lazy_constructors(): - exec(executable, namespace) + exec(executable, namespace) try: payload = PlateManagerCodeDocumentAuthority.from_namespace(namespace) From 8d3bac264a06f9702931e4eb4759c10fecd3ca4b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 11:54:42 -0400 Subject: [PATCH 2/7] Read bridge field members in help and rendering without null presence flags --- .../object_state_field_help_service.py | 11 +++---- .../mcp/dev_client_renderers/object_state.py | 19 +++-------- ...test_mcp_dev_client_knowledge_renderers.py | 13 ++++---- tests/unit/agent/test_mcp_server.py | 32 ++++++++----------- .../test_object_state_field_help_service.py | 13 ++++---- tests/unit/agent/test_ui_bridge_service.py | 12 +++---- tests/unit/pyqt_gui/test_ui_agent_bridge.py | 12 +++---- 7 files changed, 47 insertions(+), 65 deletions(-) diff --git a/openhcs/agent/services/object_state_field_help_service.py b/openhcs/agent/services/object_state_field_help_service.py index 7d612f800e..191377bfdb 100644 --- a/openhcs/agent/services/object_state_field_help_service.py +++ b/openhcs/agent/services/object_state_field_help_service.py @@ -319,15 +319,12 @@ def _description_with_value_context( field: UiObjectStateFieldSummary, max_description_chars: int, ) -> str | None: - callable_context = self._callable_value_context( - field.resolved_value, - max_description_chars=max_description_chars, + describe = lambda value: self._callable_value_context( + value, max_description_chars=max_description_chars ) + callable_context = field.resolved_value.project(describe) if callable_context is None: - callable_context = self._callable_value_context( - field.raw_value, - max_description_chars=max_description_chars, - ) + callable_context = field.raw_value.project(describe) if callable_context is None: return parameter_description if parameter_description: diff --git a/openhcs/mcp/dev_client_renderers/object_state.py b/openhcs/mcp/dev_client_renderers/object_state.py index c88c746312..25ac34dce3 100644 --- a/openhcs/mcp/dev_client_renderers/object_state.py +++ b/openhcs/mcp/dev_client_renderers/object_state.py @@ -2,6 +2,8 @@ from __future__ import annotations +from objectstate.field_value import FieldValue + from formbridge.bridge.dto import ( AllFields, UiCatalogPageMetadata, @@ -78,9 +80,9 @@ def _field_mark(field: UiObjectStateFieldSemanticCarrier) -> str: @classmethod def _preview_text( - cls, preview: UiObjectStateValuePreview | None, value: object + cls, preview: UiObjectStateValuePreview | None, value: FieldValue ) -> str: - return cls.text(value) if preview is None else preview.text + return value.project(cls.text) if preview is None else preview.text @classmethod def _provenance_text(cls, provenance: UiObjectStateFieldProvenance | None) -> str: @@ -218,31 +220,18 @@ def render_payload( @staticmethod def _semantic_summary(fields: tuple[UiObjectStateFieldSemanticCarrier, ...]) -> str: - raw_none_resolved = sum( - 1 - for field in fields - if field.raw_value_is_none and not field.resolved_value_is_none - ) - resolved_none_raw = sum( - 1 - for field in fields - if not field.raw_value_is_none and field.resolved_value_is_none - ) semantic = sum( 1 for field in fields if field.dirty or field.signature_diff or field.inherited_value - or field.raw_value_is_none is not field.resolved_value_is_none ) return ( "Returned semantics: " f"dirty={sum(1 for field in fields if field.dirty)} " f"default_diff={sum(1 for field in fields if field.signature_diff)} " f"inherited={sum(1 for field in fields if field.inherited_value)} " - f"raw_none_resolved={raw_none_resolved} " - f"resolved_none_raw={resolved_none_raw} " f"plain={len(fields) - semantic}" ) diff --git a/tests/unit/agent/test_mcp_dev_client_knowledge_renderers.py b/tests/unit/agent/test_mcp_dev_client_knowledge_renderers.py index 9a2d628bc4..10b9874cf3 100644 --- a/tests/unit/agent/test_mcp_dev_client_knowledge_renderers.py +++ b/tests/unit/agent/test_mcp_dev_client_knowledge_renderers.py @@ -1,6 +1,7 @@ """Knowledge, function and ObjectState renderers over typed DTO fixtures.""" from __future__ import annotations +from objectstate.field_value import Inherit, Value import ast from pathlib import Path @@ -183,19 +184,19 @@ def _field_summary(path: str, **semantics) -> UiObjectStateFieldSummary: raw_value_type="int", resolved_value_type="int", dirty=True, - raw_value=4, - resolved_value=4, + raw_value=Value(4), + resolved_value=Value(4), ), UiObjectStateFieldProjection( field_path="worker_mode", field_name="worker_mode", container_path="", object_state_path_type="openhcs.config.PipelineConfig", - raw_value_type="NoneType", + raw_value_type="Inherit", resolved_value_type="str", - raw_value_is_none=True, + raw_value=Inherit(), inherited_value=True, - resolved_value="process", + resolved_value=Value("process"), provenance=UiObjectStateFieldProvenance( "global", "openhcs.config.GlobalConfig", "worker_mode" ), @@ -316,7 +317,7 @@ def _field_summary(path: str, **semantics) -> UiObjectStateFieldSummary: FIELD_LIST, ( "returned=2 offset=0 limit=200 truncated=False", - "Returned semantics: dirty=1 default_diff=0 inherited=1 raw_none_resolved=1 resolved_none_raw=0 plain=0", + "Returned semantics: dirty=1 default_diff=0 inherited=1 plain=0", "Filters: scope_ids=scope-1 contains=worker field_filter=dirty", "Scope [*_] scope=scope-1:", "provenance=global:worker_mode (GlobalConfig)", diff --git a/tests/unit/agent/test_mcp_server.py b/tests/unit/agent/test_mcp_server.py index da094cfe01..3de16c6fa1 100644 --- a/tests/unit/agent/test_mcp_server.py +++ b/tests/unit/agent/test_mcp_server.py @@ -1,3 +1,4 @@ +from objectstate.field_value import Inherit, Value from openhcs.agent.dto.session import DatasetRowState from openhcs.mcp.dev_client_core import PollCompleted, PollFailed, PollTimedOut import asyncio @@ -1068,20 +1069,18 @@ def test_mcp_widget_tree_projection_preserves_semantic_action_values(): field_path="microscope", signature_diff=True, semantic_markers=("_",), - raw_value=None, - resolved_value=0, + raw_value=Inherit(), + resolved_value=Value(0), raw_value_preview=UiObjectStateValuePreview( - type_name="None", - is_none=True, - text="None", + type_name="Inherit", + is_none=False, + text="Inherit()", ), resolved_value_preview=UiObjectStateValuePreview( type_name="int", is_none=False, text="0", ), - raw_value_is_none=True, - resolved_value_is_none=False, inherited_value=True, provenance=UiObjectStateFieldProvenance( source_scope_id="global_config", @@ -1107,12 +1106,10 @@ def test_mcp_widget_tree_projection_preserves_semantic_action_values(): assert action["signature_diff"] is True assert "dirty" not in action assert action["semantic_markers"] == ["_"] - assert "raw_value" not in action - assert action["raw_value_is_none"] is True - assert "resolved_value" not in action - assert action["raw_value_preview"]["text"] == "None" + assert action["raw_value"] == {"Inherit": {}} + assert action["resolved_value"] == {"Value": {"value": 0}} + assert action["raw_value_preview"]["text"] == "Inherit()" assert action["resolved_value_preview"]["text"] == "0" - assert "resolved_value_is_none" not in action assert action["inherited_value"] is True assert action["provenance"] == { "source_scope_id": "global_config", @@ -1254,7 +1251,7 @@ def test_mcp_widget_tree_projection_can_return_full_action_fields(): assert "object_name" in action assert "accessible_name" in action assert "raw_value" in action - assert action["raw_value"] is None + assert action["raw_value"] == {"Unset": {}} def test_mcp_widget_tree_binding_projects_request_and_compact_actions(): @@ -1376,9 +1373,9 @@ async def call_widget_tree(arguments): assert compact_payload["projected"] is True compact_action = compact_payload["actionable_widgets"][0] assert compact_action["path_id"] == "root/0" - assert "raw_value" not in compact_action + assert compact_action["raw_value"] == {"Unset": {}} assert full_payload["actionable_widgets"][0]["object_name"] == "compile_button" - assert full_payload["actionable_widgets"][0]["raw_value"] is None + assert full_payload["actionable_widgets"][0]["raw_value"] == {"Unset": {}} assert ui_bridge_service.connections[0].timeout_ms == 1234 first_request = ui_bridge_service.widget_tree_requests[0] assert first_request.window_id == "datasets" @@ -2132,8 +2129,7 @@ def field_summary(field_path: str) -> UiObjectStateFieldSummary: is_none=False, text="'resolved'", ), - raw_value_is_none=True, - resolved_value_is_none=False, + raw_value=Inherit(), inherited_value=True, ) @@ -2796,8 +2792,6 @@ def field_summary(field_path: str, *, signature_diff: bool = False): is_none=False, text="1", ), - raw_value_is_none=False, - resolved_value_is_none=False, inherited_value=False, ) diff --git a/tests/unit/agent/test_object_state_field_help_service.py b/tests/unit/agent/test_object_state_field_help_service.py index 90285be7d1..b936014322 100644 --- a/tests/unit/agent/test_object_state_field_help_service.py +++ b/tests/unit/agent/test_object_state_field_help_service.py @@ -1,3 +1,4 @@ +from objectstate.field_value import Value from dataclasses import dataclass from enum import Enum @@ -208,7 +209,7 @@ def invoke(self, operation, request, connection): dirty=False, signature_diff=True, last_changed=False, - raw_value=[ + raw_value=Value([ { "kind": "callable", "name": "count_cells_single_channel", @@ -226,8 +227,8 @@ def invoke(self, operation, request, connection): { "min_cell_area": 40, }, - ], - resolved_value=[ + ]), + resolved_value=Value([ { "kind": "callable", "name": "count_cells_single_channel", @@ -245,7 +246,7 @@ def invoke(self, operation, request, connection): { "min_cell_area": 40, }, - ], + ]), ), ), ), @@ -394,8 +395,8 @@ def invoke(self, operation, request, connection): signature_diff=False, last_changed=False, parameter_description="Logical tile grid.", - raw_value_is_none=True, - resolved_value_is_none=True, + raw_value=Value(None), + resolved_value=Value(None), ), ), ), diff --git a/tests/unit/agent/test_ui_bridge_service.py b/tests/unit/agent/test_ui_bridge_service.py index 304da17fb9..c409e20e8e 100644 --- a/tests/unit/agent/test_ui_bridge_service.py +++ b/tests/unit/agent/test_ui_bridge_service.py @@ -1,4 +1,5 @@ from __future__ import annotations +from objectstate.field_value import Inherit, Value from formbridge.selection import AllItems, SelectedItems from openhcs.agent.dto.session import DatasetRowState @@ -1764,23 +1765,22 @@ def field_summary( field_name=field_path.rsplit(".", 1)[-1], container_path=(field_path.rsplit(".", 1)[0] if "." in field_path else ""), object_state_path_type="openhcs.core.config.NapariStreamingConfig", - raw_value_type="None" if inherited_value else "bool", + raw_value_type="Inherit" if inherited_value else "bool", resolved_value_type="bool", dirty=False, signature_diff=False, last_changed=False, raw_value_preview=UiObjectStateValuePreview( - type_name="None", - is_none=True, - text="None", + type_name="Inherit" if inherited_value else "bool", + is_none=False, + text="Inherit()" if inherited_value else "False", ), resolved_value_preview=UiObjectStateValuePreview( type_name="bool", is_none=False, text="False", ), - raw_value_is_none=inherited_value, - resolved_value_is_none=False, + raw_value=Inherit() if inherited_value else Value(False), inherited_value=inherited_value, ) diff --git a/tests/unit/pyqt_gui/test_ui_agent_bridge.py b/tests/unit/pyqt_gui/test_ui_agent_bridge.py index 93d779faa7..f8c588c2e7 100644 --- a/tests/unit/pyqt_gui/test_ui_agent_bridge.py +++ b/tests/unit/pyqt_gui/test_ui_agent_bridge.py @@ -2515,14 +2515,13 @@ def test_global_config_object_state_fields_use_stable_window_id() -> None: assert global_scope.signature_diff_marker == "_" assert field.address.object_state_scope_id == OpenHCSUiWindowId.global_config assert field.address.window_id == OpenHCSUiWindowId.global_config - assert field.raw_value is None - assert field.resolved_value is None + from objectstate.field_value import Unset + assert field.raw_value == Unset() + assert field.resolved_value == Unset() assert field.raw_value_preview is not None assert field.resolved_value_preview is not None assert field.raw_value_preview.text == "2" assert field.resolved_value_preview.text == "2" - assert field.raw_value_is_none is False - assert field.resolved_value_is_none is False assert field.semantic_markers == ("*", "_") @@ -2580,8 +2579,9 @@ def test_object_state_fields_can_include_full_values_on_request() -> None: ) field = global_scope.fields[0] - assert field.raw_value == 2 - assert field.resolved_value == 2 + from objectstate.field_value import Value + assert field.raw_value == Value(2) + assert field.resolved_value == Value(2) assert field.raw_value_preview is not None assert field.resolved_value_preview is not None From d0d442539cd8620c095bdd1cff8e099ff9802600 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 12:24:26 -0400 Subject: [PATCH 3/7] Use declaration references and preview behavior across MCP consumers --- .../object_state_field_help_service.py | 32 +----- .../mcp/dev_client_renderers/object_state.py | 31 +++--- ...test_mcp_dev_client_knowledge_renderers.py | 30 +++--- tests/unit/agent/test_mcp_server.py | 98 +++++-------------- .../test_object_state_field_help_service.py | 18 +--- tests/unit/agent/test_ui_bridge_service.py | 18 +--- tests/unit/pyqt_gui/test_ui_agent_bridge.py | 2 +- 7 files changed, 59 insertions(+), 170 deletions(-) diff --git a/openhcs/agent/services/object_state_field_help_service.py b/openhcs/agent/services/object_state_field_help_service.py index 191377bfdb..c9fe294705 100644 --- a/openhcs/agent/services/object_state_field_help_service.py +++ b/openhcs/agent/services/object_state_field_help_service.py @@ -2,10 +2,8 @@ from __future__ import annotations -import importlib from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass -from types import ModuleType from formbridge.services.parameter_help_service import ( NO_PARAMETER_DESCRIPTION, @@ -138,7 +136,7 @@ def describe( ) try: - help_target = self._import_qualified_target(field.object_state_path_type) + help_target = field.declared_by.resolve() parameter_info = UnifiedParameterAnalyzer.analyze(help_target).get( field.field_name ) @@ -158,7 +156,7 @@ def describe( parameter_description = self._description_with_function_parameter_context( parameter_content.description, help_target, - field.object_state_path_type, + field.declared_by.import_path, field.field_name, request.max_description_chars, ) @@ -176,8 +174,6 @@ def describe( schema_version=SCHEMA_VERSION, address=field.address, field=field, - object_type=scope.object_type, - help_target_type=field.object_state_path_type, parameter_name=field.field_name, target_summary=target_docstring.summary, target_description=target_docstring.description, @@ -289,30 +285,6 @@ def _scope_and_field( return scope, None return None, None - @staticmethod - def _import_qualified_target( - qualified_name: str, - ) -> type | Callable[..., object]: - module_name, _, qualname = qualified_name.partition(":") - if not qualname: - module_name, _, qualname = qualified_name.rpartition(".") - if not module_name or not qualname: - raise ValueError(f"Help target name is not importable: {qualified_name!r}") - module = importlib.import_module(module_name) - symbol = ObjectStateFieldHelpService._resolve_qualname(module, qualname) - if not isinstance(symbol, type) and not callable(symbol): - raise TypeError( - f"Imported symbol is not a class or callable: {qualified_name!r}" - ) - return symbol - - @staticmethod - def _resolve_qualname(module: ModuleType, qualname: str) -> object: - symbol: object = module - for name in qualname.split("."): - symbol = vars(symbol)[name] - return symbol - def _description_with_value_context( self, parameter_description: str | None, diff --git a/openhcs/mcp/dev_client_renderers/object_state.py b/openhcs/mcp/dev_client_renderers/object_state.py index 25ac34dce3..8cbe160483 100644 --- a/openhcs/mcp/dev_client_renderers/object_state.py +++ b/openhcs/mcp/dev_client_renderers/object_state.py @@ -7,6 +7,7 @@ from formbridge.bridge.dto import ( AllFields, UiCatalogPageMetadata, + UiObjectStateFieldDeclaration, UiObjectStateFieldHelpResult, UiObjectStateFieldListResult, UiObjectStateFieldMutationResult, @@ -48,11 +49,11 @@ def field_line( cls, field: UiObjectStateFieldSemanticCarrier, field_path: str, - object_state_path_type: str, + declared_by: UiObjectStateFieldDeclaration, ) -> str: return ( f" [{cls._field_mark(field)}] {field_path}: " - f"target={cls.short_type(object_state_path_type)} " + f"target={declared_by.display_name} " f"raw={cls._preview_text(field.raw_value_preview, field.raw_value)} " "-> " f"resolved={cls._preview_text(field.resolved_value_preview, field.resolved_value)} " @@ -63,7 +64,7 @@ def field_line( @classmethod def summary_line(cls, field: UiObjectStateFieldSummary) -> str: return cls.field_line( - field, field.address.field_path, field.object_state_path_type + field, field.address.field_path, field.declared_by ) @staticmethod @@ -80,9 +81,9 @@ def _field_mark(field: UiObjectStateFieldSemanticCarrier) -> str: @classmethod def _preview_text( - cls, preview: UiObjectStateValuePreview | None, value: FieldValue + cls, preview: UiObjectStateValuePreview, value: FieldValue ) -> str: - return value.project(cls.text) if preview is None else preview.text + return preview.render(value, cls.text) @classmethod def _provenance_text(cls, provenance: UiObjectStateFieldProvenance | None) -> str: @@ -90,16 +91,9 @@ def _provenance_text(cls, provenance: UiObjectStateFieldProvenance | None) -> st return "" return ( f"{cls.text(provenance.source_scope_id)}:" - f"{cls.text(provenance.source_field_path)} " - f"({cls.short_type(provenance.source_type)})" + f"{cls.text(provenance.source_field_path)}" ) - @staticmethod - def short_type(value: str | None) -> str: - if not value: - return "" - return value.rsplit(".", 1)[-1] - class ObjectStateScopeRenderer(ObjectStatePresentation): """Compact renderer for ObjectState scope catalogs.""" @@ -149,7 +143,7 @@ def _scope_line(cls, scope: UiObjectStateScopeSummary) -> str: ) line = ( f"- [{mark}] scope={scope.identity.object_state_scope_id}: " - f"type={scope.object_type} params={scope.parameter_count} " + f"params={scope.parameter_count} " f"dirty={scope.dirty_field_count} " f"default_diff={scope.signature_diff_field_count} " f"unsaved={scope.has_unsaved_changes} " @@ -207,13 +201,13 @@ def render_payload( ) lines.append( f"Scope [{mark}] scope={scope.scope_id}: " - f"type={scope.object_type} dirty={scope.dirty_field_count} " + f"dirty={scope.dirty_field_count} " f"default_diff={scope.signature_diff_field_count} " f"unsaved={scope.has_unsaved_changes} " f"overrides={scope.has_default_overrides}" ) lines.extend( - cls.field_line(field, field.field_path, field.object_state_path_type) + cls.field_line(field, field.field_path, field.declared_by) for field in scope.fields ) return "\n".join(lines) @@ -267,10 +261,7 @@ def render_payload( "ObjectState field help: " f"scope={payload.address.object_state_scope_id} " f"field={payload.address.field_path}", - "Target: " - f"object={cls.short_type(payload.object_type)} " - f"help_target={cls.short_type(payload.help_target_type)} " - f"parameter={cls.text(payload.parameter_name)}", + f"Parameter: {cls.text(payload.parameter_name)}", ] if payload.field is not None: lines.append("Field:") diff --git a/tests/unit/agent/test_mcp_dev_client_knowledge_renderers.py b/tests/unit/agent/test_mcp_dev_client_knowledge_renderers.py index 10b9874cf3..5bebde8c94 100644 --- a/tests/unit/agent/test_mcp_dev_client_knowledge_renderers.py +++ b/tests/unit/agent/test_mcp_dev_client_knowledge_renderers.py @@ -1,6 +1,7 @@ """Knowledge, function and ObjectState renderers over typed DTO fixtures.""" from __future__ import annotations +from formbridge.bridge.dto import UiObjectStateFieldDeclaration from objectstate.field_value import Inherit, Value import ast @@ -50,7 +51,7 @@ UiObjectStateScopeCatalog, UiObjectStateScopeIdentity, UiObjectStateScopeSummary, - UiObjectStateValuePreview, + UiObjectStateTextPreview, UiSemanticAddress, ) from openhcs.mcp.dev_client_core import ( @@ -115,9 +116,7 @@ def _field_summary(path: str, **semantics) -> UiObjectStateFieldSummary: address=UiSemanticAddress(object_state_scope_id="scope-1", field_path=path), field_name=path.rsplit(".", 1)[-1], container_path="", - object_state_path_type="openhcs.config.PipelineConfig", - raw_value_type="int", - resolved_value_type="int", + declared_by=UiObjectStateFieldDeclaration(module='openhcs.config', qualname='PipelineConfig'), **semantics, ) @@ -131,7 +130,6 @@ def _field_summary(path: str, **semantics) -> UiObjectStateFieldSummary: UiObjectStateScopeSummary( schema_version=SCHEMA_VERSION, identity=UiObjectStateScopeIdentity(object_state_scope_id="scope-1"), - object_type="PipelineConfig", parameter_count=4, dirty_field_count=1, signature_diff_field_count=0, @@ -142,8 +140,8 @@ def _field_summary(path: str, **semantics) -> UiObjectStateFieldSummary: _field_summary( "num_workers", dirty=True, - raw_value_preview=UiObjectStateValuePreview("int", False, "4"), - resolved_value_preview=UiObjectStateValuePreview("int", False, "4"), + raw_value_preview=UiObjectStateTextPreview("4"), + resolved_value_preview=UiObjectStateTextPreview("4"), ), ), ), @@ -170,7 +168,6 @@ def _field_summary(path: str, **semantics) -> UiObjectStateFieldSummary: scopes=( UiObjectStateFieldScopeProjection( scope_id="scope-1", - object_type="PipelineConfig", dirty_field_count=1, signature_diff_field_count=1, has_unsaved_changes=True, @@ -180,9 +177,7 @@ def _field_summary(path: str, **semantics) -> UiObjectStateFieldSummary: field_path="num_workers", field_name="num_workers", container_path="", - object_state_path_type="openhcs.config.PipelineConfig", - raw_value_type="int", - resolved_value_type="int", + declared_by=UiObjectStateFieldDeclaration(module='openhcs.config', qualname='PipelineConfig'), dirty=True, raw_value=Value(4), resolved_value=Value(4), @@ -191,14 +186,12 @@ def _field_summary(path: str, **semantics) -> UiObjectStateFieldSummary: field_path="worker_mode", field_name="worker_mode", container_path="", - object_state_path_type="openhcs.config.PipelineConfig", - raw_value_type="Inherit", - resolved_value_type="str", + declared_by=UiObjectStateFieldDeclaration(module='openhcs.config', qualname='PipelineConfig'), raw_value=Inherit(), inherited_value=True, resolved_value=Value("process"), provenance=UiObjectStateFieldProvenance( - "global", "openhcs.config.GlobalConfig", "worker_mode" + "global", "worker_mode" ), ), ), @@ -307,7 +300,7 @@ def _field_summary(path: str, **semantics) -> UiObjectStateFieldSummary: SCOPE_CATALOG, ( "ObjectState scopes: scopes=1 token=7 branch=main snapshot=3 active=False", - "- [*] scope=scope-1: type=PipelineConfig params=4 dirty=1 default_diff=0", + "- [*] scope=scope-1: params=4 dirty=1 default_diff=0", "fields=1/4 next=1", " [*] num_workers: target=PipelineConfig raw=4 -> resolved=4", "Next field page: rerun with --include-fields --field-offset 1 --field-limit 1", @@ -320,7 +313,7 @@ def _field_summary(path: str, **semantics) -> UiObjectStateFieldSummary: "Returned semantics: dirty=1 default_diff=0 inherited=1 plain=0", "Filters: scope_ids=scope-1 contains=worker field_filter=dirty", "Scope [*_] scope=scope-1:", - "provenance=global:worker_mode (GlobalConfig)", + "provenance=global:worker_mode", ), ), ( @@ -328,12 +321,11 @@ def _field_summary(path: str, **semantics) -> UiObjectStateFieldSummary: schema_version=SCHEMA_VERSION, address=UiSemanticAddress("scope-1", "num_workers"), field=_field_summary("num_workers"), - object_type="openhcs.config.PipelineConfig", target_summary=" Worker count. " * 30, description="Number of workers.", description_truncated=True, ), - ("scope=scope-1 field=num_workers", "object=PipelineConfig", "Field:", "...", "Description truncated"), + ("scope=scope-1 field=num_workers", "target=PipelineConfig", "Field:", "...", "Description truncated"), ), ( UiObjectStateFieldMutationResult( diff --git a/tests/unit/agent/test_mcp_server.py b/tests/unit/agent/test_mcp_server.py index 3de16c6fa1..635c4ccdd3 100644 --- a/tests/unit/agent/test_mcp_server.py +++ b/tests/unit/agent/test_mcp_server.py @@ -1,3 +1,4 @@ +from formbridge.bridge.dto import UiObjectStateFieldDeclaration from objectstate.field_value import Inherit, Value from openhcs.agent.dto.session import DatasetRowState from openhcs.mcp.dev_client_core import PollCompleted, PollFailed, PollTimedOut @@ -73,7 +74,7 @@ UiObjectStateScopeCatalog, UiObjectStateScopeIdentity, UiObjectStateScopeSummary, - UiObjectStateValuePreview, + UiObjectStateTextPreview, UiSemanticAddress, UiStateSurfaceDocument, UiStateSurfaceCatalog, @@ -1071,20 +1072,15 @@ def test_mcp_widget_tree_projection_preserves_semantic_action_values(): semantic_markers=("_",), raw_value=Inherit(), resolved_value=Value(0), - raw_value_preview=UiObjectStateValuePreview( - type_name="Inherit", - is_none=False, + raw_value_preview=UiObjectStateTextPreview( text="Inherit()", ), - resolved_value_preview=UiObjectStateValuePreview( - type_name="int", - is_none=False, + resolved_value_preview=UiObjectStateTextPreview( text="0", ), inherited_value=True, provenance=UiObjectStateFieldProvenance( source_scope_id="global_config", - source_type="default", ), ), ), @@ -1108,12 +1104,11 @@ def test_mcp_widget_tree_projection_preserves_semantic_action_values(): assert action["semantic_markers"] == ["_"] assert action["raw_value"] == {"Inherit": {}} assert action["resolved_value"] == {"Value": {"value": 0}} - assert action["raw_value_preview"]["text"] == "Inherit()" - assert action["resolved_value_preview"]["text"] == "0" + assert action["raw_value_preview"]["UiObjectStateTextPreview"]["text"] == "Inherit()" + assert action["resolved_value_preview"]["UiObjectStateTextPreview"]["text"] == "0" assert action["inherited_value"] is True assert action["provenance"] == { "source_scope_id": "global_config", - "source_type": "default", "source_field_path": None, } @@ -2113,20 +2108,14 @@ def field_summary(field_path: str) -> UiObjectStateFieldSummary: ), field_name=field_path.rsplit(".", 1)[-1], container_path=(field_path.rsplit(".", 1)[0] if "." in field_path else ""), - object_state_path_type="openhcs.core.config.NapariStreamingConfig", - raw_value_type="None", - resolved_value_type="str", + declared_by=UiObjectStateFieldDeclaration(module='openhcs.core.config', qualname='NapariStreamingConfig'), dirty=False, signature_diff=False, last_changed=False, - raw_value_preview=UiObjectStateValuePreview( - type_name="None", - is_none=True, + raw_value_preview=UiObjectStateTextPreview( text="None", ), - resolved_value_preview=UiObjectStateValuePreview( - type_name="str", - is_none=False, + resolved_value_preview=UiObjectStateTextPreview( text="'resolved'", ), raw_value=Inherit(), @@ -2154,7 +2143,6 @@ def list_object_state_scopes(self, request, connection): identity=UiObjectStateScopeIdentity( object_state_scope_id="global_config", ), - object_type="GlobalPipelineConfig", parameter_count=5, dirty_field_count=0, signature_diff_field_count=0, @@ -2280,7 +2268,6 @@ def list_object_state_scopes(self, request, connection): identity=UiObjectStateScopeIdentity( object_state_scope_id="global_config", ), - object_type="GlobalPipelineConfig", parameter_count=1, dirty_field_count=0, signature_diff_field_count=0, @@ -2290,7 +2277,6 @@ def list_object_state_scopes(self, request, connection): identity=UiObjectStateScopeIdentity( object_state_scope_id="/tmp/plate", ), - object_type="PipelineOrchestrator", parameter_count=1, dirty_field_count=0, signature_diff_field_count=0, @@ -2352,7 +2338,6 @@ def list_object_state_scopes(self, request, connection): identity=UiObjectStateScopeIdentity( object_state_scope_id="global_config", ), - object_type="GlobalPipelineConfig", parameter_count=1, dirty_field_count=0, signature_diff_field_count=0, @@ -2366,22 +2351,14 @@ def list_object_state_scopes(self, request, connection): ), field_name="colormap", container_path="napari_display_config", - object_state_path_type=( - "openhcs.core.config.NapariDisplayConfig" - ), - raw_value_type="str", - resolved_value_type="str", + declared_by=UiObjectStateFieldDeclaration(module='openhcs.core.config', qualname='NapariDisplayConfig'), dirty=False, signature_diff=False, last_changed=False, - raw_value_preview=UiObjectStateValuePreview( - type_name="str", - is_none=False, + raw_value_preview=UiObjectStateTextPreview( text="'gray'", ), - resolved_value_preview=UiObjectStateValuePreview( - type_name="str", - is_none=False, + resolved_value_preview=UiObjectStateTextPreview( text="'gray'", ), ), @@ -2421,7 +2398,7 @@ async def call_describe_tool(): assert request.include_field_descriptions is True assert request.field_limit == 1 assert request.field_paths == ("napari_display_config.colormap",) - assert payload["help_target_type"] == "openhcs.core.config.NapariDisplayConfig" + assert payload["field"]["declared_by"] == {"module": "openhcs.core.config", "qualname": "NapariDisplayConfig"} assert payload["summary"] == "• colormap (str)" @@ -2457,7 +2434,6 @@ def list_object_state_scopes(self, request, connection): identity=UiObjectStateScopeIdentity( object_state_scope_id="plate::step::function_0", ), - object_type=target_name, parameter_count=1, dirty_field_count=0, signature_diff_field_count=0, @@ -2470,23 +2446,17 @@ def list_object_state_scopes(self, request, connection): ), field_name="threshold", container_path="", - object_state_path_type=target_name, - raw_value_type="float", - resolved_value_type="float", + declared_by=UiObjectStateFieldDeclaration(module=target_name.rpartition(".")[0], qualname=target_name.rpartition(".")[2]), dirty=False, signature_diff=False, last_changed=False, parameter_description=( "Numeric cutoff used for segmentation." ), - raw_value_preview=UiObjectStateValuePreview( - type_name="float", - is_none=False, + raw_value_preview=UiObjectStateTextPreview( text="1.0", ), - resolved_value_preview=UiObjectStateValuePreview( - type_name="float", - is_none=False, + resolved_value_preview=UiObjectStateTextPreview( text="1.0", ), ), @@ -2522,7 +2492,7 @@ async def call_describe_tool(): assert ui_bridge_service.requests[0].include_field_descriptions is False assert ui_bridge_service.requests[1].include_field_descriptions is True assert payload["address"]["object_state_scope_id"] == "plate::step::function_0" - assert payload["help_target_type"] == target_name + assert payload["field"]["declared_by"] == {"module": mcp_help_threshold_function.__module__, "qualname": mcp_help_threshold_function.__qualname__} assert payload["description"] == "Numeric cutoff used for segmentation." @@ -2545,9 +2515,7 @@ def field_summary(scope_id: str) -> UiObjectStateFieldSummary: ), field_name="threshold", container_path="", - object_state_path_type=target_name, - raw_value_type="float", - resolved_value_type="float", + declared_by=UiObjectStateFieldDeclaration(module=target_name.rpartition(".")[0], qualname=target_name.rpartition(".")[2]), dirty=False, signature_diff=False, last_changed=False, @@ -2575,7 +2543,6 @@ def list_object_state_scopes(self, request, connection): identity=UiObjectStateScopeIdentity( object_state_scope_id="plate::step::function_0", ), - object_type=target_name, parameter_count=1, dirty_field_count=0, signature_diff_field_count=0, @@ -2586,7 +2553,6 @@ def list_object_state_scopes(self, request, connection): identity=UiObjectStateScopeIdentity( object_state_scope_id="plate::step::function_1", ), - object_type=target_name, parameter_count=1, dirty_field_count=0, signature_diff_field_count=0, @@ -2645,7 +2611,6 @@ def list_object_state_scopes(self, request, connection): identity=UiObjectStateScopeIdentity( object_state_scope_id="plate::step::function_0", ), - object_type=target_name, parameter_count=1, dirty_field_count=0, signature_diff_field_count=0, @@ -2658,23 +2623,17 @@ def list_object_state_scopes(self, request, connection): ), field_name="threshold", container_path="", - object_state_path_type=target_name, - raw_value_type="float", - resolved_value_type="float", + declared_by=UiObjectStateFieldDeclaration(module=target_name.rpartition(".")[0], qualname=target_name.rpartition(".")[2]), dirty=False, signature_diff=False, last_changed=False, parameter_description=( "Numeric cutoff used for segmentation." ), - raw_value_preview=UiObjectStateValuePreview( - type_name="float", - is_none=False, + raw_value_preview=UiObjectStateTextPreview( text="1.0", ), - resolved_value_preview=UiObjectStateValuePreview( - type_name="float", - is_none=False, + resolved_value_preview=UiObjectStateTextPreview( text="1.0", ), ), @@ -2693,7 +2652,7 @@ def list_object_state_scopes(self, request, connection): ) assert result.errors == () - assert result.help_target_type == target_name + assert result.field.declared_by.import_path == target_name assert result.parameter_name == "threshold" assert result.summary == "• threshold (float)" assert result.description == "Numeric cutoff used for segmentation." @@ -2776,20 +2735,14 @@ def field_summary(field_path: str, *, signature_diff: bool = False): ), field_name=field_path, container_path="", - object_state_path_type="openhcs.core.config.GlobalPipelineConfig", - raw_value_type="int", - resolved_value_type="int", + declared_by=UiObjectStateFieldDeclaration(module='openhcs.core.config', qualname='GlobalPipelineConfig'), dirty=False, signature_diff=signature_diff, last_changed=False, - raw_value_preview=UiObjectStateValuePreview( - type_name="int", - is_none=False, + raw_value_preview=UiObjectStateTextPreview( text="1", ), - resolved_value_preview=UiObjectStateValuePreview( - type_name="int", - is_none=False, + resolved_value_preview=UiObjectStateTextPreview( text="1", ), inherited_value=False, @@ -2821,7 +2774,6 @@ def list_object_state_scopes(self, request, connection): identity=UiObjectStateScopeIdentity( object_state_scope_id="global_config", ), - object_type="GlobalPipelineConfig", parameter_count=10, dirty_field_count=0, signature_diff_field_count=5, diff --git a/tests/unit/agent/test_object_state_field_help_service.py b/tests/unit/agent/test_object_state_field_help_service.py index b936014322..52e3d76a88 100644 --- a/tests/unit/agent/test_object_state_field_help_service.py +++ b/tests/unit/agent/test_object_state_field_help_service.py @@ -1,3 +1,4 @@ +from formbridge.bridge.dto import UiObjectStateFieldDeclaration from objectstate.field_value import Value from dataclasses import dataclass from enum import Enum @@ -69,7 +70,6 @@ def invoke(self, operation, request, connection): identity=UiObjectStateScopeIdentity( object_state_scope_id="enum-config", ), - object_type=target_name, parameter_count=1, dirty_field_count=0, signature_diff_field_count=0, @@ -82,9 +82,7 @@ def invoke(self, operation, request, connection): ), field_name="mode", container_path="", - object_state_path_type=target_name, - raw_value_type="EnumHelpMode", - resolved_value_type="EnumHelpMode", + declared_by=UiObjectStateFieldDeclaration(module=target_name.rpartition(".")[0], qualname=target_name.rpartition(".")[2]), dirty=False, signature_diff=False, last_changed=False, @@ -188,7 +186,6 @@ def invoke(self, operation, request, connection): identity=UiObjectStateScopeIdentity( object_state_scope_id="plate::step_0", ), - object_type="FunctionStep", parameter_count=1, dirty_field_count=0, signature_diff_field_count=0, @@ -201,11 +198,7 @@ def invoke(self, operation, request, connection): ), field_name="func", container_path="", - object_state_path_type=( - "openhcs.core.steps.function_step.FunctionStep" - ), - raw_value_type="tuple", - resolved_value_type="tuple", + declared_by=UiObjectStateFieldDeclaration(module='openhcs.core.steps.function_step', qualname='FunctionStep'), dirty=False, signature_diff=True, last_changed=False, @@ -375,7 +368,6 @@ def invoke(self, operation, request, connection): identity=UiObjectStateScopeIdentity( object_state_scope_id="plate::step::function_0", ), - object_type=target_name, parameter_count=3, dirty_field_count=0, signature_diff_field_count=0, @@ -388,9 +380,7 @@ def invoke(self, operation, request, connection): ), field_name="grid_dimensions", container_path="", - object_state_path_type=target_name, - raw_value_type="NoneType", - resolved_value_type="NoneType", + declared_by=UiObjectStateFieldDeclaration(module=target_name.rpartition(".")[0], qualname=target_name.rpartition(".")[2]), dirty=False, signature_diff=False, last_changed=False, diff --git a/tests/unit/agent/test_ui_bridge_service.py b/tests/unit/agent/test_ui_bridge_service.py index c409e20e8e..629b1ad083 100644 --- a/tests/unit/agent/test_ui_bridge_service.py +++ b/tests/unit/agent/test_ui_bridge_service.py @@ -1,4 +1,5 @@ from __future__ import annotations +from formbridge.bridge.dto import UiObjectStateFieldDeclaration from objectstate.field_value import Inherit, Value from formbridge.selection import AllItems, SelectedItems from openhcs.agent.dto.session import DatasetRowState @@ -60,7 +61,7 @@ UiObjectStateScopeIdentity, UiObjectStateScopeListRequest, UiObjectStateScopeSummary, - UiObjectStateValuePreview, + UiObjectStateTextPreview, UiSemanticAddress, UiSnapshotCatalog, UiSnapshotListRequest, @@ -1713,7 +1714,6 @@ def list_object_state_scopes( identity=UiObjectStateScopeIdentity( object_state_scope_id="global_config", ), - object_type="GlobalPipelineConfig", parameter_count=1, dirty_field_count=0, signature_diff_field_count=0, @@ -1723,7 +1723,6 @@ def list_object_state_scopes( identity=UiObjectStateScopeIdentity( object_state_scope_id=PLATE_SCOPE_ID, ), - object_type="PipelineOrchestrator", parameter_count=1, dirty_field_count=0, signature_diff_field_count=0, @@ -1764,20 +1763,14 @@ def field_summary( ), field_name=field_path.rsplit(".", 1)[-1], container_path=(field_path.rsplit(".", 1)[0] if "." in field_path else ""), - object_state_path_type="openhcs.core.config.NapariStreamingConfig", - raw_value_type="Inherit" if inherited_value else "bool", - resolved_value_type="bool", + declared_by=UiObjectStateFieldDeclaration(module='openhcs.core.config', qualname='NapariStreamingConfig'), dirty=False, signature_diff=False, last_changed=False, - raw_value_preview=UiObjectStateValuePreview( - type_name="Inherit" if inherited_value else "bool", - is_none=False, + raw_value_preview=UiObjectStateTextPreview( text="Inherit()" if inherited_value else "False", ), - resolved_value_preview=UiObjectStateValuePreview( - type_name="bool", - is_none=False, + resolved_value_preview=UiObjectStateTextPreview( text="False", ), raw_value=Inherit() if inherited_value else Value(False), @@ -1804,7 +1797,6 @@ def list_object_state_scopes( identity=UiObjectStateScopeIdentity( object_state_scope_id="global_config", ), - object_type="GlobalPipelineConfig", parameter_count=3, dirty_field_count=0, signature_diff_field_count=0, diff --git a/tests/unit/pyqt_gui/test_ui_agent_bridge.py b/tests/unit/pyqt_gui/test_ui_agent_bridge.py index f8c588c2e7..7fa250ba39 100644 --- a/tests/unit/pyqt_gui/test_ui_agent_bridge.py +++ b/tests/unit/pyqt_gui/test_ui_agent_bridge.py @@ -2639,7 +2639,7 @@ def test_object_state_exact_field_paths_include_path_type_and_description() -> N assert len(global_scope.fields) == 1 field = global_scope.fields[0] assert field.address.field_path == "napari_display_config" - assert field.object_state_path_type == "openhcs.core.config.NapariDisplayConfig" + assert field.declared_by.resolve() is state.fields.napari_display_config.declaring_type assert field.parameter_description is not None assert ( field.parameter_description From f0c07c411fcaccd7e16df03dc17876aaa0b077db Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 15:22:34 -0400 Subject: [PATCH 4/7] Migrate declared configuration consumers and typed metadata --- openhcs/agent/services/config_service.py | 26 +++-- .../services/pipeline_authoring_service.py | 4 +- openhcs/core/axis_filter.py | 6 +- openhcs/core/config.py | 16 ++-- openhcs/core/materialization/core.py | 6 +- openhcs/core/pipeline/compiler.py | 5 +- openhcs/core/pipeline/path_planner.py | 4 +- openhcs/core/source_bindings.py | 6 +- openhcs/core/steps/abstract.py | 5 +- .../pyqt_gui/widgets/step_parameter_editor.py | 95 ++----------------- 10 files changed, 47 insertions(+), 126 deletions(-) diff --git a/openhcs/agent/services/config_service.py b/openhcs/agent/services/config_service.py index 5e441c6185..ab0cd8a820 100644 --- a/openhcs/agent/services/config_service.py +++ b/openhcs/agent/services/config_service.py @@ -23,7 +23,8 @@ ) from metaclass_registry import AutoRegisterMeta -from objectstate import get_base_type_for_lazy +from objectstate.declarations import InheritedDefault +from objectstate.ui_visibility import UIParameterVisibilityPolicy from formbridge.services.parameter_help_service import ( class_docstring_text, dataclass_parameter_descriptions, @@ -64,6 +65,7 @@ from openhcs.core.steps.abstract import AbstractStep from openhcs.core.steps.function_step import FunctionStep from openhcs.core.axes import AxisFamily, GroupingDeclaration +from python_introspect import resolve_declared_type AgentConfig: TypeAlias = GlobalPipelineConfig | PipelineConfig @@ -811,8 +813,8 @@ def _schema_for_field( source_type = lazy_base or field_type nested_types = _nested_dataclass_types(field_type) ui_hidden = False - if "ui_hidden" in field.metadata: - ui_hidden = bool(field.metadata["ui_hidden"]) + if UIParameterVisibilityPolicy in field.metadata: + ui_hidden = UIParameterVisibilityPolicy.field_hidden(field) default_repr, default_origin = _default_schema(field) return ConfigFieldSchema( path=field.name if field_path is None else field_path, @@ -832,8 +834,7 @@ def _schema_for_field( lazy=lazy_base is not None, inheritable=( inheritable - or "_inherited_default" in field.metadata - or "_inherited_default_factory" in field.metadata + or InheritedDefault in field.metadata ), declaring_type=_type_repr(_field_declaring_type(declaring_cls, field.name)), default_origin=default_origin, @@ -948,7 +949,8 @@ def _selected_config_type_schemas( def _lazy_base_type(field_type) -> type | None: if not isinstance(field_type, type): return None - return get_base_type_for_lazy(field_type) + base_type = resolve_declared_type(field_type) + return base_type if base_type is not field_type else None def _type_repr(field_type) -> str: @@ -958,12 +960,9 @@ def _type_repr(field_type) -> str: def _default_schema(field) -> tuple[str | None, str | None]: - inherited_default = field.metadata.get("_inherited_default", MISSING) - inherited_factory = field.metadata.get("_inherited_default_factory", MISSING) - if inherited_default is not MISSING: - return _default_value_repr(inherited_default), "inherited_default" - if inherited_factory is not MISSING: - return _factory_default_repr(inherited_factory), "inherited_factory" + if InheritedDefault in field.metadata: + inherited = field.metadata[InheritedDefault] + return _default_value_repr(inherited.value()), inherited.schema_origin if field.default is not MISSING: return _default_value_repr(field.default), "field_default" if field.default_factory is not MISSING: @@ -998,7 +997,6 @@ def _parameter_default_repr(parameter: inspect.Parameter) -> str | None: def _field_declaring_type(cls: type, field_name: str) -> type: source_cls = _lazy_base_type(cls) or cls - inherited_metadata = frozenset(("_inherited_default", "_inherited_default_factory")) fallback = source_cls for candidate in source_cls.__mro__: if field_name not in candidate.__dict__.get("__annotations__", {}): @@ -1012,7 +1010,7 @@ def _field_declaring_type(cls: type, field_name: str) -> type: ) if candidate_field is None: continue - if not inherited_metadata.intersection(candidate_field.metadata): + if InheritedDefault not in candidate_field.metadata: return candidate return fallback diff --git a/openhcs/agent/services/pipeline_authoring_service.py b/openhcs/agent/services/pipeline_authoring_service.py index 859d969f1c..840f3f9515 100644 --- a/openhcs/agent/services/pipeline_authoring_service.py +++ b/openhcs/agent/services/pipeline_authoring_service.py @@ -8,7 +8,6 @@ from itertools import count from typing import TypeAlias -from objectstate import get_base_type_for_lazy from openhcs.agent.dto.common import SCHEMA_VERSION, RenderedSource from formbridge.contracts import AgentError, AgentWarning @@ -45,6 +44,7 @@ ) from openhcs.core.steps.abstract import AbstractStep from openhcs.core.steps.function_step import FunctionStep +from python_introspect import resolve_declared_type StepConfig: TypeAlias = object PIPELINE_EMPTY_WARNING = AgentWarning( @@ -475,6 +475,6 @@ def _step_config_class_from_patch(patch: ConfigPatch) -> type[StepConfig]: def _instantiate_config_patch(patch: ConfigPatch) -> StepConfig: config_class = _step_config_class_from_patch(patch) - coercion_class = get_base_type_for_lazy(config_class) or config_class + coercion_class = resolve_declared_type(config_class) values = coerce_dataclass_patch_values(coercion_class, patch.values) return config_class(**values) diff --git a/openhcs/core/axis_filter.py b/openhcs/core/axis_filter.py index 8737233ad3..b4fbfd98bb 100644 --- a/openhcs/core/axis_filter.py +++ b/openhcs/core/axis_filter.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from collections.abc import Mapping from typing import TYPE_CHECKING, TypeAlias +from python_introspect import resolve_declared_type if TYPE_CHECKING: from openhcs.core.config import PartitionFilterConfig, PartitionFilterMode @@ -53,13 +54,12 @@ def resolution_for( self, config: "PartitionFilterConfig", ) -> StepAxisFilterResolution | None: - from objectstate import get_base_type_for_lazy - config_type = get_base_type_for_lazy(type(config)) or type(config) + config_type = resolve_declared_type(type(config)) config_mro = config_type.mro() matches: list[tuple[int, StepAxisFilterResolution]] = [] for owner_type, resolution in self.resolutions_by_config_type.items(): - owner_base = get_base_type_for_lazy(owner_type) or owner_type + owner_base = resolve_declared_type(owner_type) if not isinstance(owner_base, type): continue if owner_base not in config_mro: diff --git a/openhcs/core/config.py b/openhcs/core/config.py index 73eb94a6b7..899a1dc8ae 100644 --- a/openhcs/core/config.py +++ b/openhcs/core/config.py @@ -7,6 +7,7 @@ """ import hashlib +from objectstate.ui_visibility import UIParameterVisibilityPolicy import json import logging import inspect @@ -463,12 +464,11 @@ def accepts_partition_filter_provenance(cls, source_type: type | None) -> bool: if source_type is None: return True - from objectstate import get_base_type_for_lazy - config_type = get_base_type_for_lazy(cls) or cls - source_base = get_base_type_for_lazy(source_type) or source_type + config_type = resolve_declared_type(cls) + source_base = resolve_declared_type(source_type) branch_type = cls.partition_filter_inheritance_branch() - branch_base = get_base_type_for_lazy(branch_type) or branch_type + branch_base = resolve_declared_type(branch_type) return issubclass(source_base, config_type) or source_base in branch_base.mro() @@ -1026,10 +1026,9 @@ def source_bindings_defaults_to_base( defaults: SourceBindingsConfig, ) -> SourceBindingsConfig: """Return concrete source-binding defaults from eager or lazy config values.""" - from objectstate import get_base_type_for_lazy - lazy_base_type = get_base_type_for_lazy(type(defaults)) - if lazy_base_type is SourceBindingsConfig: + lazy_base_type = resolve_declared_type(type(defaults)) + if lazy_base_type is SourceBindingsConfig and type(defaults) is not SourceBindingsConfig: concrete_defaults = SourceBindingsConfig() return SourceBindingsConfig( **{ @@ -1226,7 +1225,7 @@ class StreamingDefaults(Enableable, StepPartitionFilterConfig): scope_accent_color: Annotated[Optional[str], abbreviation("accent")] = field( default=None, - metadata={"ui_hidden": True}, + metadata=UIParameterVisibilityPolicy.field_metadata(True), ) """Exact owner-projected scope accent used to frame a managed viewer.""" @@ -1471,6 +1470,7 @@ def runtime_config_parameter( from objectstate import config_context from objectstate.lazy_factory import resolve_lazy_configurations_for_serialization +from python_introspect import resolve_declared_type with config_context(PipelineConfig()): EMPTY_SOURCE_BINDINGS: StepSourceBindingsConfig = ( diff --git a/openhcs/core/materialization/core.py b/openhcs/core/materialization/core.py index 82dd33f25d..22069784e7 100644 --- a/openhcs/core/materialization/core.py +++ b/openhcs/core/materialization/core.py @@ -4,6 +4,8 @@ """ from __future__ import annotations + +from objectstate.declarations import ConfigurationRebuild from openhcs.core.runtime_plane_projection import RuntimeSlicePlaneAxis import copy @@ -4693,7 +4695,7 @@ def metadata_for_slices( @dataclass(init=False) -class MaterializationSpec(ArtifactMaterializationPayload): +class MaterializationSpec(ArtifactMaterializationPayload, ConfigurationRebuild): """Declarative materialization spec. The spec is a list of *writer options* objects. Writer dispatch is inferred @@ -4744,7 +4746,7 @@ def __init__( self.write_mode = write_mode @classmethod - def __objectstate_rebuild__( + def from_fields( cls, *, outputs: tuple[FileOutputOptions, ...], diff --git a/openhcs/core/pipeline/compiler.py b/openhcs/core/pipeline/compiler.py index 30bc6f421c..9eaa248f91 100644 --- a/openhcs/core/pipeline/compiler.py +++ b/openhcs/core/pipeline/compiler.py @@ -99,7 +99,7 @@ ) from openhcs.core.steps.abstract import AbstractStep from openhcs.core.partition_filter import PartitionFilterProcessor -from objectstate import ObjectState, ObjectStateRegistry, get_base_type_for_lazy +from objectstate import ObjectState, ObjectStateRegistry from openhcs.core.steps.function_step import FunctionStep # Used for isinstance check from dataclasses import dataclass, replace from openhcs.core.axes import AxisFamily @@ -108,6 +108,7 @@ CompileProgressPhase, RunningProgressStatus, ) +from python_introspect import resolve_declared_type if TYPE_CHECKING: from openhcs.core.orchestrator.orchestrator import PipelineOrchestrator @@ -426,7 +427,7 @@ def _collect_streaming_config( step_plan: CompiledStepPlan, config_type: type[StreamingConfig], ) -> None: - resolved_config_type = get_base_type_for_lazy(config_type) or config_type + resolved_config_type = resolve_declared_type(config_type) configs = tuple( value for value in vars(step).values() diff --git a/openhcs/core/pipeline/path_planner.py b/openhcs/core/pipeline/path_planner.py index 7658fa5856..845ed3f46f 100644 --- a/openhcs/core/pipeline/path_planner.py +++ b/openhcs/core/pipeline/path_planner.py @@ -35,7 +35,6 @@ normalize_function_pattern, ) from openhcs.core.callable_contract import CallableContract, FunctionStepExecutionScope -from objectstate import get_base_type_for_lazy from openhcs.core.invocation_artifacts import ( ArtifactDeclarationStepContext, PIPELINE_INPUT_ARTIFACT, @@ -80,6 +79,7 @@ from openhcs.core.steps.abstract import AbstractStep from openhcs.core.steps.function_step import FunctionStep from openhcs.core.axes import Axis, AxisFamily, GroupingDeclaration, Ungrouped +from python_introspect import resolve_declared_type logger = logging.getLogger(__name__) @@ -929,7 +929,7 @@ def build_step_compiled_function_pattern( else pipeline_values[parameter_name] ) parameter_type = ( - get_base_type_for_lazy(parameter.annotation) or parameter.annotation + resolve_declared_type(parameter.annotation) ) if not isinstance(provider, parameter_type): raise TypeError( diff --git a/openhcs/core/source_bindings.py b/openhcs/core/source_bindings.py index 486ac7675b..6d033cac9e 100644 --- a/openhcs/core/source_bindings.py +++ b/openhcs/core/source_bindings.py @@ -2013,7 +2013,7 @@ def _normalize_common_fields(self) -> None: from objectstate.field_access import DataclassFieldAccess metadata_rules = DataclassFieldAccess.raw_value(self, "metadata_rules") - if FieldValue.of(metadata_rules).has_value: + if FieldValue.of(metadata_rules).available(): metadata_rules = tuple(metadata_rules) for rule in metadata_rules: if not isinstance(rule, MetadataExtractionRule): @@ -2025,14 +2025,14 @@ def _normalize_common_fields(self) -> None: object.__setattr__(self, "metadata_rules", metadata_rules) match_plan = DataclassFieldAccess.raw_value(self, "match_plan") - if FieldValue.of(match_plan).has_value and not isinstance(match_plan, SourceSetPairing | None): + if FieldValue.of(match_plan).available() and not isinstance(match_plan, SourceSetPairing | None): raise TypeError( f"{type(self).__name__}.match_plan must be a SourceSetPairing, " f"got {type(match_plan).__name__}." ) metadata_fields = DataclassFieldAccess.raw_value(self, "metadata_fields") - if FieldValue.of(metadata_fields).has_value: + if FieldValue.of(metadata_fields).available(): metadata_fields = FieldSpec.merge_exact( (tuple(metadata_fields),), context=f"{type(self).__name__}.metadata_fields", diff --git a/openhcs/core/steps/abstract.py b/openhcs/core/steps/abstract.py index 2cb064dea0..27d34523b3 100644 --- a/openhcs/core/steps/abstract.py +++ b/openhcs/core/steps/abstract.py @@ -14,7 +14,7 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Iterable, get_type_hints -from objectstate import get_base_type_for_lazy, semantic_values_equal +from objectstate import semantic_values_equal # Import LazyStepMaterializationConfig for type hints @@ -34,6 +34,7 @@ RuntimeArtifactLocation, StoredRuntimeValue, ) +from python_introspect import resolve_declared_type # ExecutingContext is used in type hints if TYPE_CHECKING: @@ -255,7 +256,7 @@ def config_classes_by_field_name(cls) -> dict[str, type]: if field_name != "self" and parameter.kind is inspect.Parameter.KEYWORD_ONLY and isinstance((declared_type := type_hints.get(field_name)), type) - and is_dataclass(get_base_type_for_lazy(declared_type) or declared_type) + and is_dataclass(resolve_declared_type(declared_type)) } def declaration_parameters(self) -> dict[str, object]: diff --git a/openhcs/pyqt_gui/widgets/step_parameter_editor.py b/openhcs/pyqt_gui/widgets/step_parameter_editor.py index 56f2c3a7cd..10f9b949de 100644 --- a/openhcs/pyqt_gui/widgets/step_parameter_editor.py +++ b/openhcs/pyqt_gui/widgets/step_parameter_editor.py @@ -1,3 +1,5 @@ +from objectstate.declarations import ValueDeclaration +from python_introspect import resolve_optional, resolve_annotated """ Step Parameter Editor Widget for PyQt6 GUI. @@ -179,7 +181,6 @@ def __init__( # Get current parameter values from ObjectState, the editor model. parameter_types = {} - self._step_level_configs = {} for name, info in param_info.items(): # All AbstractStep parameters are relevant for editing @@ -189,18 +190,6 @@ def __init__( f"ObjectState for scope_id={self.scope_id} is missing " f"step parameter {name!r}" ) - current_value = state_values[name] - - # CRITICAL FIX: For lazy dataclass parameters, leave current_value as None - # This allows the UI to show placeholders and use lazy resolution properly - if current_value is None and self._is_optional_lazy_dataclass_in_pipeline( - info.param_type, name - ): - # Don't create concrete instances - leave as None for placeholder resolution - # The UI will handle lazy resolution and show appropriate placeholders - # Mark this as a step-level config for special handling - self._step_level_configs[name] = True - parameter_types[name] = info.param_type # Track dataclass-backed parameters for the hierarchy tree @@ -298,85 +287,15 @@ def apply_source_bindings_preview_context(self) -> None: inventory=inventory, ) - def _is_optional_lazy_dataclass_in_pipeline(self, param_type, param_name): - """ - Check if parameter is an optional lazy dataclass that exists in PipelineConfig. - - This enables automatic step-level config creation for any parameter that: - 1. Is Optional[SomeDataclass] - 2. SomeDataclass exists as a field type in PipelineConfig (type-based matching) - 3. The dataclass has lazy resolution capabilities - - No manual mappings needed - uses type-based discovery. - """ - - # Check if parameter is Optional[dataclass] - if not ParameterTypeUtils.is_optional_dataclass(param_type): - return False - - # Get the inner dataclass type - inner_type = ParameterTypeUtils.get_optional_inner_type(param_type) - - # Find if this type exists as a field in PipelineConfig (type-based matching) - pipeline_field_name = self._find_pipeline_field_by_type(inner_type) - if not pipeline_field_name: - return False - - # Check if the dataclass has lazy resolution capabilities - try: - test_instance = inner_type() - except Exception: - return False - - instance_members = set(dir(test_instance)) - return { - "_resolve_field_value", - "_lazy_resolution_config", - }.issubset(instance_members) - def _find_pipeline_field_by_type(self, target_type): - """ - Find the field in PipelineConfig that matches the target type. - - This is type-based discovery - no manual mappings needed. - """ - for field in dataclasses.fields(PipelineConfig): - # Use string comparison to handle type identity issues - if str(field.type) == str(target_type): - return field.name - return None def _collect_dataclass_parameters(self, parameter_types): - """Return dataclass-based parameters for building the hierarchy tree.""" - dataclass_params = {} - for field_name, param_type in parameter_types.items(): - obj_type = self._extract_dataclass_from_param_type(param_type) - if obj_type is not None: - dataclass_params[field_name] = obj_type - - return dataclass_params - - def _extract_dataclass_from_param_type(self, param_type): - """Resolve the concrete dataclass type from the annotated parameter.""" - resolved_type = param_type - - try: - origin = get_origin(param_type) - except Exception: - origin = None - - if origin is Union: - args = [arg for arg in get_args(param_type) if arg is not type(None)] - if len(args) == 1: - resolved_type = args[0] - - if resolved_type is None or isinstance(resolved_type, str): - return None - - if dataclasses.is_dataclass(resolved_type): - return resolved_type + declarations = {} + for name, annotation in parameter_types.items(): + for owner in ValueDeclaration.of(resolve_optional(resolve_annotated(annotation))).configuration_owners(): + declarations[name] = owner.declaring_type + return declarations - return None def _create_configuration_tree(self) -> Optional[QTreeWidget]: """Create and populate the configuration hierarchy tree.""" From 560b443de878ba0504cccf36a3b4dfbb62c8c2ca Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 15:37:57 -0400 Subject: [PATCH 5/7] Share source-plan declarations and preserve field directives during normalization --- openhcs/core/config.py | 147 +++------------ openhcs/core/source_bindings.py | 173 +++++++++--------- ...t_source_binding_declared_normalization.py | 58 ++++++ 3 files changed, 172 insertions(+), 206 deletions(-) create mode 100644 tests/unit/test_source_binding_declared_normalization.py diff --git a/openhcs/core/config.py b/openhcs/core/config.py index 899a1dc8ae..54e41cbea3 100644 --- a/openhcs/core/config.py +++ b/openhcs/core/config.py @@ -84,8 +84,6 @@ NamedSourceBinding, SourceBindingDeclarationsMixin, SourceFilterClause, - _SourceBindingPlanBase, - normalize_source_binding_values, ) from openhcs.core.source_metadata import ( SourceMetadataFields, @@ -608,7 +606,7 @@ class ProcessingConfig(AnnotatedDataclassValidationMixin): abbreviation="src", ) @dataclass(frozen=True) -class SourceBindingsConfig(SourceBindingDeclarationsMixin, _SourceBindingPlanBase): +class SourceBindingsConfig(SourceBindingDeclarationsMixin): """Canonical pipeline declaration for selecting and naming plate sources. Normal plate initialization resolves these bindings over exact planes emitted @@ -621,23 +619,12 @@ class SourceBindingsConfig(SourceBindingDeclarationsMixin, _SourceBindingPlanBas source_filters: tuple[SourceFilterClause, ...] = () """Filters limiting the source universe before named bindings are resolved.""" - bindings: tuple[NamedSourceBinding, ...] = () - """Named semantic source bindings available to pipelines and inherited by steps.""" - image_plane_sources: tuple[ImagePlaneSource, ...] = () """Exact image-plane source records available for source-binding resolution.""" imported_metadata_tables: tuple[ImportedMetadataTable, ...] = () """External metadata tables joined to source records before matching.""" - source_stack_components: tuple[type[Axis], ...] = () - """Ordered plate components that form one logical source image stack.""" - - source_spatial_domain: SourceSpatialDomain = field( - default_factory=SourceSpatialDomain - ) - """Declared intrinsic image dimensions, independent of stack transport.""" - grouping_metadata_fields: tuple[str, ...] = () """Metadata field names used to partition matched sources into execution groups.""" @@ -651,115 +638,39 @@ class SourceBindingsConfig(SourceBindingDeclarationsMixin, _SourceBindingPlanBas """ def __post_init__(self) -> None: - self._normalize_common_fields() - source_filters = object.__getattribute__(self, "source_filters") - if source_filters is not None: - source_filters = normalize_source_binding_values( - "SourceBindingsConfig.source_filters", - source_filters, - SourceFilterClause, - ) - bindings = object.__getattribute__(self, "bindings") - if bindings is not None: - bindings = normalize_source_binding_values( - f"{type(self).__name__}.bindings", - bindings, - NamedSourceBinding, - ) - bindings = tuple( - replace( - binding, - selector=replace( - binding.selector, - metadata=tuple( - replace( - selector, - value=self.coerce_metadata_scalar( - selector.field, - selector.value, - ), - ) - for selector in binding.selector.metadata - ), + super().__post_init__() + self._normalize_field("source_filters", self._tuple_values) + self._normalize_field("image_plane_sources", self._tuple_values) + self._normalize_field("imported_metadata_tables", self._tuple_values) + self._normalize_field("grouping_metadata_fields", self._normalize_grouping_fields) + self._normalize_field("source_voxel_spacing", self._validate_field_value) + + def _normalize_bindings(self, name, values): + bindings = super()._normalize_bindings(name, values) + return tuple( + replace( + binding, + selector=replace( + binding.selector, + metadata=tuple( + replace( + selector, + value=self.coerce_metadata_scalar(selector.field, selector.value), + ) + for selector in binding.selector.metadata ), - ) - for binding in bindings - ) - seen_aliases: set[str] = set() - if bindings is not None: - for binding in bindings: - if binding.alias in seen_aliases: - raise ValueError( - f"{type(self).__name__}.bindings contains duplicate alias " - f"{binding.alias!r}." - ) - seen_aliases.add(binding.alias) - object.__setattr__(self, "source_filters", source_filters) - object.__setattr__(self, "bindings", bindings) - image_plane_sources = object.__getattribute__(self, "image_plane_sources") - if image_plane_sources is not None: - image_plane_sources = normalize_source_binding_values( - f"{type(self).__name__}.image_plane_sources", - image_plane_sources, - ImagePlaneSource, + ), ) - object.__setattr__(self, "image_plane_sources", image_plane_sources) - - imported_metadata_tables = object.__getattribute__( - self, - "imported_metadata_tables", - ) - if imported_metadata_tables is not None: - imported_metadata_tables = normalize_source_binding_values( - f"{type(self).__name__}.imported_metadata_tables", - imported_metadata_tables, - ImportedMetadataTable, - ) - object.__setattr__( - self, - "imported_metadata_tables", - imported_metadata_tables, - ) - - source_stack_components = object.__getattribute__( - self, - "source_stack_components", - ) - if source_stack_components is not None: - source_stack_components = ComponentSet.collect( - source_stack_components - ).as_tuple() - object.__setattr__( - self, - "source_stack_components", - source_stack_components, + for binding in bindings ) - grouping_metadata_fields = object.__getattribute__( - self, - "grouping_metadata_fields", - ) - if grouping_metadata_fields is not None: - grouping_metadata_fields = tuple( - str(value).strip() for value in grouping_metadata_fields - ) - if any(not value for value in grouping_metadata_fields): - raise ValueError( - f"{type(self).__name__}.grouping_metadata_fields cannot contain " - "empty names." - ) - grouping_metadata_fields = tuple(dict.fromkeys(grouping_metadata_fields)) - object.__setattr__(self, "grouping_metadata_fields", grouping_metadata_fields) - - source_voxel_spacing = object.__getattribute__(self, "source_voxel_spacing") - if source_voxel_spacing is not None and not isinstance( - source_voxel_spacing, - SourceVoxelSpacing, - ): - raise TypeError( - f"{type(self).__name__}.source_voxel_spacing must be " - "SourceVoxelSpacing." + def _normalize_grouping_fields(self, name, values): + names = tuple(str(value).strip() for value in values) + if any(not value for value in names): + raise ValueError( + f"{type(self).__name__}.grouping_metadata_fields cannot contain empty names." ) + return tuple(dict.fromkeys(names)) @property def has_primary_content(self) -> bool: diff --git a/openhcs/core/source_bindings.py b/openhcs/core/source_bindings.py index 6d033cac9e..5c2ec873a5 100644 --- a/openhcs/core/source_bindings.py +++ b/openhcs/core/source_bindings.py @@ -16,13 +16,19 @@ from urllib.parse import unquote, urlsplit from metaclass_registry import AutoRegisterMeta +from objectstate.field_value import FieldValue +from objectstate.field_access import DataclassFieldAccess +from python_introspect.annotation_types import resolved_class_annotations from openhcs.core.artifacts.plans import ArtifactSpecRef from openhcs.core.artifacts.specs import ArtifactSpec, ArtifactSpecCollection from openhcs.core.artifacts.kinds import ArtifactType, ImageArtifactType from openhcs.core.component_set import ComponentSet from polystore.formats import get_format_from_extension -from python_introspect import FieldLabel, RegisteredClassChoices +from python_introspect import ( + AnnotatedDataclassValidationMixin, FieldLabel, RegisteredClassChoices, + resolve_declared_type, validate_annotation_value, +) from openhcs.core.runtime_tabular_values import FieldSpec from openhcs.core.source_metadata import ( SourceMetadataFields, @@ -1558,12 +1564,81 @@ def requires_step_input_component_stack( ) -class SourceBindingDeclarationsMixin: - """Shared named-binding view for editable and compiled source plans.""" +@dataclass(frozen=True, kw_only=True) +class _SourceBindingPlanBase(ABC): + """Shared declarations and raw-field normalization for source-binding plans.""" + + metadata_rules: tuple[MetadataExtractionRule, ...] = () + """Regex/metadata extraction rules adding semantic fields to matching sources.""" + + match_plan: SourceSetPairing = PairByOrder() + """How the matched aliases pair into logical source sets.""" + + metadata_fields: tuple[FieldSpec, ...] = () + """Exact scalar schema for source-literal metadata fields.""" + + bindings: tuple[NamedSourceBinding, ...] = () + """Named semantic source bindings available to pipelines and steps.""" + + source_stack_components: tuple[type[Axis], ...] = () + """Ordered plate components forming one logical source image stack.""" + + source_spatial_domain: SourceSpatialDomain = field(default_factory=SourceSpatialDomain) + """Declared intrinsic image dimensions, independent of stack transport.""" + + def _normalize_field(self, name, normalize): + """Normalize only declared data; leave inheritance and absence untouched.""" + member = FieldValue.of(DataclassFieldAccess.raw_value(self, name)) + object.__setattr__(self, name, member.map(lambda value: normalize(name, value))) + + def __post_init__(self) -> None: + self._normalize_field("metadata_rules", self._tuple_values) + self._normalize_field("match_plan", self._validate_field_value) + self._normalize_field("metadata_fields", self._normalize_metadata_fields) + self._normalize_field("bindings", self._normalize_bindings) + self._normalize_field("source_stack_components", self._normalize_stack_components) + + def _tuple_values(self, name, values): + return self._validate_field_value(name, tuple(values)) + + def _validate_field_value(self, name, value): + annotation = resolved_class_annotations(resolve_declared_type(type(self)))[name] + validate_annotation_value(annotation, value, path=f"{type(self).__name__}.{name}") + return value + + def _normalize_metadata_fields(self, name, values): + return FieldSpec.merge_exact( + (tuple(values),), context=f"{type(self).__name__}.metadata_fields" + ) + + def _normalize_bindings(self, name, values): + bindings = self._tuple_values(name, values) + seen_aliases: set[str] = set() + for binding in bindings: + if binding.alias in seen_aliases: + raise ValueError( + f"{type(self).__name__}.bindings contains duplicate alias " + f"{binding.alias!r}." + ) + seen_aliases.add(binding.alias) + return bindings + + def _normalize_stack_components(self, name, values): + return ComponentSet.collect(values).as_tuple() + + @property + def metadata_rule_declarations(self) -> tuple[MetadataExtractionRule, ...]: + """Metadata rules explicitly declared on this plan.""" + return tuple(self.metadata_rules) + + @property + @abstractmethod + def has_primary_content(self) -> bool: + """Whether the subclass-specific binding payload is empty.""" + - bindings: tuple[NamedSourceBinding, ...] | None - source_stack_components: tuple[type[Axis], ...] - source_spatial_domain: SourceSpatialDomain +class SourceBindingDeclarationsMixin(_SourceBindingPlanBase): + """Shared named-binding view for editable and compiled source plans.""" @property def binding_declarations(self) -> tuple[NamedSourceBinding, ...]: @@ -1995,60 +2070,6 @@ def for_artifact_refs( ) -@dataclass(frozen=True, kw_only=True) -class _SourceBindingPlanBase(ABC): - """Shared typed source-binding plan fields across editable and compiled views.""" - - metadata_rules: tuple[MetadataExtractionRule, ...] = () - """Regex/metadata extraction rules that add semantic fields for matching sources.""" - - match_plan: SourceSetPairing = PairByOrder() - """How the matched aliases pair into logical source sets.""" - - metadata_fields: tuple[FieldSpec, ...] = () - """Exact scalar schema for source-literal metadata fields.""" - - def _normalize_common_fields(self) -> None: - from objectstate.field_value import FieldValue - from objectstate.field_access import DataclassFieldAccess - - metadata_rules = DataclassFieldAccess.raw_value(self, "metadata_rules") - if FieldValue.of(metadata_rules).available(): - metadata_rules = tuple(metadata_rules) - for rule in metadata_rules: - if not isinstance(rule, MetadataExtractionRule): - raise TypeError( - f"{type(self).__name__}.metadata_rules must contain " - "MetadataExtractionRule values, got " - f"{type(rule).__name__}." - ) - object.__setattr__(self, "metadata_rules", metadata_rules) - - match_plan = DataclassFieldAccess.raw_value(self, "match_plan") - if FieldValue.of(match_plan).available() and not isinstance(match_plan, SourceSetPairing | None): - raise TypeError( - f"{type(self).__name__}.match_plan must be a SourceSetPairing, " - f"got {type(match_plan).__name__}." - ) - - metadata_fields = DataclassFieldAccess.raw_value(self, "metadata_fields") - if FieldValue.of(metadata_fields).available(): - metadata_fields = FieldSpec.merge_exact( - (tuple(metadata_fields),), - context=f"{type(self).__name__}.metadata_fields", - ) - object.__setattr__(self, "metadata_fields", metadata_fields) - - @property - def metadata_rule_declarations(self) -> tuple[MetadataExtractionRule, ...]: - """Metadata rules explicitly declared on this plan.""" - - return tuple(self.metadata_rules or ()) - - @property - @abstractmethod - def has_primary_content(self) -> bool: - """Whether the subclass-specific binding payload is empty.""" @@ -2086,36 +2107,12 @@ def source_binding_group_keys_for_group_by( @dataclass(frozen=True, slots=True) -class CompiledSourceBindingPlan(SourceBindingDeclarationsMixin, _SourceBindingPlanBase): - """Immutable compile-time source binding plan for one step.""" - - bindings: tuple[NamedSourceBinding, ...] = () - source_stack_components: tuple[type[Axis], ...] = () - source_spatial_domain: SourceSpatialDomain = field( - default_factory=SourceSpatialDomain - ) +class CompiledSourceBindingPlan(SourceBindingDeclarationsMixin, AnnotatedDataclassValidationMixin): + """Immutable, concrete compile-time source binding plan for one step.""" def __post_init__(self) -> None: - bindings = normalize_source_binding_values( - "CompiledSourceBindingPlan.bindings", - self.bindings, - NamedSourceBinding, - ) - seen_aliases: set[str] = set() - for binding in bindings: - if binding.alias in seen_aliases: - raise ValueError( - "CompiledSourceBindingPlan.bindings contains duplicate alias " - f"{binding.alias!r}." - ) - seen_aliases.add(binding.alias) - object.__setattr__(self, "bindings", bindings) - object.__setattr__( - self, - "source_stack_components", - ComponentSet.collect(self.source_stack_components).as_tuple(), - ) - self._normalize_common_fields() + SourceBindingDeclarationsMixin.__post_init__(self) + AnnotatedDataclassValidationMixin.__post_init__(self) @property def has_primary_content(self) -> bool: diff --git a/tests/unit/test_source_binding_declared_normalization.py b/tests/unit/test_source_binding_declared_normalization.py new file mode 100644 index 0000000000..3d13272569 --- /dev/null +++ b/tests/unit/test_source_binding_declared_normalization.py @@ -0,0 +1,58 @@ +"""Shared source-plan declarations own normalization without consuming directives.""" +from dataclasses import fields +from typing import get_type_hints + +import pytest + +from objectstate import DataclassFieldAccess, LazyDataclassFactory +from objectstate.field_value import Inherit, Unset +from openhcs.core.config import SourceBindingsConfig +from openhcs.core.source_bindings import ( + CompiledSourceBindingPlan, NamedSourceBinding, SourceBindingDeclarationsMixin, +) + + +@pytest.mark.parametrize("meaning", [Inherit(), Unset()]) +def test_normalization_preserves_directive_fields(meaning): + lazy = LazyDataclassFactory.make_lazy_simple(SourceBindingsConfig) + config = lazy( + metadata_rules=meaning, match_plan=meaning, metadata_fields=meaning, + bindings=meaning, source_stack_components=meaning, source_filters=meaning, + image_plane_sources=meaning, imported_metadata_tables=meaning, + grouping_metadata_fields=meaning, source_voxel_spacing=meaning, + ) + with pytest.raises(TypeError): + CompiledSourceBindingPlan(bindings=meaning) + for name in ( + "metadata_rules", "match_plan", "metadata_fields", "bindings", + "source_stack_components", "source_filters", "image_plane_sources", + "imported_metadata_tables", "grouping_metadata_fields", "source_voxel_spacing", + ): + assert DataclassFieldAccess.raw_value(config, name) == meaning + + +@pytest.mark.parametrize("owner", [SourceBindingsConfig, CompiledSourceBindingPlan]) +def test_common_normalization_and_duplicate_validation_are_inherited(owner): + binding = NamedSourceBinding(alias="DNA") + config = owner(bindings=[binding], source_stack_components=[]) + assert config.bindings == (binding,) + assert config.source_stack_components == () + with pytest.raises(ValueError, match="duplicate alias"): + owner(bindings=[binding, binding]) + with pytest.raises(TypeError, match="must be NamedSourceBinding"): + owner(bindings=["not a binding"]) + with pytest.raises(TypeError): + owner(bindings=None) + + +def test_binding_view_inherits_its_one_field_declaration(): + assert "bindings" not in SourceBindingDeclarationsMixin.__annotations__ + expected = tuple[NamedSourceBinding, ...] + assert get_type_hints(SourceBindingDeclarationsMixin)["bindings"] == expected + assert get_type_hints(SourceBindingsConfig)["bindings"] == expected + assert get_type_hints(CompiledSourceBindingPlan)["bindings"] == expected + base_fields = {item.name: item for item in fields(SourceBindingDeclarationsMixin)} + for owner in (SourceBindingsConfig, CompiledSourceBindingPlan): + declared = {item.name: item for item in fields(owner)} + for name in ("bindings", "source_stack_components", "source_spatial_domain"): + assert declared[name] is base_fields[name] From 1c30debee04dec6f3a36696e2a9d1cd7ee952de5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 16:07:15 -0400 Subject: [PATCH 6/7] Normalize source plans from declarations and cooperate through C3 --- openhcs/core/config.py | 19 ++-- openhcs/core/source_bindings.py | 101 ++++++++++-------- ...t_source_binding_declared_normalization.py | 47 +++++++- 3 files changed, 111 insertions(+), 56 deletions(-) diff --git a/openhcs/core/config.py b/openhcs/core/config.py index 54e41cbea3..19dd956ca8 100644 --- a/openhcs/core/config.py +++ b/openhcs/core/config.py @@ -83,6 +83,7 @@ ImportedMetadataTable, NamedSourceBinding, SourceBindingDeclarationsMixin, + SourceFieldTransform, SourceFilterClause, ) from openhcs.core.source_metadata import ( @@ -625,7 +626,9 @@ class SourceBindingsConfig(SourceBindingDeclarationsMixin): imported_metadata_tables: tuple[ImportedMetadataTable, ...] = () """External metadata tables joined to source records before matching.""" - grouping_metadata_fields: tuple[str, ...] = () + grouping_metadata_fields: tuple[str, ...] = field(default=(), metadata={ + SourceFieldTransform: SourceFieldTransform(lambda owner, value: owner._normalize_grouping_fields(value)), + }) """Metadata field names used to partition matched sources into execution groups.""" source_voxel_spacing: SourceVoxelSpacing = field(default_factory=SourceVoxelSpacing) @@ -637,16 +640,8 @@ class SourceBindingsConfig(SourceBindingDeclarationsMixin): but scalar physical-pixel-size functions require isotropic X/Y calibration. """ - def __post_init__(self) -> None: - super().__post_init__() - self._normalize_field("source_filters", self._tuple_values) - self._normalize_field("image_plane_sources", self._tuple_values) - self._normalize_field("imported_metadata_tables", self._tuple_values) - self._normalize_field("grouping_metadata_fields", self._normalize_grouping_fields) - self._normalize_field("source_voxel_spacing", self._validate_field_value) - - def _normalize_bindings(self, name, values): - bindings = super()._normalize_bindings(name, values) + def _normalize_bindings(self, values): + bindings = super()._normalize_bindings(values) return tuple( replace( binding, @@ -664,7 +659,7 @@ def _normalize_bindings(self, name, values): for binding in bindings ) - def _normalize_grouping_fields(self, name, values): + def _normalize_grouping_fields(self, values): names = tuple(str(value).strip() for value in values) if any(not value for value in names): raise ValueError( diff --git a/openhcs/core/source_bindings.py b/openhcs/core/source_bindings.py index 5c2ec873a5..d6d72dcde7 100644 --- a/openhcs/core/source_bindings.py +++ b/openhcs/core/source_bindings.py @@ -8,8 +8,8 @@ import tempfile import urllib.request from abc import ABC, abstractmethod -from collections.abc import Iterable, Mapping, Sequence -from dataclasses import dataclass, field, replace +from collections.abc import Callable, Iterable, Mapping, Sequence +from dataclasses import dataclass, field, fields, replace from pathlib import Path, PureWindowsPath from types import MappingProxyType from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Self, TypeVar @@ -18,6 +18,7 @@ from metaclass_registry import AutoRegisterMeta from objectstate.field_value import FieldValue from objectstate.field_access import DataclassFieldAccess +from objectstate.declarations import ValueDeclaration from python_introspect.annotation_types import resolved_class_annotations from openhcs.core.artifacts.plans import ArtifactSpecRef @@ -26,7 +27,7 @@ from openhcs.core.component_set import ComponentSet from polystore.formats import get_format_from_extension from python_introspect import ( - AnnotatedDataclassValidationMixin, FieldLabel, RegisteredClassChoices, + DataclassPostInit, AnnotatedDataclassValidationMixin, FieldLabel, RegisteredClassChoices, resolve_declared_type, validate_annotation_value, ) from openhcs.core.runtime_tabular_values import FieldSpec @@ -1564,9 +1565,27 @@ def requires_step_input_component_stack( ) +@dataclass(frozen=True) +class SourceFieldTransform: + """A field-declared data coercion, followed by annotation validation.""" + + transform: Callable[[object, object], object] + + def apply(self, owner, value, validate): + return validate(self.transform(owner, value)) + + +@dataclass(frozen=True) +class ValidatedSourceFieldTransform(SourceFieldTransform): + """Extra behavior that requires already-valid declared input.""" + + def apply(self, owner, value, validate): + return self.transform(owner, validate(value)) + + @dataclass(frozen=True, kw_only=True) -class _SourceBindingPlanBase(ABC): - """Shared declarations and raw-field normalization for source-binding plans.""" +class _SourceBindingPlanBase(DataclassPostInit, ABC): + """Shared source-plan fields and declaration-driven raw initialization.""" metadata_rules: tuple[MetadataExtractionRule, ...] = () """Regex/metadata extraction rules adding semantic fields to matching sources.""" @@ -1574,58 +1593,58 @@ class _SourceBindingPlanBase(ABC): match_plan: SourceSetPairing = PairByOrder() """How the matched aliases pair into logical source sets.""" - metadata_fields: tuple[FieldSpec, ...] = () + metadata_fields: tuple[FieldSpec, ...] = field(default=(), metadata={ + SourceFieldTransform: SourceFieldTransform( + lambda owner, value: FieldSpec.merge_exact( + (value,), context=f"{type(owner).__name__}.metadata_fields" + ) + ), + }) """Exact scalar schema for source-literal metadata fields.""" - bindings: tuple[NamedSourceBinding, ...] = () + bindings: tuple[NamedSourceBinding, ...] = field(default=(), metadata={ + SourceFieldTransform: ValidatedSourceFieldTransform(lambda owner, value: owner._normalize_bindings(value)), + }) """Named semantic source bindings available to pipelines and steps.""" - source_stack_components: tuple[type[Axis], ...] = () + source_stack_components: tuple[type[Axis], ...] = field(default=(), metadata={ + SourceFieldTransform: SourceFieldTransform(lambda owner, value: ComponentSet.collect(value).as_tuple()), + }) """Ordered plate components forming one logical source image stack.""" source_spatial_domain: SourceSpatialDomain = field(default_factory=SourceSpatialDomain) """Declared intrinsic image dimensions, independent of stack transport.""" - def _normalize_field(self, name, normalize): - """Normalize only declared data; leave inheritance and absence untouched.""" - member = FieldValue.of(DataclassFieldAccess.raw_value(self, name)) - object.__setattr__(self, name, member.map(lambda value: normalize(name, value))) - def __post_init__(self) -> None: - self._normalize_field("metadata_rules", self._tuple_values) - self._normalize_field("match_plan", self._validate_field_value) - self._normalize_field("metadata_fields", self._normalize_metadata_fields) - self._normalize_field("bindings", self._normalize_bindings) - self._normalize_field("source_stack_components", self._normalize_stack_components) - - def _tuple_values(self, name, values): - return self._validate_field_value(name, tuple(values)) - - def _validate_field_value(self, name, value): - annotation = resolved_class_annotations(resolve_declared_type(type(self)))[name] - validate_annotation_value(annotation, value, path=f"{type(self).__name__}.{name}") - return value - - def _normalize_metadata_fields(self, name, values): - return FieldSpec.merge_exact( - (tuple(values),), context=f"{type(self).__name__}.metadata_fields" - ) - - def _normalize_bindings(self, name, values): - bindings = self._tuple_values(name, values) + annotations = resolved_class_annotations(resolve_declared_type(type(self))) + for declared in fields(self): + annotation = annotations[declared.name] + declaration = ValueDeclaration.of(annotation) + member = declaration.admit(DataclassFieldAccess.raw_value(self, declared.name)) + + def validate(value): + validate_annotation_value(annotation, value, path=f"{type(self).__name__}.{declared.name}") + return value + + def normalize(value): + value = declaration.normalize_data(value) + if SourceFieldTransform in declared.metadata: + return declared.metadata[SourceFieldTransform].apply(self, value, validate) + return validate(value) + + object.__setattr__(self, declared.name, member.map(normalize)) + super().__post_init__() + + def _normalize_bindings(self, bindings): seen_aliases: set[str] = set() for binding in bindings: if binding.alias in seen_aliases: raise ValueError( - f"{type(self).__name__}.bindings contains duplicate alias " - f"{binding.alias!r}." + f"{type(self).__name__}.bindings contains duplicate alias {binding.alias!r}." ) seen_aliases.add(binding.alias) return bindings - def _normalize_stack_components(self, name, values): - return ComponentSet.collect(values).as_tuple() - @property def metadata_rule_declarations(self) -> tuple[MetadataExtractionRule, ...]: """Metadata rules explicitly declared on this plan.""" @@ -2110,10 +2129,6 @@ def source_binding_group_keys_for_group_by( class CompiledSourceBindingPlan(SourceBindingDeclarationsMixin, AnnotatedDataclassValidationMixin): """Immutable, concrete compile-time source binding plan for one step.""" - def __post_init__(self) -> None: - SourceBindingDeclarationsMixin.__post_init__(self) - AnnotatedDataclassValidationMixin.__post_init__(self) - @property def has_primary_content(self) -> bool: return bool(self.bindings) diff --git a/tests/unit/test_source_binding_declared_normalization.py b/tests/unit/test_source_binding_declared_normalization.py index 3d13272569..3551e6d117 100644 --- a/tests/unit/test_source_binding_declared_normalization.py +++ b/tests/unit/test_source_binding_declared_normalization.py @@ -1,14 +1,16 @@ """Shared source-plan declarations own normalization without consuming directives.""" -from dataclasses import fields +from dataclasses import dataclass, field, fields from typing import get_type_hints import pytest +from python_introspect import DataclassPostInit from objectstate import DataclassFieldAccess, LazyDataclassFactory from objectstate.field_value import Inherit, Unset from openhcs.core.config import SourceBindingsConfig from openhcs.core.source_bindings import ( CompiledSourceBindingPlan, NamedSourceBinding, SourceBindingDeclarationsMixin, + SourceFieldTransform, ) @@ -56,3 +58,46 @@ def test_binding_view_inherits_its_one_field_declaration(): declared = {item.name: item for item in fields(owner)} for name in ("bindings", "source_stack_components", "source_spatial_domain"): assert declared[name] is base_fields[name] + + +def test_new_field_normalizes_and_validates_without_a_roster_edit(): + @dataclass(frozen=True) + class ExtendedPlan(CompiledSourceBindingPlan): + names: tuple[str, ...] = () + + assert ExtendedPlan(names=["DNA"]).names == ("DNA",) + with pytest.raises(TypeError, match=r"names\[0\].*str"): + ExtendedPlan(names=[3]) + + +def test_extra_behavior_is_declared_on_the_new_field(): + @dataclass(frozen=True) + class ExtendedPlan(CompiledSourceBindingPlan): + names: tuple[str, ...] = field(default=(), metadata={ + SourceFieldTransform: SourceFieldTransform( + lambda owner, values: tuple(value.strip() for value in values) + ), + }) + + assert ExtendedPlan(names=[" DNA "]).names == ("DNA",) + + +def test_post_init_uses_c3_and_runs_the_next_capability_once(): + events = [] + + class TrailingInit(DataclassPostInit): + def __post_init__(self): + assert type(self.bindings) is tuple or self.bindings == Inherit() + events.append("trailing") + super().__post_init__() + + @dataclass(frozen=True) + class ExtendedPlan(CompiledSourceBindingPlan, TrailingInit): + pass + + ExtendedPlan(bindings=[]) + assert events == ["trailing"] + with pytest.raises(TypeError): + ExtendedPlan(bindings=Inherit()) + assert events == ["trailing", "trailing"] + assert "__post_init__" not in CompiledSourceBindingPlan.__dict__ From 81849573a02367c82e21624f16255cf73a9f8f83 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 16:08:13 -0400 Subject: [PATCH 7/7] Delete replaced source-plan boxing import --- openhcs/core/source_bindings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openhcs/core/source_bindings.py b/openhcs/core/source_bindings.py index d6d72dcde7..9e61f6fcca 100644 --- a/openhcs/core/source_bindings.py +++ b/openhcs/core/source_bindings.py @@ -16,7 +16,6 @@ from urllib.parse import unquote, urlsplit from metaclass_registry import AutoRegisterMeta -from objectstate.field_value import FieldValue from objectstate.field_access import DataclassFieldAccess from objectstate.declarations import ValueDeclaration from python_introspect.annotation_types import resolved_class_annotations