From f98a1f1470f75a547c5778af81135729fc103382 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 00:41:15 -0400 Subject: [PATCH 01/15] State the explicit field-value contract and complete consumer cutover --- docs/declared-field-state.md | 60 ++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/declared-field-state.md diff --git a/docs/declared-field-state.md b/docs/declared-field-state.md new file mode 100644 index 0000000..945e822 --- /dev/null +++ b/docs/declared-field-state.md @@ -0,0 +1,60 @@ +# Declared field values and handles (#32, #46) + +## Contract and authorized migration + +A public config field read returns its effective value. A concrete stored value, +including Python `None`, is already effective and never requests inheritance. +`Inherit()` requests the existing context/MRO resolver. `Unset()` means that no +value is available, not Python `None` and not a request to inherit. Stored reads +remain an explicit operation of `DataclassFieldAccess`; copying and source +writing use stored reads, not implicit effective reads. + +This is an intentional API/data cutover, not a compatible second interpretation +of `None`. Existing lazy saved configs that used `None` for inheritance require +an explicit one-time migration on preserved copies before installation. Ordinary +nullable concrete fields are not rewritten. No legacy reader belongs in src. + +## Existing owners and complete consumers + +- `DataclassFieldAccess` owns stored dataclass access. Resolver, merge, raw copy, + serialization and layer comparison consume it instead of private raw readers. +- `LazyMethodBindings` and the existing lazy factory own config field access and + constructor defaults. Their inheritance defaults become `Inherit()` while the + declared domain annotation retains whether `None` is an actual valid value. +- The existing one resolver in `dual_axis_resolver.py` owns context/MRO selection + and source provenance. Inherit continues that selection; a concrete None stops + it. Missing defaults return Unset, not None. +- `ContextLayerStack` owns the layers. Global-config editing starts from the + declared-default global object rather than adding a live global layer then + masking it with a None mode. Delete `mask_with_none` and constructor patching. +- `ObjectState` owns reset, raw/resolved snapshots, provenance and dirty state. + Reset takes the recorded declaration default, which is Inherit for lazy fields. + Code generation, inspection, structural leaves and source comparison derive the + same state. The existing structural `MissingValue` is replaced by Unset; no + duplicate missing-state spelling remains. +- OpenHCS, FormBridge, PyQt and Toad callers must distinguish inherited controls + from a literal nullable value and stop patching constructors. Core consumers + are included in the same source census. + +## Field identity next (#46) + +Extend existing declaration/topology and `FieldProxy` behavior, not a parallel +path registry. A declaration-bound field is validated on acquisition and owns +read, write, reset, dirty/provenance queries and subscriptions. All callbacks and +callers carry that field identity; dotted text is an external display/encoding +projection, not the writable API. Remove the old string write/reset/read and +subscription API in the same migration. Native-forms #61 remains separately owned; +its path topology changes must be integrated normally, not copied or edited in +its worktree. + +## Delivery order + +1. Complete source/class/decision/caller census with existing NRA syntax/family + tooling across ObjectState and its production consumers; report dynamic OPEN + bindings rather than claiming an AST proves dispatch. +2. Migrate the state/default/resolution/copy/codegen/UI family coherently. Push a + working #32 checkpoint and all consumer branches, without aliases or fallbacks. +3. Migrate the handle family and delete string APIs for #46. +4. Batch focused checks, then use the actual affected applications with preserved + saved data/copies. Source tests, private installs and shared installed acceptance + are separate claims. Codex retains shared installation ownership. From ac0233a0b9d060733ac9a7c7a41749db94d24344 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 00:57:20 -0400 Subject: [PATCH 02/15] Own inherited and absent field values; remove None-mode merging and constructor patches --- src/objectstate/__init__.py | 8 +- src/objectstate/codegen.py | 17 +- src/objectstate/context_manager.py | 301 +++++-------------------- src/objectstate/dual_axis_resolver.py | 97 +++----- src/objectstate/field_value.py | 104 +++++++++ src/objectstate/inspection.py | 11 +- src/objectstate/lazy_factory.py | 172 +++----------- src/objectstate/object_state.py | 31 +-- src/objectstate/parameter_structure.py | 7 + src/objectstate/resolution_sources.py | 9 +- src/objectstate/subfield_semantics.py | 35 ++- 11 files changed, 286 insertions(+), 506 deletions(-) create mode 100644 src/objectstate/field_value.py diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index 5e7ef44..140ab56 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -59,7 +59,6 @@ get_base_type_for_lazy, get_lazy_type_for_base, # Constructor patching for code execution - patch_lazy_constructors, # Global config type checking GlobalConfigBase, GlobalConfigMeta, @@ -131,8 +130,6 @@ from objectstate.field_access import DataclassFieldAccess, DottedFieldPath, FieldAccessError from objectstate.parameter_owner import ParameterOwner from objectstate.subfield_semantics import ( - MISSING, - MissingValue, ObjectStateSubfieldSemantic, ObjectStateSubfieldSemanticIndex, StructuralFieldPath, @@ -152,7 +149,6 @@ 'get_base_type_for_lazy', 'get_lazy_type_for_base', # Constructor patching for code execution - 'patch_lazy_constructors', # Global config type checking 'GlobalConfigBase', 'GlobalConfigMeta', @@ -194,8 +190,6 @@ 'DottedFieldPath', 'FieldAccessError', 'ParameterOwner', - 'MISSING', - 'MissingValue', 'ObjectStateSubfieldSemantic', 'ObjectStateSubfieldSemanticIndex', 'StructuralFieldPath', @@ -281,3 +275,5 @@ 'Tuple', 'Optional', ] + +from objectstate.field_value import FieldValue, Inherit, Unset diff --git a/src/objectstate/codegen.py b/src/objectstate/codegen.py index 453ee8d..bc0b903 100644 --- a/src/objectstate/codegen.py +++ b/src/objectstate/codegen.py @@ -27,6 +27,7 @@ ) from objectstate.field_access import DataclassFieldAccess +from objectstate.field_value import FieldValue from objectstate.lazy_factory import LazyDataclass, LazyResolutionDataclass @@ -38,7 +39,7 @@ def lazy_dataclass_carries_values(value: LazyDataclass) -> bool: """Whether a lazy dataclass has a constructor-set field or a concrete value.""" return bool(_set_field_names(value)) or any( - DataclassFieldAccess.raw_value(value, field.name) is not None + FieldValue.of(DataclassFieldAccess.raw_value(value, field.name)).has_value for field in fields(value) ) @@ -128,10 +129,22 @@ class LazyDataclassFormatter(LazyResolutionFormatter): formats = LazyDataclass def clean_field_needs_source(self, value: object, field: Field, current: object) -> bool: - if current is None: + if FieldValue.is_inherited(current): return False if field.name in _set_field_names(value): return True if isinstance(current, LazyDataclass): return lazy_dataclass_carries_values(current) return True + + +class FieldValueFormatter(SourceFormatter): + """Field states render their declared constructor, never a None stand-in.""" + priority = 38 + formats = FieldValue + + def can_format(self, value: object) -> bool: + return isinstance(value, self.formats) + + def format(self, value: FieldValue, context: FormatContext) -> SourceFragment: + return value.source(context) diff --git a/src/objectstate/context_manager.py b/src/objectstate/context_manager.py index 5218869..c3952da 100644 --- a/src/objectstate/context_manager.py +++ b/src/objectstate/context_manager.py @@ -25,6 +25,8 @@ from typing import Any, Dict, get_type_hints from dataclasses import fields, is_dataclass from python_introspect import resolve_annotated, resolve_optional +from objectstate.field_access import DataclassFieldAccess +from objectstate.field_value import FieldValue logger = logging.getLogger(__name__) @@ -45,17 +47,14 @@ class NoActiveContextError(RuntimeError): class ContextLayer: """One object entered with ``config_context``, with the scope it was entered under. - ``scope_id`` is ``None`` for a layer entered without one. ``masks_outer_layers`` - is True for a layer entered with ``mask_with_none``: a ``None`` in it is final, - so no layer outside it supplies a value. + ``scope_id`` is ``None`` for a layer entered without one. """ - __slots__ = ("scope_id", "obj", "masks_outer_layers", "_configs") + __slots__ = ("scope_id", "obj", "_configs") - def __init__(self, scope_id: str | None, obj: Any, masks_outer_layers: bool = False): + def __init__(self, scope_id: str | None, obj: Any): self.scope_id = scope_id self.obj = obj - self.masks_outer_layers = masks_outer_layers self._configs: dict[type, Any] | None = None @property @@ -98,213 +97,56 @@ def __repr__(self) -> str: 'context_layer_stack', default=NO_LAYERS ) -def _merge_nested_dataclass(base, override, mask_with_none: bool = False): - """ - Recursively merge nested dataclass fields. - - For each field in override: - - If value is None and mask_with_none=False: skip (don't override base) - - If value is None and mask_with_none=True: override with None (mask base) - - If value is dataclass: recursively merge with base's value - - Otherwise: use override value +def _merge_nested_dataclass(base, override): + """Merge stored values; Inherit delegates, while None is a concrete override.""" + return FieldValue.of(override).overlay(base, _merge_concrete_dataclass) - Args: - base: Base dataclass instance - override: Override dataclass instance - mask_with_none: If True, None values override base values - Returns: - Merged dataclass instance - """ +def _merge_concrete_dataclass(base, override): if not is_dataclass(base) or not is_dataclass(override): return override - from objectstate.lazy_factory import get_base_type_for_lazy, replace_raw - base_type = get_base_type_for_lazy(type(base)) or type(base) override_type = get_base_type_for_lazy(type(override)) or type(override) - # Lazy overlays of the same owner inherit on the base instance. An authored - # nominal change instead owns the result, including subtype-only fields. result_owner = base if base_type is override_type else override - base_fields = {field_info.name for field_info in fields(base)} - merge_values = {} - for field_info in fields(override): - field_name = field_info.name - override_value = object.__getattribute__(override, field_name) - if field_name not in base_fields: - # This field belongs only to the authored nominal owner. - continue - base_value = object.__getattribute__(base, field_name) - - if override_value is None: - if mask_with_none: - # None overrides base value (masking mode) - merge_values[field_name] = None - else: - # None means inherit, including when the result owner changes. - if result_owner is override: - merge_values[field_name] = base_value - elif is_dataclass(override_value): - # Recursively merge nested dataclass - if base_value is not None and is_dataclass(base_value): - merge_values[field_name] = _merge_nested_dataclass(base_value, override_value, mask_with_none) - else: - merge_values[field_name] = override_value - else: - # Concrete value - use override - merge_values[field_name] = override_value - - # Merge on the nominal owner using replace_raw to preserve None values - # (dataclasses.replace triggers lazy resolution, baking in resolved values) - if merge_values: - return replace_raw(result_owner, **merge_values) - else: - return result_owner + base_fields = DataclassFieldAccess.field_names(base) + values = { + f.name: _merge_nested_dataclass( + DataclassFieldAccess.raw_value(base, f.name), + DataclassFieldAccess.raw_value(override, f.name), + ) + for f in fields(override) if f.name in base_fields + } + return replace_raw(result_owner, **values) if values else result_owner @contextmanager -def config_context( - obj, - mask_with_none: bool = False, - use_live_global: bool = True, - scope_id: str | None = None, -): - """ - Create new context scope with obj's matching fields merged into base config. - - This is the universal context manager for all config context needs. It works by: - 1. Finding fields that exist on both obj and the base config type - 2. Using matching field values to create a temporary merged config - 3. Setting that as the current context - - Args: - obj: Object with config fields (pipeline_config, step, etc.) - mask_with_none: If True, None values override/mask base config values, and - field resolution does not look at any layer outside this one. - If False (default), None values are ignored (normal inheritance). - Use True when editing GlobalPipelineConfig to mask the - loaded instance with static class defaults. - use_live_global: If True (default), use LIVE global config (UI sees unsaved edits). - If False, use SAVED global config (compiler sees saved values). - scope_id: The scope this layer belongs to (e.g., "plate_123::step_0"). Field - resolution reports it as the source scope of every value the layer - supplies. A layer entered without one supplies values all the same and - is reported with source scope ``None``. - - Usage: - # UI operations (default - uses LIVE) - with config_context(orchestrator.pipeline_config): - # UI sees unsaved global config edits - - # Compilation (explicit - uses SAVED) - with config_context(orchestrator.pipeline_config, use_live_global=False): - # Compiler uses saved global config only - - # With a scope id - with config_context(step, scope_id="plate_123::step_0"): - # Values this layer supplies are reported as coming from that scope - """ - # Get current context as base for nested contexts, or fall back to base global config - current_context = get_current_temp_global() - base_config = current_context if current_context is not None else get_base_global_config(use_live=use_live_global) - - # Find matching fields between obj and base config type +def config_context(obj, use_live_global: bool = True, scope_id: str | None = None): + """Enter one layer. A stored None always supplies a concrete value.""" + from objectstate.config import get_base_config_type + from objectstate.lazy_factory import replace_raw + current = get_current_temp_global() + base = current if current is not None else get_base_global_config(use_live=use_live_global) overrides = {} if obj is not None: - from objectstate.config import get_base_config_type - - base_config_type = get_base_config_type() - - for field_info in fields(base_config_type): - field_name = field_info.name - expected_type = field_info.type - - # Check if obj has this field + read = DataclassFieldAccess.raw_value if is_dataclass(type(obj)) else getattr + for declaration in fields(get_base_config_type()): try: - # Use object.__getattribute__ to avoid triggering lazy resolution - if hasattr(obj, field_name): - value = object.__getattribute__(obj, field_name) - # CRITICAL: When mask_with_none=True, None values override base config - # This allows static defaults to mask loaded instance values - if value is not None or mask_with_none: - # When masking with None, always include the value (even if None) - if mask_with_none: - # For nested dataclasses, merge with mask_with_none=True - if is_dataclass(value): - base_value = getattr(base_config, field_name, None) - if base_value is not None and is_dataclass(base_value): - merged_nested = _merge_nested_dataclass(base_value, value, mask_with_none=True) - overrides[field_name] = merged_nested - else: - overrides[field_name] = value - else: - overrides[field_name] = value - # Normal mode: only include non-None values - elif value is not None: - # Check if value is compatible (handles lazy-to-base type mapping) - if _is_compatible_config_type(value, expected_type): - # CRITICAL FIX: Do NOT call to_base_config() here! - # to_base_config() passes None values to base class constructor, - # but frozen dataclasses with non-Optional defaults substitute the - # default value for None. This breaks cross-hierarchy inheritance. - # Example: LazyPartitionFilterConfig(partition_filter_mode=None) - # becomes PartitionFilterConfig(partition_filter_mode=INCLUDE) - # instead of keeping None. - # - # Instead, pass lazy dataclass directly to _merge_nested_dataclass, - # which uses object.__getattribute__ to get raw values and correctly - # skips None overrides. - - # Recursively merge nested dataclass fields - if is_dataclass(value): - base_value = getattr(base_config, field_name, None) - if base_value is not None and is_dataclass(base_value): - merged_nested = _merge_nested_dataclass(base_value, value, mask_with_none=False) - overrides[field_name] = merged_nested - else: - # No base value to merge with - convert if needed - if hasattr(value, 'to_base_config'): - value = value.to_base_config() - overrides[field_name] = value - else: - # Non-dataclass field, use override as-is - overrides[field_name] = value + value = read(obj, declaration.name) except AttributeError: continue - - # Create merged config if we have overrides - # Use replace_raw to preserve None values (dataclasses.replace triggers lazy resolution) - if overrides: - from objectstate.lazy_factory import replace_raw - merged_config = replace_raw(base_config, **overrides) - else: - merged_config = base_config - - # Track the type in the context type stack - current_types = context_type_stack.get() - new_types = current_types + (type(obj),) if obj is not None else current_types - - # A context entered while none is active starts from the global config, so the - # global config is its outermost layer. - if current_context is None: - current_stack = ContextLayerStack((ContextLayer("", base_config),)) - else: - current_stack = context_layer_stack.get() - new_stack = ( - current_stack.with_layer(ContextLayer(scope_id, obj, mask_with_none)) - if obj is not None - else current_stack - ) - - merged_token = current_temp_global.set(merged_config) - type_token = context_type_stack.set(new_types) - layer_token = context_layer_stack.set(new_stack) - try: - yield - finally: - current_temp_global.reset(merged_token) - context_type_stack.reset(type_token) - context_layer_stack.reset(layer_token) + base_value = DataclassFieldAccess.raw_value(base, declaration.name) + overrides[declaration.name] = _merge_nested_dataclass(base_value, value) + merged = replace_raw(base, **overrides) if overrides else base + types = context_type_stack.get() + stack = context_layer_stack.get() if current is not None else ContextLayerStack((ContextLayer("", base),)) + if obj is not None: + stack = stack.with_layer(ContextLayer(scope_id, obj)) + types += (type(obj),) + with ExitStack() as resources: + for variable, value in ((current_temp_global, merged), (context_type_stack, types), (context_layer_stack, stack)): + resources.callback(variable.reset, variable.set(value)) + yield merged def get_context_type_stack(): @@ -584,59 +426,22 @@ def build_context_stack( current_scope_id: str | None = None, use_live: bool = True, ): - """ - Enter the complete layer stack that resolves ``object_instance``; leave it on exit. - - The stack is built from the arguments and the global config alone. A context the - caller has entered is set aside for the duration of the block and is active again - after it: none of its layers is below, between or above the layers entered here. - This is how an ``ObjectState`` resolves, so a state's resolved values depend on - its object, the states registered above it and the global config, and never on - where the read was made. A lazy object that no state owns is resolved by plain - attribute access against whatever ``config_context`` is entered. + """Resolve against one explicit baseline and the recorded ancestor layers. - Layers, outermost first: - 1. The global config, under scope ``""``. When ``object_instance`` is itself a - global config, a fresh instance of its type holding the declared defaults is - entered above it, also under scope ``""``, and masks it. - 2. Each ancestor, under its own scope id. Global-config ancestors are left out - because layer 1 already stands for them. - 3. ``object_instance``, under ``current_scope_id``. - - Nothing is set until the ``with`` block is entered. Any layer that cannot be - built raises, and the layers entered before it are left again. - - Args: - object_instance: The object whose fields are being resolved. - ancestor_objects_with_scopes: ``(scope_id, object)`` pairs, least specific first. - current_scope_id: Scope id of ``object_instance``. - use_live: Read the live global config (True) or the saved one (False). + Editing a global config starts from its declared defaults, not a live object + hidden under a masking mode. Other states start from the selected global. """ from objectstate.lazy_factory import GlobalConfigBase - - def enter(stack: ExitStack, obj: object, scope_id: str | None, mask: bool = False): - stack.enter_context( - config_context(obj, mask_with_none=mask, use_live_global=use_live, scope_id=scope_id) - ) - - with ExitStack() as stack: - # With no context active, the first layer entered starts from the global config. - for variable, no_context in ( - (current_temp_global, None), - (context_type_stack, ()), - (context_layer_stack, NO_LAYERS), - ): - stack.callback(variable.reset, variable.set(no_context)) - - if isinstance(object_instance, GlobalConfigBase): - enter(stack, type(object_instance)(), "", mask=True) - - for scope_id, ancestor_obj in ancestor_objects_with_scopes: - if isinstance(ancestor_obj, GlobalConfigBase): - continue - enter(stack, ancestor_obj, scope_id) - - enter(stack, object_instance, current_scope_id) + editing_global = isinstance(object_instance, GlobalConfigBase) + baseline = type(object_instance)() if editing_global else get_base_global_config(use_live=use_live) + layers = ContextLayerStack((ContextLayer("", baseline),)) + with ExitStack() as resources: + for variable, value in ((current_temp_global, baseline), (context_type_stack, ()), (context_layer_stack, layers)): + resources.callback(variable.reset, variable.set(value)) + for scope_id, ancestor in ancestor_objects_with_scopes: + if not isinstance(ancestor, GlobalConfigBase): + resources.enter_context(config_context(ancestor, use_live_global=use_live, scope_id=scope_id)) + resources.enter_context(config_context(object_instance, use_live_global=use_live, scope_id=current_scope_id)) yield @@ -644,7 +449,7 @@ def merge_configs(base, overrides: Dict[str, Any]): """ Merge overrides into base config, creating new immutable instance. - A ``None`` override is kept: it means "inherit this field from the parent context". + A concrete ``None`` override is kept; only ``Inherit()`` requests inheritance. Raises whatever the config's constructor raises when the overrides are not accepted. Args: diff --git a/src/objectstate/dual_axis_resolver.py b/src/objectstate/dual_axis_resolver.py index b36733b..6cdb938 100644 --- a/src/objectstate/dual_axis_resolver.py +++ b/src/objectstate/dual_axis_resolver.py @@ -13,6 +13,9 @@ from dataclasses import MISSING, fields, is_dataclass from typing import Any, NamedTuple +from objectstate.field_access import DataclassFieldAccess +from objectstate.field_value import FieldValue, Unset + logger = logging.getLogger(__name__) @@ -47,68 +50,42 @@ def _normalize_to_base(t: type) -> type: return get_base_type_for_lazy(t) or t -def _direct_owner_field_default(config_type: type, field_name: str) -> tuple[bool, Any]: - """Return the default owned by config_type for field_name. - - Lazy config resolution intentionally forces inherited dataclass fields to - None so they can inherit through context and MRO. Fields declared directly - on the concrete owner still have their semantic class defaults on the base - config type. Those defaults are load-bearing when no scope supplies a - concrete value. - """ +def _direct_owner_field_default(config_type: type, field_name: str): + """A declared own default, or Unset when this owner delegates the default.""" if not is_dataclass(config_type): - return False, None - - field_obj = config_type.__dataclass_fields__.get(field_name) - if field_obj is None: - return False, None - - metadata = field_obj.metadata or {} - if "_inherited_default" in metadata or "_inherited_default_factory" in metadata: - return False, None - - if field_obj.default is not MISSING: - return True, field_obj.default - - if field_obj.default_factory is not MISSING: - return True, field_obj.default_factory() - - return False, None + return Unset() + declaration = config_type.__dataclass_fields__.get(field_name) + if declaration is None or "_inherited_default" in declaration.metadata: + return Unset() + if declaration.default is not MISSING: + return declaration.default + if declaration.default_factory is not MISSING: + return declaration.default_factory() + return Unset() def declared_default(container_type: type, field_name: str) -> tuple[Any, type | None]: - """The value a field has when no context layer supplies one. - - In order: the default the base type declares for the field itself, the first - concrete class attribute along the base type's MRO, then the default the field - had before it was rebuilt to inherit (kept in the field's metadata). - - Returns the value and, when the base type declares a default for the field - itself, that base type; otherwise ``None`` in its place. - """ - container_base = _normalize_to_base(container_type) - has_own_default, own_default = _direct_owner_field_default(container_base, field_name) - own_default_type = container_base if has_own_default else None - if own_default is not None: - return own_default, own_default_type - - for mro_class in container_base.__mro__: - class_value = getattr(mro_class, field_name, None) - if class_value is not None: - return class_value, own_default_type - + """Read the declared default, retaining an explicit None as a real value.""" + base = _normalize_to_base(container_type) + own = _direct_owner_field_default(base, field_name) + own_type = base if FieldValue.of(own).has_value else None + if FieldValue.of(own).has_value: + return own, own_type + for owner in base.__mro__: + value = getattr(owner, field_name, Unset()) + if FieldValue.of(value).has_value: + return value, own_type if is_dataclass(container_type): - for field_definition in fields(container_type): - if field_definition.name != field_name: + for declaration in fields(container_type): + if declaration.name != field_name: continue - metadata = field_definition.metadata - inherited = metadata.get("_inherited_default", MISSING) - if inherited is not MISSING: - return inherited, own_default_type - factory = metadata.get("_inherited_default_factory", MISSING) + inherited = declaration.metadata.get("_inherited_default", MISSING) + if inherited is not MISSING and FieldValue.of(inherited).has_value: + return inherited, own_type + factory = declaration.metadata.get("_inherited_default_factory", MISSING) if factory is not MISSING: - return factory(), own_default_type - return None, own_default_type + return factory(), own_type + return Unset(), own_type def resolve_with_provenance(container_type: type, field_name: str) -> ResolvedField: @@ -164,11 +141,7 @@ class is tried only after every layer was asked for the more specific one. """ container_base = _normalize_to_base(container_type) - visible_layers = [] - for layer in reversed(layers): - visible_layers.append(layer) - if layer.masks_outer_layers: - break + visible_layers = tuple(reversed(layers)) # Where the field lives when every layer leaves it None: the outermost layer # holding the first MRO type that any layer holds. @@ -190,10 +163,10 @@ class is tried only after every layer was asked for the more specific one. held_by_a_layer = True outermost_scope = layer.scope_id try: - value = object.__getattribute__(config_instance, field_name) + value = DataclassFieldAccess.raw_value(config_instance, field_name) except AttributeError: continue - if value is not None: + if FieldValue.of(value).has_value: return ResolvedField(value, layer.scope_id, mro_type) if held_by_a_layer and not unset_found: diff --git a/src/objectstate/field_value.py b/src/objectstate/field_value.py new file mode 100644 index 0000000..128ade7 --- /dev/null +++ b/src/objectstate/field_value.py @@ -0,0 +1,104 @@ +"""Stored field meaning, independent of the domain value (including None).""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Callable + + +class FieldValue(ABC): + """Interpret a stored value once; consumers invoke its field operations.""" + + __slots__ = () + inherited = False + has_value = False + + @classmethod + def of(cls, value: Any) -> FieldValue: + return value if isinstance(value, cls) else Value(value) + + @staticmethod + def is_inherited(value: Any) -> bool: + return isinstance(value, FieldValue) and value.inherited + + @abstractmethod + def resolve(self, inherited: Callable[[], Any]) -> Any: + """Read the effective value, requesting inheritance only when declared.""" + + @abstractmethod + def overlay(self, base: Any, merge: Callable[[Any, Any], Any]) -> Any: + """Apply this stored value over a base without interpreting None.""" + + def __repr__(self) -> str: + return f"{type(self).__name__}()" + + def __eq__(self, other: object) -> bool: + return type(self) is type(other) + + def __hash__(self) -> int: + return hash(type(self)) + + def __copy__(self): + return self + + def __deepcopy__(self, memo): + return self + + def source(self, context): + from pycodify import SourceFragment, imported_name + reference = imported_name(type(self).__module__, type(self).__qualname__, context) + return SourceFragment(f"{reference.code}()", reference.imports) + + +class Inherit(FieldValue): + """Continue the existing context/MRO lookup instead of supplying a value.""" + + __slots__ = () + inherited = True + + def resolve(self, inherited: Callable[[], Any]) -> Any: + return inherited() + + def overlay(self, base: Any, merge: Callable[[Any, Any], Any]) -> Any: + return base + + +class Unset(FieldValue): + """No value is available. This is neither None nor an inheritance request.""" + + __slots__ = () + + def resolve(self, inherited: Callable[[], Any]) -> Any: + return self + + def overlay(self, base: Any, merge: Callable[[Any, Any], Any]) -> Any: + return self + + +class Value(FieldValue): + """A concrete domain value, including an explicitly stored None.""" + + __slots__ = ('value',) + has_value = True + + def __init__(self, value: Any): + self.value = value + + def resolve(self, inherited: Callable[[], Any]) -> Any: + return self.value + + def overlay(self, base: Any, merge: Callable[[Any, Any], Any]) -> Any: + return merge(base, self.value) + + def __eq__(self, other: object) -> bool: + from objectstate.value_semantics import semantic_values_equal + return isinstance(other, Value) and semantic_values_equal(self.value, other.value) + + __hash__ = None + + def __deepcopy__(self, memo): + from copy import deepcopy + return Value(deepcopy(self.value, memo)) + + def source(self, context): + from pycodify import to_source + return to_source(self.value, context) diff --git a/src/objectstate/inspection.py b/src/objectstate/inspection.py index cc5df51..fc2905e 100644 --- a/src/objectstate/inspection.py +++ b/src/objectstate/inspection.py @@ -1,6 +1,7 @@ """Read-only inspection of registered ObjectStates and their fields.""" from __future__ import annotations +from objectstate.field_value import FieldValue, Unset import logging from collections.abc import Callable @@ -78,7 +79,7 @@ class FieldInspection: def of(cls, state: ObjectState, field_path: str): """Inspect ``field_path``; a value that fails to resolve is reported unavailable.""" - resolved_value = None + resolved_value = Unset() resolved_value_available = False try: resolved_value = state.get_resolved_value(field_path) @@ -119,6 +120,10 @@ def signature_diff(self) -> bool: def last_changed(self) -> bool: return self.field_path == self.state.last_changed_field + @property + def raw_value_is_inherited(self) -> bool: + return FieldValue.is_inherited(self.raw_value) + @property def raw_value_is_none(self) -> bool: return self.raw_value is None @@ -131,9 +136,7 @@ def resolved_value_is_none(self) -> bool: def inherited_value(self) -> bool: """The field holds no value of its own and inherits a resolved one.""" - return self.raw_value is None and self.resolved_value_available and ( - self.resolved_value is not None - ) + return FieldValue.is_inherited(self.raw_value) and self.resolved_value_available and FieldValue.of(self.resolved_value).has_value @property def semantic_markers(self) -> tuple[str, ...]: diff --git a/src/objectstate/lazy_factory.py b/src/objectstate/lazy_factory.py index ec9e7d4..be41086 100644 --- a/src/objectstate/lazy_factory.py +++ b/src/objectstate/lazy_factory.py @@ -9,6 +9,8 @@ import sys from abc import ABC from contextlib import contextmanager +from objectstate.field_value import FieldValue, Inherit, Unset +from objectstate.field_access import DataclassFieldAccess from dataclasses import MISSING, dataclass, field, fields, is_dataclass, make_dataclass from functools import lru_cache from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union, get_type_hints @@ -126,20 +128,20 @@ def _inherited_default_metadata(field_definition) -> dict: return metadata -def rebuild_with_none_defaults( +def rebuild_with_inherited_defaults( cls: Type, - field_names_to_none: Optional[set] = None, + inherited_field_names: Optional[set] = None, new_name: Optional[str] = None ) -> Type: """ Rebuild a dataclass via make_dataclass with None defaults for specified fields. - This is the UNIFIED approach for both base classes (inherit_as_none) and lazy classes. + This is the UNIFIED approach for both base classes (inherit_fields) and lazy classes. Instead of patching Field objects after @dataclass, we rebuild with correct defaults. Args: cls: The dataclass to rebuild - field_names_to_none: Fields that should have default=None. + inherited_field_names: Fields that should have default=None. If None, ALL fields get default=None (for lazy classes). new_name: Optional new class name (for lazy classes) @@ -149,9 +151,9 @@ def rebuild_with_none_defaults( if not dataclasses.is_dataclass(cls): raise ValueError(f"{cls} is not a dataclass") - if field_names_to_none is None: + if inherited_field_names is None: # All fields get None (for lazy classes) - field_names_to_none = {f.name for f in fields(cls)} + inherited_field_names = {f.name for f in fields(cls)} annotations = _resolved_dataclass_annotations(cls) @@ -159,15 +161,15 @@ def rebuild_with_none_defaults( field_defs = [] for f in fields(cls): declared_type = annotations.get(f.name, f.type) - if f.name in field_names_to_none: + if f.name in inherited_field_names: # Force None default, but preserve original default in metadata for fallback # This allows standalone usage to fall back to parent's static default field_defs.append( ( f.name, - make_optional(declared_type), + declared_type, field( - default=None, + default=Inherit(), metadata=_inherited_default_metadata(f), ), ) @@ -251,7 +253,7 @@ def replace_raw(instance, **changes): field_values[f.name] = changes[f.name] else: # Use object.__getattribute__ to get raw value (bypass lazy __getattribute__) - field_values[f.name] = object.__getattribute__(instance, f.name) + field_values[f.name] = DataclassFieldAccess.raw_value(instance, f.name) # Create new instance with raw values return type(instance)(**field_values) @@ -565,27 +567,7 @@ def _single_declared_config_field( # Functional fallback strategies -def _get_raw_field_value(obj: Any, field_name: str) -> Any: - """ - Get raw field value bypassing lazy property getters to prevent infinite recursion. - - Uses object.__getattribute__() to access stored values directly without triggering - lazy resolution, which would create circular dependencies in the resolution chain. - - Args: - obj: Object to get field from - field_name: Name of field to access - - Returns: - Raw field value or None if field doesn't exist - Raises: - AttributeError: If field doesn't exist (fail-loud behavior) - """ - try: - return object.__getattribute__(obj, field_name) - except AttributeError: - return None def bind_lazy_resolution_to_class(cls: Type) -> None: @@ -630,7 +612,7 @@ def create_getattribute() -> Callable[[Any, str], Any]: def __getattribute__(self: Any, name: str) -> Any: value = object.__getattribute__(self, name) # Fast path: non-None values never need resolution - if value is not None: + if not FieldValue.is_inherited(value): return value # PERFORMANCE: Use pre-computed frozenset (O(1) lookup) instead of # rebuilding {f.name for f in fields(self.__class__)} (O(n)) on every access. @@ -649,15 +631,7 @@ def __getattribute__(self: Any, name: str) -> Any: stack = context_layer_stack.get() if not stack.layers: return declared_default(_cls, name)[0] - resolved_value = resolve_in_stack(stack, _cls, name).value - if resolved_value is not None: - return resolved_value - - # A nested dataclass field that nothing supplies reads as an empty instance. - field_obj = _cls.__dict__['_fields_by_name'].get(name) - if field_obj and is_dataclass(field_obj.type): - return field_obj.type() - return None + return value.resolve(lambda: resolve_in_stack(stack, _cls, name).value) return __getattribute__ @@ -674,7 +648,7 @@ def to_base_config(self): # causing resolution to use the wrong/stale context and losing the GlobalPipelineConfig base. # We must extract raw None values here, let config_context() merge them into the hierarchy, # and THEN resolution happens later with the properly built context. - field_values = {f.name: object.__getattribute__(self, f.name) for f in fields(self)} + field_values = {f.name: DataclassFieldAccess.raw_value(self, f.name) for f in fields(self)} return base_class(**field_values) return to_base_config @@ -703,11 +677,8 @@ def from_config(cls, *configs, inherited=None): for field_definition in fields(base_class): if not field_definition.init: continue - value = object.__getattribute__(config, field_definition.name) - if inherited is not None and value == object.__getattribute__( - inherited, - field_definition.name, - ): + value = DataclassFieldAccess.raw_value(config, field_definition.name) + if inherited is not None and value == DataclassFieldAccess.raw_value(inherited, field_definition.name): continue values[field_definition.name] = value return cls(**values) @@ -831,14 +802,14 @@ def _introspect_dataclass_fields( ), ) else: - final_field_type = make_optional(field_type) + final_field_type = field_type # CRITICAL FIX: For lazy configs, ALL non-dataclass fields should default to None # This enables proper inheritance from parent configs and placeholder styling field_def = ( field_definition.name, final_field_type, dataclasses.field( - default=None, + default=Inherit(), metadata=_inherited_default_metadata(field_definition), ), ) @@ -1002,98 +973,7 @@ def make_lazy_simple( # Constructor Patching for Code Execution # ============================================================================= -@contextmanager -def patch_lazy_constructors(types: Optional[List[Type]] = None): - """ - Context manager that patches lazy dataclass constructors to preserve None vs concrete distinction. - - This is critical for code editors that use exec() to create dataclass instances. - Without patching, lazy dataclasses would resolve None values to concrete defaults - during construction, making it impossible to distinguish between explicitly set - values and inherited values. - - The patched constructor sets fields provided in kwargs and otherwise uses the - dataclass defaults/default_factory (or None if none exist). This preserves the - None vs concrete distinction while still instantiating nested lazy configs. - - Args: - types: Optional list of lazy types to patch. If None, uses every type - in the authoritative lazy-to-base registry. - - Usage: - # Patch during code execution - with patch_lazy_constructors(): - exec(code_string, namespace) - # Lazy dataclasses created during exec() will preserve None values - - Example: - # Without patching: - LazyZarrConfig(compression='gzip') # All unspecified fields resolve to defaults - - # With patching: - with patch_lazy_constructors(): - LazyZarrConfig(compression='gzip') # Only compression is set, rest are None - """ - lazy_types = ( - list(_lazy_type_registry) - if types is None - else list(types) - ) - - if not lazy_types: - # No types to patch - just yield - yield - return - - # Store original constructors - original_constructors: Dict[Type, callable] = {} - - # Patch all lazy types - for lazy_type in lazy_types: - # Store original constructor - original_constructors[lazy_type] = lazy_type.__init__ - - # Create patched constructor that uses raw values - def create_patched_init(dataclass_type, original_init): - def patched_init(self, **kwargs): - # Materialize the raw constructor payload without reading fields - # through lazy ``__getattribute__``. The nominal constructor must - # still run: it owns dataclass ``__post_init__`` validation and any - # other declaration-level invariants. - raw_values = {} - for field_definition in dataclasses.fields(dataclass_type): - if field_definition.name in kwargs: - value = kwargs[field_definition.name] - elif field_definition.default_factory is not dataclasses.MISSING: # type: ignore - value = field_definition.default_factory() - elif field_definition.default is not dataclasses.MISSING: - value = field_definition.default - else: - value = None - - raw_values[field_definition.name] = value - - original_init(self, **raw_values) - - # The generated lazy constructor tracks every received keyword. - # Restore the authored subset because the remaining raw values were - # supplied only to preserve inheritance during reconstruction. - object.__setattr__(self, '_explicitly_set_fields', set(kwargs.keys())) - - return patched_init - - # Apply the patch - lazy_type.__init__ = create_patched_init( - lazy_type, - original_constructors[lazy_type], - ) - try: - yield - finally: - # Restore original constructors - for lazy_type, original_init in original_constructors.items(): - lazy_type.__init__ = original_init # ContextProvider infrastructure removed - was dead code feeding broken frame.f_locals manipulation @@ -1366,14 +1246,14 @@ def create_global_default_decorator(target_config_class: Type): 'configs_to_inject': [] } - def global_default_decorator(cls=None, *, optional: bool = False, inherit_as_none: bool = True, ui_hidden: bool = False, preview_label: Optional[str] = None, abbreviation: Optional[str] = None, field_abbreviations: Optional[Dict[str, str]] = None, always_viewable_fields: Optional[List[str]] = None): + def global_default_decorator(cls=None, *, optional: bool = False, inherit_fields: bool = True, ui_hidden: bool = False, preview_label: Optional[str] = None, abbreviation: Optional[str] = None, field_abbreviations: Optional[Dict[str, str]] = None, always_viewable_fields: Optional[List[str]] = None): """ Decorator that can be used with or without parameters. Args: cls: The class being decorated (when used without parentheses) optional: Whether to wrap the field type with Optional (default: False) - inherit_as_none: Whether to set all inherited fields to None by default (default: True) + inherit_fields: Whether to set all inherited fields to None by default (default: True) ui_hidden: Whether to hide from UI (apply decorator but don't inject into global config) (default: False) preview_label: Short label for list item previews (e.g., "NAP", "FIJI", "MAT"). If set, config will appear in preview when enabled. Registered in PREVIEW_LABEL_REGISTRY. @@ -1387,12 +1267,12 @@ def global_default_decorator(cls=None, *, optional: bool = False, inherit_as_non """ def decorator(actual_cls): # UNIFIED NONE-FORCING: Single make_dataclass rebuild instead of old 3-stage approach - if inherit_as_none: + if inherit_fields: # Rebuild class with None defaults for inherited fields # This replaces the old pre-process setattr + post-process Field patching inherited_fields = get_inherited_field_names(actual_cls) if inherited_fields: - actual_cls = rebuild_with_none_defaults(actual_cls, inherited_fields) + actual_cls = rebuild_with_inherited_defaults(actual_cls, inherited_fields) field_name = _camel_to_snake(actual_cls.__name__) @@ -1443,7 +1323,7 @@ def decorator(actual_cls): 'config_class': actual_cls, 'field_name': field_name, 'optional': optional, # Store the optional flag - 'inherit_as_none': inherit_as_none, # Store the inherit_as_none flag + 'inherit_fields': inherit_fields, # Store the inherit_fields flag 'ui_hidden': ui_hidden # Store the ui_hidden flag for field metadata }) @@ -1476,7 +1356,7 @@ def decorator(actual_cls): ALWAYS_VIEWABLE_FIELDS_REGISTRY[lazy_class] = always_viewable_fields # Note: No Stage 3 post-processing needed! - # - Base class: rebuilt via rebuild_with_none_defaults() above + # - Base class: rebuilt via rebuild_with_inherited_defaults() above # - Lazy class: _introspect_dataclass_fields() already sets None defaults return actual_cls @@ -1518,7 +1398,7 @@ def _inject_multiple_fields_into_dataclass(target_class: Type, configs: List[Dic # Mathematical simplification: Unified field construction with algebraic common factors def create_field_definition(config): - """Create field definition with optional and inherit_as_none support.""" + """Create field definition with optional and inherit_fields support.""" field_type = config['config_class'] is_optional = config.get('optional', False) is_ui_hidden = config.get('ui_hidden', False) diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 59eb28d..4d14af3 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -19,6 +19,7 @@ from typing import Any, Optional from objectstate.field_access import DataclassFieldAccess, DottedFieldPath +from objectstate.field_value import FieldValue, Inherit, Unset from objectstate.construction_binding import ( DelegatedStateBinding, DirectStateBinding, @@ -36,7 +37,6 @@ from objectstate.resolution_sources import ResolutionSources from objectstate.lazy_factory import is_global_config_type from objectstate.subfield_semantics import ( - MISSING, ObjectStateSubfieldSemanticIndex, build_subfield_semantic_index, changed_structural_leaf_paths, @@ -852,7 +852,7 @@ def _fields_with_other_sources( path for config_type, field_name in differing for path in self.inheritance_field_paths(config_type, field_name) - if own_values.get(path) is None + if FieldValue.is_inherited(own_values.get(path, Unset())) } def _resolve_saved_again_if_sources_changed(self, sources: ResolutionSources) -> bool: @@ -1273,12 +1273,12 @@ def subfield_semantics( self._ensure_saved_resolved() path = owner_field_path - raw_value = self.parameters.get(path.value, MISSING) + raw_value = self.parameters.get(path.value, Unset()) resolved_value = self.get_resolved_value(path.value) saved_resolved_value = self.get_saved_resolved_value(path.value) declaration = self._parameter_structure.declarations.get(path.value) signature_default_value = ( - declaration.default_value if declaration is not None else MISSING + declaration.default_value if declaration is not None else Unset() ) return build_subfield_semantic_index( @@ -1815,7 +1815,7 @@ def _recompute_invalid_fields(self, sources: ResolutionSources) -> set[str]: raw_value = self.parameters[name] if self._parameter_structure.is_container_parameter(name, raw_value, self.parameters): continue - if raw_value is not None: + if not FieldValue.is_inherited(raw_value): explicit_fields.append(name) else: inherited_fields.append(name) @@ -1921,7 +1921,7 @@ def reset_parameter(self, param_name: str) -> None: # Use signature defaults (CLASS defaults), not instance values # This ensures reset goes back to None for lazy fields, not saved concrete values declaration = self._parameter_structure.declarations.get(param_name) - default_value = declaration.default_value if declaration is not None else None + default_value = declaration.default_value if declaration is not None else Unset() self.update_parameter(param_name, default_value) def signature_default(self, param_name: str) -> Any: @@ -2022,7 +2022,7 @@ def _normalize_callable_value(value: Any) -> Any: normalized.append(item) continue if isinstance(item, tuple) and len(item) == 2 and callable(item[0]) and isinstance(item[1], dict): - pruned_kwargs = {k: v for k, v in item[1].items() if v is not None} + pruned_kwargs = {k: v for k, v in item[1].items() if FieldValue.of(v).has_value} normalized.append(item[0] if not pruned_kwargs else (item[0], pruned_kwargs)) if len(normalized) == 1 and callable(normalized[0]): return normalized[0] @@ -2189,7 +2189,7 @@ def _resolve_parameter_snapshot( use_live=not use_saved, ): for dotted_path in parameters.keys(): - raw_value = values.get(dotted_path) + raw_value = values.get(dotted_path, Unset()) container_type = structure.owner_paths.get(dotted_path) parts = dotted_path.split('.') @@ -2217,8 +2217,8 @@ def _resolve_parameter_snapshot( # - Top-level fields on root (num_workers on PipelineConfig) where parts == 1 field_name = parts[-1] - if raw_value is None: - # Field needs resolution - use combined resolve + provenance walk + if FieldValue.is_inherited(raw_value): + # The declared inheritance state invokes the one resolver. resolved_val, source_scope, source_type = resolve_with_provenance(container_type, field_name) snapshot[dotted_path] = resolved_val @@ -2568,6 +2568,8 @@ def _extract_all_parameters_flat( # Build dotted path dotted_path = f'{prefix}.{param_name}' if prefix else param_name + info = structure.declare(dotted_path, info) + # Get current value if is_function: # For functions: use signature default from UnifiedParameterAnalyzer @@ -2576,12 +2578,13 @@ def _extract_all_parameters_flat( else: # For class instances: bypass lazy resolution via object.__getattribute__ try: - current_value = object.__getattribute__(obj, param_name) + current_value = ( + DataclassFieldAccess.raw_value(obj, param_name) + if is_dataclass(type(obj)) else getattr(obj, param_name) + ) except AttributeError: current_value = info.default_value - structure.declarations[dotted_path] = info - # Check if this is a nested dataclass # First try from type annotation, then fall back to checking actual value nested_type = cls._get_nested_dataclass_type(info.param_type) @@ -2592,7 +2595,7 @@ def _extract_all_parameters_flat( if current_value is not None and is_dataclass(type(current_value)): nested_type = type(current_value) - if nested_type is not None and current_value is not None: + if nested_type is not None and is_dataclass(type(current_value)): # Store the nested config type reference at this path structure.owner_paths[dotted_path] = nested_type diff --git a/src/objectstate/parameter_structure.py b/src/objectstate/parameter_structure.py index ce26de8..8e5839e 100644 --- a/src/objectstate/parameter_structure.py +++ b/src/objectstate/parameter_structure.py @@ -9,6 +9,7 @@ from python_introspect import ParameterInfo from objectstate.field_access import DottedFieldPath +from objectstate.field_value import Unset from objectstate.parameter_owner import ParameterOwner @@ -24,6 +25,12 @@ class ParameterStructure: exclusions: list[str] = field(default_factory=list) excluded_values: dict[str, Any] = field(default_factory=dict) + def declare(self, path: str, info: ParameterInfo) -> ParameterInfo: + """Admit external signature absence once, as Unset rather than None.""" + declaration = info._replace(default_value=Unset()) if info.is_required else info + self.declarations[path] = declaration + return declaration + @property def defaults(self) -> dict[str, Any]: """Derive editor reset values from admitted parameter declarations.""" diff --git a/src/objectstate/resolution_sources.py b/src/objectstate/resolution_sources.py index cfd7745..0a6dc65 100644 --- a/src/objectstate/resolution_sources.py +++ b/src/objectstate/resolution_sources.py @@ -23,6 +23,9 @@ from dataclasses import fields, is_dataclass from typing import Any, NamedTuple +from objectstate.field_access import DataclassFieldAccess +from objectstate.field_value import Unset + FieldKey = tuple[type, str] # How many (old object, new object) answers are kept. Each entry holds both @@ -131,11 +134,11 @@ def _compare_layer_objects(before: Any, now: Any) -> set[FieldKey]: def _stored(config: Any, name: str) -> Any: """The raw field of a config, without lazy resolution; ``None`` when absent.""" if config is None: - return None + return Unset() try: - return object.__getattribute__(config, name) + return DataclassFieldAccess.raw_value(config, name) except AttributeError: - return None + return Unset() def _same_stored_value(a: Any, b: Any) -> bool: diff --git a/src/objectstate/subfield_semantics.py b/src/objectstate/subfield_semantics.py index a3ec9ef..fdb87a6 100644 --- a/src/objectstate/subfield_semantics.py +++ b/src/objectstate/subfield_semantics.py @@ -7,19 +7,12 @@ from typing import Generic, TypeVar from objectstate.field_access import DataclassFieldAccess, DottedFieldPath +from objectstate.field_value import FieldValue, Unset SubfieldValueT = TypeVar("SubfieldValueT") -@dataclass(frozen=True, slots=True) -class MissingValue: - """Nominal sentinel for an absent structural leaf.""" - - -MISSING = MissingValue() - - class StructuralSegmentKind(str, Enum): """Closed segment kinds for structural value paths.""" @@ -226,10 +219,10 @@ def build_subfield_semantic_index( _build_leaf( owner_field_path=owner_field_path, relative_path=relative_path, - raw_value=raw_leaves.get(relative_path, MISSING), - resolved_value=resolved_leaves.get(relative_path, MISSING), - saved_resolved_value=saved_leaves.get(relative_path, MISSING), - signature_default_value=default_leaves.get(relative_path, MISSING), + raw_value=raw_leaves.get(relative_path, Unset()), + resolved_value=resolved_leaves.get(relative_path, Unset()), + saved_resolved_value=saved_leaves.get(relative_path, Unset()), + signature_default_value=default_leaves.get(relative_path, Unset()), ) for relative_path in ordered_paths ) @@ -238,7 +231,7 @@ def build_subfield_semantic_index( owner_field_path=owner_field_path, owner_dirty=owner_dirty, owner_signature_diff=owner_signature_diff, - owner_inherited_value=raw_value is None and resolved_value is not None, + owner_inherited_value=FieldValue.is_inherited(raw_value) and FieldValue.of(resolved_value).has_value, leaves=leaves, ) @@ -257,8 +250,8 @@ def changed_structural_leaf_paths( relative_path for relative_path in _ordered_paths(old_leaves, new_leaves) if relative_path.segments - and old_leaves.get(relative_path, MISSING) - != new_leaves.get(relative_path, MISSING) + and old_leaves.get(relative_path, Unset()) + != new_leaves.get(relative_path, Unset()) ) return tuple( f"{owner_field_path.value}{relative_path.display_suffix()}" @@ -275,10 +268,10 @@ def _build_leaf( saved_resolved_value, signature_default_value, ) -> ObjectStateSubfieldSemantic: - raw_present = raw_value is not MISSING - resolved_present = resolved_value is not MISSING - saved_resolved_present = saved_resolved_value is not MISSING - signature_default_present = signature_default_value is not MISSING + raw_present = FieldValue.of(raw_value).has_value + resolved_present = FieldValue.of(resolved_value).has_value + saved_resolved_present = FieldValue.of(saved_resolved_value).has_value + signature_default_present = FieldValue.of(signature_default_value).has_value dirty = resolved_value != saved_resolved_value signature_diff = raw_value != signature_default_value inherited_value = not raw_present and resolved_present @@ -289,7 +282,7 @@ def _build_leaf( if signature_diff or inherited_value: markers.append("_") value_type = resolved_value if resolved_present else raw_value - value_type_name = None if value_type is MISSING else type(value_type).__qualname__ + value_type_name = type(value_type).__qualname__ if FieldValue.of(value_type).has_value else None return ObjectStateSubfieldSemantic( owner_field_path=owner_field_path, @@ -312,7 +305,7 @@ def _build_leaf( def _leaf_values(value) -> dict: - if value is MISSING: + if not FieldValue.of(value).has_value: return {} return dict(_iter_leaf_values(value, StructuralValuePath())) From abde79d8c19fcf586741af4387416d31f1aa5f64 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 01:28:20 -0400 Subject: [PATCH 03/15] Export declared field states as the public value contract --- src/objectstate/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index 140ab56..11a8e52 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -148,7 +148,10 @@ 'register_lazy_type_mapping', 'get_base_type_for_lazy', 'get_lazy_type_for_base', - # Constructor patching for code execution + # Stored field meaning + 'FieldValue', + 'Inherit', + 'Unset', # Global config type checking 'GlobalConfigBase', 'GlobalConfigMeta', From 0586f2741d83ac0def05842af9491b4ec25da821 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 02:30:41 -0400 Subject: [PATCH 04/15] Share snapshot construction and preserve null and absence through source generation --- src/objectstate/codegen.py | 25 ++-- src/objectstate/field_value.py | 7 ++ src/objectstate/inspection.py | 4 +- src/objectstate/object_state.py | 164 ++----------------------- src/objectstate/parameter_structure.py | 36 ++++++ 5 files changed, 60 insertions(+), 176 deletions(-) diff --git a/src/objectstate/codegen.py b/src/objectstate/codegen.py index bc0b903..a6de789 100644 --- a/src/objectstate/codegen.py +++ b/src/objectstate/codegen.py @@ -1,15 +1,15 @@ """Python source for lazily resolved dataclasses, through pycodify. -Importing this module registers two formatters. Both write raw field values, -never resolved ones: a ``None`` field inherits from context and stays ``None`` -in the source. +Importing this module registers the field formatters. Both write raw field values, +never resolved ones. ``None`` remains literal data; ``Inherit`` and ``Unset`` +retain their distinct meanings in the source. - :class:`LazyResolutionFormatter` (any :class:`LazyResolutionDataclass`): clean mode writes the fields whose raw value differs from the field default. - :class:`LazyDataclassFormatter` (factory-made :class:`LazyDataclass`): clean mode writes a field when it was passed to the constructor or holds a concrete - value; an inherited ``None`` is omitted, and so is a nested lazy dataclass - that carries nothing. + value. Inherited fields may be omitted; an empty nested configuration is + still an object, not a literal None. Full mode writes every field. """ @@ -35,13 +35,6 @@ def _set_field_names(value: LazyDataclass) -> frozenset[str]: return frozenset(object.__getattribute__(value, "_explicitly_set_fields")) -def lazy_dataclass_carries_values(value: LazyDataclass) -> bool: - """Whether a lazy dataclass has a constructor-set field or a concrete value.""" - - return bool(_set_field_names(value)) or any( - FieldValue.of(DataclassFieldAccess.raw_value(value, field.name)).has_value - for field in fields(value) - ) def _is_instance_of_dataclass(value: object) -> bool: @@ -76,11 +69,7 @@ def field_needs_source(current: object, default: object, context: FormatContext) if not context.clean_mode: return True - if isinstance(current, LazyDataclass): - if not lazy_dataclass_carries_values(current): - return False - return not raw_values_equal(current, default) - return current != default + return not raw_values_equal(current, default) def _field_default(field: Field) -> object: @@ -134,7 +123,7 @@ def clean_field_needs_source(self, value: object, field: Field, current: object) if field.name in _set_field_names(value): return True if isinstance(current, LazyDataclass): - return lazy_dataclass_carries_values(current) + return not raw_values_equal(current, _field_default(field)) return True diff --git a/src/objectstate/field_value.py b/src/objectstate/field_value.py index 128ade7..720ad6c 100644 --- a/src/objectstate/field_value.py +++ b/src/objectstate/field_value.py @@ -20,6 +20,10 @@ def of(cls, value: Any) -> FieldValue: def is_inherited(value: Any) -> bool: return isinstance(value, FieldValue) and value.inherited + def map(self, convert: Callable[[Any], Any]) -> Any: + """Convert domain data without converting an inheritance/absence directive.""" + return self + @abstractmethod def resolve(self, inherited: Callable[[], Any]) -> Any: """Read the effective value, requesting inheritance only when declared.""" @@ -86,6 +90,9 @@ def __init__(self, value: Any): def resolve(self, inherited: Callable[[], Any]) -> Any: return self.value + def map(self, convert: Callable[[Any], Any]) -> Any: + return convert(self.value) + def overlay(self, base: Any, merge: Callable[[Any, Any], Any]) -> Any: return merge(base, self.value) diff --git a/src/objectstate/inspection.py b/src/objectstate/inspection.py index fc2905e..f1bc535 100644 --- a/src/objectstate/inspection.py +++ b/src/objectstate/inspection.py @@ -83,7 +83,7 @@ def of(cls, state: ObjectState, field_path: str): resolved_value_available = False try: resolved_value = state.get_resolved_value(field_path) - resolved_value_available = True + resolved_value_available = FieldValue.of(resolved_value).has_value except Exception: logger.debug( "Failed to resolve ObjectState field %s on scope %r.", @@ -136,7 +136,7 @@ def resolved_value_is_none(self) -> bool: def inherited_value(self) -> bool: """The field holds no value of its own and inherits a resolved one.""" - return FieldValue.is_inherited(self.raw_value) and self.resolved_value_available and FieldValue.of(self.resolved_value).has_value + return FieldValue.is_inherited(self.raw_value) and self.resolved_value_available @property def semantic_markers(self) -> tuple[str, ...]: diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 4d14af3..a150fa0 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -472,17 +472,13 @@ def to_resolved_object(self) -> Any: self._check_and_sync_delegate() self._ensure_live_resolved() assert self._live_resolved is not None - return self._reconstruct_from_resolved( - "", self._live_resolved, self._extraction_target, self._parameter_structure - ) + return self._parameter_structure.reconstruct('', self._live_resolved, self._extraction_target, resolved=True) def to_saved_resolved_object(self) -> Any: """Reconstruct this state as an object with saved resolved values.""" self._check_and_sync_delegate() self._ensure_saved_resolved() - return self._reconstruct_from_resolved( - "", self._saved_resolved, self._extraction_target, self._parameter_structure - ) + return self._parameter_structure.reconstruct('', self._saved_resolved, self._extraction_target, resolved=True) @classmethod def resolve_saved_object( @@ -521,7 +517,7 @@ def resolve_saved_object( # A compilation capture owns inherited mutable values too. The editor's # saved-object API intentionally keeps its existing ancestor lifetime. snapshot = cls._copy_parameters_for_saved_baseline(snapshot) - concrete = cls._reconstruct_from_resolved("", snapshot, target, structure) + concrete = structure.reconstruct('', snapshot, target, resolved=True) return concrete, provenance @property @@ -987,10 +983,7 @@ def _dataclass_ancestor_parameter_updates( if not (isinstance(prefix_type, type) and is_dataclass(prefix_type)): continue - rebuilt_value = self._reconstruct_from_parameter_snapshot( - prefix, - parameters, - ) + rebuilt_value = self._parameter_structure.reconstruct(prefix, parameters, self._extraction_target) if parameters.get(prefix) != rebuilt_value: updates[prefix] = rebuilt_value parameters[prefix] = rebuilt_value @@ -1209,9 +1202,7 @@ def get_resolved_value(self, param_name: str) -> Any: # This is a container field - reconstruct's dataclass from live resolved values field_type = self._path_to_type.get(param_name) if field_type is not None and is_dataclass(field_type): - return self._reconstruct_from_resolved( - param_name, self._live_resolved, self._extraction_target, self._parameter_structure - ) + return self._parameter_structure.reconstruct(param_name, self._live_resolved, self._extraction_target, resolved=True) result = self._live_resolved.get(param_name) @@ -1249,9 +1240,7 @@ def get_saved_resolved_value(self, param_name: str) -> Any: # This is a container field - reconstruct's dataclass from saved resolved values field_type = self._path_to_type.get(param_name) if field_type is not None and is_dataclass(field_type): - return self._reconstruct_from_resolved( - param_name, self._saved_resolved, self._extraction_target, self._parameter_structure - ) + return self._parameter_structure.reconstruct(param_name, self._saved_resolved, self._extraction_target, resolved=True) # Return the simple value (or None if not found) return self._saved_resolved.get(param_name) @@ -1292,71 +1281,6 @@ def subfield_semantics( ) - @classmethod - def _reconstruct_from_resolved( - cls, - prefix: str, - resolved_snapshot: dict[str, Any], - target: Any, - structure: ParameterStructure, - ) -> Any: - """Reconstruct the nominal object and nested dataclasses from a snapshot. - - Unified method for both live and saved resolved values. The only difference - is which snapshot dict is passed in (_live_resolved or _saved_resolved). - - Args: - prefix: Current path prefix (e.g., 'analysis_consolidation_config') - resolved_snapshot: The snapshot dict to reconstruct from (_live_resolved or _saved_resolved) - - Returns: - Reconstructed dataclass instance with resolved values - """ - from objectstate.lazy_factory import get_base_type_for_lazy - - # Determine the type to reconstruct - if not prefix: - obj_type = type(target) - else: - obj_type = structure.owner_paths.get(prefix) - if obj_type is None: - raise ValueError(f"No type mapping for prefix: {prefix}") - - # Normalize to base type for lazy dataclasses - obj_type = get_base_type_for_lazy(obj_type) or obj_type - - prefix_dot = f'{prefix}.' if prefix else '' - - # Collect direct fields and nested prefixes from resolved snapshot - direct_fields = {} - nested_prefixes = set() - - for path, value in resolved_snapshot.items(): - if not path.startswith(prefix_dot): - continue - - remainder = path[len(prefix_dot):] - - if '.' in remainder: - # This is a nested field - collect the first component - first_component = remainder.split('.')[0] - nested_prefixes.add(first_component) - else: - # Direct field of this object - direct_fields[remainder] = value - - # Reconstruct nested dataclasses first - for nested_name in nested_prefixes: - nested_path = f'{prefix_dot}{nested_name}' - nested_obj = cls._reconstruct_from_resolved( - nested_path, resolved_snapshot, target, structure - ) - direct_fields[nested_name] = nested_obj - - # Instantiate the dataclass with all resolved fields - result = obj_type(**direct_fields) - - return result def get_provenance(self, param_name: str) -> tuple[str | None, type] | None: """Get the source scope_id and type for an inherited field value. @@ -1972,7 +1896,7 @@ def reconstruct_top_level_parameters(self) -> dict[str, Any]: and param_type != root_type ) if nested_dataclass: - values[param_name] = self._reconstruct_from_prefix(param_name) + values[param_name] = self._parameter_structure.reconstruct(param_name, self.parameters, self._extraction_target) else: values[param_name] = self.parameters[param_name] @@ -2727,7 +2651,7 @@ def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True # This ensures we pick up changes to nested fields like # 'partition_filter_config.partition_filter' logger.debug(f"🔧 to_object: Reconstructing nested dataclass '{field_name}' from flat storage") - reconstructed = self._reconstruct_from_prefix(field_name) + reconstructed = self._parameter_structure.reconstruct(field_name, self.parameters, self._extraction_target) logger.debug(f"🔧 to_object: Reconstructed '{field_name}' type={type(reconstructed).__name__}") # Log some field values for debugging if hasattr(reconstructed, 'enabled'): @@ -2790,79 +2714,7 @@ def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True return self._cached_object - def _reconstruct_from_prefix(self, prefix: str) -> Any: - """Recursively reconstruct dataclass from flat parameters. - - Args: - prefix: Current path prefix (e.g., 'partition_filter_config') - - Returns: - Reconstructed dataclass instance - """ - return self._reconstruct_from_parameter_snapshot(prefix, self.parameters) - - def _reconstruct_from_parameter_snapshot( - self, - prefix: str, - parameters: dict[str, Any], - ) -> Any: - """Recursively reconstruct dataclass from a flat parameter snapshot.""" - # Determine the type to reconstruct - if not prefix: - # Root level - use extraction target type (handles delegation) - obj_type = type(self._extraction_target) - else: - # Nested level - look up type from _path_to_type - obj_type = self._path_to_type.get(prefix) - if obj_type is None: - raise ValueError(f"No type mapping for prefix: {prefix}") - - prefix_dot = f'{prefix}.' if prefix else '' - # Collect direct fields and nested prefixes - direct_fields = {} - nested_prefixes = set() - - for path, value in parameters.items(): - if not path.startswith(prefix_dot): - continue - - remainder = path[len(prefix_dot):] - - if '.' in remainder: - # This is a nested field - collect the first component - first_component = remainder.split('.')[0] - nested_prefixes.add(first_component) - else: - # Direct field of this object - direct_fields[remainder] = value - - # Reconstruct nested dataclasses first - for nested_name in nested_prefixes: - nested_path = f'{prefix_dot}{nested_name}' - nested_obj = self._reconstruct_from_parameter_snapshot( - nested_path, - parameters, - ) - direct_fields[nested_name] = nested_obj - - # CRITICAL: Do NOT filter out None values! - # None can be a semantic value, such as "inherit from parent context". - # When a user explicitly resets a field to None, we MUST pass that None - # to the dataclass constructor so lazy resolution can walk up the MRO. - # Filtering None would cause the dataclass to use its class-level default - # instead of the user's explicit None, breaking inheritance. - - # At root level, include constructor params excluded from editing. - if not prefix: - direct_fields.update(self._excluded_params) - - - # Instantiate the dataclass with ALL fields including None values - result = obj_type(**direct_fields) - - - return result def _get_changed_params_with_types( self, old_target: Any, new_target: Any diff --git a/src/objectstate/parameter_structure.py b/src/objectstate/parameter_structure.py index 8e5839e..e7163a1 100644 --- a/src/objectstate/parameter_structure.py +++ b/src/objectstate/parameter_structure.py @@ -78,3 +78,39 @@ def clone(self) -> ParameterStructure: self, **{item.name: copy.copy(getattr(self, item.name)) for item in fields(self)}, ) + + def reconstruct(self, prefix, values, target, *, resolved=False): + """Build one projection from its own payload, preserving scalar terminals. + + A container's stored None/Inherit/Unset is authoritative even when an + editor retains child drafts. Nominal type comes from that payload, never + from the declaration-owner index. Live and saved projections share this. + """ + from objectstate.field_access import DataclassFieldAccess + from objectstate.lazy_factory import get_base_type_for_lazy, replace_raw + + prototype = values[prefix] if prefix else target + if prefix and not is_dataclass(prototype): + return prototype + prefix_dot = f"{prefix}." if prefix else "" + direct = {} + for path, value in values.items(): + if not path.startswith(prefix_dot): + continue + remainder = path[len(prefix_dot):] + if "." in remainder: + continue + direct[remainder] = ( + self.reconstruct(path, values, target, resolved=resolved) + if is_dataclass(value) else value + ) + if not prefix: + direct.update(self.excluded_values) + if is_dataclass(prototype): + if not resolved: + return replace_raw(prototype, **direct) + constructor = get_base_type_for_lazy(type(prototype)) or type(prototype) + arguments = DataclassFieldAccess.raw_init_values(prototype, constructor) + arguments.update(direct) + return constructor(**arguments) + return type(prototype)(**direct) From ac67ba215300ce338a5efb48ccd50c88a0d7ba5f Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 02:59:48 -0400 Subject: [PATCH 05/15] WIP: bind field operations and observer lifetimes to declared FieldProxy handles --- docs/declared-field-state.md | 43 +++ src/objectstate/edit_session.py | 22 +- src/objectstate/inspection.py | 10 +- src/objectstate/object_state.py | 426 ++++++++++++++-------- src/objectstate/object_state_registry.py | 20 +- src/objectstate/transaction_checkpoint.py | 6 +- 6 files changed, 330 insertions(+), 197 deletions(-) diff --git a/docs/declared-field-state.md b/docs/declared-field-state.md index 945e822..1653b78 100644 --- a/docs/declared-field-state.md +++ b/docs/declared-field-state.md @@ -47,6 +47,49 @@ subscription API in the same migration. Native-forms #61 remains separately owne its path topology changes must be integrated normally, not copied or edited in its worktree. +## Working checkpoints (not completion) + +The #32 checkpoint `0586f27`, FormBridge `4d12e1e`, and PyQt `fb1c044` +passed a real source-paired Qt form journey: literal None and inherited 77 +remain distinct; an already open form receives a backend change to None; +native keystrokes commit through the existing debounce; the view disposes. +Source generation round-trips None/Inherit/Unset/zero. Installed pycodify lacks +`imported_name`, so this is paired-source evidence, not installed acceptance. +Nested/inline controls, all callers, legacy saved-source migration and installed +acceptance are unfinished. The no-config form-manager constructor also still +fails because its color scheme defaults to None; the real application supplies +a ColorScheme. Doctrine owns follow-through on that discovered default defect. + +The following #46 producer prototype is deliberately unready for installation: + +- `state.field(Settings, "text")`, `state.fields.text`, and + `state.fields.bind(Settings, ("text",))` admit a declaration before use. +- Handles own `raw`, `value`, `default`, `write`, `reset`, `dirty`, `inspect`, + `subscribe` and `subscribe_value`. Subscriptions return their release operation + and deliver field handles, not path strings. +- RootFields inherits declaration discovery and subscription lifetime behavior; + only its live root schema and changed-field projection differ. +- The old producer get_resolved_value/update_parameter/reset_parameter, + public parameters storage, and on/off observer entry points are removed. + Producer checkpoint/registry/inspection/edit-session consumers were migrated. +- A fresh producer editing session checks read/write/reset/dirty, leaf/root + subscription delivery and release, inspection and rejection of a nonexistent + declaration. This does NOT establish all applications work. +- **Known structural gap:** handles currently retain a ParameterStructure + identity. Schema reconstruction/undo must not retire an otherwise unchanged + declaration. Canonical declaration lifetime and the overloaded owner_paths + index require repair in ParameterStructure before calling this API complete. +- Dirty/path metadata APIs and external consumer ports remain unfinished. The + companion frontend branches still target the preceding #32 producer checkpoint; + do not combine their source with the unready #46 producer head. + +Evidence is owned by doctrine-segment under +`/home/ts/.cache/agent-scratch/objectstate-declared-fields/`: +`handles-before.json` (NRA imports/attributes/calls and complete class census), +`handle-producer-declared-live.txt`, `form-manager-owner-journey.txt`, +`reconstruction-live.txt`, and `source-state-roundtrip-paired.txt`. +AST coverage is lexical; dynamic bindings are not claimed resolved. + ## Delivery order 1. Complete source/class/decision/caller census with existing NRA syntax/family diff --git a/src/objectstate/edit_session.py b/src/objectstate/edit_session.py index 774af19..465ab2b 100644 --- a/src/objectstate/edit_session.py +++ b/src/objectstate/edit_session.py @@ -30,12 +30,10 @@ class ObjectStateEditSession(Generic[EditedObjectT]): def state(self) -> ObjectState | None: return self.state_provider() - def require_state(self, parameter_name: str) -> ObjectState: + def require_state(self) -> ObjectState: state = self.state if state is None: - raise KeyError( - f"ObjectStateEditSession has no state for parameter {parameter_name!r}" - ) + raise RuntimeError("ObjectStateEditSession has no editing state") return state def to_object(self, *, update_delegate: bool = False) -> EditedObjectT: @@ -53,19 +51,3 @@ def to_object(self, *, update_delegate: bool = False) -> EditedObjectT: f"{type(value).__name__}; expected {self.expected_type.__name__}" ) return cast(EditedObjectT, value) - - def has_parameter(self, parameter_name: str) -> bool: - state = self.state - return state is not None and parameter_name in state.parameters - - def parameter_value(self, parameter_name: str): - state = self.require_state(parameter_name) - if parameter_name not in state.parameters: - raise KeyError(f"ObjectState parameter does not exist: {parameter_name!r}") - return state.parameters[parameter_name] - - def update_parameter(self, parameter_name: str, value) -> None: - state = self.require_state(parameter_name) - if parameter_name not in state.parameters: - raise KeyError(f"ObjectState parameter does not exist: {parameter_name!r}") - state.update_parameter(parameter_name, value) diff --git a/src/objectstate/inspection.py b/src/objectstate/inspection.py index f1bc535..ea9b212 100644 --- a/src/objectstate/inspection.py +++ b/src/objectstate/inspection.py @@ -76,13 +76,15 @@ class FieldInspection: provenance: FieldProvenance | None @classmethod - def of(cls, state: ObjectState, field_path: str): - """Inspect ``field_path``; a value that fails to resolve is reported unavailable.""" + def of(cls, field): + """Capture one admitted field, never look up a caller-spelled path.""" + state = field.state + field_path = field._path resolved_value = Unset() resolved_value_available = False try: - resolved_value = state.get_resolved_value(field_path) + resolved_value = field.value resolved_value_available = FieldValue.of(resolved_value).has_value except Exception: logger.debug( @@ -102,7 +104,7 @@ def of(cls, state: ObjectState, field_path: str): return cls( state=state, field_path=field_path, - raw_value=state.parameters[field_path], + raw_value=field.raw, resolved_value=resolved_value, resolved_value_available=resolved_value_available, provenance=provenance, diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index a150fa0..9d1347b 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -18,7 +18,7 @@ from types import FunctionType from typing import Any, Optional -from objectstate.field_access import DataclassFieldAccess, DottedFieldPath +from objectstate.field_access import DataclassFieldAccess, DottedFieldPath, FieldAccessError from objectstate.field_value import FieldValue, Inherit, Unset from objectstate.construction_binding import ( DelegatedStateBinding, @@ -47,75 +47,195 @@ logger = logging.getLogger(__name__) class FieldProxy: - """Type-safe proxy for accessing ObjectState fields via dotted attribute syntax. + """One declaration bound to an editing state. - Provides IDE autocomplete while using flat internal storage: - - External API: state.fields.partition_filter_config.partition_filter (type-safe) - - Internal: state.parameters['partition_filter_config.partition_filter'] (flat dict) + The structure, not dataclass reflection repeated by each consumer, admits the + field. The handle owns its reads, reset and subscriptions; ObjectState owns + the complete mutation/invalidation transaction. """ + def __init__(self, state: 'ObjectState', path: str): + state._check_and_sync_delegate() + if path not in state._parameter_structure.declarations: + raise FieldAccessError(f"No declared field {path!r} on {type(state.saved_object).__name__}") + self._state = state + self._path = path + self._structure = state._parameter_structure - def __init__(self, state: 'ObjectState', path: str, field_type: type): - """Initialize FieldProxy. + @property + def state(self): + return self._state - Args: - state: The ObjectState this proxy accesses - path: Current dotted path (empty for root) - field_type: Type of the object at this path - """ - object.__setattr__(self, '_state', state) - object.__setattr__(self, '_path', path) - object.__setattr__(self, '_type', field_type) + @property + def declaration(self): + return self._structure.declarations[self._path] - def __getattr__(self, name: str) -> Any: - """Get field value or nested FieldProxy. + @property + def name(self): + return self.declaration.name - Args: - name: Field name to access + @property + def annotation(self): + return self.declaration.param_type - Returns: - FieldProxy for nested dataclass fields, or resolved value for leaf fields - """ - new_path = f'{self._path}.{name}' if self._path else name + @property + def default(self): + return self.declaration.default_value - # Get field info from the type - if not is_dataclass(self._type): - type_name = getattr(self._type, '__name__', str(self._type)) - raise AttributeError(f"{type_name} is not a dataclass") + @property + def description(self): + return self.declaration.description - field_info = None - for f in dataclass_fields(self._type): - if f.name == name: - field_info = f - break + @property + def declaring_type(self): + return self._structure.owner_paths[self._path] - if field_info is None: - type_name = getattr(self._type, '__name__', str(self._type)) - raise AttributeError(f"{type_name} has no field '{name}'") + @property + def present(self): + return self._structure is self._state._parameter_structure and self._path in self._state._parameters - # Check if field is a nested dataclass - field_type = field_info.type + def _require_present(self): + self._state._check_and_sync_delegate() + if not self.present: + raise FieldAccessError(f"Field {self._path!r} belongs to a retired declaration") - # Handle Optional[DataclassType] - from typing import Union, get_args, get_origin - origin = get_origin(field_type) - if origin is Union: - args = get_args(field_type) - if len(args) == 2 and type(None) in args: - inner_type = next(arg for arg in args if arg is not type(None)) - if is_dataclass(inner_type) and isinstance(inner_type, type): - return FieldProxy(self._state, new_path, inner_type) + @property + def raw(self): + self._require_present() + return self._state._parameters[self._path] + + @property + def value(self): + self._require_present() + return self._state._read_field(self) + + @property + def saved_value(self): + return self._state._read_saved_field(self) - # Handle direct dataclass type - if isinstance(field_type, type) and is_dataclass(field_type): - return FieldProxy(self._state, new_path, field_type) + @property + def dirty(self): + self._state._ensure_live_resolved() + return self._path in self._state._dirty_fields + + @property + def signature_diff(self): + return self.raw != self.default + + def write(self, value): + self._require_present() + return self._state._write_field(self, value) + + def reset(self): + from copy import deepcopy + return self.write(deepcopy(self.default)) + + @property + def children(self): + self._require_present() + return tuple(self._state._field_at(path.value) for path in self._structure.direct_paths.get(DottedFieldPath(self._path), ())) + + def __getattr__(self, name): + self._require_present() + for child in self._structure.direct_paths.get(DottedFieldPath(self._path), ()): + if child.field_name == name: + return self._state._field_at(child.value) + raise AttributeError(f"Field {self._path!r} has no declared child {name!r}") + + def _notify_subscriber(self, callback, paths): + owner = DottedFieldPath(self._path) + selected = [path for path in paths if owner.contains_path(path)] + if selected: + callback(frozenset(self._state._field_at(path) for path in selected if path in self._state._parameter_structure.declarations)) + + def _subscribe(self, callback, listen, unlisten): + def changed(paths): + self._notify_subscriber(callback, paths) + listen(changed) + def release(): + unlisten(changed) + return release + + def subscribe(self, callback): + """Observe this field's state; receive changed field handles, never strings.""" + return self._subscribe(callback, self._state._listen_state, self._state._unlisten_state) + + def subscribe_value(self, callback): + """Observe resolved-value changes, including changes inherited from elsewhere.""" + return self._subscribe(callback, self._state._listen_resolved, self._state._unlisten_resolved) + + def __eq__(self, other): + return isinstance(other, FieldProxy) and self._state is other._state and self._structure is other._structure and self._path == other._path + + def __hash__(self): + return hash((id(self._state), id(self._structure), self._path)) + + def __repr__(self): + return f"{type(self).__name__}({self._path!r})" + + @property + def address(self): + """The existing address codec owns display/wire spelling, not callers.""" + return DottedFieldPath(self._path) + + def inspect(self): + from objectstate.inspection import FieldInspection + return FieldInspection.of(self) + + +class RootFields(FieldProxy): + """The live root namespace shares field discovery, not a captured schema.""" + def __init__(self, state): + self._state = state + + @property + def _structure(self): + return self._state._parameter_structure + + @property + def _path(self): + return '' + + @property + def present(self): + return True + + @property + def raw(self): + return self._state.to_object() + + @property + def value(self): + return self._state.to_resolved_object() + + def write(self, value): + return self._state.update_object_instance(value) + + def reset(self): + return self._state.reset_all_parameters() + + def __iter__(self): + self._state._check_and_sync_delegate() + return iter(tuple(self._state._field_at(path) for path in self._structure.declarations)) + + def bind(self, owner, names, field_type=FieldProxy): + """Bind an already declared attribute vector to this editing state.""" + target = self._state._extraction_target + if not ((isinstance(owner, type) and isinstance(target, owner)) or target is owner): + raise FieldAccessError(f"{owner!r} does not declare this editing target") + if isinstance(names, str) or not names: + raise TypeError("Binding requires declared attribute segments, not a dotted string") + path = DottedFieldPath('') + for name in names: + candidates = self._structure.direct_paths.get(path, ()) + path = next((child for child in candidates if child.field_name == name), None) + if path is None: + raise FieldAccessError(f"No declared field segment {name!r}") + return field_type(self._state, path.value) + + def _notify_subscriber(self, callback, paths): + callback(frozenset(self._state._field_at(path) for path in paths if path in self._structure.declarations)) - # Leaf field - get resolved value - return self._state.get_resolved_value(new_path) - def __setattr__(self, name: str, value: Any) -> None: - """Prevent attribute setting - use state.update_parameter() instead.""" - _ = (name, value) # Suppress unused warnings - raise AttributeError("FieldProxy is read-only. Use state.update_parameter(path, value) to set values.") class ObjectState: @@ -203,7 +323,7 @@ def __init__( # Extract parameters using FLAT extraction (dotted paths) # This replaces the old UnifiedParameterAnalyzer + _create_nested_states() approach - self.parameters: dict[str, Any] = {} + self._parameters: dict[str, Any] = {} extraction_target = self._extraction_target # Flatten parameter extraction - walk nested dataclasses recursively. @@ -273,7 +393,7 @@ def __init__( # CRITICAL: Initialize _saved_parameters BEFORE _compute_resolved_snapshot(use_saved=True) # because that method reads from _saved_parameters to get raw values. - self._saved_parameters = self._copy_parameters_for_saved_baseline(self.parameters) + self._saved_parameters = self._copy_parameters_for_saved_baseline(self._parameters) # CRITICAL: Compute saved_resolved using SAVED ancestor context, not LIVE. # This ensures saved baseline represents "what would this object's values be @@ -458,14 +578,17 @@ def saved_object(self) -> Any: return self._extraction_target @property - def fields(self) -> FieldProxy: - """Type-safe field access via FieldProxy. + def fields(self) -> RootFields: + return RootFields(self) - Returns: - FieldProxy for accessing fields with IDE autocomplete: - state.fields.partition_filter_config.partition_filter → resolved value - """ - return FieldProxy(self, '', type(self.object_instance)) + def _field_at(self, path: str) -> FieldProxy: + return FieldProxy(self, path) + + def field(self, owner: ParameterOwner, name: str) -> FieldProxy: + matches = [path for path, declaration in self._parameter_structure.declarations.items() if declaration.name == name and self._parameter_structure.owner_paths[path] is owner] + if len(matches) != 1: + raise FieldAccessError(f"Expected one declared {owner!r}.{name}, found {len(matches)}; use the declaration namespace for repeated owners") + return self._field_at(matches[0]) def to_resolved_object(self) -> Any: """Reconstruct this state as an object with live resolved values.""" @@ -565,7 +688,7 @@ def has_parameter_descendants(self, field_path: str) -> bool: # === Resolved Change Subscription === - def on_resolved_changed(self, callback: Callable[[set[str]], None]) -> None: + def _listen_resolved(self, callback: Callable[[set[str]], None]) -> None: """Subscribe to resolved value change notifications. The callback is called when resolved values actually change (not just when @@ -580,17 +703,17 @@ def on_resolved_changed(self, callback: Callable[[set[str]], None]) -> None: if callback not in self._on_resolved_changed_callbacks: self._on_resolved_changed_callbacks.append(callback) - def off_resolved_changed(self, callback: Callable[[set[str]], None]) -> None: + def _unlisten_resolved(self, callback: Callable[[set[str]], None]) -> None: """Unsubscribe from resolved value change notifications.""" if callback in self._on_resolved_changed_callbacks: self._on_resolved_changed_callbacks.remove(callback) - def on_state_changed(self, callback: Callable[[set[str]], None]) -> None: + def _listen_state(self, callback: Callable[[set[str]], None]) -> None: """Subscribe to materialized state change notifications (dirty/signature diffs).""" if callback not in self._on_state_changed_callbacks: self._on_state_changed_callbacks.append(callback) - def off_state_changed(self, callback: Callable[[set[str]], None]) -> None: + def _unlisten_state(self, callback: Callable[[set[str]], None]) -> None: """Unsubscribe from materialized state change notifications.""" if callback in self._on_state_changed_callbacks: self._on_state_changed_callbacks.remove(callback) @@ -843,7 +966,7 @@ def _fields_with_other_sources( differing = sources.fields_that_differ(resolved_from) if differing is None: return None - own_values = self._saved_parameters if use_saved else self.parameters + own_values = self._saved_parameters if use_saved else self._parameters return { path for config_type, field_name in differing @@ -936,8 +1059,8 @@ def reset_all_parameters(self) -> None: """Reset all parameters to defaults.""" self._in_reset = True try: - for param_name in list(self.parameters.keys()): - self.reset_parameter(param_name) + for param_name in list(self._parameters.keys()): + self._field_at(param_name).reset() finally: self._in_reset = False @@ -949,7 +1072,7 @@ def _dataclass_parameter_updates(self, prefix: str, value: Any) -> dict[str, Any prefix_dot = f"{prefix}." if not any( path.startswith(prefix_dot) - for path in self.parameters + for path in self._parameters if path != prefix ): return {} @@ -958,7 +1081,7 @@ def _dataclass_parameter_updates(self, prefix: str, value: Any) -> dict[str, Any for field in dataclass_fields(value): field_path = f"{prefix_dot}{field.name}" field_value = DataclassFieldAccess.raw_value(value, field.name) - if field_path in self.parameters: + if field_path in self._parameters: updates[field_path] = field_value updates.update(self._dataclass_parameter_updates(field_path, field_value)) return updates @@ -1006,7 +1129,7 @@ def inheritance_field_paths( paths: list[str] = [] for dotted_path, container_type in self._path_to_type.items(): - if dotted_path not in self.parameters: + if dotted_path not in self._parameters: continue if not (dotted_path.endswith(f".{field_name}") or dotted_path == field_name): continue @@ -1035,7 +1158,7 @@ def inheritance_field_paths( return tuple(paths) - def update_parameter(self, param_name: str, value: Any) -> set[str]: + def _write_field(self, field: FieldProxy, value: Any) -> set[str]: """Update parameter value in state. Enforces invariants: @@ -1054,18 +1177,19 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]: Returns: Resolved field paths whose visible values changed. """ + param_name = field._path # Auto-detect delegate changes before parameter access self._check_and_sync_delegate() - if param_name not in self.parameters: + if param_name not in self._parameters: logger.warning( f"⚠️ update_parameter({param_name!r}) called on ObjectState(scope={self.scope_id!r}) " - f"but parameter does not exist. Available: {list(self.parameters.keys())[:5]}..." + f"but parameter does not exist. Available: {list(self._parameters.keys())[:5]}..." ) return set() child_updates = self._dataclass_parameter_updates(param_name, value) - pending_parameters = dict(self.parameters) + pending_parameters = dict(self._parameters) pending_parameters[param_name] = value for child_path, child_value in child_updates.items(): pending_parameters[child_path] = child_value @@ -1075,16 +1199,16 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]: ) # EARLY EXIT: No change, no invalidation, no flash - current_value = self.parameters[param_name] + current_value = self._parameters[param_name] changed_child_params = { child_path for child_path, child_value in child_updates.items() - if self.parameters.get(child_path) != child_value + if self._parameters.get(child_path) != child_value } changed_ancestor_params = { ancestor_path for ancestor_path, ancestor_value in ancestor_updates.items() - if self.parameters.get(ancestor_path) != ancestor_value + if self._parameters.get(ancestor_path) != ancestor_value } changed_storage_params = set(changed_child_params) | changed_ancestor_params if current_value != value: @@ -1106,11 +1230,11 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]: ObjectStateRegistry.ensure_baseline_snapshot() # Update state directly (no type conversion - that's VIEW responsibility) - self.parameters[param_name] = value + self._parameters[param_name] = value for child_path, child_value in child_updates.items(): - self.parameters[child_path] = child_value + self._parameters[child_path] = child_value for ancestor_path, ancestor_value in ancestor_updates.items(): - self.parameters[ancestor_path] = ancestor_value + self._parameters[ancestor_path] = ancestor_value # SELF-INVALIDATION: Mark changed fields and same-state inherited siblings. local_invalidations = set(changed_semantic_params) @@ -1165,7 +1289,7 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]: param_path = DottedFieldPath(param_name) is_container = any( p != param_name and param_path.contains_path(p) - for p in self.parameters.keys() + for p in self._parameters.keys() ) snapshot_field = ( self._select_changed_field(changed_semantic_params) @@ -1177,7 +1301,7 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]: return changed_paths - def get_resolved_value(self, param_name: str) -> Any: + def _read_field(self, field: FieldProxy) -> Any: """Get resolved value for a field from the bulk snapshot. Args: @@ -1189,6 +1313,7 @@ def get_resolved_value(self, param_name: str) -> Any: For dataclass container fields, returns a reconstructed dataclass instance with all sub-fields populated from live resolved values. """ + param_name = field._path # Auto-detect delegate changes before resolving values self._check_and_sync_delegate() self._ensure_live_resolved() @@ -1209,7 +1334,7 @@ def get_resolved_value(self, param_name: str) -> Any: return result - def get_saved_resolved_value(self, param_name: str) -> Any: + def _read_saved_field(self, field: FieldProxy) -> Any: """Get saved resolved value for a field from the saved snapshot. Unlike get_resolved_value() which returns live values (including unsaved edits), @@ -1227,6 +1352,7 @@ def get_saved_resolved_value(self, param_name: str) -> Any: Saved resolved value from _saved_resolved snapshot. For dataclass fields, returns a reconstructed dataclass instance. """ + param_name = field._path # Auto-detect delegate changes before resolving values self._check_and_sync_delegate() @@ -1262,9 +1388,9 @@ def subfield_semantics( self._ensure_saved_resolved() path = owner_field_path - raw_value = self.parameters.get(path.value, Unset()) - resolved_value = self.get_resolved_value(path.value) - saved_resolved_value = self.get_saved_resolved_value(path.value) + raw_value = self._parameters.get(path.value, Unset()) + resolved_value = self._read_field(self._field_at(path.value)) + saved_resolved_value = self._read_saved_field(self._field_at(path.value)) declaration = self._parameter_structure.declarations.get(path.value) signature_default_value = ( declaration.default_value if declaration is not None else Unset() @@ -1385,7 +1511,7 @@ def project_ui_visible_field_path(self, container_type: type, field_name: str) - return None field_path = f"{path_prefix}.{field_name}" if path_prefix else field_name - if field_path not in self._path_to_type and field_path not in self.parameters: + if field_path not in self._path_to_type and field_path not in self._parameters: return None return field_path @@ -1408,7 +1534,7 @@ def _find_visible_subclass_field_path(self, hidden_type: type, field_name: str) continue field_path = f"{path}.{field_name}" - if field_path in self._path_to_type or field_path in self.parameters: + if field_path in self._path_to_type or field_path in self._parameters: return field_path return None @@ -1430,10 +1556,10 @@ def resolve_for_type(self, container_type: type, field_name: str) -> Any: path_prefix = self.find_path_for_type(container_type) if path_prefix is None: # Type not found - try the field_name directly (top-level field) - return self.get_resolved_value(field_name) + return self._read_field(self._field_at(field_name)) full_path = f'{path_prefix}.{field_name}' - return self.get_resolved_value(full_path) + return self._read_field(self._field_at(full_path)) def invalidate_cache(self) -> None: """Invalidate resolved cache - forces full recompute on next access.""" @@ -1457,7 +1583,7 @@ def invalidate_field(self, field_name: str) -> None: PERFORMANCE: Field-level invalidation - only the changed field needs recomputation, not all 20+ fields in the config. """ - if field_name in self.parameters: + if field_name in self._parameters: self._invalid_fields.add(field_name) @staticmethod @@ -1494,7 +1620,7 @@ def _changed_parameter_values( for param_path in pending_parameters: if param_path not in changed_params: continue - old_value = self.parameters.get(param_path) + old_value = self._parameters.get(param_path) new_value = pending_parameters.get(param_path) if old_value != new_value: changed_values[param_path] = (old_value, new_value) @@ -1508,7 +1634,7 @@ def notification_fields_for_value_changes( fields: set[str] = set() for param_path, (old_value, new_value) in changed_values.items(): - if self._parameter_structure.is_container_parameter(param_path, new_value, self.parameters): + if self._parameter_structure.is_container_parameter(param_path, new_value, self._parameters): continue fields.add(param_path) fields.update( @@ -1623,7 +1749,7 @@ def update_object_instance(self, new_instance: Any) -> None: new_instance: The new object instance to extract parameters from """ self._ensure_live_resolved(notify_flash=False) - previous_parameters = dict(self.parameters) + previous_parameters = dict(self._parameters) previous_saved_parameters = dict(self._saved_parameters) ObjectStateRegistry.mark_snapshot_dirty_scope(self.scope_id) old_live_resolved = copy.deepcopy(self._live_resolved or {}) @@ -1649,11 +1775,11 @@ def update_object_instance(self, new_instance: Any) -> None: ) # Update saved parameters to match - self._saved_parameters = self._copy_parameters_for_saved_baseline(self.parameters) + self._saved_parameters = self._copy_parameters_for_saved_baseline(self._parameters) if not semantic_values_equal( previous_parameters, - self.parameters, + self._parameters, ) or not semantic_values_equal( previous_saved_parameters, self._saved_parameters, @@ -1732,12 +1858,12 @@ def _recompute_invalid_fields(self, sources: ResolutionSources) -> set[str]: explicit_fields = [] inherited_fields = [] for name in self._invalid_fields: - if name not in self.parameters: + if name not in self._parameters: continue # Safety check: skip any container entries that might have leaked in # (containers should NOT be in parameters — only leaf fields are tracked) - raw_value = self.parameters[name] - if self._parameter_structure.is_container_parameter(name, raw_value, self.parameters): + raw_value = self._parameters[name] + if self._parameter_structure.is_container_parameter(name, raw_value, self._parameters): continue if not FieldValue.is_inherited(raw_value): explicit_fields.append(name) @@ -1747,7 +1873,7 @@ def _recompute_invalid_fields(self, sources: ResolutionSources) -> set[str]: # Explicit values: use parameters directly (no resolution needed) for name in explicit_fields: old_val = self._live_resolved.get(name) - explicit_val = self.parameters[name] + explicit_val = self._parameters[name] if old_val != explicit_val: changed_paths.add(name) changed_values[name] = (old_val, explicit_val) @@ -1788,7 +1914,7 @@ def _recompute_invalid_fields(self, sources: ResolutionSources) -> set[str]: # Non-lazy field: just use raw value (None) logger.debug(f"⚠️ _recompute: {dotted_path} has non-lazy container_type={container_type.__name__}, using raw value=None") old_val = self._live_resolved.get(dotted_path) - raw_val = self.parameters.get(dotted_path) + raw_val = self._parameters.get(dotted_path) if old_val != raw_val: changed_paths.add(dotted_path) changed_values[dotted_path] = (old_val, raw_val) @@ -1834,27 +1960,7 @@ def last_changed_field(self) -> str | None: """ return self._last_changed_field - def reset_parameter(self, param_name: str) -> None: - """Reset parameter to signature default (None for lazy dataclasses). - - Delegates to update_parameter() to ensure consistent invalidation behavior. - """ - if param_name not in self.parameters: - return - # Use signature defaults (CLASS defaults), not instance values - # This ensures reset goes back to None for lazy fields, not saved concrete values - declaration = self._parameter_structure.declarations.get(param_name) - default_value = declaration.default_value if declaration is not None else Unset() - self.update_parameter(param_name, default_value) - - def signature_default(self, param_name: str) -> Any: - """Return the signature default recorded for one flat parameter path.""" - if param_name not in self._parameter_structure.declarations: - raise KeyError( - f"No signature default recorded for parameter {param_name!r}." - ) - return self._parameter_structure.declarations[param_name].default_value def get_current_values(self) -> dict[str, Any]: """ @@ -1863,12 +1969,12 @@ def get_current_values(self) -> dict[str, Any]: With flat storage, this returns the flat dict with dotted paths. Callers needing nested structure should use to_object() instead. - For ObjectState, this reads directly from self.parameters. + For ObjectState, this reads directly from self._parameters. PFM overrides this to also read from widgets. """ # Auto-detect delegate changes before accessing parameters self._check_and_sync_delegate() - return dict(self.parameters) + return dict(self._parameters) def reconstruct_top_level_parameters(self) -> dict[str, Any]: """Return top-level parameters with nested dataclass values rebuilt. @@ -1884,7 +1990,7 @@ def reconstruct_top_level_parameters(self) -> dict[str, Any]: root_type = type(self._extraction_target) top_level_params = { key - for key in self.parameters + for key in self._parameters if "." not in key } @@ -1896,9 +2002,9 @@ def reconstruct_top_level_parameters(self) -> dict[str, Any]: and param_type != root_type ) if nested_dataclass: - values[param_name] = self._parameter_structure.reconstruct(param_name, self.parameters, self._extraction_target) + values[param_name] = self._parameter_structure.reconstruct(param_name, self._parameters, self._extraction_target) else: - values[param_name] = self.parameters[param_name] + values[param_name] = self._parameters[param_name] return values @@ -1918,7 +2024,7 @@ def signature_diff_fields(self) -> set[str]: @property def is_raw_dirty(self) -> bool: """Check if raw parameters differ from saved parameters (not resolved values).""" - return self.parameters != self._saved_parameters + return self._parameters != self._saved_parameters def _update_raw_dirty(self) -> bool: """Recompute raw dirty state, return True if it changed.""" @@ -1971,7 +2077,7 @@ def _compute_signature_diff_fields(self) -> set[str]: declared nested containers. """ result = set() - for k, v in self.parameters.items(): + for k, v in self._parameters.items(): if k in self._parameter_structure.declarations: sig_default = self._parameter_structure.declarations[k].default_value is_diff = v != sig_default @@ -2061,7 +2167,7 @@ def prepare_for_snapshot_capture(self) -> None: def _compute_resolved_snapshot(self, use_saved: bool = False) -> dict[str, Any]: """Resolve editor fields using the selected live or saved ancestor epoch.""" - if not isinstance(self.parameters, dict): + if not isinstance(self._parameters, dict): raise TypeError("ObjectState.parameters must remain a canonical flat dictionary.") if not isinstance(self._saved_parameters, dict): raise TypeError("ObjectState._saved_parameters must remain a canonical flat dictionary.") @@ -2070,9 +2176,9 @@ def _compute_resolved_snapshot(self, use_saved: bool = False) -> dict[str, Any]: current_obj = ( self.saved_object if use_saved else self.to_object(update_delegate=False) ) - parameters = self._saved_parameters if use_saved else self.parameters + parameters = self._saved_parameters if use_saved else self._parameters snapshot, provenance = self._resolve_parameter_snapshot( - self.parameters, + self._parameters, parameters, self._parameter_structure, current_obj, @@ -2176,7 +2282,7 @@ def mark_saved(self) -> None: This mirrors what restore_saved() does but in the opposite direction. Invalidation is based on comparing the OLD object_instance (about to be replaced) - with the NEW self.parameters (live values used for reconstruction). + with the NEW self._parameters (live values used for reconstruction). """ logger.debug(f"🐛 mark_saved: ENTER for scope={self.scope_id!r}, obj_type={type(self.object_instance).__name__}, _saved_parameters is None={self._saved_parameters is None}") # Ensure live cache is populated for accurate dirty computation post-save @@ -2187,11 +2293,11 @@ def mark_saved(self) -> None: old_instance_values = {} if not isinstance(self.object_instance, type): # Extract raw attribute values from the old object_instance - for param_name in self.parameters.keys(): + for param_name in self._parameters.keys(): # Skip container entries (nested dataclass instances) - if param_name in self.parameters: - raw_value = self.parameters.get(param_name) - if self._parameter_structure.is_container_parameter(param_name, raw_value, self.parameters): + if param_name in self._parameters: + raw_value = self._parameters.get(param_name) + if self._parameter_structure.is_container_parameter(param_name, raw_value, self._parameters): continue # Get the old value by navigating dotted path on the extraction target @@ -2210,14 +2316,14 @@ def mark_saved(self) -> None: # Find parameters that differ between old object_instance and new live parameters # These are the fields that changed and need descendant invalidation changed_params = [] - for param_name in self.parameters.keys(): + for param_name in self._parameters.keys(): # Skip container entries - raw_value = self.parameters.get(param_name) - if self._parameter_structure.is_container_parameter(param_name, raw_value, self.parameters): + raw_value = self._parameters.get(param_name) + if self._parameter_structure.is_container_parameter(param_name, raw_value, self._parameters): continue old_value = old_instance_values.get(param_name) - new_value = self.parameters.get(param_name) + new_value = self._parameters.get(param_name) if old_value != new_value: changed_params.append(param_name) @@ -2235,7 +2341,7 @@ def mark_saved(self) -> None: self._extraction_target = self.object_instance # Keep in sync # Update saved parameters (after object_instance update, before invalidation) - self._saved_parameters = self._copy_parameters_for_saved_baseline(self.parameters) + self._saved_parameters = self._copy_parameters_for_saved_baseline(self._parameters) # NOW invalidate descendant caches AFTER object_instance is updated # This ensures descendants see the NEW object_instance when they recompute @@ -2292,7 +2398,7 @@ def mark_saved(self) -> None: state = ObjectStateRegistry._states.get(descendant_scope) if state is not None: logger.debug(f"🔧 mark_saved: Processing descendant state scope={descendant_scope!r}, obj_type={type(state.object_instance).__name__}") - logger.debug(f"🐛 mark_saved descendant: _saved_parameters is None={state._saved_parameters is None}, parameters is None={state.parameters is None}") + logger.debug(f"🐛 mark_saved descendant: _saved_parameters is None={state._saved_parameters is None}, parameters is None={state._parameters is None}") # Log dirty fields BEFORE recompute logger.debug(f"🔧 mark_saved: BEFORE recompute - dirty_fields={state._dirty_fields}, _saved_resolved_keys={list(state._saved_resolved.keys())[:3]}") # Recompute descendant's saved_resolved using new ancestor saved values @@ -2301,7 +2407,7 @@ def mark_saved(self) -> None: except AttributeError as e: logger.error(f"🐛 mark_saved descendant: ERROR in _compute_resolved_snapshot for scope={descendant_scope!r}! Error: {e}") logger.error(f"🐛 mark_saved descendant: state._saved_parameters type={type(state._saved_parameters).__name__}, is None={state._saved_parameters is None}") - logger.error(f"🐛 mark_saved descendant: state.parameters type={type(state.parameters).__name__}, is None={state.parameters is None}") + logger.error(f"🐛 mark_saved descendant: state.parameters type={type(state._parameters).__name__}, is None={state._parameters is None}") raise # Sync materialized state so dirty fields are recalculated state._sync_materialized_state() @@ -2344,7 +2450,7 @@ def _restore_saved_impl(self, *, propagate_descendants: bool = True) -> None: # BEFORE clearing parameters (we need _path_to_type) changed_params_with_types = [] logger.debug(f"🐛 restore_saved: About to iterate parameters, _saved_parameters type={type(self._saved_parameters).__name__}, is None={self._saved_parameters is None}") - for param_name, current_value in self.parameters.items(): + for param_name, current_value in self._parameters.items(): try: saved_value = self._saved_parameters.get(param_name) except AttributeError as e: @@ -2374,7 +2480,7 @@ def _restore_saved_impl(self, *, propagate_descendants: bool = True) -> None: # CRITICAL: Also restore _saved_parameters to match current parameters # After restore, parameters == saved (both extracted from object_instance) - self._saved_parameters = self._copy_parameters_for_saved_baseline(self.parameters) + self._saved_parameters = self._copy_parameters_for_saved_baseline(self._parameters) self.invalidate_cache() # Invalidate cached reconstructed object (may contain unsaved edits) @@ -2545,19 +2651,19 @@ def _replace_parameter_structure( ) -> None: """Extract one canonical flat parameter structure and index it once.""" - self.parameters.clear() + self._parameters.clear() self._parameter_structure = ParameterStructure.for_target(obj, exclude_params) self._extract_all_parameters_flat( obj, - self.parameters, + self._parameters, self._parameter_structure, prefix="", exclude_params=exclude_params, ) if initial_values: for param_name, value in initial_values.items(): - self.parameters[param_name] = value - self.parameters.update( + self._parameters[param_name] = value + self._parameters.update( self._dataclass_parameter_updates(param_name, value) ) self._index_parameter_paths() @@ -2566,7 +2672,7 @@ def _index_parameter_paths(self) -> None: """Index immutable direct-child topology from canonical flat parameters.""" paths_by_owner: dict[DottedFieldPath, list[DottedFieldPath]] = {} - for dotted_path in self.parameters: + for dotted_path in self._parameters: owner_path, separator, _field_name = dotted_path.rpartition(".") if not separator: owner_path = "" @@ -2626,7 +2732,7 @@ def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True # For delegation, work with the extraction target (delegate), not object_instance target = self._extraction_target - # Collect ALL top-level field updates from self.parameters + # Collect ALL top-level field updates from self._parameters # This includes both primitive fields AND nested dataclass fields field_updates = {} root_type = type(target) @@ -2634,8 +2740,8 @@ def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True if '.' not in field_name: # Check if this field's TYPE is a dataclass (not the instance value) # We need to check the TYPE because the instance value might be stale - # (e.g., self.parameters['partition_filter_config'] might have partition_filter=2 - # even though self.parameters['partition_filter_config.partition_filter'] = None) + # (e.g., self._parameters['partition_filter_config'] might have partition_filter=2 + # even though self._parameters['partition_filter_config.partition_filter'] = None) field_type = self._path_to_type.get(field_name) # CRITICAL FIX: _path_to_type stores CONTAINER type for leaf fields, # but FIELD type for nested dataclass fields. We must distinguish: @@ -2651,7 +2757,7 @@ def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True # This ensures we pick up changes to nested fields like # 'partition_filter_config.partition_filter' logger.debug(f"🔧 to_object: Reconstructing nested dataclass '{field_name}' from flat storage") - reconstructed = self._parameter_structure.reconstruct(field_name, self.parameters, self._extraction_target) + reconstructed = self._parameter_structure.reconstruct(field_name, self._parameters, self._extraction_target) logger.debug(f"🔧 to_object: Reconstructed '{field_name}' type={type(reconstructed).__name__}") # Log some field values for debugging if hasattr(reconstructed, 'enabled'): @@ -2659,7 +2765,7 @@ def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True field_updates[field_name] = reconstructed else: # Primitive field: use value directly from parameters - value = self.parameters.get(field_name) + value = self._parameters.get(field_name) field_updates[field_name] = value # Reconstruct the target object (either object_instance or delegate) @@ -2727,7 +2833,7 @@ def _get_changed_params_with_types( changed_params = [] # Get all parameter names from the new extraction target - for param_name in self.parameters.keys(): + for param_name in self._parameters.keys(): old_value = self._get_param_value_from_target(old_target, param_name) new_value = self._get_param_value_from_target(new_target, param_name) diff --git a/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index e23bf77..781b6ec 100644 --- a/src/objectstate/object_state_registry.py +++ b/src/objectstate/object_state_registry.py @@ -942,7 +942,7 @@ def atomic(cls, label: str, scope_id: Optional[str] = None) -> Generator[None, N # Register step ObjectState ObjectStateRegistry.register(step_state) # Update pipeline's step_scope_ids - pipeline_state.update_parameter("step_scope_ids", new_ids) + pipeline_state.fields.step_scope_ids.write(new_ids) # Register function ObjectState ObjectStateRegistry.register(func_state) # Single snapshot recorded here with label "add step" @@ -1053,7 +1053,7 @@ def _registry_mutation_signature( ) for key, state in sorted(cls._states.items()): parameters, saved_parameters = state.copy_parameter_pair_preserving_callable_identity( - state.parameters, + state._parameters, state._saved_parameters, ) entries.append( @@ -1252,7 +1252,7 @@ def _record_snapshot_internal( key: StateSnapshot( saved_resolved=state._saved_resolved, live_resolved=state._live_resolved, - parameters=state.parameters, + parameters=state._parameters, saved_parameters=state._saved_parameters, provenance=state._live_provenance, construction=state.construction_binding(), @@ -1506,7 +1506,7 @@ def _prepare_time_travel_states(cls, snapshot: Snapshot) -> Dict[str, PreparedSt ) cls._apply_time_travel_snapshot_state(schema, state_snapshot, materialize=False) binding.validate_scope_membership(schema, values.keys()) - schema._raw_dirty = schema.parameters != schema._saved_parameters + schema._raw_dirty = schema._parameters != schema._saved_parameters schema._dirty_fields = schema._compute_dirty_fields() schema._signature_diff_fields = schema._compute_signature_diff_fields() prepared[scope] = PreparedStateRestore( @@ -1547,7 +1547,7 @@ def reconcile(current: dict, historical: dict) -> dict: return replace( snapshot, - parameters=reconcile(schema.parameters, snapshot.parameters), + parameters=reconcile(schema._parameters, snapshot.parameters), saved_parameters=reconcile( schema._saved_parameters, snapshot.saved_parameters, @@ -1641,7 +1641,7 @@ def _restore_time_travel_state( state, state_snap, materialize=(diff.requires_snapshot_apply or was_restored_from_limbo or construction_changed), ) - is_concrete_dirty = state.parameters != state._saved_parameters + is_concrete_dirty = state._parameters != state._saved_parameters if is_concrete_dirty: cls._log_time_travel_concrete_dirty(scope_key, state) @@ -1659,7 +1659,7 @@ def _time_travel_state_diff( state: 'ObjectState', state_snap: StateSnapshot, ) -> TimeTravelStateDiff: - current_params = state.parameters.copy() if state.parameters else {} + current_params = state._parameters.copy() if state._parameters else {} current_live = state._live_resolved.copy() if state._live_resolved else {} live_changed_paths = cls._time_travel_changed_paths( @@ -1843,7 +1843,7 @@ def _apply_time_travel_snapshot_state( state._saved_resolved = state_snap.saved_resolved state._live_resolved = state_snap.live_resolved state._live_provenance = state_snap.provenance - state.parameters = state_snap.parameters + state._parameters = state_snap.parameters state._index_parameter_paths() state._saved_parameters = state_snap.saved_parameters state._cached_object = None @@ -1857,8 +1857,8 @@ def _apply_time_travel_snapshot_state( @staticmethod def _log_time_travel_concrete_dirty(scope_key: str, state: "ObjectState") -> None: - for param_key in set(state.parameters.keys()) | set(state._saved_parameters.keys()): - parameter_value = state.parameters.get(param_key) + for param_key in set(state._parameters.keys()) | set(state._saved_parameters.keys()): + parameter_value = state._parameters.get(param_key) saved_value = state._saved_parameters.get(param_key) if parameter_value != saved_value: logger.debug( diff --git a/src/objectstate/transaction_checkpoint.py b/src/objectstate/transaction_checkpoint.py index 79864da..6d03668 100644 --- a/src/objectstate/transaction_checkpoint.py +++ b/src/objectstate/transaction_checkpoint.py @@ -59,7 +59,7 @@ def capture( copied = cls._checkpoint_mappings( state, { - "parameters": state.parameters, + "parameters": state._parameters, "saved_parameters": state._saved_parameters, "live_resolved": state._live_resolved, "saved_resolved": state._saved_resolved, @@ -123,7 +123,7 @@ def restore(self, state: ObjectState, *, emit_notifications: bool = True) -> Non state._parent_state = self.parent_state state._parent_field_name = self.construction.parent_field state._parameter_structure = self.structure.clone() - state.parameters = copied["parameters"] + state._parameters = copied["parameters"] state._index_parameter_paths() state._saved_parameters = copied["saved_parameters"] state._live_resolved = copied["live_resolved"] @@ -158,7 +158,7 @@ def _changed_fields(self, state: ObjectState) -> set[str]: fields: set[str] = set() missing = object() for current, target in ( - (state.parameters, self.parameters), + (state._parameters, self.parameters), (state._saved_parameters, self.saved_parameters), (state._live_resolved or {}, self.live_resolved or {}), (state._saved_resolved, self.saved_resolved), From 6db243dce2b351cb784cdd9048eabc9593f0b2ce Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 03:43:18 -0400 Subject: [PATCH 06/15] Own declaration lifetime and share full/targeted container projection --- docs/declared-field-state.md | 29 +- src/objectstate/__init__.py | 3 +- src/objectstate/context_manager.py | 28 +- src/objectstate/field_access.py | 8 + src/objectstate/inspection.py | 27 +- src/objectstate/object_state.py | 541 +++++------------------ src/objectstate/object_state_registry.py | 4 +- src/objectstate/parameter_structure.py | 31 +- tests/test_declared_field_handles.py | 122 +++++ 9 files changed, 333 insertions(+), 460 deletions(-) create mode 100644 tests/test_declared_field_handles.py diff --git a/docs/declared-field-state.md b/docs/declared-field-state.md index 1653b78..3e8318f 100644 --- a/docs/declared-field-state.md +++ b/docs/declared-field-state.md @@ -75,10 +75,24 @@ The following #46 producer prototype is deliberately unready for installation: - A fresh producer editing session checks read/write/reset/dirty, leaf/root subscription delivery and release, inspection and rejection of a nonexistent declaration. This does NOT establish all applications work. -- **Known structural gap:** handles currently retain a ParameterStructure - identity. Schema reconstruction/undo must not retire an otherwise unchanged - declaration. Canonical declaration lifetime and the overloaded owner_paths - index require repair in ParameterStructure before calling this API complete. +- Declaration lifetime is now tied to state + declaring owner + address, not a + temporary ParameterStructure instance. Restore, same-schema replacement and + actual registered-history undo retain the original handle; a same-name field + on an unrelated declaration cannot be targeted through it. +- ParameterStructure.declare is the sole owner_paths writer. The index contains + declaration owners only. Payload types come from the projection. The mixed + _path_to_type alias, duplicate partial resolver and competing type-based + reconstruction decisions are deleted. Full and targeted reads share one + resolver, including descendant invalidation when a container becomes None, + Inherit or Unset. Retained child drafts cannot override that container. +- Nine focused producer tests pass. The source-paired bridge mutation/catalog + journey preserves null, empty string and string 'None', resets to inheritance, + rejects an unknown address and travels backward through registered history + while retaining the original field handle. +- **Remaining bridge defect, owned by doctrine:** an omitted mutation value + still defaults to None in the request DTO. Omission must be admitted as absence + and rejected before mutation unless reset was requested; explicit JSON null + must remain data. This needs repair at the existing request/codec boundary. - Dirty/path metadata APIs and external consumer ports remain unfinished. The companion frontend branches still target the preceding #32 producer checkpoint; do not combine their source with the unready #46 producer head. @@ -86,8 +100,11 @@ The following #46 producer prototype is deliberately unready for installation: Evidence is owned by doctrine-segment under `/home/ts/.cache/agent-scratch/objectstate-declared-fields/`: `handles-before.json` (NRA imports/attributes/calls and complete class census), -`handle-producer-declared-live.txt`, `form-manager-owner-journey.txt`, -`reconstruction-live.txt`, and `source-state-roundtrip-paired.txt`. +`handle-producer-declared-live.txt`, `declaration-owner-projection-live.txt`, +`declared-field-handles-final-tests.txt`, `bridge-handle-history-journey.txt`, +`form-manager-owner-journey.txt`, `reconstruction-live.txt`, and +`source-state-roundtrip-paired.txt`. Bridge verification additionally pairs the +existing zmqruntime source because the installed package lacks execution.config. AST coverage is lexical; dynamic bindings are not claimed resolved. ## Delivery order diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index 11a8e52..92eee5c 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -211,7 +211,7 @@ # Object state management from objectstate.object_state_registry import ObjectStateRegistry from objectstate.scope_key import ScopeKey -from objectstate.object_state import ObjectState +from objectstate.object_state import FieldProxy, ObjectState from objectstate.object_state_metadata import ( ObjectStateMetadataContract, ObjectStateMetadataContractRegistry, @@ -246,6 +246,7 @@ __all__ += [ # Object state 'ObjectState', + 'FieldProxy', 'ObjectStateRegistry', 'ScopeKey', 'retain_only_authored_paths', diff --git a/src/objectstate/context_manager.py b/src/objectstate/context_manager.py index c3952da..f156339 100644 --- a/src/objectstate/context_manager.py +++ b/src/objectstate/context_manager.py @@ -587,21 +587,14 @@ def extract_all_configs(context_obj) -> Dict[type, Any]: # Type-driven extraction: Use dataclass field annotations to find config fields if is_dataclass(type(context_obj)): for field_name, declared_type in _declared_config_fields(type(context_obj)): - # Read the stored value: a None field holds no config in this object, - # and resolving it here would ask the context that is being read. - field_value = object.__getattribute__(context_obj, field_name) - if field_value is None: - continue - if not _is_compatible_config_type(field_value, declared_type): - raise TypeError( - f"{type(context_obj).__name__}.{field_name} declares " - f"{declared_type.__name__}, got {type(field_value).__name__}." - ) - _record_config_instance( - configs, - field_value, + # Only concrete values contribute instances. Inherit/Unset do not + # recursively ask the context whose membership is being collected. + raw = DataclassFieldAccess.raw_value(context_obj, field_name) + FieldValue.of(raw).map(lambda concrete: _record_config_instance( + configs, concrete, source=f"{type(context_obj).__name__}.{field_name}", - ) + expected_type=declared_type, + )) # For non-dataclass objects (orchestrators, etc.), extract dataclass attributes else: @@ -621,11 +614,16 @@ def _record_config_instance( config_instance: Any, *, source: str, + expected_type: type | None = None, ) -> None: - """Record one nominal config identity and reject ambiguous duplicates.""" + """Admit concrete config instances, checking their declaration and identity.""" from objectstate.lazy_factory import get_base_type_for_lazy + if config_instance is None: + return # A concrete nullable slot contributes no config instance. + if expected_type is not None and not _is_compatible_config_type(config_instance, expected_type): + raise TypeError(f"{source} declares {expected_type.__name__}, got {type(config_instance).__name__}.") instance_type = type(config_instance) base_type = get_base_type_for_lazy(instance_type) or instance_type existing = configs.get(base_type) diff --git a/src/objectstate/field_access.py b/src/objectstate/field_access.py index 44d2c2f..0d1514d 100644 --- a/src/objectstate/field_access.py +++ b/src/objectstate/field_access.py @@ -29,6 +29,14 @@ def field_name(self) -> str: return self.parts[-1] if self.parts else "" + def bind(self, state): + """Admit a decoded external address as a declaration-bound field.""" + return state._field_at(self.value) + + @property + def parent(self) -> "DottedFieldPath": + return DottedFieldPath(".".join(self.parts[:-1])) + def child(self, field_name: str) -> "DottedFieldPath": if self.value == "": return DottedFieldPath(field_name) diff --git a/src/objectstate/inspection.py b/src/objectstate/inspection.py index ea9b212..6ce7261 100644 --- a/src/objectstate/inspection.py +++ b/src/objectstate/inspection.py @@ -7,7 +7,7 @@ from collections.abc import Callable from dataclasses import dataclass -from objectstate.object_state import ObjectState +from objectstate.object_state import FieldProxy, ObjectState from objectstate.object_state_registry import ObjectStateRegistry logger = logging.getLogger(__name__) @@ -68,8 +68,7 @@ def source_field_path(self) -> str | None: class FieldInspection: """One field of one ObjectState: raw and resolved value, edit state and provenance.""" - state: ObjectState - field_path: str + field: FieldProxy raw_value: object resolved_value: object resolved_value_available: bool @@ -94,7 +93,7 @@ def of(cls, field): exc_info=True, ) provenance = None - match state.get_provenance(field_path): + match field.provenance: case (source_scope_id, source_type) if source_type is not None: provenance = FieldProvenance( source_scope_id=source_scope_id, @@ -102,8 +101,7 @@ def of(cls, field): field_name=field_path.rsplit(".", 1)[-1], ) return cls( - state=state, - field_path=field_path, + field=field, raw_value=field.raw, resolved_value=resolved_value, resolved_value_available=resolved_value_available, @@ -112,15 +110,15 @@ def of(cls, field): @property def dirty(self) -> bool: - return self.field_path in self.state.dirty_fields + return self.field.dirty @property def signature_diff(self) -> bool: - return self.field_path in self.state.signature_diff_fields + return self.field.signature_diff @property def last_changed(self) -> bool: - return self.field_path == self.state.last_changed_field + return self.field_path == self.field.state.last_changed_field @property def raw_value_is_inherited(self) -> bool: @@ -144,13 +142,18 @@ def inherited_value(self) -> bool: def semantic_markers(self) -> tuple[str, ...]: return field_markers(dirty=self.dirty, signature_diff=self.signature_diff) + @property + def field_path(self) -> str: + """External address spelling is projected through the existing codec.""" + return self.field.address.value + @property def field_name(self) -> str: - return self.field_path.rsplit(".", 1)[-1] + return self.field.name @property def container_path(self) -> str: - return self.field_path.rsplit(".", 1)[0] if "." in self.field_path else "" + return self.field.address.parent.value @property def raw_value_type(self) -> str: @@ -164,4 +167,4 @@ def resolved_value_type(self) -> str | None: def declaring_type(self) -> str: """Qualified name of the dataclass that declares this field.""" - return type_qualname(self.state.type_for_path(self.field_path)) + return type_qualname(self.field.declaring_type) diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 9d1347b..a31aa61 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -59,7 +59,11 @@ def __init__(self, state: 'ObjectState', path: str): raise FieldAccessError(f"No declared field {path!r} on {type(state.saved_object).__name__}") self._state = state self._path = path - self._structure = state._parameter_structure + self._owner = state._parameter_structure.binding_owner(path) + + @property + def _structure(self): + return self._state._parameter_structure @property def state(self): @@ -67,6 +71,7 @@ def state(self): @property def declaration(self): + self._require_present() return self._structure.declarations[self._path] @property @@ -87,11 +92,11 @@ def description(self): @property def declaring_type(self): - return self._structure.owner_paths[self._path] + return self._owner @property def present(self): - return self._structure is self._state._parameter_structure and self._path in self._state._parameters + return self._path in self._state._parameter_structure.declarations and self._state._parameter_structure.binding_owner(self._path) is self._owner def _require_present(self): self._state._check_and_sync_delegate() @@ -101,7 +106,7 @@ def _require_present(self): @property def raw(self): self._require_present() - return self._state._parameters[self._path] + return self._state._parameters.get(self._path, Unset()) @property def value(self): @@ -110,10 +115,12 @@ def value(self): @property def saved_value(self): + self._require_present() return self._state._read_saved_field(self) @property def dirty(self): + self._require_present() self._state._ensure_live_resolved() return self._path in self._state._dirty_fields @@ -164,10 +171,10 @@ def subscribe_value(self, callback): return self._subscribe(callback, self._state._listen_resolved, self._state._unlisten_resolved) def __eq__(self, other): - return isinstance(other, FieldProxy) and self._state is other._state and self._structure is other._structure and self._path == other._path + return isinstance(other, FieldProxy) and self._state is other._state and self._owner is other._owner and self._path == other._path def __hash__(self): - return hash((id(self._state), id(self._structure), self._path)) + return hash((id(self._state), id(self._owner), self._path)) def __repr__(self): return f"{type(self).__name__}({self._path!r})" @@ -177,6 +184,11 @@ def address(self): """The existing address codec owns display/wire spelling, not callers.""" return DottedFieldPath(self._path) + @property + def provenance(self): + self._require_present() + return self._state._read_provenance(self) + def inspect(self): from objectstate.inspection import FieldInspection return FieldInspection.of(self) @@ -187,9 +199,6 @@ class RootFields(FieldProxy): def __init__(self, state): self._state = state - @property - def _structure(self): - return self._state._parameter_structure @property def _path(self): @@ -199,6 +208,11 @@ def _path(self): def present(self): return True + __hash__ = None + + def __eq__(self, other): + return isinstance(other, RootFields) and self._state is other._state + @property def raw(self): return self._state.to_object() @@ -217,6 +231,9 @@ def __iter__(self): self._state._check_and_sync_delegate() return iter(tuple(self._state._field_at(path) for path in self._structure.declarations)) + def __len__(self): + return len(self._structure.declarations) + def bind(self, owner, names, field_type=FieldProxy): """Bind an already declared attribute vector to this editing state.""" target = self._state._extraction_target @@ -585,7 +602,7 @@ def _field_at(self, path: str) -> FieldProxy: return FieldProxy(self, path) def field(self, owner: ParameterOwner, name: str) -> FieldProxy: - matches = [path for path, declaration in self._parameter_structure.declarations.items() if declaration.name == name and self._parameter_structure.owner_paths[path] is owner] + matches = [path for path, declaration in self._parameter_structure.declarations.items() if declaration.name == name and self._parameter_structure.binding_owner(path) is owner] if len(matches) != 1: raise FieldAccessError(f"Expected one declared {owner!r}.{name}, found {len(matches)}; use the declaration namespace for repeated owners") return self._field_at(matches[0]) @@ -653,20 +670,6 @@ def parameter_descriptions(self) -> dict[str, str | None]: """ return self._parameter_structure.descriptions - def type_for_path(self, field_path: str) -> ParameterOwner: - """Return the ObjectState-recorded type for a dotted field path. - - Leaf paths return the dataclass/function owner that declares the - field. Nested dataclass paths return the nested dataclass type itself. - """ - if field_path == "": - return type(self.object_instance) - try: - return self._path_to_type[field_path] - except KeyError as exc: - raise KeyError( - f"ObjectState path is not registered: {field_path!r}" - ) from exc def direct_parameter_paths( self, @@ -887,7 +890,7 @@ def _ensure_live_resolved(self, notify_flash: bool = True) -> set[str]: changed_paths = self.notification_fields_for_value_changes(changed_values) live_resolved_again = True else: - self._invalid_fields.update(stale) + self._invalidate_fields(stale) live_resolved_again = bool(self._invalid_fields) if live_resolved_again: changed_paths = self._recompute_invalid_fields(sources) @@ -1024,36 +1027,6 @@ def _analyze_parameters( return UnifiedParameterAnalyzer.analyze(obj, exclude_params=exclude_params) - @staticmethod - def _get_nested_dataclass_type(param_type: Any) -> type | None: - """Get the nested dataclass type if param_type is a nested dataclass. - - Args: - param_type: The parameter type to check - - Returns: - The dataclass type if nested, None otherwise - """ - from typing import Union, get_args, get_origin - - # Check Optional[dataclass] - origin = get_origin(param_type) - if origin is Union: - args = get_args(param_type) - if len(args) == 2 and type(None) in args: - inner_type = next(arg for arg in args if arg is not type(None)) - if is_dataclass(inner_type): - return inner_type - - # Check direct dataclass (but not the type itself) - if is_dataclass(param_type) and not isinstance(param_type, type): - # param_type is an instance, not a type - shouldn't happen but handle it - return type(param_type) - - if is_dataclass(param_type): - return param_type - - return None def reset_all_parameters(self) -> None: """Reset all parameters to defaults.""" @@ -1102,10 +1075,6 @@ def _dataclass_ancestor_parameter_updates( prefix = ".".join(path_parts[:part_count]) if prefix not in parameters: continue - prefix_type = self._path_to_type.get(prefix) - if not (isinstance(prefix_type, type) and is_dataclass(prefix_type)): - continue - rebuilt_value = self._parameter_structure.reconstruct(prefix, parameters, self._extraction_target) if parameters.get(prefix) != rebuilt_value: updates[prefix] = rebuilt_value @@ -1128,7 +1097,7 @@ def inheritance_field_paths( target_base_type = target_type paths: list[str] = [] - for dotted_path, container_type in self._path_to_type.items(): + for dotted_path, container_type in self._parameter_structure.owner_paths.items(): if dotted_path not in self._parameters: continue if not (dotted_path.endswith(f".{field_name}") or dotted_path == field_name): @@ -1237,14 +1206,14 @@ def _write_field(self, field: FieldProxy, value: Any) -> set[str]: self._parameters[ancestor_path] = ancestor_value # SELF-INVALIDATION: Mark changed fields and same-state inherited siblings. - local_invalidations = set(changed_semantic_params) + local_invalidations = set(changed_storage_params) for changed_param in changed_semantic_params: - container_type = self._path_to_type.get(changed_param, type(self.object_instance)) + container_type = self._parameter_structure.owner_paths.get(changed_param, type(self.object_instance)) leaf_field_name = changed_param.split('.')[-1] if '.' in changed_param else changed_param local_invalidations.update( self.inheritance_field_paths(container_type, leaf_field_name) ) - self._invalid_fields.update(local_invalidations) + self._invalidate_fields(local_invalidations) self._cached_object = None # Invalidate cached reconstructed object self._cached_object_applied = False @@ -1258,7 +1227,7 @@ def _write_field(self, field: FieldProxy, value: Any) -> set[str]: # This is critical for sibling inheritance: when # PartitionFilterConfig.partition_filter changes, we need to invalidate # PathPlanningConfig.partition_filter (which inherits from PartitionFilterConfig) - container_type = self._path_to_type.get(changed_param, type(self.object_instance)) + container_type = self._parameter_structure.owner_paths.get(changed_param, type(self.object_instance)) # Extract leaf field name for invalidation matching leaf_field_name = changed_param.split('.')[-1] if '.' in changed_param else changed_param @@ -1302,74 +1271,18 @@ def _write_field(self, field: FieldProxy, value: Any) -> set[str]: return changed_paths def _read_field(self, field: FieldProxy) -> Any: - """Get resolved value for a field from the bulk snapshot. - - Args: - param_name: Field name to resolve (can be dotted path like - 'path_planning_config.partition_filter') - - Returns: - Resolved value from _live_resolved snapshot. - For dataclass container fields, returns a reconstructed dataclass instance - with all sub-fields populated from live resolved values. - """ - param_name = field._path - # Auto-detect delegate changes before resolving values + """Read this projection, preserving scalar terminals and nominal shape.""" self._check_and_sync_delegate() self._ensure_live_resolved() - assert self._live_resolved is not None # Guaranteed by _ensure_live_resolved - - # Check if this is a container/dataclass field (has subfields in _live_resolved) - prefix = f"{param_name}." - has_subfields = any(key.startswith(prefix) for key in self._live_resolved.keys()) - - if has_subfields: - # This is a container field - reconstruct's dataclass from live resolved values - field_type = self._path_to_type.get(param_name) - if field_type is not None and is_dataclass(field_type): - return self._parameter_structure.reconstruct(param_name, self._live_resolved, self._extraction_target, resolved=True) + return self._parameter_structure.reconstruct(field._path, self._live_resolved, self._extraction_target, resolved=True) - result = self._live_resolved.get(param_name) - - - return result def _read_saved_field(self, field: FieldProxy) -> Any: - """Get saved resolved value for a field from the saved snapshot. - - Unlike get_resolved_value() which returns live values (including unsaved edits), - this returns the saved baseline with inheritance applied. This is useful for - compilation and other operations that should only consider saved state. - - For container fields (dataclasses), this reconstructs the entire nested - dataclass with all sub-fields populated from saved resolved values. - - Args: - param_name: Field name to resolve (can be dotted path like - 'path_planning_config.partition_filter') - - Returns: - Saved resolved value from _saved_resolved snapshot. - For dataclass fields, returns a reconstructed dataclass instance. - """ - param_name = field._path - # Auto-detect delegate changes before resolving values + """The saved projection owns its own payload, independently of live data.""" self._check_and_sync_delegate() - self._ensure_saved_resolved() + return self._parameter_structure.reconstruct(field._path, self._saved_resolved, self._extraction_target, resolved=True) - # Check if this is a container/dataclass field (has subfields in _saved_resolved) - prefix = f"{param_name}." - has_subfields = any(key.startswith(prefix) for key in self._saved_resolved.keys()) - - if has_subfields: - # This is a container field - reconstruct's dataclass from saved resolved values - field_type = self._path_to_type.get(param_name) - if field_type is not None and is_dataclass(field_type): - return self._parameter_structure.reconstruct(param_name, self._saved_resolved, self._extraction_target, resolved=True) - - # Return the simple value (or None if not found) - return self._saved_resolved.get(param_name) def subfield_semantics( self, @@ -1408,33 +1321,10 @@ def subfield_semantics( - def get_provenance(self, param_name: str) -> tuple[str | None, type] | None: - """Get the source scope_id and type for an inherited field value. - - For fields where the local value is None (inherited), returns the scope_id - of the ancestor that provided the value AND the type that has it. - Used for click-to-source navigation in the UI. - - The source_type may differ from the local container type due to MRO inheritance. - For example, PartitionFilterConfig.partition_filter might inherit from PathPlanningConfig. - - NOTE: Returns provenance even when the resolved value is None (signature default). - A "concrete None" just means the class default is None and nothing overrode it. - - Args: - param_name: Field name (can be dotted path like 'path_planning_config.partition_filter') - - Returns: - (source_scope_id, source_type): The scope and type that provided the value, - or None if the value is set on this field itself (not inherited). - - When another config of this same object provides the value, the scope - is this state's own ``scope_id``. For a state without a scope id that - is ``None``, so ``(None, source_type)`` means "this object, through - ``source_type``". - """ + def _read_provenance(self, field: FieldProxy): + """Source of an inherited value; literal None never invents a source.""" self._ensure_live_resolved() - result = self._live_provenance.get(param_name) + result = self._live_provenance.get(field._path) if result is None: return None # Field is set on this object, not inherited scope_id, source_type = result @@ -1482,7 +1372,7 @@ def find_path_for_type(self, container_type: type) -> str | None: # Look for paths where the TYPE matches (normalized comparison) # The path for a nested config is the one WITHOUT a dot suffix that has the type - for path, typ in self._path_to_type.items(): + for path, typ in self._parameter_structure.container_types(self._parameters): typ_base = get_base_type_for_lazy(typ) or typ if not isinstance(typ_base, type): continue @@ -1511,7 +1401,7 @@ def project_ui_visible_field_path(self, container_type: type, field_name: str) - return None field_path = f"{path_prefix}.{field_name}" if path_prefix else field_name - if field_path not in self._path_to_type and field_path not in self._parameters: + if field_path not in self._parameter_structure.owner_paths and field_path not in self._parameters: return None return field_path @@ -1521,7 +1411,7 @@ def _find_visible_subclass_field_path(self, hidden_type: type, field_name: str) from objectstate.ui_visibility import UIVisibilityRegistry hidden_base = get_base_type_for_lazy(hidden_type) or hidden_type - for path, typ in self._path_to_type.items(): + for path, typ in self._parameter_structure.container_types(self._parameters): if "." in path: continue @@ -1534,7 +1424,7 @@ def _find_visible_subclass_field_path(self, hidden_type: type, field_name: str) continue field_path = f"{path}.{field_name}" - if field_path in self._path_to_type or field_path in self._parameters: + if field_path in self._parameter_structure.owner_paths or field_path in self._parameters: return field_path return None @@ -1583,8 +1473,10 @@ def invalidate_field(self, field_name: str) -> None: PERFORMANCE: Field-level invalidation - only the changed field needs recomputation, not all 20+ fields in the config. """ - if field_name in self._parameters: - self._invalid_fields.add(field_name) + self._invalidate_fields((field_name,)) + + def _invalidate_fields(self, fields): + self._invalid_fields.update(self._parameter_structure.affected_paths(fields)) @staticmethod def _select_changed_field(changed_paths: set[str]) -> str | None: @@ -1665,13 +1557,7 @@ def _most_specific_notification_fields(fields: set[str]) -> set[str]: result.add(field_path.value) return result - @property - def _path_to_type(self) -> dict[str, ParameterOwner]: - return self._parameter_structure.owner_paths - @_path_to_type.setter - def _path_to_type(self, value: dict[str, ParameterOwner]) -> None: - self._parameter_structure.owner_paths = value @property def _direct_parameter_paths(self) -> dict[DottedFieldPath, tuple[DottedFieldPath, ...]]: @@ -1818,7 +1704,7 @@ def update_object_instance(self, new_instance: Any) -> None: self._sync_materialized_state() for path in changed_paths: - container_type = self._path_to_type.get(path, type(self.object_instance)) + container_type = self._parameter_structure.owner_paths.get(path, type(self.object_instance)) leaf_field_name = path.split(".")[-1] ObjectStateRegistry.invalidate_by_type_and_scope( scope_id=self.scope_id, @@ -1837,119 +1723,28 @@ def update_object_instance(self, new_instance: Any) -> None: ) def _recompute_invalid_fields(self, sources: ResolutionSources) -> set[str]: - """Recompute only the invalid fields of the live snapshot, from ``sources``. - - PERFORMANCE: For explicitly set values, use parameters directly. - Only build context stack for inherited (None) values. - - Returns: - Set of paths whose resolved values actually changed (for UI notification). - """ - from objectstate.context_manager import build_context_stack - - changed_paths: set[str] = set() - changed_values: dict[str, tuple[Any, Any]] = {} - - # _live_resolved must exist when this is called (from _ensure_live_resolved) + """Use the same resolver for admitted invalid fields as for a full snapshot.""" if self._live_resolved is None: - return changed_paths + return set() + selected = self._invalid_fields & self._parameters.keys() + snapshot, provenance = self._resolve_parameter_snapshot( + self._parameters, self._parameters, self._parameter_structure, + self.to_object(update_delegate=False, sync_delegate=False), + self.scope_id, sources.ancestors, use_saved=False, field_paths=selected, + ) + changed = { + path: (self._live_resolved.get(path, Unset()), value) + for path, value in snapshot.items() + if not semantic_values_equal(self._live_resolved.get(path, Unset()), value) + } + self._live_resolved.update(snapshot) + for path in snapshot: + self._live_provenance.pop(path, None) + self._live_provenance.update(provenance) + if changed: + self._set_last_changed_values(changed) + return self.notification_fields_for_value_changes(changed) - # Separate explicit vs inherited fields, skipping container entries - explicit_fields = [] - inherited_fields = [] - for name in self._invalid_fields: - if name not in self._parameters: - continue - # Safety check: skip any container entries that might have leaked in - # (containers should NOT be in parameters — only leaf fields are tracked) - raw_value = self._parameters[name] - if self._parameter_structure.is_container_parameter(name, raw_value, self._parameters): - continue - if not FieldValue.is_inherited(raw_value): - explicit_fields.append(name) - else: - inherited_fields.append(name) - - # Explicit values: use parameters directly (no resolution needed) - for name in explicit_fields: - old_val = self._live_resolved.get(name) - explicit_val = self._parameters[name] - if old_val != explicit_val: - changed_paths.add(name) - changed_values[name] = (old_val, explicit_val) - logger.debug( - f"RECOMPUTE EXPLICIT CHANGED [{self.scope_id}] {name}: " - f"old={old_val!r} -> new={explicit_val!r}" - ) - self._live_resolved[name] = explicit_val - # Clear provenance for explicit values - they're no longer inherited - if name in self._live_provenance: - del self._live_provenance[name] - - # Inherited values: need context stack for lazy resolution + provenance - if inherited_fields: - from objectstate.dual_axis_resolver import resolve_with_provenance - from objectstate.lazy_factory import has_lazy_resolution - - # ARCHITECTURAL FIX: Reconstruct current object from parameters for real-time MRO. - # Using object_instance (original saved state) gives stale values, breaking inheritance. - # Calling to_object() with sync_delegate=False reconstructs from CURRENT parameters - # without triggering delegate sync that would invalidate our cache mid-computation. - current_obj = self.to_object(update_delegate=False, sync_delegate=False) - - with build_context_stack( - object_instance=current_obj, - ancestor_objects_with_scopes=sources.ancestors, - current_scope_id=self.scope_id, - ): - # For each inherited field, resolve using dual-axis resolution with provenance - for dotted_path in inherited_fields: - container_type = self._path_to_type.get(dotted_path) - if container_type is None: - logger.debug(f"⚠️ _recompute: {dotted_path} has no container_type in _path_to_type") - continue - # Non-lazy fields with None stay as None rather than - # participating in contextual inheritance. - if not is_dataclass(container_type) or not has_lazy_resolution(container_type): - # Non-lazy field: just use raw value (None) - logger.debug(f"⚠️ _recompute: {dotted_path} has non-lazy container_type={container_type.__name__}, using raw value=None") - old_val = self._live_resolved.get(dotted_path) - raw_val = self._parameters.get(dotted_path) - if old_val != raw_val: - changed_paths.add(dotted_path) - changed_values[dotted_path] = (old_val, raw_val) - self._live_resolved[dotted_path] = raw_val - continue - parts = dotted_path.split('.') - field_name = parts[-1] - - # Use resolve_with_provenance for SINGLE walk that gets both value AND source - value, source_scope_id, source_type = resolve_with_provenance(container_type, field_name) - - old_val = self._live_resolved.get(dotted_path) - if old_val != value: - changed_paths.add(dotted_path) - changed_values[dotted_path] = (old_val, value) - logger.debug( - f"RECOMPUTE INHERITED CHANGED [{self.scope_id}] {dotted_path}: " - f"old={old_val!r} -> new={value!r}" - ) - else: - logger.debug( - f"RECOMPUTE INHERITED UNCHANGED [{self.scope_id}] {dotted_path}: " - f"old={old_val!r} == new={value!r}" - ) - self._live_resolved[dotted_path] = value - - # Update provenance for this field - self._live_provenance[dotted_path] = (source_scope_id, source_type) - - # Store for navigation only when recomputation observed concrete deltas. - # First-populate paths legitimately have no resolved delta; preserving the - # raw update_parameter() paths keeps time-travel/flash navigation precise. - if changed_values: - self._set_last_changed_values(changed_values) - return self.notification_fields_for_value_changes(changed_values) @property def last_changed_field(self) -> str | None: @@ -1977,36 +1772,13 @@ def get_current_values(self) -> dict[str, Any]: return dict(self._parameters) def reconstruct_top_level_parameters(self) -> dict[str, Any]: - """Return top-level parameters with nested dataclass values rebuilt. - - ObjectState stores nested fields in a flat dotted-path mapping. Function - pattern kwargs need the top-level call signature values, not the flat UI - storage shape. This method exposes that boundary without requiring UI - widgets or service code to reach into ObjectState private helpers. - """ + """Project local constructor argument names, not the editing address map.""" self._check_and_sync_delegate() - - values: dict[str, Any] = {} - root_type = type(self._extraction_target) - top_level_params = { - key - for key in self._parameters - if "." not in key + return { + path.field_name: self._parameter_structure.reconstruct(path.value, self._parameters, self._extraction_target) + for path in self._parameter_structure.direct_paths.get(DottedFieldPath(''), ()) } - for param_name in top_level_params: - param_type = self._path_to_type.get(param_name) - nested_dataclass = ( - param_type is not None - and is_dataclass(param_type) - and param_type != root_type - ) - if nested_dataclass: - values[param_name] = self._parameter_structure.reconstruct(param_name, self._parameters, self._extraction_target) - else: - values[param_name] = self._parameters[param_name] - - return values # ==================== MATERIALIZED DIFFS ==================== @@ -2192,84 +1964,56 @@ def _compute_resolved_snapshot(self, use_saved: bool = False) -> dict[str, Any]: @classmethod def _resolve_parameter_snapshot( - cls, - parameters: dict[str, Any], - values: dict[str, Any], - structure: ParameterStructure, - current_obj: Any, - scope_id: str | None, - ancestor_objects_with_scopes: Sequence[tuple[str, Any]], - *, - use_saved: bool, - ) -> tuple[dict[str, Any], dict[str, tuple[str | None, type | None]]]: - """Resolve raw fields and their provenance in one scoped semantic epoch.""" + cls, parameters, values, structure, current_obj, scope_id, + ancestor_objects_with_scopes, *, use_saved, field_paths=None, + ): + """One resolution algorithm for complete and targeted projections. + + A container entry carries the projection's nominal payload; reads rebuild + its effective children through ParameterStructure.reconstruct. FieldValue + alone decides whether a stored value invokes inheritance. + """ from objectstate.context_manager import build_context_stack from objectstate.dual_axis_resolver import resolve_with_provenance - from objectstate.lazy_factory import has_lazy_resolution - - snapshot: dict[str, Any] = {} - provenance: dict[str, tuple[str | None, type | None]] = {} - # UNIFIED: Resolve ALL fields in single context stack - # For each path, check if it has a lazy dataclass container type + snapshot = {} + provenance = {} with build_context_stack( object_instance=current_obj, ancestor_objects_with_scopes=ancestor_objects_with_scopes, - current_scope_id=scope_id, - use_live=not use_saved, + current_scope_id=scope_id, use_live=not use_saved, ): - for dotted_path in parameters.keys(): - raw_value = values.get(dotted_path, Unset()) - container_type = structure.owner_paths.get(dotted_path) - parts = dotted_path.split('.') - - # Check if this path is a CONTAINER entry (value is a nested dataclass) - # vs a LEAF field (value is primitive, even if container_type is a dataclass) - is_container_entry = structure.is_container_parameter( - dotted_path, raw_value, parameters - ) - - if is_container_entry: - # Container-level entry - SKIP from snapshot - # Containers are kept in parameters for UI rendering but excluded from - # dirty comparison since we compare leaf fields instead - pass - elif ( - container_type is not None - and is_dataclass(container_type) - and has_lazy_resolution(container_type) - ): - # Leaf field inside a LAZY dataclass - resolve value AND provenance in ONE walk - # CRITICAL: Only resolve for lazy dataclasses! Non-lazy dataclasses with None - # defaults should keep None as-is, not trigger inheritance resolution. - # This handles both: - # - Nested fields (processing_config.group_by) where parts > 1 - # - Top-level fields on root (num_workers on PipelineConfig) where parts == 1 - field_name = parts[-1] - - if FieldValue.is_inherited(raw_value): - # The declared inheritance state invokes the one resolver. - resolved_val, source_scope, source_type = resolve_with_provenance(container_type, field_name) - snapshot[dotted_path] = resolved_val - - # Keep provenance in the same epoch as the resolved value. - provenance[dotted_path] = (source_scope, source_type) + def resolve(path): + if path in snapshot: + return snapshot[path] + address = DottedFieldPath(path) + owner = structure.owner_paths[path] + if address.parent.value: + parent = resolve(address.parent.value) + # Read the effective parent, never retained drafts under a + # null, absent or inherited container. Its own nominal type + # supplies inherited defaults for this source's child. + if is_dataclass(parent): + raw = DataclassFieldAccess.raw_value(parent, address.field_name) + owner = type(parent) else: - # Field has concrete local value - no resolution needed - resolved_val = raw_value - snapshot[dotted_path] = resolved_val - - logger.debug( - f"SNAPSHOT [{scope_id}] {dotted_path}: " - f"raw={raw_value!r} -> resolved={resolved_val!r} (type={type(resolved_val).__name__})" - ) + raw = Unset() else: - # Non-lazy field (regular dataclass, class instance, callable) - use raw value directly - # None stays as None, no inheritance resolution - snapshot[dotted_path] = raw_value - + raw = structure.reconstruct(path, values, current_obj) + def inherited(): + if not isinstance(owner, type): + return Unset() # A callable parameter has no config/MRO layer. + result = resolve_with_provenance(owner, address.field_name) + provenance[path] = (result.source_scope, result.source_type) + return result.value + snapshot[path] = FieldValue.of(raw).resolve(inherited) + return snapshot[path] + for path in parameters: + if field_paths is None or path in field_paths: + resolve(path) return snapshot, provenance + def mark_saved(self) -> None: """Mark current state as saved baseline. @@ -2348,7 +2092,7 @@ def mark_saved(self) -> None: # CRITICAL: Also invalidate saved_resolved cache so descendants recompute their saved baseline logger.debug(f"🔧 mark_saved: Starting invalidation for changed_params={changed_params}, scope={self.scope_id!r}") for param_name in changed_params: - container_type = self._path_to_type.get(param_name, type(self.object_instance)) + container_type = self._parameter_structure.owner_paths.get(param_name, type(self.object_instance)) leaf_field_name = param_name.split('.')[-1] if '.' in param_name else param_name logger.debug(f"🔧 mark_saved: Invalidating param={param_name}, container_type={container_type.__name__ if hasattr(container_type, '__name__') else type(container_type).__name__}, leaf_field={leaf_field_name}") @@ -2447,7 +2191,7 @@ def _restore_saved_impl(self, *, propagate_descendants: bool = True) -> None: """Internal restore implementation (wrapped by restore_saved atomic block).""" # Find parameters that differ from saved baseline AND capture their container types - # BEFORE clearing parameters (we need _path_to_type) + # Capture declaration owners before extracting the saved schema. changed_params_with_types = [] logger.debug(f"🐛 restore_saved: About to iterate parameters, _saved_parameters type={type(self._saved_parameters).__name__}, is None={self._saved_parameters is None}") for param_name, current_value in self._parameters.items(): @@ -2457,7 +2201,7 @@ def _restore_saved_impl(self, *, propagate_descendants: bool = True) -> None: logger.error(f"🐛 restore_saved: ERROR accessing _saved_parameters.get({param_name!r})! _saved_parameters type={type(self._saved_parameters).__name__}, is None={self._saved_parameters is None}, scope={self.scope_id!r}") raise if current_value != saved_value: - container_type = self._path_to_type.get(param_name, type(self.object_instance)) + container_type = self._parameter_structure.owner_paths.get(param_name, type(self.object_instance)) leaf_field_name = param_name.split('.')[-1] if '.' in param_name else param_name changed_params_with_types.append((param_name, container_type, leaf_field_name)) @@ -2598,7 +2342,7 @@ def _extract_all_parameters_flat( # Build dotted path dotted_path = f'{prefix}.{param_name}' if prefix else param_name - info = structure.declare(dotted_path, info) + info = structure.declare(dotted_path, info, owner_target) # Get current value if is_function: @@ -2615,20 +2359,7 @@ def _extract_all_parameters_flat( except AttributeError: current_value = info.default_value - # Check if this is a nested dataclass - # First try from type annotation, then fall back to checking actual value - nested_type = cls._get_nested_dataclass_type(info.param_type) - - # The actual dataclass owns both its flattened fields and their - # reconstruction, including concrete nominal subtypes and lazy - # overlays. An annotation supplies the owner only without a value. - if current_value is not None and is_dataclass(type(current_value)): - nested_type = type(current_value) - - if nested_type is not None and is_dataclass(type(current_value)): - # Store the nested config type reference at this path - structure.owner_paths[dotted_path] = nested_type - + if is_dataclass(type(current_value)): # Store the nested dataclass instance in parameters (needed for UI rendering) parameters[dotted_path] = current_value @@ -2640,7 +2371,6 @@ def _extract_all_parameters_flat( # Leaf field - store value and container type parameters[dotted_path] = current_value # Store the owner target that has this field. - structure.owner_paths[dotted_path] = owner_target def _replace_parameter_structure( self, @@ -2734,39 +2464,7 @@ def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True # Collect ALL top-level field updates from self._parameters # This includes both primitive fields AND nested dataclass fields - field_updates = {} - root_type = type(target) - for field_name in self._path_to_type: - if '.' not in field_name: - # Check if this field's TYPE is a dataclass (not the instance value) - # We need to check the TYPE because the instance value might be stale - # (e.g., self._parameters['partition_filter_config'] might have partition_filter=2 - # even though self._parameters['partition_filter_config.partition_filter'] = None) - field_type = self._path_to_type.get(field_name) - # CRITICAL FIX: _path_to_type stores CONTAINER type for leaf fields, - # but FIELD type for nested dataclass fields. We must distinguish: - # - If field_type == root_type, it's a leaf field (container type stored) - # - If field_type != root_type AND is_dataclass, it's a nested dataclass - is_nested_dataclass = ( - field_type is not None and - is_dataclass(field_type) and - field_type != root_type # Not the container type - ) - if is_nested_dataclass: - # Nested dataclass: ALWAYS recursively reconstruct from flat storage - # This ensures we pick up changes to nested fields like - # 'partition_filter_config.partition_filter' - logger.debug(f"🔧 to_object: Reconstructing nested dataclass '{field_name}' from flat storage") - reconstructed = self._parameter_structure.reconstruct(field_name, self._parameters, self._extraction_target) - logger.debug(f"🔧 to_object: Reconstructed '{field_name}' type={type(reconstructed).__name__}") - # Log some field values for debugging - if hasattr(reconstructed, 'enabled'): - logger.debug(f"🔧 to_object: '{field_name}'.enabled = {reconstructed.enabled}") - field_updates[field_name] = reconstructed - else: - # Primitive field: use value directly from parameters - value = self._parameters.get(field_name) - field_updates[field_name] = value + field_updates = self.reconstruct_top_level_parameters() # Reconstruct the target object (either object_instance or delegate) reconstructed = None @@ -2822,6 +2520,7 @@ def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True + def _get_changed_params_with_types( self, old_target: Any, new_target: Any ) -> list[tuple[str, type, str]]: @@ -2838,7 +2537,7 @@ def _get_changed_params_with_types( new_value = self._get_param_value_from_target(new_target, param_name) if old_value != new_value: - container_type = self._path_to_type.get(param_name, type(self.object_instance)) + container_type = self._parameter_structure.owner_paths.get(param_name, type(self.object_instance)) leaf_field_name = param_name.split('.')[-1] if '.' in param_name else param_name changed_params.append((param_name, container_type, leaf_field_name)) diff --git a/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index 781b6ec..a960921 100644 --- a/src/objectstate/object_state_registry.py +++ b/src/objectstate/object_state_registry.py @@ -853,7 +853,7 @@ def _invalidate_field_in_matching_states( ) -> None: """Find fields in state that could inherit from target_base_type and invalidate them. - With flat storage, we scan _path_to_type to find all dotted paths whose + The declaration structure selects field addresses whose declaring container type matches or inherits from target_base_type. A field should be invalidated if: @@ -877,7 +877,7 @@ def _invalidate_field_in_matching_states( # DEBUG: Log entry into type matching if _debug: logger.debug(f"🔍 _invalidate_field_in_matching_states: state_scope={state.scope_id}, target_base_type={target_base_type.__name__}, field_name={field_name}") - logger.debug(f"🔍 state._path_to_type has {len(state._path_to_type)} entries") + logger.debug(f"🔍 state._parameter_structure.owner_paths has {len(state._parameter_structure.owner_paths)} entries") for dotted_path in state.inheritance_field_paths( target_base_type, diff --git a/src/objectstate/parameter_structure.py b/src/objectstate/parameter_structure.py index e7163a1..3953f19 100644 --- a/src/objectstate/parameter_structure.py +++ b/src/objectstate/parameter_structure.py @@ -25,12 +25,37 @@ class ParameterStructure: exclusions: list[str] = field(default_factory=list) excluded_values: dict[str, Any] = field(default_factory=dict) - def declare(self, path: str, info: ParameterInfo) -> ParameterInfo: - """Admit external signature absence once, as Unset rather than None.""" + def declare(self, path: str, info: ParameterInfo, owner: ParameterOwner) -> ParameterInfo: + """Admit a declaration and its owner together, never the payload's type.""" declaration = info._replace(default_value=Unset()) if info.is_required else info self.declarations[path] = declaration + self.owner_paths[path] = owner return declaration + def binding_owner(self, path: str) -> ParameterOwner: + """Immutable declaration identity survives schema copying and undo.""" + from objectstate.lazy_factory import get_base_type_for_lazy + + owner = self.owner_paths[path] + return (get_base_type_for_lazy(owner) or owner) if isinstance(owner, type) else owner + + def container_types(self, values: dict[str, Any]): + """Nominal payload types come from the projection, not declaration owners.""" + for path, value in values.items(): + if is_dataclass(value): + yield path, type(value) + + def affected_paths(self, changed): + """A container's presence/source is a dependency of every declared child.""" + affected = set(changed) & self.declarations.keys() + pending = list(affected) + while pending: + for child in self.direct_paths.get(DottedFieldPath(pending.pop()), ()): + if child.value not in affected: + affected.add(child.value) + pending.append(child.value) + return affected + @property def defaults(self) -> dict[str, Any]: """Derive editor reset values from admitted parameter declarations.""" @@ -89,7 +114,7 @@ def reconstruct(self, prefix, values, target, *, resolved=False): from objectstate.field_access import DataclassFieldAccess from objectstate.lazy_factory import get_base_type_for_lazy, replace_raw - prototype = values[prefix] if prefix else target + prototype = values.get(prefix, Unset()) if prefix else target if prefix and not is_dataclass(prototype): return prototype prefix_dot = f"{prefix}." if prefix else "" diff --git a/tests/test_declared_field_handles.py b/tests/test_declared_field_handles.py new file mode 100644 index 0000000..04fe163 --- /dev/null +++ b/tests/test_declared_field_handles.py @@ -0,0 +1,122 @@ +"""Declared identities and field meaning through real editing lifetimes.""" +from dataclasses import dataclass, field as dataclass_field + +import pytest + +from objectstate import ( + DataclassFieldAccess, Inherit, LazyDataclassFactory, ObjectState, Unset, + set_base_config_type, set_global_config, +) +from objectstate.field_access import FieldAccessError + + +@dataclass +class Detail: + value: int | None = 8 + + +@dataclass +class Settings: + text: str | None = None + count: int | None = 4 + detail: Detail | None = dataclass_field(default_factory=Detail) + + +def editor(): + set_base_config_type(Settings) + set_global_config(Settings, Settings(count=77, detail=Detail(91))) + lazy_detail = LazyDataclassFactory.make_lazy_simple(Detail) + lazy_settings = LazyDataclassFactory.make_lazy_simple(Settings) + return ObjectState(lazy_settings(text=None, detail=lazy_detail())), lazy_settings, lazy_detail + + +@pytest.mark.parametrize('stored, expected', [(None, None), (Inherit(), 77), (Unset(), Unset()), (0, 0)]) +def test_field_meaning_in_full_and_targeted_projections(stored, expected): + state, _, _ = editor() + count = state.field(Settings, 'count') + count.write(stored) + assert count.raw == stored + assert count.value == expected + assert DataclassFieldAccess.raw_value(state.to_object(), 'count') == stored + assert state.to_resolved_object().count == expected + + +def test_subscription_identity_and_release(): + state, _, _ = editor() + text = state.fields.text + changes = [] + roots = [] + release = text.subscribe(lambda changed: changes.append((text.value, changed))) + release_root = state.fields.subscribe(roots.append) + text.write('edited') + assert text.dirty + text.reset() + assert text.value is None and not text.dirty + assert [value for value, _ in changes] == ['edited', None] + assert all(changed == {text} for _, changed in changes) + assert roots and all(text in changed for changed in roots) + release() + release_root() + text.write('released') + assert len(changes) == 2 + assert text.inspect().declaring_type.endswith('.Settings') + + +def test_identity_survives_restore_and_same_declaration_replacement(): + state, lazy_settings, lazy_detail = editor() + text = state.fields.text + identity_hash = hash(text) + text.write('edited') + state.restore_saved() + assert text.value is None + assert text == state.fields.text and hash(text) == identity_hash + state.update_object_instance(lazy_settings(text='replacement', detail=lazy_detail(value=12))) + assert text.value == 'replacement' + assert text == state.fields.text and hash(text) == identity_hash + + +def test_container_presence_dominates_retained_child_drafts(): + state, _, _ = editor() + detail = state.field(Settings, 'detail') + child = state.fields.bind(Settings, ('detail', 'value')) + assert detail.declaring_type is Settings + assert child.declaring_type is Detail + assert detail.value.value == detail.saved_value.value == 91 + child.write(15) + detail.write(None) + assert child.raw == 15 + assert detail.value is None and isinstance(child.value, Unset) + assert state.to_resolved_object().detail is None + assert detail.saved_value.value == 91 + detail.write(Inherit()) + assert detail.value.value == child.value == 91 + assert state.to_resolved_object().detail.value == 91 + detail.write(Unset()) + assert isinstance(detail.value, Unset) and isinstance(child.value, Unset) + detail.write(Detail(12)) + assert detail.value.value == child.value == 12 + + +def test_regular_declaration_explicitly_requests_inheritance(): + state, _, _ = editor() + state.update_object_instance(Settings(count=Inherit())) + assert state.fields.count.value == 77 + state.fields.count.write(None) + assert state.fields.count.value is None + + +def test_wrong_and_retired_declarations_fail_at_the_handle(): + state, _, _ = editor() + with pytest.raises(FieldAccessError): + state.field(Settings, 'typo') + text = state.fields.text + @dataclass + class Other: + text: str = 'unrelated' + state.update_object_instance(Other()) + assert not text.present + with pytest.raises(FieldAccessError): + text.write('wrong target') + assert state.fields.text.value == 'unrelated' + for deleted in ('parameters', 'get_resolved_value', 'get_saved_resolved_value', 'update_parameter', 'reset_parameter', 'on_state_changed', 'on_resolved_changed', 'type_for_path', 'get_provenance'): + assert not hasattr(state, deleted) From cc83a3eb236fb726e65ffa6cbe8f13a7167da025 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 04:20:21 -0400 Subject: [PATCH 07/15] Separate field namespace capability from declared field identity --- docs/declared-field-state.md | 25 +++++--- src/objectstate/object_state.py | 94 +++++++++++++++------------- tests/test_declared_field_handles.py | 34 +++++++++- 3 files changed, 101 insertions(+), 52 deletions(-) diff --git a/docs/declared-field-state.md b/docs/declared-field-state.md index 3e8318f..7a7a35b 100644 --- a/docs/declared-field-state.md +++ b/docs/declared-field-state.md @@ -67,8 +67,10 @@ The following #46 producer prototype is deliberately unready for installation: - Handles own `raw`, `value`, `default`, `write`, `reset`, `dirty`, `inspect`, `subscribe` and `subscribe_value`. Subscriptions return their release operation and deliver field handles, not path strings. -- RootFields inherits declaration discovery and subscription lifetime behavior; - only its live root schema and changed-field projection differ. +- FieldNamespace owns declaration discovery and subscription lifetime behavior + shared by FieldProxy and RootFields. A root is a namespace, not a declared + field: it no longer falsely advertises a field default/value contract. A root + declaration literally named `value` can therefore be acquired normally. - The old producer get_resolved_value/update_parameter/reset_parameter, public parameters storage, and on/off observer entry points are removed. Producer checkpoint/registry/inspection/edit-session consumers were migrated. @@ -85,7 +87,7 @@ The following #46 producer prototype is deliberately unready for installation: reconstruction decisions are deleted. Full and targeted reads share one resolver, including descendant invalidation when a container becomes None, Inherit or Unset. Retained child drafts cannot override that container. -- Nine focused producer tests pass. The source-paired bridge mutation/catalog +- Eleven focused producer tests pass. The source-paired bridge mutation/catalog journey preserves null, empty string and string 'None', resets to inheritance, rejects an unknown address and travels backward through registered history while retaining the original field handle. @@ -93,9 +95,16 @@ The following #46 producer prototype is deliberately unready for installation: still defaults to None in the request DTO. Omission must be admitted as absence and rejected before mutation unless reset was requested; explicit JSON null must remain data. This needs repair at the existing request/codec boundary. -- Dirty/path metadata APIs and external consumer ports remain unfinished. The - companion frontend branches still target the preceding #32 producer checkpoint; - do not combine their source with the unready #46 producer head. +- Toad #687 now consumes handles throughout forms, saved preferences and sort + subscriptions. Its forms are keyed by handles and release observers on unmount. + Nullable inputs commit real values rather than checking a box over backend + None; hints stay placeholders, never stored input. Annotated optional members + retain their metadata. Twenty-four form/settings tests pass. A real source- + paired Textual journey covers native clicks/keystrokes, null, empty/string + 'None', open refresh, registered undo and unmount release. +- Dirty/path metadata APIs and Qt/OpenHCS/Core consumer ports remain unfinished. + Qt/OpenHCS companion branches still target the preceding #32 producer + checkpoint; do not combine those with the unready #46 producer head. Evidence is owned by doctrine-segment under `/home/ts/.cache/agent-scratch/objectstate-declared-fields/`: @@ -103,7 +112,9 @@ Evidence is owned by doctrine-segment under `handle-producer-declared-live.txt`, `declaration-owner-projection-live.txt`, `declared-field-handles-final-tests.txt`, `bridge-handle-history-journey.txt`, `form-manager-owner-journey.txt`, `reconstruction-live.txt`, and -`source-state-roundtrip-paired.txt`. Bridge verification additionally pairs the +`source-state-roundtrip-paired.txt`. Latest namespace/Toad receipts are +`namespace-admission-final-tests.txt`, `toad-nullable-annotation-final-tests.txt` +and `toad-nullable-annotation-final-journey.txt`. Bridge verification additionally pairs the existing zmqruntime source because the installed package lacks execution.config. AST coverage is lexical; dynamic bindings are not claimed resolved. diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index a31aa61..95b05a7 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -46,7 +46,50 @@ logger = logging.getLogger(__name__) -class FieldProxy: +class FieldNamespace: + """Declaration discovery and observer lifetime shared by roots and fields. + + A namespace promises neither a field default nor a writable datum. Each + concrete scope owns its admission check and changed-field projection. + """ + @property + def _structure(self): + return self._state._parameter_structure + + @property + def state(self): + return self._state + + @property + def children(self): + self._require_present() + return tuple(self._state._field_at(path.value) for path in self._structure.direct_paths.get(DottedFieldPath(self._path), ())) + + def __getattr__(self, name): + self._require_present() + for child in self._structure.direct_paths.get(DottedFieldPath(self._path), ()): + if child.field_name == name: + return self._state._field_at(child.value) + raise AttributeError(f"Field {self._path!r} has no declared child {name!r}") + + def _subscribe(self, callback, listen, unlisten): + def changed(paths): + self._notify_subscriber(callback, paths) + listen(changed) + def release(): + unlisten(changed) + return release + + def subscribe(self, callback): + """Observe this field's state; receive changed field handles, never strings.""" + return self._subscribe(callback, self._state._listen_state, self._state._unlisten_state) + + def subscribe_value(self, callback): + """Observe resolved-value changes, including changes inherited from elsewhere.""" + return self._subscribe(callback, self._state._listen_resolved, self._state._unlisten_resolved) + + +class FieldProxy(FieldNamespace): """One declaration bound to an editing state. The structure, not dataclass reflection repeated by each consumer, admits the @@ -61,13 +104,7 @@ def __init__(self, state: 'ObjectState', path: str): self._path = path self._owner = state._parameter_structure.binding_owner(path) - @property - def _structure(self): - return self._state._parameter_structure - @property - def state(self): - return self._state @property def declaration(self): @@ -136,17 +173,7 @@ def reset(self): from copy import deepcopy return self.write(deepcopy(self.default)) - @property - def children(self): - self._require_present() - return tuple(self._state._field_at(path.value) for path in self._structure.direct_paths.get(DottedFieldPath(self._path), ())) - def __getattr__(self, name): - self._require_present() - for child in self._structure.direct_paths.get(DottedFieldPath(self._path), ()): - if child.field_name == name: - return self._state._field_at(child.value) - raise AttributeError(f"Field {self._path!r} has no declared child {name!r}") def _notify_subscriber(self, callback, paths): owner = DottedFieldPath(self._path) @@ -154,21 +181,8 @@ def _notify_subscriber(self, callback, paths): if selected: callback(frozenset(self._state._field_at(path) for path in selected if path in self._state._parameter_structure.declarations)) - def _subscribe(self, callback, listen, unlisten): - def changed(paths): - self._notify_subscriber(callback, paths) - listen(changed) - def release(): - unlisten(changed) - return release - def subscribe(self, callback): - """Observe this field's state; receive changed field handles, never strings.""" - return self._subscribe(callback, self._state._listen_state, self._state._unlisten_state) - def subscribe_value(self, callback): - """Observe resolved-value changes, including changes inherited from elsewhere.""" - return self._subscribe(callback, self._state._listen_resolved, self._state._unlisten_resolved) def __eq__(self, other): return isinstance(other, FieldProxy) and self._state is other._state and self._owner is other._owner and self._path == other._path @@ -194,7 +208,7 @@ def inspect(self): return FieldInspection.of(self) -class RootFields(FieldProxy): +class RootFields(FieldNamespace): """The live root namespace shares field discovery, not a captured schema.""" def __init__(self, state): self._state = state @@ -204,28 +218,15 @@ def __init__(self, state): def _path(self): return '' - @property - def present(self): - return True __hash__ = None def __eq__(self, other): return isinstance(other, RootFields) and self._state is other._state - @property - def raw(self): - return self._state.to_object() - @property - def value(self): - return self._state.to_resolved_object() - def write(self, value): - return self._state.update_object_instance(value) - def reset(self): - return self._state.reset_all_parameters() def __iter__(self): self._state._check_and_sync_delegate() @@ -236,6 +237,7 @@ def __len__(self): def bind(self, owner, names, field_type=FieldProxy): """Bind an already declared attribute vector to this editing state.""" + self._require_present() target = self._state._extraction_target if not ((isinstance(owner, type) and isinstance(target, owner)) or target is owner): raise FieldAccessError(f"{owner!r} does not declare this editing target") @@ -252,6 +254,9 @@ def bind(self, owner, names, field_type=FieldProxy): def _notify_subscriber(self, callback, paths): callback(frozenset(self._state._field_at(path) for path in paths if path in self._structure.declarations)) + def _require_present(self): + self._state._check_and_sync_delegate() + @@ -602,6 +607,7 @@ def _field_at(self, path: str) -> FieldProxy: return FieldProxy(self, path) def field(self, owner: ParameterOwner, name: str) -> FieldProxy: + self._check_and_sync_delegate() matches = [path for path, declaration in self._parameter_structure.declarations.items() if declaration.name == name and self._parameter_structure.binding_owner(path) is owner] if len(matches) != 1: raise FieldAccessError(f"Expected one declared {owner!r}.{name}, found {len(matches)}; use the declaration namespace for repeated owners") diff --git a/tests/test_declared_field_handles.py b/tests/test_declared_field_handles.py index 04fe163..52ae8cc 100644 --- a/tests/test_declared_field_handles.py +++ b/tests/test_declared_field_handles.py @@ -4,7 +4,7 @@ import pytest from objectstate import ( - DataclassFieldAccess, Inherit, LazyDataclassFactory, ObjectState, Unset, + DataclassFieldAccess, FieldProxy, Inherit, LazyDataclassFactory, ObjectState, Unset, set_base_config_type, set_global_config, ) from objectstate.field_access import FieldAccessError @@ -120,3 +120,35 @@ class Other: assert state.fields.text.value == 'unrelated' for deleted in ('parameters', 'get_resolved_value', 'get_saved_resolved_value', 'update_parameter', 'reset_parameter', 'on_state_changed', 'on_resolved_changed', 'type_for_path', 'get_provenance'): assert not hasattr(state, deleted) + + +def test_factory_checks_the_current_delegate_before_admission(): + set_base_config_type(Settings) + set_global_config(Settings, Settings()) + class Lifecycle: + __objectstate_delegate__ = 'config' + def __init__(self): + self.config = Settings() + @dataclass + class Other: + text: str = 'other' + owner = Lifecycle() + state = ObjectState(owner) + owner.config = Other() + with pytest.raises(FieldAccessError): + state.field(Settings, 'text') + with pytest.raises(FieldAccessError): + state.fields.bind(Settings, ('text',)) + + +def test_root_namespace_is_not_a_field_and_can_name_value(): + @dataclass + class Named: + value: int = 5 + set_base_config_type(Named) + set_global_config(Named, Named()) + state = ObjectState(Named()) + assert isinstance(state.fields.value, FieldProxy) + assert state.fields.value.value == 5 + with pytest.raises(AttributeError): + _ = state.fields.default From 6357aee170b97fb7fd8a9ec27cbb4c49972ac720 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 05:19:41 -0400 Subject: [PATCH 08/15] Record working Qt field workflow and remaining application ports --- docs/declared-field-state.md | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/docs/declared-field-state.md b/docs/declared-field-state.md index 7a7a35b..5555aff 100644 --- a/docs/declared-field-state.md +++ b/docs/declared-field-state.md @@ -56,9 +56,10 @@ native keystrokes commit through the existing debounce; the view disposes. Source generation round-trips None/Inherit/Unset/zero. Installed pycodify lacks `imported_name`, so this is paired-source evidence, not installed acceptance. Nested/inline controls, all callers, legacy saved-source migration and installed -acceptance are unfinished. The no-config form-manager constructor also still -fails because its color scheme defaults to None; the real application supplies -a ColorScheme. Doctrine owns follow-through on that discovered default defect. +acceptance are unfinished. That checkpoint also exposed the default manager's +color-scheme failure. PyQt `ef7243c` fixes its actual cause: DerivationContext +copied an unused None over ParameterFormConfig's resolved scheme. The competing +slot is deleted. Default and explicitly configured schemes pass the real builder. The following #46 producer prototype is deliberately unready for installation: @@ -102,9 +103,21 @@ The following #46 producer prototype is deliberately unready for installation: retain their metadata. Twenty-four form/settings tests pass. A real source- paired Textual journey covers native clicks/keystrokes, null, empty/string 'None', open refresh, registered undo and unmount release. -- Dirty/path metadata APIs and Qt/OpenHCS/Core consumer ports remain unfinished. - Qt/OpenHCS companion branches still target the preceding #32 producer - checkpoint; do not combine those with the unready #46 producer head. +- PyQt `ef7243c` consumes handles in the real form manager and mutation pipeline. + FieldWriteEvent and FieldResetEvent own their distinct operations; the common + stage invokes one mutation. Deleted per-widget reset copies, duplicate root- + path assembly, the duplicate native-input pipeline, and the unused initial- + values mirror. Enabled paint and ancestor effects derive from backend fields, + not checkbox paint or None-to-True fallback. One owner traverses the subtree; + its last-painted effective state includes ancestor changes. Scope-owned + observer releases and nested signal-block restoration are exercised. + Six focused Qt cases pass, including real default-manager construction, + native typing/reset clicks, one hook/publication, nullable and absent values, + stale checkbox paint, open/nested backend enable changes and disposal. +- Dirty/path metadata APIs and full Qt/OpenHCS/Core ports remain unfinished. + Qt window/list/bridge callers still need migration; OpenHCS still targets the + preceding #32 producer checkpoint. Do not install the current branch heads as + a finished paired stack. Evidence is owned by doctrine-segment under `/home/ts/.cache/agent-scratch/objectstate-declared-fields/`: @@ -114,7 +127,11 @@ Evidence is owned by doctrine-segment under `form-manager-owner-journey.txt`, `reconstruction-live.txt`, and `source-state-roundtrip-paired.txt`. Latest namespace/Toad receipts are `namespace-admission-final-tests.txt`, `toad-nullable-annotation-final-tests.txt` -and `toad-nullable-annotation-final-journey.txt`. Bridge verification additionally pairs the +and `toad-nullable-annotation-final-journey.txt`. Qt receipts are +`qt-handle-mutation-owner-journey.txt` and `qt-field-reset-enabled-final-tests.txt`; +`qt-handle-consumers.txt` records the original selected caller census, and the +NRA transformations remain under the producer checkout's owned `.scratch/`. +Bridge verification additionally pairs the existing zmqruntime source because the installed package lacks execution.config. AST coverage is lexical; dynamic bindings are not claimed resolved. From c4748d2e514abf6e0038b631c71aadde7bd7eb04 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 05:29:51 -0400 Subject: [PATCH 09/15] Keep the declared field document as a lasting API contract only --- docs/declared-field-state.md | 200 ++++++++++------------------------- 1 file changed, 55 insertions(+), 145 deletions(-) diff --git a/docs/declared-field-state.md b/docs/declared-field-state.md index 5555aff..155856d 100644 --- a/docs/declared-field-state.md +++ b/docs/declared-field-state.md @@ -1,148 +1,58 @@ -# Declared field values and handles (#32, #46) +# Declared field values and handles -## Contract and authorized migration +## Field meaning -A public config field read returns its effective value. A concrete stored value, +A public field read returns its effective value. A concrete stored value, including Python `None`, is already effective and never requests inheritance. -`Inherit()` requests the existing context/MRO resolver. `Unset()` means that no -value is available, not Python `None` and not a request to inherit. Stored reads -remain an explicit operation of `DataclassFieldAccess`; copying and source -writing use stored reads, not implicit effective reads. - -This is an intentional API/data cutover, not a compatible second interpretation -of `None`. Existing lazy saved configs that used `None` for inheritance require -an explicit one-time migration on preserved copies before installation. Ordinary -nullable concrete fields are not rewritten. No legacy reader belongs in src. - -## Existing owners and complete consumers - -- `DataclassFieldAccess` owns stored dataclass access. Resolver, merge, raw copy, - serialization and layer comparison consume it instead of private raw readers. -- `LazyMethodBindings` and the existing lazy factory own config field access and - constructor defaults. Their inheritance defaults become `Inherit()` while the - declared domain annotation retains whether `None` is an actual valid value. -- The existing one resolver in `dual_axis_resolver.py` owns context/MRO selection - and source provenance. Inherit continues that selection; a concrete None stops - it. Missing defaults return Unset, not None. -- `ContextLayerStack` owns the layers. Global-config editing starts from the - declared-default global object rather than adding a live global layer then - masking it with a None mode. Delete `mask_with_none` and constructor patching. -- `ObjectState` owns reset, raw/resolved snapshots, provenance and dirty state. - Reset takes the recorded declaration default, which is Inherit for lazy fields. - Code generation, inspection, structural leaves and source comparison derive the - same state. The existing structural `MissingValue` is replaced by Unset; no - duplicate missing-state spelling remains. -- OpenHCS, FormBridge, PyQt and Toad callers must distinguish inherited controls - from a literal nullable value and stop patching constructors. Core consumers - are included in the same source census. - -## Field identity next (#46) - -Extend existing declaration/topology and `FieldProxy` behavior, not a parallel -path registry. A declaration-bound field is validated on acquisition and owns -read, write, reset, dirty/provenance queries and subscriptions. All callbacks and -callers carry that field identity; dotted text is an external display/encoding -projection, not the writable API. Remove the old string write/reset/read and -subscription API in the same migration. Native-forms #61 remains separately owned; -its path topology changes must be integrated normally, not copied or edited in -its worktree. - -## Working checkpoints (not completion) - -The #32 checkpoint `0586f27`, FormBridge `4d12e1e`, and PyQt `fb1c044` -passed a real source-paired Qt form journey: literal None and inherited 77 -remain distinct; an already open form receives a backend change to None; -native keystrokes commit through the existing debounce; the view disposes. -Source generation round-trips None/Inherit/Unset/zero. Installed pycodify lacks -`imported_name`, so this is paired-source evidence, not installed acceptance. -Nested/inline controls, all callers, legacy saved-source migration and installed -acceptance are unfinished. That checkpoint also exposed the default manager's -color-scheme failure. PyQt `ef7243c` fixes its actual cause: DerivationContext -copied an unused None over ParameterFormConfig's resolved scheme. The competing -slot is deleted. Default and explicitly configured schemes pass the real builder. - -The following #46 producer prototype is deliberately unready for installation: - -- `state.field(Settings, "text")`, `state.fields.text`, and - `state.fields.bind(Settings, ("text",))` admit a declaration before use. -- Handles own `raw`, `value`, `default`, `write`, `reset`, `dirty`, `inspect`, - `subscribe` and `subscribe_value`. Subscriptions return their release operation - and deliver field handles, not path strings. -- FieldNamespace owns declaration discovery and subscription lifetime behavior - shared by FieldProxy and RootFields. A root is a namespace, not a declared - field: it no longer falsely advertises a field default/value contract. A root - declaration literally named `value` can therefore be acquired normally. -- The old producer get_resolved_value/update_parameter/reset_parameter, - public parameters storage, and on/off observer entry points are removed. - Producer checkpoint/registry/inspection/edit-session consumers were migrated. -- A fresh producer editing session checks read/write/reset/dirty, leaf/root - subscription delivery and release, inspection and rejection of a nonexistent - declaration. This does NOT establish all applications work. -- Declaration lifetime is now tied to state + declaring owner + address, not a - temporary ParameterStructure instance. Restore, same-schema replacement and - actual registered-history undo retain the original handle; a same-name field - on an unrelated declaration cannot be targeted through it. -- ParameterStructure.declare is the sole owner_paths writer. The index contains - declaration owners only. Payload types come from the projection. The mixed - _path_to_type alias, duplicate partial resolver and competing type-based - reconstruction decisions are deleted. Full and targeted reads share one - resolver, including descendant invalidation when a container becomes None, - Inherit or Unset. Retained child drafts cannot override that container. -- Eleven focused producer tests pass. The source-paired bridge mutation/catalog - journey preserves null, empty string and string 'None', resets to inheritance, - rejects an unknown address and travels backward through registered history - while retaining the original field handle. -- **Remaining bridge defect, owned by doctrine:** an omitted mutation value - still defaults to None in the request DTO. Omission must be admitted as absence - and rejected before mutation unless reset was requested; explicit JSON null - must remain data. This needs repair at the existing request/codec boundary. -- Toad #687 now consumes handles throughout forms, saved preferences and sort - subscriptions. Its forms are keyed by handles and release observers on unmount. - Nullable inputs commit real values rather than checking a box over backend - None; hints stay placeholders, never stored input. Annotated optional members - retain their metadata. Twenty-four form/settings tests pass. A real source- - paired Textual journey covers native clicks/keystrokes, null, empty/string - 'None', open refresh, registered undo and unmount release. -- PyQt `ef7243c` consumes handles in the real form manager and mutation pipeline. - FieldWriteEvent and FieldResetEvent own their distinct operations; the common - stage invokes one mutation. Deleted per-widget reset copies, duplicate root- - path assembly, the duplicate native-input pipeline, and the unused initial- - values mirror. Enabled paint and ancestor effects derive from backend fields, - not checkbox paint or None-to-True fallback. One owner traverses the subtree; - its last-painted effective state includes ancestor changes. Scope-owned - observer releases and nested signal-block restoration are exercised. - Six focused Qt cases pass, including real default-manager construction, - native typing/reset clicks, one hook/publication, nullable and absent values, - stale checkbox paint, open/nested backend enable changes and disposal. -- Dirty/path metadata APIs and full Qt/OpenHCS/Core ports remain unfinished. - Qt window/list/bridge callers still need migration; OpenHCS still targets the - preceding #32 producer checkpoint. Do not install the current branch heads as - a finished paired stack. - -Evidence is owned by doctrine-segment under -`/home/ts/.cache/agent-scratch/objectstate-declared-fields/`: -`handles-before.json` (NRA imports/attributes/calls and complete class census), -`handle-producer-declared-live.txt`, `declaration-owner-projection-live.txt`, -`declared-field-handles-final-tests.txt`, `bridge-handle-history-journey.txt`, -`form-manager-owner-journey.txt`, `reconstruction-live.txt`, and -`source-state-roundtrip-paired.txt`. Latest namespace/Toad receipts are -`namespace-admission-final-tests.txt`, `toad-nullable-annotation-final-tests.txt` -and `toad-nullable-annotation-final-journey.txt`. Qt receipts are -`qt-handle-mutation-owner-journey.txt` and `qt-field-reset-enabled-final-tests.txt`; -`qt-handle-consumers.txt` records the original selected caller census, and the -NRA transformations remain under the producer checkout's owned `.scratch/`. -Bridge verification additionally pairs the -existing zmqruntime source because the installed package lacks execution.config. -AST coverage is lexical; dynamic bindings are not claimed resolved. - -## Delivery order - -1. Complete source/class/decision/caller census with existing NRA syntax/family - tooling across ObjectState and its production consumers; report dynamic OPEN - bindings rather than claiming an AST proves dispatch. -2. Migrate the state/default/resolution/copy/codegen/UI family coherently. Push a - working #32 checkpoint and all consumer branches, without aliases or fallbacks. -3. Migrate the handle family and delete string APIs for #46. -4. Batch focused checks, then use the actual affected applications with preserved - saved data/copies. Source tests, private installs and shared installed acceptance - are separate claims. Codex retains shared installation ownership. +`Inherit()` requests context/MRO resolution. `Unset()` means that no value is +available: it is neither Python `None` nor a request to inherit. + +`FieldValue` owns resolution, overlay, conversion and source rendering for +these meanings. Domain conversions apply to concrete values, not directives. +A container's None/Inherit/Unset meaning takes precedence over retained child +drafts when reconstructing it. + +`DataclassFieldAccess` owns stored dataclass access. Copying, serialization and +layer comparison use stored reads rather than implicit effective reads. +The resolver owns context/MRO selection and provenance: an inherited value +continues that selection; concrete None stops it. + +Lazy constructor defaults are `Inherit()` without changing whether the domain +annotation permits None. Required parameters without a value use `Unset()`. +Reset restores the declaration default; saved values are a separate baseline. +Global-config editing starts from declared defaults rather than masking live +values with None. + +## Declaration-bound handles + +`ParameterStructure` owns declarations, their owners and topology. A payload's +nominal type does not replace its declaration owner. Reconstruction uses that +shared topology for raw and resolved projections. + +Acquire a validated field with `state.field(Settings, "text")`, +`state.fields.text`, or `state.fields.bind(Settings, ("text",))`. A handle owns: + +- Stored `raw`, effective `value`, declaration `default` and `saved_value` reads. +- `write(value)`, `reset()`, dirty/signature queries and inspection/provenance. +- Raw-state `subscribe(callback)` and effective `subscribe_value(callback)`. + Callbacks receive handles; subscription returns its release operation. + +A handle identifies its state, declaration owner and address. Compatible +restoration, same-schema replacement and registered-history undo preserve that +identity. A same-name field on an unrelated declaration does not inherit it. + +`FieldNamespace` shares declaration discovery and observer lifetime behavior. +`RootFields` and `FieldProxy` are sibling capabilities: a root is a namespace, +not a field with a datum. A root declaration named `value` is therefore valid. +Factories synchronize the current editing delegate before admitting an owner. + +`DottedFieldPath.bind(state)` admits an external address once. Paths remain +useful for wire encoding, source documents and UI navigation; subsequent field +reads, mutations and subscriptions use the admitted handle. + +## Saved source + +There is no second runtime interpretation of None. Historical lazy configs +that used None for inheritance need an explicit migration on preserved copies; +ordinary nullable concrete values must not be rewritten. Source generation +preserves literal None, inheritance, absence and empty lazy objects distinctly. From 9faba28afbdf97b70663d7bc9b5cf304079b4e94 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 11:15:06 -0400 Subject: [PATCH 10/15] Retain one resolved field member and dispatch meaning through member behavior --- src/objectstate/dual_axis_resolver.py | 10 ++-- src/objectstate/field_value.py | 36 ++++++++++++-- src/objectstate/inspection.py | 36 +++----------- src/objectstate/object_state.py | 2 +- src/objectstate/subfield_semantics.py | 14 +++--- tests/test_inspection.py | 49 +++++++++++++++---- tests/test_scopeless_state_resolution.py | 62 +++++++++++++----------- 7 files changed, 125 insertions(+), 84 deletions(-) diff --git a/src/objectstate/dual_axis_resolver.py b/src/objectstate/dual_axis_resolver.py index 6cdb938..53efd50 100644 --- a/src/objectstate/dual_axis_resolver.py +++ b/src/objectstate/dual_axis_resolver.py @@ -68,19 +68,19 @@ def declared_default(container_type: type, field_name: str) -> tuple[Any, type | """Read the declared default, retaining an explicit None as a real value.""" base = _normalize_to_base(container_type) own = _direct_owner_field_default(base, field_name) - own_type = base if FieldValue.of(own).has_value else None - if FieldValue.of(own).has_value: + own_type = base if FieldValue.of(own).available() else None + if FieldValue.of(own).available(): return own, own_type for owner in base.__mro__: value = getattr(owner, field_name, Unset()) - if FieldValue.of(value).has_value: + if FieldValue.of(value).available(): return value, own_type if is_dataclass(container_type): for declaration in fields(container_type): if declaration.name != field_name: continue inherited = declaration.metadata.get("_inherited_default", MISSING) - if inherited is not MISSING and FieldValue.of(inherited).has_value: + if inherited is not MISSING and FieldValue.of(inherited).available(): return inherited, own_type factory = declaration.metadata.get("_inherited_default_factory", MISSING) if factory is not MISSING: @@ -166,7 +166,7 @@ class is tried only after every layer was asked for the more specific one. value = DataclassFieldAccess.raw_value(config_instance, field_name) except AttributeError: continue - if FieldValue.of(value).has_value: + if FieldValue.of(value).available(): return ResolvedField(value, layer.scope_id, mro_type) if held_by_a_layer and not unset_found: diff --git a/src/objectstate/field_value.py b/src/objectstate/field_value.py index 720ad6c..9c05139 100644 --- a/src/objectstate/field_value.py +++ b/src/objectstate/field_value.py @@ -9,8 +9,6 @@ class FieldValue(ABC): """Interpret a stored value once; consumers invoke its field operations.""" __slots__ = () - inherited = False - has_value = False @classmethod def of(cls, value: Any) -> FieldValue: @@ -18,12 +16,30 @@ def of(cls, value: Any) -> FieldValue: @staticmethod def is_inherited(value: Any) -> bool: - return isinstance(value, FieldValue) and value.inherited + return FieldValue.of(value).inherits() + + def available(self) -> bool: + """Whether this member supplies domain data, including literal None.""" + return False + + def inherits(self) -> bool: + return False + + def inherited_from(self, resolved: FieldValue) -> bool: + return False + + def present_to(self, display) -> None: + """Let a view paint this directive without inventing a datum.""" + display.present_directive(self) def map(self, convert: Callable[[Any], Any]) -> Any: """Convert domain data without converting an inheritance/absence directive.""" return self + def project(self, convert: Callable[[Any], Any]) -> Any: + """Pass the datum or directive itself to the view, without context lookup.""" + return convert(self.resolve(lambda: self)) + @abstractmethod def resolve(self, inherited: Callable[[], Any]) -> Any: """Read the effective value, requesting inheritance only when declared.""" @@ -57,7 +73,12 @@ class Inherit(FieldValue): """Continue the existing context/MRO lookup instead of supplying a value.""" __slots__ = () - inherited = True + + def inherits(self) -> bool: + return True + + def inherited_from(self, resolved: FieldValue) -> bool: + return resolved.available() def resolve(self, inherited: Callable[[], Any]) -> Any: return inherited() @@ -82,11 +103,16 @@ class Value(FieldValue): """A concrete domain value, including an explicitly stored None.""" __slots__ = ('value',) - has_value = True def __init__(self, value: Any): self.value = value + def available(self) -> bool: + return True + + def present_to(self, display) -> None: + display.present_value(self.value) + def resolve(self, inherited: Callable[[], Any]) -> Any: return self.value diff --git a/src/objectstate/inspection.py b/src/objectstate/inspection.py index 6ce7261..9d45509 100644 --- a/src/objectstate/inspection.py +++ b/src/objectstate/inspection.py @@ -1,17 +1,13 @@ """Read-only inspection of registered ObjectStates and their fields.""" from __future__ import annotations -from objectstate.field_value import FieldValue, Unset - -import logging +from objectstate.field_value import FieldValue, Value from collections.abc import Callable from dataclasses import dataclass from objectstate.object_state import FieldProxy, ObjectState from objectstate.object_state_registry import ObjectStateRegistry -logger = logging.getLogger(__name__) - DIRTY_MARKER = "*" """Marks a field (or a group of fields) whose value differs from the saved value.""" @@ -70,41 +66,25 @@ class FieldInspection: field: FieldProxy raw_value: object - resolved_value: object - resolved_value_available: bool + resolved: FieldValue provenance: FieldProvenance | None @classmethod def of(cls, field): """Capture one admitted field, never look up a caller-spelled path.""" - state = field.state - field_path = field._path - - resolved_value = Unset() - resolved_value_available = False - try: - resolved_value = field.value - resolved_value_available = FieldValue.of(resolved_value).has_value - except Exception: - logger.debug( - "Failed to resolve ObjectState field %s on scope %r.", - field_path, - state.scope_id, - exc_info=True, - ) + resolved = FieldValue.of(field.value) provenance = None match field.provenance: case (source_scope_id, source_type) if source_type is not None: provenance = FieldProvenance( source_scope_id=source_scope_id, source_type=source_type, - field_name=field_path.rsplit(".", 1)[-1], + field_name=field.name, ) return cls( field=field, raw_value=field.raw, - resolved_value=resolved_value, - resolved_value_available=resolved_value_available, + resolved=resolved, provenance=provenance, ) @@ -130,13 +110,13 @@ def raw_value_is_none(self) -> bool: @property def resolved_value_is_none(self) -> bool: - return self.resolved_value_available and self.resolved_value is None + return self.resolved == Value(None) @property def inherited_value(self) -> bool: """The field holds no value of its own and inherits a resolved one.""" - return FieldValue.is_inherited(self.raw_value) and self.resolved_value_available + return FieldValue.of(self.raw_value).inherited_from(self.resolved) @property def semantic_markers(self) -> tuple[str, ...]: @@ -161,7 +141,7 @@ def raw_value_type(self) -> str: @property def resolved_value_type(self) -> str | None: - return type_name(self.resolved_value) if self.resolved_value_available else None + return self.resolved.project(type_name) @property def declaring_type(self) -> str: diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 95b05a7..fc91895 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -1830,7 +1830,7 @@ def _normalize_callable_value(value: Any) -> Any: normalized.append(item) continue if isinstance(item, tuple) and len(item) == 2 and callable(item[0]) and isinstance(item[1], dict): - pruned_kwargs = {k: v for k, v in item[1].items() if FieldValue.of(v).has_value} + pruned_kwargs = {k: v for k, v in item[1].items() if FieldValue.of(v).available()} normalized.append(item[0] if not pruned_kwargs else (item[0], pruned_kwargs)) if len(normalized) == 1 and callable(normalized[0]): return normalized[0] diff --git a/src/objectstate/subfield_semantics.py b/src/objectstate/subfield_semantics.py index fdb87a6..a595958 100644 --- a/src/objectstate/subfield_semantics.py +++ b/src/objectstate/subfield_semantics.py @@ -231,7 +231,7 @@ def build_subfield_semantic_index( owner_field_path=owner_field_path, owner_dirty=owner_dirty, owner_signature_diff=owner_signature_diff, - owner_inherited_value=FieldValue.is_inherited(raw_value) and FieldValue.of(resolved_value).has_value, + owner_inherited_value=FieldValue.of(raw_value).inherited_from(FieldValue.of(resolved_value)), leaves=leaves, ) @@ -268,10 +268,10 @@ def _build_leaf( saved_resolved_value, signature_default_value, ) -> ObjectStateSubfieldSemantic: - raw_present = FieldValue.of(raw_value).has_value - resolved_present = FieldValue.of(resolved_value).has_value - saved_resolved_present = FieldValue.of(saved_resolved_value).has_value - signature_default_present = FieldValue.of(signature_default_value).has_value + raw_present = FieldValue.of(raw_value).available() + resolved_present = FieldValue.of(resolved_value).available() + saved_resolved_present = FieldValue.of(saved_resolved_value).available() + signature_default_present = FieldValue.of(signature_default_value).available() dirty = resolved_value != saved_resolved_value signature_diff = raw_value != signature_default_value inherited_value = not raw_present and resolved_present @@ -282,7 +282,7 @@ def _build_leaf( if signature_diff or inherited_value: markers.append("_") value_type = resolved_value if resolved_present else raw_value - value_type_name = type(value_type).__qualname__ if FieldValue.of(value_type).has_value else None + value_type_name = type(value_type).__qualname__ if FieldValue.of(value_type).available() else None return ObjectStateSubfieldSemantic( owner_field_path=owner_field_path, @@ -305,7 +305,7 @@ def _build_leaf( def _leaf_values(value) -> dict: - if not FieldValue.of(value).has_value: + if not FieldValue.of(value).available(): return {} return dict(_iter_leaf_values(value, StructuralValuePath())) diff --git a/tests/test_inspection.py b/tests/test_inspection.py index 97d5ddc..7dd469e 100644 --- a/tests/test_inspection.py +++ b/tests/test_inspection.py @@ -2,7 +2,9 @@ import pytest -from objectstate import ObjectState, ObjectStateRegistry, set_base_config_type +from objectstate import Inherit, LazyDataclassFactory, ObjectState, ObjectStateRegistry, Unset, set_base_config_type, set_global_config +from objectstate.field_value import Value +from objectstate.object_state import FieldProxy from objectstate.inspection import ( DIRTY_MARKER, SIGNATURE_DIFF_MARKER, @@ -15,7 +17,7 @@ @dataclass class Inspected: count: int = 1 - label: str = "x" + label: str | None = "x" @pytest.fixture(autouse=True) @@ -35,13 +37,11 @@ def test_markers_are_ordered_dirty_then_signature_difference(): def test_field_inspection_reports_value_edit_state_and_types(): state = ObjectState(Inspected(count=3), scope_id="/inspected") ObjectStateRegistry.register(state, _skip_snapshot=True) - state.update_parameter("label", "y") - - label = FieldInspection.of(state, "label") - count = FieldInspection.of(state, "count") - - assert (label.raw_value, label.resolved_value) == ("y", "y") - assert label.resolved_value_available + state.fields.label.write("y") + label = state.fields.label.inspect() + count = state.fields.count.inspect() + assert (label.raw_value, label.resolved) == ("y", Value("y")) + assert label.resolved.available() assert label.dirty and label.last_changed and label.semantic_markers[0] == DIRTY_MARKER assert count.signature_diff and not count.dirty assert count.semantic_markers == (SIGNATURE_DIFF_MARKER,) @@ -49,3 +49,34 @@ def test_field_inspection_reports_value_edit_state_and_types(): assert count.declaring_type.endswith(".Inspected") assert not count.inherited_value and count.provenance is None assert state_markers(state) == (DIRTY_MARKER, SIGNATURE_DIFF_MARKER) + + +@pytest.mark.parametrize("raw, expected", [(None, Value(None)), (Unset(), Unset()), (Inherit(), Value("global")), ("", Value(""))]) +def test_resolved_meaning_is_one_member_and_retains_literal_null(raw, expected): + set_global_config(Inspected, Inspected(label="global")) + lazy = LazyDataclassFactory.make_lazy_simple(Inspected) + inspected = ObjectState(lazy(label=raw)).fields.label.inspect() + assert inspected.raw_value == raw + assert inspected.resolved == expected + assert inspected.resolved_value_is_none == (expected == Value(None)) + assert inspected.inherited_value == isinstance(raw, Inherit) + + +@pytest.mark.parametrize("directive", [Inherit(), Unset()]) +def test_projection_sends_the_absent_member_to_the_view(directive): + seen = [] + assert directive.project(lambda value: seen.append(value) or value) is directive + assert seen == [directive] + assert Value(None).project(lambda value: value) is None + + +def test_resolver_failure_propagates_its_original_identity(): + failure = RuntimeError("failed real field read") + class FailedRead(FieldProxy): + @property + def value(self): + raise failure + state = ObjectState(Inspected()) + with pytest.raises(RuntimeError) as caught: + FieldInspection.of(FailedRead(state, "label")) + assert caught.value is failure diff --git a/tests/test_scopeless_state_resolution.py b/tests/test_scopeless_state_resolution.py index 4fa65ce..601c7e9 100644 --- a/tests/test_scopeless_state_resolution.py +++ b/tests/test_scopeless_state_resolution.py @@ -1,6 +1,6 @@ """An ``ObjectState`` without a scope id resolves its fields like one with a scope id. -Its own object is a layer of its context. A field left unset inherits from another +Its own object is a layer of its context. A field marked Inherit inherits from another config of the same object before it inherits from the global config, exactly as an attribute read on the object does, and the reported source is the object itself. """ @@ -10,6 +10,7 @@ import pytest from objectstate import ( + Inherit, LazyDataclassFactory, ObjectState, config_context, @@ -17,6 +18,7 @@ set_base_config_type, set_global_config, ) +from objectstate.field_value import Value from objectstate.inspection import FieldInspection @@ -55,55 +57,57 @@ class Pipeline: def state(request) -> ObjectState: set_base_config_type(GlobalConfig) set_global_config( - GlobalConfig, GlobalConfig(WellFilterConfig(well_filter=1, mode="global")) + GlobalConfig, GlobalConfig( + WellFilterConfig(well_filter=1, mode="global"), + path_planning_config=LazyPathPlanningConfig(), + ) ) return ObjectState(Pipeline(), scope_id=request.param) -def test_unset_field_resolves_to_the_global_value_with_the_global_source(state): - assert state.parameters[OWN] is None - assert state.get_resolved_value(OWN) == 1 - assert state.get_provenance(OWN) == ("", WellFilterConfig) +def test_inherited_field_resolves_to_the_global_value_with_the_global_source(state): + own = state.fields.path_planning_config.well_filter + assert isinstance(own.raw, Inherit) + assert own.value == 1 + assert own.provenance == ("", WellFilterConfig) -def test_unset_field_inherits_from_a_sibling_config_of_the_same_object(state): +def test_inherited_field_inherits_from_a_sibling_config_of_the_same_object(state): """The case the OpenHCS dropdown-flash test met with a state without a scope id.""" - state.update_parameter(SIBLING, 7) - - assert state.parameters[OWN] is None - assert state.get_resolved_value(OWN) == 7 - assert state.get_provenance(OWN) == (state.scope_id, WellFilterConfig) - assert state.get_resolved_value("path_planning_config.mode") == "global" - assert state.get_provenance("path_planning_config.mode") == ("", WellFilterConfig) + state.fields.well_filter_config.well_filter.write(7) + own = state.fields.path_planning_config.well_filter + assert isinstance(own.raw, Inherit) + assert own.value == 7 + assert own.provenance == (state.scope_id, WellFilterConfig) + assert state.fields.path_planning_config.mode.value == "global" + assert state.fields.path_planning_config.mode.provenance == ("", WellFilterConfig) def test_state_reports_what_an_attribute_read_on_its_object_returns(state): - state.update_parameter(SIBLING, 7) + state.fields.well_filter_config.well_filter.write(7) obj = state.to_object() with config_context(obj): read = obj.path_planning_config.well_filter - assert read == state.get_resolved_value(OWN) == 7 + assert read == state.fields.path_planning_config.well_filter.value == 7 def test_value_set_on_the_field_wins_and_has_no_inherited_source(state): - state.update_parameter(SIBLING, 7) - state.update_parameter(OWN, 9) - - assert state.parameters[OWN] == 9 - assert state.get_resolved_value(OWN) == 9 - assert state.get_provenance(OWN) is None - assert state.get_resolved_value(SIBLING) == 7 + state.fields.well_filter_config.well_filter.write(7) + own = state.fields.path_planning_config.well_filter + own.write(9) + assert own.raw == 9 + assert own.value == 9 + assert own.provenance is None + assert state.fields.well_filter_config.well_filter.value == 7 def test_inspection_shows_the_inherited_value_with_its_source(state): - state.update_parameter(SIBLING, 7) - - inspection = FieldInspection.of(state, OWN) - - assert inspection.raw_value is None - assert inspection.resolved_value == 7 + state.fields.well_filter_config.well_filter.write(7) + inspection = FieldInspection.of(state.fields.path_planning_config.well_filter) + assert isinstance(inspection.raw_value, Inherit) + assert inspection.resolved == Value(7) assert inspection.provenance is not None assert inspection.provenance.source_scope_id == state.scope_id assert inspection.provenance.source_type is WellFilterConfig From 14798847e4c47790f9e8ecb1a7d28f3ca380f9d3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 11:54:42 -0400 Subject: [PATCH 11/15] Keep inspected raw and resolved meanings nominal through declared JSON members --- docs/declared-field-state.md | 7 +++ src/objectstate/codegen.py | 8 +--- src/objectstate/context_manager.py | 10 ++-- src/objectstate/field_access.py | 12 +++++ src/objectstate/field_value.py | 6 +++ src/objectstate/inspection.py | 20 +++----- src/objectstate/live_context_resolver.py | 17 ++----- src/objectstate/object_state.py | 10 ++-- src/objectstate/object_state_registry.py | 5 +- src/objectstate/parameter_structure.py | 15 +++--- src/objectstate/placeholder.py | 3 +- src/objectstate/subfield_semantics.py | 8 +--- tests/test_codegen.py | 12 +++-- tests/test_field_value_wire.py | 59 ++++++++++++++++++++++++ tests/test_inspection.py | 6 +-- tests/test_scopeless_state_resolution.py | 2 +- 16 files changed, 133 insertions(+), 67 deletions(-) create mode 100644 tests/test_field_value_wire.py diff --git a/docs/declared-field-state.md b/docs/declared-field-state.md index 155856d..7f48112 100644 --- a/docs/declared-field-state.md +++ b/docs/declared-field-state.md @@ -17,6 +17,13 @@ layer comparison use stored reads rather than implicit effective reads. The resolver owns context/MRO selection and provenance: an inherited value continues that selection; concrete None stops it. +Inspection retains `raw` and `resolved` as `FieldValue` members. The family is +dataclass-declared, so the existing declaration-aware JSON codec preserves +`Value(None)`, `Inherit()` and `Unset()` with distinct member tags. Consumers +invoke member behavior rather than transporting a value plus presence flags. +`DataclassFieldAccess.is_container` and `is_config_type` distinguish domain +configuration structure from these meaning members. + Lazy constructor defaults are `Inherit()` without changing whether the domain annotation permits None. Required parameters without a value use `Unset()`. Reset restores the declaration default; saved values are a separate baseline. diff --git a/src/objectstate/codegen.py b/src/objectstate/codegen.py index a6de789..06ab53b 100644 --- a/src/objectstate/codegen.py +++ b/src/objectstate/codegen.py @@ -16,7 +16,7 @@ from __future__ import annotations -from dataclasses import MISSING, Field, fields, is_dataclass +from dataclasses import MISSING, Field, fields from pycodify import ( FormatContext, @@ -37,14 +37,10 @@ def _set_field_names(value: LazyDataclass) -> frozenset[str]: -def _is_instance_of_dataclass(value: object) -> bool: - return is_dataclass(value) and not isinstance(value, type) - - def raw_values_equal(left: object, right: object) -> bool: """Whether two values are equal, comparing dataclasses by raw field values.""" - if _is_instance_of_dataclass(left) and _is_instance_of_dataclass(right): + if DataclassFieldAccess.is_container(left) and DataclassFieldAccess.is_container(right): if type(left) is not type(right): return False return all( diff --git a/src/objectstate/context_manager.py b/src/objectstate/context_manager.py index f156339..a3241dd 100644 --- a/src/objectstate/context_manager.py +++ b/src/objectstate/context_manager.py @@ -103,7 +103,7 @@ def _merge_nested_dataclass(base, override): def _merge_concrete_dataclass(base, override): - if not is_dataclass(base) or not is_dataclass(override): + if not DataclassFieldAccess.is_container(base) or not DataclassFieldAccess.is_container(override): return override from objectstate.lazy_factory import get_base_type_for_lazy, replace_raw base_type = get_base_type_for_lazy(type(base)) or type(base) @@ -129,7 +129,7 @@ def config_context(obj, use_live_global: bool = True, scope_id: str | None = Non base = current if current is not None else get_base_global_config(use_live=use_live_global) overrides = {} if obj is not None: - read = DataclassFieldAccess.raw_value if is_dataclass(type(obj)) else getattr + read = DataclassFieldAccess.raw_value if DataclassFieldAccess.is_container(obj) else getattr for declaration in fields(get_base_config_type()): try: value = read(obj, declaration.name) @@ -581,11 +581,11 @@ def extract_all_configs(context_obj) -> Dict[type, Any]: configs: Dict[type, Any] = {} # Include the context object itself if it's a dataclass - if is_dataclass(context_obj): + if DataclassFieldAccess.is_container(context_obj): _record_config_instance(configs, context_obj, source="context object") # Type-driven extraction: Use dataclass field annotations to find config fields - if is_dataclass(type(context_obj)): + if DataclassFieldAccess.is_container(context_obj): for field_name, declared_type in _declared_config_fields(type(context_obj)): # Only concrete values contribute instances. Inherit/Unset do not # recursively ask the context whose membership is being collected. @@ -650,7 +650,7 @@ def _extract_from_object_attributes_typed(obj, configs: Dict[type, Any]) -> None attr_value = getattr(obj, attr_name) except (AttributeError, TypeError): continue - if attr_value is not None and is_dataclass(attr_value): + if DataclassFieldAccess.is_container(attr_value): _record_config_instance( configs, attr_value, diff --git a/src/objectstate/field_access.py b/src/objectstate/field_access.py index 0d1514d..3acd540 100644 --- a/src/objectstate/field_access.py +++ b/src/objectstate/field_access.py @@ -82,6 +82,18 @@ class DataclassFieldAccess: _SLOT_DESCRIPTOR_TYPES = (MemberDescriptorType, GetSetDescriptorType) + @staticmethod + def is_config_type(value_type) -> bool: + """A dataclass declaration for domain data, not for stored meaning.""" + from objectstate.field_value import FieldValue + + return isinstance(value_type, type) and is_dataclass(value_type) and not issubclass(value_type, FieldValue) + + @classmethod + def is_container(cls, value) -> bool: + """A domain configuration instance, never a class or a meaning member.""" + return not isinstance(value, type) and cls.is_config_type(type(value)) + @staticmethod def field_names(instance_or_type) -> frozenset[str]: dataclass_type = instance_or_type if isinstance(instance_or_type, type) else type(instance_or_type) diff --git a/src/objectstate/field_value.py b/src/objectstate/field_value.py index 9c05139..32e92bd 100644 --- a/src/objectstate/field_value.py +++ b/src/objectstate/field_value.py @@ -2,9 +2,11 @@ from __future__ import annotations from abc import ABC, abstractmethod +from dataclasses import dataclass from typing import Any, Callable +@dataclass(eq=False, repr=False) class FieldValue(ABC): """Interpret a stored value once; consumers invoke its field operations.""" @@ -69,6 +71,7 @@ def source(self, context): return SourceFragment(f"{reference.code}()", reference.imports) +@dataclass(eq=False, repr=False) class Inherit(FieldValue): """Continue the existing context/MRO lookup instead of supplying a value.""" @@ -87,6 +90,7 @@ def overlay(self, base: Any, merge: Callable[[Any, Any], Any]) -> Any: return base +@dataclass(eq=False, repr=False) class Unset(FieldValue): """No value is available. This is neither None nor an inheritance request.""" @@ -99,10 +103,12 @@ def overlay(self, base: Any, merge: Callable[[Any, Any], Any]) -> Any: return self +@dataclass(eq=False, repr=False) class Value(FieldValue): """A concrete domain value, including an explicitly stored None.""" __slots__ = ('value',) + value: Any def __init__(self, value: Any): self.value = value diff --git a/src/objectstate/inspection.py b/src/objectstate/inspection.py index 9d45509..6b929ba 100644 --- a/src/objectstate/inspection.py +++ b/src/objectstate/inspection.py @@ -1,7 +1,7 @@ """Read-only inspection of registered ObjectStates and their fields.""" from __future__ import annotations -from objectstate.field_value import FieldValue, Value +from objectstate.field_value import FieldValue from collections.abc import Callable from dataclasses import dataclass @@ -65,7 +65,7 @@ class FieldInspection: """One field of one ObjectState: raw and resolved value, edit state and provenance.""" field: FieldProxy - raw_value: object + raw: FieldValue resolved: FieldValue provenance: FieldProvenance | None @@ -83,7 +83,7 @@ def of(cls, field): ) return cls( field=field, - raw_value=field.raw, + raw=FieldValue.of(field.raw), resolved=resolved, provenance=provenance, ) @@ -102,21 +102,13 @@ def last_changed(self) -> bool: @property def raw_value_is_inherited(self) -> bool: - return FieldValue.is_inherited(self.raw_value) - - @property - def raw_value_is_none(self) -> bool: - return self.raw_value is None - - @property - def resolved_value_is_none(self) -> bool: - return self.resolved == Value(None) + return self.raw.inherits() @property def inherited_value(self) -> bool: """The field holds no value of its own and inherits a resolved one.""" - return FieldValue.of(self.raw_value).inherited_from(self.resolved) + return self.raw.inherited_from(self.resolved) @property def semantic_markers(self) -> tuple[str, ...]: @@ -137,7 +129,7 @@ def container_path(self) -> str: @property def raw_value_type(self) -> str: - return type_name(self.raw_value) + return self.raw.project(type_name) @property def resolved_value_type(self) -> str | None: diff --git a/src/objectstate/live_context_resolver.py b/src/objectstate/live_context_resolver.py index 7224db2..28085c0 100644 --- a/src/objectstate/live_context_resolver.py +++ b/src/objectstate/live_context_resolver.py @@ -12,7 +12,7 @@ """ import logging -from dataclasses import is_dataclass +from objectstate.field_access import DataclassFieldAccess from typing import Any from objectstate.context_manager import config_context @@ -149,19 +149,12 @@ def resolve_in_context(contexts_remaining): return resolve_in_context(merged_contexts) def _merge_live_values(self, base_obj: object, live_values: dict[str, Any] | None) -> object: - """Merge live values into base object. + """Merge stored live values without resolving or changing their meaning. - CRITICAL: Passes None values through to dataclasses.replace(). When a field is reset - to None in a form, the None value should override the saved concrete value in the - base object. This allows the lazy resolution system to walk up the MRO to find the - inherited value from parent context. - - Example: PipelineConfig form resets partition_filter_config.enabled to None - → dataclasses.replace(saved_pipeline_config, - partition_filter_config=LazyPartitionFilterConfig(enabled=None)) - → When resolving enabled, the None triggers MRO walk to GlobalPipelineConfig + Literal null overrides saved data. Only Inherit requests context/MRO + selection; neither directive is a nested configuration container. """ - if live_values is None or not is_dataclass(base_obj): + if live_values is None or not DataclassFieldAccess.is_container(base_obj): return base_obj # Reconstruct nested dataclasses recursively diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index fc91895..e842526 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -1045,7 +1045,7 @@ def reset_all_parameters(self) -> None: def _dataclass_parameter_updates(self, prefix: str, value: Any) -> dict[str, Any]: """Flatten a dataclass container update into registered child parameters.""" - if value is None or not is_dataclass(type(value)): + if not DataclassFieldAccess.is_container(value): return {} prefix_dot = f"{prefix}." @@ -1999,7 +1999,7 @@ def resolve(path): # Read the effective parent, never retained drafts under a # null, absent or inherited container. Its own nominal type # supplies inherited defaults for this source's child. - if is_dataclass(parent): + if DataclassFieldAccess.is_container(parent): raw = DataclassFieldAccess.raw_value(parent, address.field_name) owner = type(parent) else: @@ -2360,12 +2360,12 @@ def _extract_all_parameters_flat( try: current_value = ( DataclassFieldAccess.raw_value(obj, param_name) - if is_dataclass(type(obj)) else getattr(obj, param_name) + if DataclassFieldAccess.is_container(obj) else getattr(obj, param_name) ) except AttributeError: current_value = info.default_value - if is_dataclass(type(current_value)): + if DataclassFieldAccess.is_container(current_value): # Store the nested dataclass instance in parameters (needed for UI rendering) parameters[dotted_path] = current_value @@ -2481,7 +2481,7 @@ def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True for field_name, field_value in field_updates.items(): setattr(target, field_name, field_value) reconstructed = target - elif is_dataclass(target): + elif DataclassFieldAccess.is_container(target): # CRITICAL: Use replace_raw to preserve raw None values! # dataclasses.replace triggers lazy resolution via __getattribute__, # which resolves None -> concrete defaults and breaks inheritance. diff --git a/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index a960921..b6f6d94 100644 --- a/src/objectstate/object_state_registry.py +++ b/src/objectstate/object_state_registry.py @@ -5,7 +5,8 @@ from contextlib import contextmanager from contextvars import ContextVar, Token -from dataclasses import dataclass, is_dataclass, replace +from dataclasses import dataclass, replace +from objectstate.field_access import DataclassFieldAccess import logging from typing import ( Any, @@ -1717,7 +1718,7 @@ def _time_travel_changed_param_values( for param_key in set(target_params.keys()) | set(current_params.keys()): before = current_params.get(param_key) after = target_params.get(param_key) - if is_dataclass(before) or is_dataclass(after): + if DataclassFieldAccess.is_container(before) or DataclassFieldAccess.is_container(after): continue if before != after: changed[param_key] = (before, after) diff --git a/src/objectstate/parameter_structure.py b/src/objectstate/parameter_structure.py index 3953f19..98a166f 100644 --- a/src/objectstate/parameter_structure.py +++ b/src/objectstate/parameter_structure.py @@ -3,12 +3,12 @@ from __future__ import annotations import copy -from dataclasses import dataclass, field, fields, is_dataclass, replace +from dataclasses import dataclass, field, fields, replace from typing import Any from python_introspect import ParameterInfo -from objectstate.field_access import DottedFieldPath +from objectstate.field_access import DataclassFieldAccess, DottedFieldPath from objectstate.field_value import Unset from objectstate.parameter_owner import ParameterOwner @@ -42,7 +42,7 @@ def binding_owner(self, path: str) -> ParameterOwner: def container_types(self, values: dict[str, Any]): """Nominal payload types come from the projection, not declaration owners.""" for path, value in values.items(): - if is_dataclass(value): + if DataclassFieldAccess.is_container(value): yield path, type(value) def affected_paths(self, changed): @@ -73,7 +73,7 @@ def is_container_parameter( ) -> bool: """Identify a nested dataclass entry from its actual extracted children.""" - if value is None or not is_dataclass(type(value)): + if not DataclassFieldAccess.is_container(value): return False prefix = f"{name}." return any(path.startswith(prefix) for path in parameters) @@ -111,11 +111,10 @@ def reconstruct(self, prefix, values, target, *, resolved=False): editor retains child drafts. Nominal type comes from that payload, never from the declaration-owner index. Live and saved projections share this. """ - from objectstate.field_access import DataclassFieldAccess from objectstate.lazy_factory import get_base_type_for_lazy, replace_raw prototype = values.get(prefix, Unset()) if prefix else target - if prefix and not is_dataclass(prototype): + if prefix and not DataclassFieldAccess.is_container(prototype): return prototype prefix_dot = f"{prefix}." if prefix else "" direct = {} @@ -127,11 +126,11 @@ def reconstruct(self, prefix, values, target, *, resolved=False): continue direct[remainder] = ( self.reconstruct(path, values, target, resolved=resolved) - if is_dataclass(value) else value + if DataclassFieldAccess.is_container(value) else value ) if not prefix: direct.update(self.excluded_values) - if is_dataclass(prototype): + if DataclassFieldAccess.is_container(prototype): if not resolved: return replace_raw(prototype, **direct) constructor = get_base_type_for_lazy(type(prototype)) or type(prototype) diff --git a/src/objectstate/placeholder.py b/src/objectstate/placeholder.py index f1e4668..fcbcf94 100644 --- a/src/objectstate/placeholder.py +++ b/src/objectstate/placeholder.py @@ -7,6 +7,7 @@ from typing import Any, Optional import dataclasses +from objectstate.field_access import DataclassFieldAccess import logging from objectstate.lazy_factory import has_lazy_resolution @@ -137,7 +138,7 @@ def _format_nested_dataclass_summary(dataclass_instance) -> str: formatted_value = f"{type(value).__name__}.{value.name}" elif isinstance(value, str) and len(value) > 20: # Long strings formatted_value = f"{value[:17]}..." - elif dataclasses.is_dataclass(value): # Nested dataclass + elif DataclassFieldAccess.is_container(value): formatted_value = f"{value.__class__.__name__}(...)" else: formatted_value = str(value) diff --git a/src/objectstate/subfield_semantics.py b/src/objectstate/subfield_semantics.py index a595958..db6bc31 100644 --- a/src/objectstate/subfield_semantics.py +++ b/src/objectstate/subfield_semantics.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass, fields as dataclass_fields, is_dataclass +from dataclasses import dataclass, fields as dataclass_fields from enum import Enum from typing import Generic, TypeVar @@ -311,7 +311,7 @@ def _leaf_values(value) -> dict: def _iter_leaf_values(value, path: StructuralValuePath): - if _is_dataclass_instance(value): + if DataclassFieldAccess.is_container(value): for dataclass_field in dataclass_fields(type(value)): child_path = path.child_field(dataclass_field.name) child_value = DataclassFieldAccess.raw_value(value, dataclass_field.name) @@ -335,7 +335,3 @@ def _ordered_paths(*leaf_sets: dict) -> tuple[StructuralValuePath, ...]: seen.add(path) ordered.append(path) return tuple(ordered) - - -def _is_dataclass_instance(value) -> bool: - return is_dataclass(value) and not isinstance(value, type) diff --git a/tests/test_codegen.py b/tests/test_codegen.py index 0c9bcff..eedfa8a 100644 --- a/tests/test_codegen.py +++ b/tests/test_codegen.py @@ -67,9 +67,13 @@ def test_source_loads_back_with_equal_raw_values(value, clean_mode): assert _raw(loaded) == _raw(value) -def test_clean_source_omits_inherited_fields_and_empty_nested_configs(): - source, _ = _load(LazyOuter(name="b", inner=LazyInner(depth=None)), clean_mode=True) +def test_clean_source_preserves_literal_null_and_explicit_empty_nested_configs(): + source, loaded = _load(LazyOuter(name="b", inner=LazyInner(depth=None)), clean_mode=True) assert "name='b'" in source + assert "depth=None" in source + assert DataclassFieldAccess.raw_value(DataclassFieldAccess.raw_value(loaded, "inner"), "depth") is None + + source, _ = _load(LazyOuter(name="b", inner=LazyInner()), clean_mode=True) assert "inner=LazyInner()" in source assert "depth" not in source @@ -79,7 +83,7 @@ def test_clean_source_omits_inherited_fields_and_empty_nested_configs(): def test_full_source_writes_every_raw_field(): source, _ = _load(LazyInner(mode=Speed.Mode.SLOW), clean_mode=False) - assert "depth=None" in source + assert "depth=Inherit()" in source assert "mode=Speed.Mode.SLOW" in source @@ -95,7 +99,7 @@ def test_field_needs_source_compares_lazy_raw_values(): @dataclass class Inheriting(LazyResolutionDataclass): - """A base config whose None fields resolve elsewhere.""" + """A custom accessor transforms null; source still owns the stored datum.""" mode: str | None = None size: int = 4 diff --git a/tests/test_field_value_wire.py b/tests/test_field_value_wire.py new file mode 100644 index 0000000..41181f0 --- /dev/null +++ b/tests/test_field_value_wire.py @@ -0,0 +1,59 @@ +"""Meaning stays nominal through the existing declaration-aware JSON codec.""" +import json +from dataclasses import dataclass + +import pytest +from python_introspect import dataclass_from_mapping, to_jsonable + +from objectstate.field_access import DataclassFieldAccess +from objectstate.field_value import FieldValue, Inherit, Unset, Value +from objectstate.parameter_structure import ParameterStructure + + +@dataclass +class Meanings: + raw: FieldValue + resolved: FieldValue + + +@pytest.mark.parametrize("member, stored", [ + (Value(None), {"Value": {"value": None}}), + (Value(False), {"Value": {"value": False}}), + (Value(0), {"Value": {"value": 0}}), + (Value(""), {"Value": {"value": ""}}), + (Value("None"), {"Value": {"value": "None"}}), + (Value("Unset()"), {"Value": {"value": "Unset()"}}), + (Inherit(), {"Inherit": {}}), + (Unset(), {"Unset": {}}), +]) +def test_member_tags_preserve_null_directives_and_literal_data(member, stored): + original = Meanings(member, member) + wire = json.loads(json.dumps(to_jsonable(original))) + assert wire == {"raw": stored, "resolved": stored} + decoded = dataclass_from_mapping(Meanings, wire) + assert decoded == original + assert type(decoded.raw) is type(member) + assert type(decoded.resolved) is type(member) + + +@pytest.mark.parametrize("wire", [None, {}, {"Unknown": {}}, {"Value": {}}, {"Inherit": {"value": None}}]) +def test_missing_or_untyped_members_do_not_become_literal_null(wire): + with pytest.raises((TypeError, ValueError)): + dataclass_from_mapping(Meanings, {"raw": wire, "resolved": {"Unset": {}}}) + + +@dataclass +class EmptyConfig: + pass + + +@pytest.mark.parametrize("member", [Inherit(), Unset(), Value(None)]) +def test_meaning_dataclasses_are_not_configuration_containers(member): + assert not DataclassFieldAccess.is_container(member) + assert not DataclassFieldAccess.is_config_type(type(member)) + assert DataclassFieldAccess.is_container(EmptyConfig()) + assert DataclassFieldAccess.is_config_type(EmptyConfig) + structure = ParameterStructure() + values = {"child": member, "child.retained_draft": 99, "actual": EmptyConfig()} + assert structure.reconstruct("child", values, EmptyConfig()) is member + assert list(structure.container_types(values)) == [("actual", EmptyConfig)] diff --git a/tests/test_inspection.py b/tests/test_inspection.py index 7dd469e..e9c6952 100644 --- a/tests/test_inspection.py +++ b/tests/test_inspection.py @@ -40,7 +40,7 @@ def test_field_inspection_reports_value_edit_state_and_types(): state.fields.label.write("y") label = state.fields.label.inspect() count = state.fields.count.inspect() - assert (label.raw_value, label.resolved) == ("y", Value("y")) + assert (label.raw, label.resolved) == (Value("y"), Value("y")) assert label.resolved.available() assert label.dirty and label.last_changed and label.semantic_markers[0] == DIRTY_MARKER assert count.signature_diff and not count.dirty @@ -56,9 +56,9 @@ def test_resolved_meaning_is_one_member_and_retains_literal_null(raw, expected): set_global_config(Inspected, Inspected(label="global")) lazy = LazyDataclassFactory.make_lazy_simple(Inspected) inspected = ObjectState(lazy(label=raw)).fields.label.inspect() - assert inspected.raw_value == raw + from objectstate.field_value import FieldValue + assert inspected.raw == FieldValue.of(raw) assert inspected.resolved == expected - assert inspected.resolved_value_is_none == (expected == Value(None)) assert inspected.inherited_value == isinstance(raw, Inherit) diff --git a/tests/test_scopeless_state_resolution.py b/tests/test_scopeless_state_resolution.py index 601c7e9..0fa39f5 100644 --- a/tests/test_scopeless_state_resolution.py +++ b/tests/test_scopeless_state_resolution.py @@ -106,7 +106,7 @@ def test_value_set_on_the_field_wins_and_has_no_inherited_source(state): def test_inspection_shows_the_inherited_value_with_its_source(state): state.fields.well_filter_config.well_filter.write(7) inspection = FieldInspection.of(state.fields.path_planning_config.well_filter) - assert isinstance(inspection.raw_value, Inherit) + assert isinstance(inspection.raw, Inherit) assert inspection.resolved == Value(7) assert inspection.provenance is not None assert inspection.provenance.source_scope_id == state.scope_id From 93eae56ffeb3c27a298c1e459308ce5a22d6eef8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 15:22:29 -0400 Subject: [PATCH 12/15] Compile declared value behavior and remove runtime payload classification --- docs/declared-field-state.md | 18 +- src/objectstate/__init__.py | 2 - src/objectstate/codegen.py | 13 +- src/objectstate/construction_binding.py | 111 +++- src/objectstate/context_manager.py | 245 ++------- src/objectstate/declarations.py | 436 ++++++++++++++++ src/objectstate/dual_axis_resolver.py | 81 +-- src/objectstate/field_access.py | 30 +- src/objectstate/field_value.py | 385 ++++++++++++-- src/objectstate/inspection.py | 4 +- src/objectstate/lazy_factory.py | 348 ++++--------- src/objectstate/live_context_resolver.py | 21 +- src/objectstate/object_state.py | 634 ++++++----------------- src/objectstate/object_state_registry.py | 20 +- src/objectstate/parameter_structure.py | 97 ++-- src/objectstate/placeholder.py | 166 +----- src/objectstate/resolution_sources.py | 58 +-- src/objectstate/subfield_semantics.py | 21 +- src/objectstate/ui_visibility.py | 10 +- src/objectstate/value_semantics.py | 42 +- tests/test_declarations.py | 72 +++ tests/test_inspection.py | 2 +- tests/test_value_node_deletion_gates.py | 31 ++ 23 files changed, 1434 insertions(+), 1413 deletions(-) create mode 100644 src/objectstate/declarations.py create mode 100644 tests/test_declarations.py create mode 100644 tests/test_value_node_deletion_gates.py diff --git a/docs/declared-field-state.md b/docs/declared-field-state.md index 7f48112..2629b0c 100644 --- a/docs/declared-field-state.md +++ b/docs/declared-field-state.md @@ -21,8 +21,22 @@ Inspection retains `raw` and `resolved` as `FieldValue` members. The family is dataclass-declared, so the existing declaration-aware JSON codec preserves `Value(None)`, `Inherit()` and `Unset()` with distinct member tags. Consumers invoke member behavior rather than transporting a value plus presence flags. -`DataclassFieldAccess.is_container` and `is_config_type` distinguish domain -configuration structure from these meaning members. +`ValueDeclaration.of(annotation)` compiles field behavior from the resolved +annotation, not from its payload. Its `admit(value)` preserves directives and +selects configuration, nullable, scalar and collection behavior. Leaves have no +configuration-child interface. Root constructor/function/instance namespaces +are admitted separately through `construction_binding.admit_target`. + +`ParameterOwnerDeclaration` owns shared constructor declarations. Configuration +rebuilding outside keyword initialization requires the explicit +`ConfigurationRebuild.from_fields` capability. `InheritedDefault` is the single +metadata key for inherited defaults; `ValueDefault` and `FactoryDefault` own +materialization. Unknown default names raise `KeyError`; MRO traversal selects +only declarations that actually declare the field. + +Type normalization uses python-introspect's public `resolve_declared_type`. +LazyFactory supplies its registered relation once; consumers do not repeat lazy +lookups, fallback expressions or normalization caches. Lazy constructor defaults are `Inherit()` without changing whether the domain annotation permits None. Required parameters without a value use `Unset()`. diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index 92eee5c..a06aa64 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -56,7 +56,6 @@ LazyDataclassFactory, auto_create_decorator, register_lazy_type_mapping, - get_base_type_for_lazy, get_lazy_type_for_base, # Constructor patching for code execution # Global config type checking @@ -146,7 +145,6 @@ 'LazyDataclassFactory', 'auto_create_decorator', 'register_lazy_type_mapping', - 'get_base_type_for_lazy', 'get_lazy_type_for_base', # Stored field meaning 'FieldValue', diff --git a/src/objectstate/codegen.py b/src/objectstate/codegen.py index 06ab53b..9ed214f 100644 --- a/src/objectstate/codegen.py +++ b/src/objectstate/codegen.py @@ -40,18 +40,7 @@ def _set_field_names(value: LazyDataclass) -> frozenset[str]: def raw_values_equal(left: object, right: object) -> bool: """Whether two values are equal, comparing dataclasses by raw field values.""" - if DataclassFieldAccess.is_container(left) and DataclassFieldAccess.is_container(right): - if type(left) is not type(right): - return False - return all( - raw_values_equal( - DataclassFieldAccess.raw_value(left, field.name), - DataclassFieldAccess.raw_value(right, field.name), - ) - for field in fields(left) - if field.init - ) - return left == right + return FieldValue.of(left).source_equals(FieldValue.of(right)) def field_needs_source(current: object, default: object, context: FormatContext) -> bool: diff --git a/src/objectstate/construction_binding.py b/src/objectstate/construction_binding.py index 10ec908..79aee09 100644 --- a/src/objectstate/construction_binding.py +++ b/src/objectstate/construction_binding.py @@ -6,11 +6,118 @@ from collections.abc import Collection from dataclasses import dataclass from typing import Any, TYPE_CHECKING +from inspect import isclass, isroutine + +from objectstate.field_value import FieldValue, ConfigValue, Unset +from objectstate.declarations import ValueDeclaration if TYPE_CHECKING: from objectstate.object_state import ObjectState +class DeclarationTarget: + """A real root namespace, not a synthetic field or a data-kind guess.""" + + def __init__(self, value, declaration): + self.value = value + self.declaration = declaration + + @property + def stored(self): + return self.value + + @property + def parameter_owner(self): + return self.declaration.declaring_type + + def read_path(self, path): + current = self + for name in path.split('.') if path else (): + current = current.read_raw(name) + return current + + def extract_parameters(self, state_type, parameters, structure, prefix='', exclusions=()): + for name, info in self.declaration.parameters.items(): + if name in exclusions: + continue + path = f'{prefix}.{name}' if prefix else name + structure.declare(path, info, self.parameter_owner) + member = self.read_raw(name) + parameters[path] = member + self.declaration.field_types[name].extract(member, state_type, parameters, structure, path) + + def reconstruct(self, prefix, values, structure, target, *, resolved=False): + return self.with_fields(structure.direct_values(prefix, values, target, resolved=resolved), resolved=resolved) + + def overlay_fields(self, values): + return self.with_fields(values) + + def context_configs(self): + configs = {} + for name, declaration in self.declaration.context_field_types.items(): + declaration.contribute(self.read_raw(name), configs, source=f'{self.parameter_owner.__name__}.{name}') + return configs + + def seed_callable_identity(self, memo, seen): + for name in self.declaration.parameters: + self.read_raw(name).seed_callable_identity(memo, seen) + + +class InstanceTarget(DeclarationTarget): + def context_configs(self): + configs = super().context_configs() + for owner in self.declaration.configuration_owners(): + owner.admit_data(self.value).contribute_config(configs, source='context object') + return configs + + def read_raw(self, name): + return self.declaration.read_raw(self.value, name) + + def with_fields(self, values, *, resolved=False): + return type(self)(self.declaration.construct_from(self.value, values, resolved=resolved), self.declaration) + + +class ClassTarget(DeclarationTarget): + def read_raw(self, name): + info = self.declaration.parameters[name] + return Unset() if info.is_required else self.declaration.field_types[name].admit(info.default_value) + + def with_fields(self, values, *, resolved=False): + return InstanceTarget(self.declaration.construct({name: member.stored for name, member in values.items()}, resolved=resolved), self.declaration) + + +class FunctionTarget(ClassTarget): + def read_raw(self, name): + info = self.declaration.parameters[name] + attributes = vars(self.value) + if name in attributes: + return self.declaration.field_types[name].admit(attributes[name]) + return Unset() if info.is_required else self.declaration.field_types[name].admit(info.default_value) + + def with_fields(self, values, *, resolved=False): + return ConfiguredFunctionTarget(self.value, self.declaration, values) + + +class ConfiguredFunctionTarget(FunctionTarget): + def __init__(self, value, declaration, values): + super().__init__(value, declaration) + self.values = values + + def read_raw(self, name): + return self.values[name] + + +def admit_target(value): + """Decode a root declaration once; nested data uses its field annotation.""" + if isinstance(value, (DeclarationTarget, ConfigValue)): + return value + if isclass(value): + return ClassTarget(value, ValueDeclaration.of(value).owner_scope) + if isroutine(value): + return FunctionTarget(value, ValueDeclaration.of(value).owner_scope) + return InstanceTarget(value, ValueDeclaration.of(type(value)).owner_scope) + + class StateScopeOwner(ABC): """A declaration that authoritatively owns independently scoped states.""" @@ -95,7 +202,7 @@ def validate_lifecycle(self, state: ObjectState | None, scope: str) -> None: def attach(self, state: ObjectState) -> None: state.object_instance = self.target - state._extraction_target = self.target + state._target = admit_target(self.target) state._delegate_attr = None @@ -119,5 +226,5 @@ def validate_lifecycle(self, state: ObjectState | None, scope: str) -> None: def attach(self, state: ObjectState) -> None: setattr(state.object_instance, self.delegate_attribute, self.target) - state._extraction_target = self.target + state._target = admit_target(self.target) state._delegate_attr = self.delegate_attribute diff --git a/src/objectstate/context_manager.py b/src/objectstate/context_manager.py index a3241dd..cc68b21 100644 --- a/src/objectstate/context_manager.py +++ b/src/objectstate/context_manager.py @@ -23,10 +23,12 @@ from collections.abc import Sequence from contextlib import ExitStack, contextmanager from typing import Any, Dict, get_type_hints -from dataclasses import fields, is_dataclass from python_introspect import resolve_annotated, resolve_optional from objectstate.field_access import DataclassFieldAccess from objectstate.field_value import FieldValue +from objectstate.declarations import ValueDeclaration, admit_root_value +from objectstate.construction_binding import admit_target +from python_introspect import resolve_declared_type logger = logging.getLogger(__name__) @@ -50,11 +52,11 @@ class ContextLayer: ``scope_id`` is ``None`` for a layer entered without one. """ - __slots__ = ("scope_id", "obj", "_configs") + __slots__ = ("scope_id", "value", "_configs") def __init__(self, scope_id: str | None, obj: Any): self.scope_id = scope_id - self.obj = obj + self.value = admit_target(obj) self._configs: dict[type, Any] | None = None @property @@ -62,11 +64,11 @@ def configs(self) -> dict[type, Any]: """The config instances this layer holds, by type. Extracted once per layer.""" configs = self._configs if configs is None: - configs = self._configs = extract_all_configs(self.obj) + configs = self._configs = self.value.context_configs() return configs def __repr__(self) -> str: - return f"ContextLayer({self.scope_id!r}, {type(self.obj).__name__})" + return f"ContextLayer({self.scope_id!r}, {type(self.value.stored).__name__})" class ContextLayerStack: @@ -97,52 +99,29 @@ def __repr__(self) -> str: 'context_layer_stack', default=NO_LAYERS ) -def _merge_nested_dataclass(base, override): - """Merge stored values; Inherit delegates, while None is a concrete override.""" - return FieldValue.of(override).overlay(base, _merge_concrete_dataclass) - - -def _merge_concrete_dataclass(base, override): - if not DataclassFieldAccess.is_container(base) or not DataclassFieldAccess.is_container(override): - return override - from objectstate.lazy_factory import get_base_type_for_lazy, replace_raw - base_type = get_base_type_for_lazy(type(base)) or type(base) - override_type = get_base_type_for_lazy(type(override)) or type(override) - result_owner = base if base_type is override_type else override - base_fields = DataclassFieldAccess.field_names(base) - values = { - f.name: _merge_nested_dataclass( - DataclassFieldAccess.raw_value(base, f.name), - DataclassFieldAccess.raw_value(override, f.name), - ) - for f in fields(override) if f.name in base_fields - } - return replace_raw(result_owner, **values) if values else result_owner + + @contextmanager def config_context(obj, use_live_global: bool = True, scope_id: str | None = None): - """Enter one layer. A stored None always supplies a concrete value.""" + """Enter one admitted layer; field members own merge and raw-read behavior.""" from objectstate.config import get_base_config_type - from objectstate.lazy_factory import replace_raw + current = get_current_temp_global() - base = current if current is not None else get_base_global_config(use_live=use_live_global) + base = admit_root_value(current if current is not None else get_base_global_config(use_live=use_live_global)) + incoming = admit_target(obj) overrides = {} if obj is not None: - read = DataclassFieldAccess.raw_value if DataclassFieldAccess.is_container(obj) else getattr - for declaration in fields(get_base_config_type()): - try: - value = read(obj, declaration.name) - except AttributeError: - continue - base_value = DataclassFieldAccess.raw_value(base, declaration.name) - overrides[declaration.name] = _merge_nested_dataclass(base_value, value) - merged = replace_raw(base, **overrides) if overrides else base + global_declaration = ValueDeclaration.of(get_base_config_type()) + for name in incoming.declaration.parameters.keys() & global_declaration.parameters.keys(): + overrides[name] = incoming.read_raw(name).overlay(base.read_raw(name)) + merged = base.with_fields(overrides).stored if overrides else base.stored types = context_type_stack.get() - stack = context_layer_stack.get() if current is not None else ContextLayerStack((ContextLayer("", base),)) + stack = context_layer_stack.get() if current is not None else ContextLayerStack((ContextLayer('', base),)) if obj is not None: - stack = stack.with_layer(ContextLayer(scope_id, obj)) - types += (type(obj),) + stack = stack.with_layer(ContextLayer(scope_id, incoming)) + types += (type(incoming.stored),) with ExitStack() as resources: for variable, value in ((current_temp_global, merged), (context_type_stack, types), (context_layer_stack, stack)): resources.callback(variable.reset, variable.set(value)) @@ -187,18 +166,6 @@ def get_context_layer_stack() -> ContextLayerStack: return context_layer_stack.get() -@functools.lru_cache(maxsize=None) -def _normalize_type(t): - """Normalize a type by getting its base type if it's a lazy variant. - - This function is defined here to avoid circular imports with lazy_factory. - The actual get_base_type_for_lazy is imported lazily when needed. - - PERFORMANCE: Results are cached with lru_cache since type->base_type - mappings are immutable after class creation. - """ - from objectstate.lazy_factory import get_base_type_for_lazy - return get_base_type_for_lazy(t) or t def _is_global_type(t): @@ -225,7 +192,7 @@ def _normalize_type_for_hierarchy(t): Rule: If normalization would produce a global type from a non-global type, keep the original. """ - normalized = _normalize_type(t) + normalized = resolve_declared_type(t) # Don't collapse lazy-to-global - they're distinct hierarchy levels if _is_global_type(normalized) and not _is_global_type(t): return t @@ -312,7 +279,7 @@ def get_normalized_stack(): canonical_stack = get_context_type_stack() normalized = [] for t in canonical_stack: - base_t = _normalize_type(t) + base_t = resolve_declared_type(t) if not _is_global_type(base_t): normalized.append(base_t) return normalized @@ -335,7 +302,7 @@ def get_types_before_in_stack(target_type): # First try active context stack normalized_stack = get_normalized_stack() if normalized_stack: - target_base = _normalize_type(target_type) + target_base = resolve_declared_type(target_type) # Find target's position target_index = -1 @@ -365,18 +332,17 @@ def is_ancestor_in_context(ancestor_type, descendant_type): True if ancestor_type is an ancestor of descendant_type, False otherwise. """ - from objectstate.lazy_factory import get_base_type_for_lazy # Check 1: Is ancestor_type the lazy base of descendant_type? # This handles GlobalPipelineConfig → PipelineConfig relationship # PipelineConfig is a lazy version of GlobalPipelineConfig - descendant_base = get_base_type_for_lazy(descendant_type) - if descendant_base is not None and descendant_base == ancestor_type: + descendant_base = resolve_declared_type(descendant_type) + if descendant_base is not descendant_type and descendant_base == ancestor_type: return True # Check 2: Normalize for comparison (handles nested lazy configs like LazyPathPlanningConfig) - ancestor_base = _normalize_type(ancestor_type) - descendant_normalized = _normalize_type(descendant_type) + ancestor_base = resolve_declared_type(ancestor_type) + descendant_normalized = resolve_declared_type(descendant_type) # Check 3: Active context stack (uses normalized types) normalized_stack = get_normalized_stack() @@ -395,7 +361,7 @@ def is_ancestor_in_context(ancestor_type, descendant_type): # Check 4: Known hierarchy registry (uses actual types, not normalized) ancestors = get_ancestors_from_hierarchy(descendant_type) # Check if ancestor_type OR its normalized form is in the ancestor list - return ancestor_type in ancestors or ancestor_base in [_normalize_type(a) for a in ancestors] + return ancestor_type in ancestors or ancestor_base in [resolve_declared_type(a) for a in ancestors] def is_same_type_in_context(type_a, type_b): @@ -411,7 +377,7 @@ def is_same_type_in_context(type_a, type_b): Returns: True if both types normalize to the same base type. """ - return _normalize_type(type_a) == _normalize_type(type_b) + return resolve_declared_type(type_a) == resolve_declared_type(type_b) # ============================================================================ @@ -507,22 +473,16 @@ def get_current_temp_global(): # Utility functions for debugging and introspection def get_context_info() -> Dict[str, Any]: - """ - Get information about current context for debugging. - - Returns: - Dict with context information including type, field count, etc. - """ current = get_current_temp_global() if current is None: - return {"active": False} - + return {'active': False} + node = admit_target(current) + declarations = node.declared_fields() return { - "active": True, - "type": type(current).__name__, - "field_count": len(fields(current)) if is_dataclass(current) else 0, - "non_none_fields": sum(1 for f in fields(current) - if getattr(current, f.name) is not None) if is_dataclass(current) else 0 + 'active': True, + 'type': type(current).__name__, + 'field_count': len(declarations), + 'non_none_fields': sum(node.read_raw(field.name).stored is not None for field in declarations), } @@ -543,144 +503,17 @@ def extract_all_configs_from_context() -> Dict[type, Any]: return extract_all_configs(current) -@functools.cache -def _declared_config_fields(dataclass_type: type) -> tuple[tuple[str, type], ...]: - """The fields of a dataclass type that are declared as dataclass types, with those types. - A fact about the class, so it is kept per class. - """ - annotations = get_type_hints(dataclass_type, include_extras=True) - declared = [] - for field_info in fields(dataclass_type): - field_type = annotations.get(field_info.name, field_info.type) - actual_type = resolve_optional(resolve_annotated(field_type)) - if is_dataclass(actual_type): - declared.append((field_info.name, actual_type)) - return tuple(declared) - - -def extract_all_configs(context_obj) -> Dict[type, Any]: - """ - Extract all config instances from a context object using type-driven approach. - A context layer keeps its own result (``ContextLayer.configs``); this function - itself keeps nothing. +def extract_all_configs(context_obj): + """Admit an external context and return its typed configuration members.""" + return admit_target(context_obj).context_configs() - This function leverages dataclass field type annotations to efficiently extract - config instances, avoiding string matching and runtime attribute scanning. - Args: - context_obj: Object to extract configs from (orchestrator, merged config, etc.) - Returns: - Dict mapping nominal config types to config instances. - """ - if context_obj is None: - return {} - configs: Dict[type, Any] = {} - - # Include the context object itself if it's a dataclass - if DataclassFieldAccess.is_container(context_obj): - _record_config_instance(configs, context_obj, source="context object") - - # Type-driven extraction: Use dataclass field annotations to find config fields - if DataclassFieldAccess.is_container(context_obj): - for field_name, declared_type in _declared_config_fields(type(context_obj)): - # Only concrete values contribute instances. Inherit/Unset do not - # recursively ask the context whose membership is being collected. - raw = DataclassFieldAccess.raw_value(context_obj, field_name) - FieldValue.of(raw).map(lambda concrete: _record_config_instance( - configs, concrete, - source=f"{type(context_obj).__name__}.{field_name}", - expected_type=declared_type, - )) - - # For non-dataclass objects (orchestrators, etc.), extract dataclass attributes - else: - _extract_from_object_attributes_typed(context_obj, configs) - - if logger.isEnabledFor(logging.DEBUG): - logger.debug( - "Extracted %d configs: %s", - len(configs), - [config_type.__name__ for config_type in configs], - ) - return configs - - -def _record_config_instance( - configs: Dict[type, Any], - config_instance: Any, - *, - source: str, - expected_type: type | None = None, -) -> None: - """Admit concrete config instances, checking their declaration and identity.""" - - from objectstate.lazy_factory import get_base_type_for_lazy - - if config_instance is None: - return # A concrete nullable slot contributes no config instance. - if expected_type is not None and not _is_compatible_config_type(config_instance, expected_type): - raise TypeError(f"{source} declares {expected_type.__name__}, got {type(config_instance).__name__}.") - instance_type = type(config_instance) - base_type = get_base_type_for_lazy(instance_type) or instance_type - existing = configs.get(base_type) - if existing is not None and existing is not config_instance: - raise ValueError( - f"{source} introduces a second {base_type.__name__} instance into " - "one configuration context." - ) - configs[base_type] = config_instance - - -def _extract_from_object_attributes_typed(obj, configs: Dict[type, Any]) -> None: - """ - Type-safe extraction from object attributes for non-dataclass objects. - This is used for orchestrators and other objects that aren't dataclasses - but have config attributes. Uses type checking instead of string matching. - """ - for attr_name in dir(obj): - if attr_name.startswith('_'): - continue - - try: - attr_value = getattr(obj, attr_name) - except (AttributeError, TypeError): - continue - if DataclassFieldAccess.is_container(attr_value): - _record_config_instance( - configs, - attr_value, - source=f"{type(obj).__name__}.{attr_name}", - ) - logger.debug( - "Extracted config %s from attribute %s", - type(attr_value).__name__, - attr_name, - ) - - -def _is_compatible_config_type(value, expected_type) -> bool: - """ - Check if value is compatible with expected_type, handling lazy-to-base type mapping. - This handles cases where: - - value is LazyStepMaterializationConfig, expected_type is StepMaterializationConfig - - value is a subclass of the expected type - - value is exactly the expected type - """ - from objectstate.lazy_factory import get_base_type_for_lazy - - expected_type = resolve_optional(resolve_annotated(expected_type)) - if not isinstance(expected_type, type): - return False - value_type = type(value) - value_base = get_base_type_for_lazy(value_type) or value_type - expected_base = get_base_type_for_lazy(expected_type) or expected_type - return issubclass(value_base, expected_base) def spawn_thread_with_context( diff --git a/src/objectstate/declarations.py b/src/objectstate/declarations.py new file mode 100644 index 0000000..f853851 --- /dev/null +++ b/src/objectstate/declarations.py @@ -0,0 +1,436 @@ +"""Behavior compiled from declarations; field data never chooses its kind.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from copy import copy +from dataclasses import MISSING, dataclass, field, fields, is_dataclass +from enum import Enum +from functools import cache, cached_property +from inspect import isclass +from typing import Any, ClassVar, get_args, get_origin, get_type_hints + +from python_introspect import UnifiedParameterAnalyzer, resolve_annotated, resolve_declared_type +from python_introspect.annotation_types import is_union_type + +from objectstate import field_value as members +from objectstate.field_access import DataclassFieldAccess + + +@dataclass(frozen=True, slots=True) +class InheritedDefault(ABC): + @abstractmethod + def value(self): + """Materialize the one declared inherited default.""" + + +@dataclass(frozen=True, slots=True) +class ValueDefault(InheritedDefault): + schema_origin: ClassVar[str] = 'inherited_default' + default: object + + def value(self): + return self.default + + +@dataclass(frozen=True, slots=True) +class FactoryDefault(InheritedDefault): + schema_origin: ClassVar[str] = 'inherited_factory' + factory: object + + def value(self): + return self.factory() + + +class ConfigurationRebuild(ABC): + """A configuration declaring construction different from keyword init.""" + __slots__ = () + @classmethod + @abstractmethod + def from_fields(cls, **values): + """Construct from declared init fields.""" + + +class ValueDeclaration(ABC): + def __init__(self, annotation): + self.annotation = annotation + + @staticmethod + def of(annotation): + return _compile_declaration(resolve_annotated(annotation)) + + def admit(self, value): + return members.FieldValue.of(value).map(self.admit_data) + + def scope_available(self, member): + return member.available() + + @abstractmethod + def admit_data(self, value): + """Box using behavior selected by this annotation.""" + + @abstractmethod + def configuration_owners(self): + """The configuration declarations this type admits.""" + + @abstractmethod + def choose_declaration(self, consumer, **arguments): + """Consume the annotation's form capability.""" + + def convert_structure(self, consumer, value, name): + return consumer.convert_leaf_structure(value, self.annotation, name) + + def editing_default(self, factory): + return members.Inherit() + + def lazy_field(self, declaration, annotation, factory): + metadata = dict(declaration.metadata) + if declaration.default is not MISSING: + metadata[InheritedDefault] = ValueDefault(declaration.default) + elif declaration.default_factory is not MISSING: + metadata[InheritedDefault] = FactoryDefault(declaration.default_factory) + return annotation, field(default=members.Inherit(), metadata=metadata) + + def require_config_type(self): + raise TypeError(f'Expected a configuration declaration, got {self.annotation!r}') + + @cached_property + def owner_scope(self): + return ConstructorDeclaration(self.annotation) + + def declared_config_slot(self, name): + return tuple((name, owner.declaring_type) for owner in self.configuration_owners()) + + +class ScalarDeclaration(ValueDeclaration): + def __init__(self, annotation, member_type=members.Value): + super().__init__(annotation) + self.member_type = member_type + + def admit_data(self, value): + return self.member_type(value) + + def configuration_owners(self): + return () + + def choose_declaration(self, consumer, **arguments): + return consumer.scalar_declaration(self.annotation, **arguments) + + def extract(self, member, state_type, parameters, structure, path): + return None + + def descendants(self, member, path, registered): + return {} + + def contribute(self, member, configs, *, source): + return None + + +class CollectionDeclaration(ScalarDeclaration): + def admit_data(self, value): + return members.CollectionValue(value, self) + + def serialize(self, member, resolver): + return member.fold(lambda value: self.rebuild((key, item.for_serialization(resolver)) + for key, item in self.items(value)), lambda directive: directive) + + +class SequenceDeclaration(CollectionDeclaration): + def __init__(self, annotation, container): + super().__init__(annotation) + self.container = container + + @abstractmethod + def item_declaration(self, index): + """Admit this index through its declared element annotation.""" + + def items(self, value): + return ((index, self.item_declaration(index).admit(child)) + for index, child in enumerate(value)) + + def child_path(self, path, key): + return path.child_index(key) + + def rebuild(self, items): + return self.container(member for key, member in items) + + +class RepeatedSequenceDeclaration(SequenceDeclaration): + def __init__(self, annotation, container, item): + super().__init__(annotation, container) + self.item = item + + def item_declaration(self, index): + return self.item + + +class FixedTupleDeclaration(SequenceDeclaration): + def __init__(self, annotation, items): + super().__init__(annotation, tuple) + self.item_types = items + + def item_declaration(self, index): + return self.item_types[index] + + +class MappingDeclaration(CollectionDeclaration): + def __init__(self, annotation, key, item): + super().__init__(annotation) + self.key = key + self.item = item + + def items(self, value): + return ((key, self.item.admit(child)) for key, child in value.items()) + + def child_path(self, path, key): + return path.child_field(str(key)) + + def rebuild(self, items): + return dict(items) + + def serialize(self, member, resolver): + return member.fold(lambda value: { + self.key.admit(key).for_serialization(resolver): item.for_serialization(resolver) + for key, item in self.items(value) + }, lambda directive: directive) + + +class NullableDeclaration(ValueDeclaration): + def __init__(self, annotation, inner): + super().__init__(annotation) + self.inner = inner + + def admit_data(self, value): + return members.NullValue() if value is None else self.inner.admit_data(value) + + def scope_available(self, member): + return member.fold(lambda value: value is not None, lambda directive: False) + + def configuration_owners(self): + return self.inner.configuration_owners() + + def choose_declaration(self, consumer, **arguments): + return self.inner.choose_declaration(consumer, **arguments) + + def convert_structure(self, consumer, value, name): + return None if value is None else self.inner.convert_structure(consumer, value, name) + + def extract(self, member, state_type, parameters, structure, path): + return member.fold(lambda value: None if value is None else self.inner.extract(self.inner.admit_data(value), state_type, parameters, structure, path), lambda directive: None) + + def descendants(self, member, path, registered): + return member.fold(lambda value: {} if value is None else self.inner.descendants(self.inner.admit_data(value), path, registered), lambda directive: {}) + + def contribute(self, member, configs, *, source): + return member.fold(lambda value: None if value is None else self.inner.contribute(self.inner.admit_data(value), configs, source=source), lambda directive: None) + + def resolved_child(self, member, name, owner): + return member.fold(lambda value: (members.Unset(), owner) if value is None else self.inner.resolved_child(self.inner.admit_data(value), name, owner), lambda directive: (members.Unset(), owner)) + + +class ParameterOwnerDeclaration(ValueDeclaration): + @property + def declaring_type(self): + return self.annotation + + @cached_property + def base_type(self): + return resolve_declared_type(self.declaring_type) + + @cached_property + def parameters(self): + return UnifiedParameterAnalyzer.analyze(self.declaring_type) + + @cached_property + def field_types(self): + return {name: ValueDeclaration.of(info.param_type) for name, info in self.parameters.items()} + + @property + def annotations(self): + return {name: info.param_type for name, info in self.parameters.items()} + + @cached_property + def context_field_types(self): + annotations = get_type_hints(self.declaring_type, include_extras=True) if isclass(self.declaring_type) else {} + return {**self.field_types, **{name: ValueDeclaration.of(annotation) for name, annotation in annotations.items()}} + + @property + def init_field_names(self): + return tuple(self.parameters) + + @property + def owner_scope(self): + return self + + def construct(self, values, *, resolved=False): + constructor = self.base_type if resolved else self.declaring_type + return constructor(**{name: value for name, value in values.items() if name in self.init_field_names}) + + +class ConstructorDeclaration(ParameterOwnerDeclaration): + def admit_data(self, value): + return members.Value(value) + + def configuration_owners(self): + return () + + def choose_declaration(self, consumer, **arguments): + return consumer.scalar_declaration(self.annotation, **arguments) + + def read_raw(self, value, name): + return self.context_field_types[name].admit(getattr(value, name)) + + def construct_from(self, value, values, *, resolved=False): + result = copy(value) + for name in self.init_field_names: + if name in values: + setattr(result, name, values[name].stored) + return result + + +class ConfigurationDeclaration(ParameterOwnerDeclaration): + @cached_property + def fields(self): + return tuple(item for item in fields(self.declaring_type) if item.init) + + def admit_data(self, value): + return members.ConfigValue(value, self) + + def require_config_type(self): + return self.declaring_type + + def declared_fields(self): + return self.fields + + def field_annotations(self): + return self.annotations + + def configuration_fields(self): + return tuple((name, owner.declaring_type) for name, declaration in self.field_types.items() for owner in declaration.configuration_owners()) + + def configuration_owners(self): + return (self,) + + def configuration_mro(self, field_name): + for owner in self.declaring_type.__mro__: + for declaration in ValueDeclaration.of(owner).configuration_owners(): + if field_name in declaration.context_field_types: + yield declaration + + def choose_declaration(self, consumer, **arguments): + return consumer.config_declaration(self.declaring_type, **arguments) + + def convert_structure(self, consumer, value, name): + if isinstance(value, self.declaring_type): + return value + if not isinstance(value, dict): + return super().convert_structure(consumer, value, name) + unknown = value.keys() - set(self.init_field_names) + if unknown: + raise ValueError(f'Unknown fields for {self.declaring_type.__name__}: {tuple(unknown)!r}') + return self.construct({key: consumer.convert_value_to_type(datum, self.annotations[key], key) for key, datum in value.items()}) + + def own_default(self, name): + declaration = self.declaring_type.__dataclass_fields__[name] + if InheritedDefault in declaration.metadata: + return members.Unset() + if declaration.default is not MISSING: + return self.context_field_types[name].admit(declaration.default) + if declaration.default_factory is not MISSING: + return self.context_field_types[name].admit(declaration.default_factory()) + return members.Unset() + + def inherited_default(self, name): + declaration = self.declaring_type.__dataclass_fields__[name] + if InheritedDefault in declaration.metadata: + return self.context_field_types[name].admit(declaration.metadata[InheritedDefault].value()) + return members.Unset() + + def frozen_declaration(self): + return self.declaring_type.__dataclass_params__.frozen + + def lazy_field(self, declaration, annotation, factory): + return factory.configuration_field(self, declaration, annotation) + + def editing_default(self, factory): + return factory.configuration_editing_default(self) + + def serialize(self, member, resolver): + def data(value): + arguments = {} + for name in self.init_field_names: + raw = self.read_raw(value, name) + resolved = raw.resolve(lambda name=name: self.field_types[name].admit(resolver(self.declaring_type, name).value)) + arguments[name] = resolved.for_serialization(resolver) + return self.construct(arguments, resolved=True) + return member.fold(data, lambda directive: directive) + + def read_raw(self, value, name): + return self.context_field_types[name].admit(DataclassFieldAccess.raw_value(value, name)) + + def extract(self, member, state_type, parameters, structure, path): + return member.fold(lambda value: self.admit_data(value).extract_parameters(state_type, parameters, structure, path), lambda directive: None) + + def descendants(self, member, path, registered): + return member.fold(lambda value: self.admit_data(value).descendant_values(path, registered), lambda directive: {}) + + def contribute(self, member, configs, *, source): + return member.fold(lambda value: self.admit_data(value).contribute_config(configs, source=source), lambda directive: None) + + def resolved_child(self, member, name, owner): + return member.fold(lambda value: (self.read_raw(value, name), self.declaring_type), lambda directive: (members.Unset(), owner)) + + def accepts(self, expected_type): + return issubclass(self.base_type, resolve_declared_type(expected_type)) + + def construct_from(self, value, updates, *, resolved=False): + arguments = {name: DataclassFieldAccess.raw_value(value, name) for name in self.init_field_names} + arguments.update({name: member.stored for name, member in updates.items() if name in self.init_field_names}) + return self.construct(arguments, resolved=resolved) + + def rebuild(self, value, updates, *, resolved=False): + result = self.construct_from(value, updates, resolved=resolved) + declaration = ValueDeclaration.of(self.base_type) if resolved else self + return declaration.admit_data(result) + + +class RebuildableConfigurationDeclaration(ConfigurationDeclaration): + def construct(self, values, *, resolved=False): + constructor = self.base_type if resolved else self.declaring_type + return constructor.from_fields(**{name: value for name, value in values.items() if name in self.init_field_names}) + + +def admit_root_value(value): + """Capture a root model's owner; nested fields never infer owners from data.""" + if isinstance(value, members.FieldValue): + return value + return ValueDeclaration.of(type(value)).admit(value) + + +@cache +def _compile_declaration(annotation): + """One shape decision, whose input is an annotation rather than field data.""" + if is_union_type(annotation): + arms = get_args(annotation) + nonnull = tuple(arm for arm in arms if arm is not type(None)) + if len(nonnull) == 1 and len(nonnull) != len(arms): + return NullableDeclaration(annotation, ValueDeclaration.of(nonnull[0])) + origin = get_origin(annotation) or annotation + arguments = get_args(annotation) + if origin in (list, tuple): + if origin is tuple and arguments and arguments[-1] is not Ellipsis: + return FixedTupleDeclaration(annotation, tuple(ValueDeclaration.of(item) for item in arguments)) + return RepeatedSequenceDeclaration(annotation, origin, ValueDeclaration.of(arguments[0] if arguments else Any)) + if origin is dict: + key, item = arguments if arguments else (Any, Any) + return MappingDeclaration(annotation, ValueDeclaration.of(key), ValueDeclaration.of(item)) + if annotation is type(None): + return ScalarDeclaration(annotation, members.NullValue) + if isinstance(annotation, type): + if is_dataclass(annotation): + declaration = RebuildableConfigurationDeclaration if issubclass(annotation, ConfigurationRebuild) else ConfigurationDeclaration + return declaration(annotation) + if issubclass(annotation, Enum): + return ScalarDeclaration(annotation, members.EnumValue) + if issubclass(annotation, str): + return ScalarDeclaration(annotation, members.TextValue) + return ScalarDeclaration(annotation) diff --git a/src/objectstate/dual_axis_resolver.py b/src/objectstate/dual_axis_resolver.py index 53efd50..d1ed949 100644 --- a/src/objectstate/dual_axis_resolver.py +++ b/src/objectstate/dual_axis_resolver.py @@ -10,11 +10,12 @@ import functools import logging -from dataclasses import MISSING, fields, is_dataclass from typing import Any, NamedTuple from objectstate.field_access import DataclassFieldAccess from objectstate.field_value import FieldValue, Unset +from objectstate.declarations import ValueDeclaration +from python_introspect import resolve_declared_type logger = logging.getLogger(__name__) @@ -35,57 +36,18 @@ class ResolvedField(NamedTuple): source_type: type | None -@functools.lru_cache(maxsize=None) -def _normalize_to_base(t: type) -> type: - """Normalize lazy type to its base type for comparison. - - LazyPartitionFilterConfig -> PartitionFilterConfig - PartitionFilterConfig -> PartitionFilterConfig - - PERFORMANCE: Results are cached with lru_cache since type->base_type - mappings are immutable after class creation. Import is hoisted to - avoid per-call import overhead. - """ - from objectstate.lazy_factory import get_base_type_for_lazy - return get_base_type_for_lazy(t) or t - - -def _direct_owner_field_default(config_type: type, field_name: str): - """A declared own default, or Unset when this owner delegates the default.""" - if not is_dataclass(config_type): - return Unset() - declaration = config_type.__dataclass_fields__.get(field_name) - if declaration is None or "_inherited_default" in declaration.metadata: - return Unset() - if declaration.default is not MISSING: - return declaration.default - if declaration.default_factory is not MISSING: - return declaration.default_factory() - return Unset() - - def declared_default(container_type: type, field_name: str) -> tuple[Any, type | None]: - """Read the declared default, retaining an explicit None as a real value.""" - base = _normalize_to_base(container_type) - own = _direct_owner_field_default(base, field_name) - own_type = base if FieldValue.of(own).available() else None - if FieldValue.of(own).available(): - return own, own_type - for owner in base.__mro__: - value = getattr(owner, field_name, Unset()) - if FieldValue.of(value).available(): - return value, own_type - if is_dataclass(container_type): - for declaration in fields(container_type): - if declaration.name != field_name: - continue - inherited = declaration.metadata.get("_inherited_default", MISSING) - if inherited is not MISSING and FieldValue.of(inherited).available(): - return inherited, own_type - factory = declaration.metadata.get("_inherited_default_factory", MISSING) - if factory is not MISSING: - return factory(), own_type - return Unset(), own_type + """Defaults come from declarations, never slot descriptors or guessed attributes.""" + base = ValueDeclaration.of(resolve_declared_type(container_type)) + own = base.own_default(field_name) + own_type = base.declaring_type if own.available() else None + if own.available(): + return own.stored, own_type + for declaration in base.configuration_mro(field_name): + value = declaration.own_default(field_name) + if value.available(): + return value.stored, own_type + return ValueDeclaration.of(container_type).inherited_default(field_name).stored, own_type def resolve_with_provenance(container_type: type, field_name: str) -> ResolvedField: @@ -139,7 +101,7 @@ class is tried only after every layer was asked for the more specific one. The order is stated with an example in ``docs/context_system.rst`` under "Precedence Order"; this function is its implementation. """ - container_base = _normalize_to_base(container_type) + container_base = resolve_declared_type(container_type) visible_layers = tuple(reversed(layers)) @@ -149,10 +111,8 @@ class is tried only after every layer was asked for the more specific one. unset_type: type | None = None unset_found = False - for mro_class in container_base.__mro__: - if not is_dataclass(mro_class): - continue - mro_type = _normalize_to_base(mro_class) + for declaration in ValueDeclaration.of(container_base).configuration_mro(field_name): + mro_type = resolve_declared_type(declaration.declaring_type) outermost_scope: str | None = None held_by_a_layer = False @@ -160,14 +120,11 @@ class is tried only after every layer was asked for the more specific one. config_instance = layer.configs.get(mro_type) if config_instance is None: continue + value = config_instance.read_raw(field_name) held_by_a_layer = True outermost_scope = layer.scope_id - try: - value = DataclassFieldAccess.raw_value(config_instance, field_name) - except AttributeError: - continue - if FieldValue.of(value).available(): - return ResolvedField(value, layer.scope_id, mro_type) + if value.available(): + return ResolvedField(value.stored, layer.scope_id, mro_type) if held_by_a_layer and not unset_found: unset_found = True diff --git a/src/objectstate/field_access.py b/src/objectstate/field_access.py index 3acd540..3ac42ac 100644 --- a/src/objectstate/field_access.py +++ b/src/objectstate/field_access.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass, fields, is_dataclass +from dataclasses import dataclass, fields from types import GetSetDescriptorType, MemberDescriptorType from typing import Iterable, Iterator @@ -82,37 +82,13 @@ class DataclassFieldAccess: _SLOT_DESCRIPTOR_TYPES = (MemberDescriptorType, GetSetDescriptorType) - @staticmethod - def is_config_type(value_type) -> bool: - """A dataclass declaration for domain data, not for stored meaning.""" - from objectstate.field_value import FieldValue - - return isinstance(value_type, type) and is_dataclass(value_type) and not issubclass(value_type, FieldValue) - - @classmethod - def is_container(cls, value) -> bool: - """A domain configuration instance, never a class or a meaning member.""" - return not isinstance(value, type) and cls.is_config_type(type(value)) - @staticmethod def field_names(instance_or_type) -> frozenset[str]: - dataclass_type = instance_or_type if isinstance(instance_or_type, type) else type(instance_or_type) - if not is_dataclass(dataclass_type): - raise TypeError( - "DataclassFieldAccess requires a dataclass instance or type; " - f"got {dataclass_type!r}." - ) - return frozenset(field.name for field in fields(dataclass_type)) + return frozenset(field.name for field in fields(instance_or_type)) @staticmethod def init_field_names(instance_or_type) -> tuple[str, ...]: - dataclass_type = instance_or_type if isinstance(instance_or_type, type) else type(instance_or_type) - if not is_dataclass(dataclass_type): - raise TypeError( - "DataclassFieldAccess requires a dataclass instance or type; " - f"got {dataclass_type!r}." - ) - return tuple(field.name for field in fields(dataclass_type) if field.init) + return tuple(field.name for field in fields(instance_or_type) if field.init) @classmethod def has_field(cls, instance_or_type, field_name: str) -> bool: diff --git a/src/objectstate/field_value.py b/src/objectstate/field_value.py index 32e92bd..ec2d0d1 100644 --- a/src/objectstate/field_value.py +++ b/src/objectstate/field_value.py @@ -1,15 +1,19 @@ -"""Stored field meaning, independent of the domain value (including None).""" +"""Stored meaning and declaration-admitted configuration capabilities.""" from __future__ import annotations from abc import ABC, abstractmethod +from copy import deepcopy from dataclasses import dataclass from typing import Any, Callable +from pycodify import SourceFragment, imported_name, to_source + +from objectstate.field_access import DottedFieldPath +from objectstate.value_semantics import semantic_values_equal, seed_callable_identity + @dataclass(eq=False, repr=False) class FieldValue(ABC): - """Interpret a stored value once; consumers invoke its field operations.""" - __slots__ = () @classmethod @@ -17,46 +21,53 @@ def of(cls, value: Any) -> FieldValue: return value if isinstance(value, cls) else Value(value) @staticmethod - def is_inherited(value: Any) -> bool: + def is_inherited(value): return FieldValue.of(value).inherits() - def available(self) -> bool: - """Whether this member supplies domain data, including literal None.""" - return False + @abstractmethod + def available(self): + """Whether domain data is supplied, including literal null.""" - def inherits(self) -> bool: + def inherits(self): return False - def inherited_from(self, resolved: FieldValue) -> bool: + def inherited_from(self, resolved): return False - def present_to(self, display) -> None: - """Let a view paint this directive without inventing a datum.""" - display.present_directive(self) + @abstractmethod + def present_to(self, display): + """Paint data or a directive through its declared operation.""" - def map(self, convert: Callable[[Any], Any]) -> Any: - """Convert domain data without converting an inheritance/absence directive.""" - return self + @abstractmethod + def map(self, convert): + """Convert data, preserving directives.""" - def project(self, convert: Callable[[Any], Any]) -> Any: - """Pass the datum or directive itself to the view, without context lookup.""" - return convert(self.resolve(lambda: self)) + @abstractmethod + def fold(self, data, directive): + """Consume both meanings explicitly without probing their payloads.""" + + def project(self, convert): + return convert(self.stored) @abstractmethod - def resolve(self, inherited: Callable[[], Any]) -> Any: - """Read the effective value, requesting inheritance only when declared.""" + def resolve(self, inherited): + """Only inheritance requests context lookup.""" @abstractmethod - def overlay(self, base: Any, merge: Callable[[Any, Any], Any]) -> Any: - """Apply this stored value over a base without interpreting None.""" + def overlay(self, base): + """Apply this meaning over another admitted member.""" + + @property + def stored(self): + return self - def __repr__(self) -> str: - return f"{type(self).__name__}()" + def __repr__(self): + return f'{type(self).__name__}()' - def __eq__(self, other: object) -> bool: + def __eq__(self, other): return type(self) is type(other) - def __hash__(self) -> int: + def __hash__(self): return hash(type(self)) def __copy__(self): @@ -66,78 +77,340 @@ def __deepcopy__(self, memo): return self def source(self, context): - from pycodify import SourceFragment, imported_name reference = imported_name(type(self).__module__, type(self).__qualname__, context) - return SourceFragment(f"{reference.code}()", reference.imports) + return SourceFragment(f'{reference.code}()', reference.imports) -@dataclass(eq=False, repr=False) -class Inherit(FieldValue): - """Continue the existing context/MRO lookup instead of supplying a value.""" +class LeafValue(FieldValue): + """Terminal meanings do not implement field or child access.""" + __slots__ = () + + def notification_paths(self, path, structure): + return {path} + + def payload_types(self, path): + return () + + def source_equals(self, other): + return self == other + + def reconstruct(self, prefix, values, structure, target, *, resolved=False): + return self + + def merge_config(self, override): + return override + + def history_changes(self, before, path): + return before.history_to_leaf(self, path) + + def history_to_leaf(self, after, path): + return {} if self == after else {path: (self, after)} + + def history_to_config(self, after, path): + return {path: (self, after)} + def structural_leaves(self, path): + return ((path, self.stored),) + + def placeholder_text(self, *, nested=False): + return str(self) + + def for_serialization(self, resolver): + return self.stored + + +class DirectiveValue(LeafValue): + __slots__ = () + + def available(self): + return False + + def present_to(self, display): + display.present_directive(self) + + def map(self, convert): + return self + + def fold(self, data, directive): + return directive(self) + + def seed_callable_identity(self, memo, seen): + memo[id(self)] = self + + def contribute_config(self, configs, *, source, expected_type=None): + return None + + +@dataclass(eq=False, repr=False) +class Inherit(DirectiveValue): __slots__ = () - def inherits(self) -> bool: + def inherits(self): return True - def inherited_from(self, resolved: FieldValue) -> bool: + def inherited_from(self, resolved): return resolved.available() - def resolve(self, inherited: Callable[[], Any]) -> Any: + def resolve(self, inherited): return inherited() - def overlay(self, base: Any, merge: Callable[[Any, Any], Any]) -> Any: + def overlay(self, base): return base @dataclass(eq=False, repr=False) -class Unset(FieldValue): - """No value is available. This is neither None nor an inheritance request.""" - +class Unset(DirectiveValue): __slots__ = () - def resolve(self, inherited: Callable[[], Any]) -> Any: + def resolve(self, inherited): return self - def overlay(self, base: Any, merge: Callable[[Any, Any], Any]) -> Any: + def overlay(self, base): return self @dataclass(eq=False, repr=False) -class Value(FieldValue): - """A concrete domain value, including an explicitly stored None.""" - +class Value(LeafValue): __slots__ = ('value',) value: Any - def __init__(self, value: Any): + def __init__(self, value): self.value = value - def available(self) -> bool: + def available(self): return True - def present_to(self, display) -> None: + def present_to(self, display): display.present_value(self.value) - def resolve(self, inherited: Callable[[], Any]) -> Any: - return self.value + def resolve(self, inherited): + return self - def map(self, convert: Callable[[Any], Any]) -> Any: + def map(self, convert): return convert(self.value) - def overlay(self, base: Any, merge: Callable[[Any, Any], Any]) -> Any: - return merge(base, self.value) + def fold(self, data, directive): + return data(self.value) + + def overlay(self, base): + return self + + @property + def stored(self): + return self.value + + def seed_callable_identity(self, memo, seen): + seed_callable_identity(self.value, memo, seen) + + def placeholder_text(self, *, nested=False): + return str(self.value) + + def for_serialization(self, resolver): + return self.value + + def render_preview(self, consumer, request): + return consumer.scalar_preview(request) - def __eq__(self, other: object) -> bool: - from objectstate.value_semantics import semantic_values_equal + def __eq__(self, other): return isinstance(other, Value) and semantic_values_equal(self.value, other.value) __hash__ = None def __deepcopy__(self, memo): - from copy import deepcopy - return Value(deepcopy(self.value, memo)) + return type(self)(deepcopy(self.value, memo)) def source(self, context): - from pycodify import to_source return to_source(self.value, context) + + +class NullValue(Value): + __slots__ = () + + def __init__(self, value=None): + super().__init__(value) + + def placeholder_text(self, *, nested=False): + return '(none)' + + def contribute_config(self, configs, *, source, expected_type=None): + return None + + +class EnumValue(Value): + __slots__ = () + + def placeholder_text(self, *, nested=False): + return f'{self.value.__class__.__name__}.{self.value.name}' + + +class TextValue(Value): + __slots__ = () + + def placeholder_text(self, *, nested=False): + return self.value[:17] + '...' if nested and len(self.value) > 20 else self.value + + +class ConfigurationFields: + """Actual configuration field capability; terminal values never inherit it.""" + __slots__ = () + + def declared_fields(self): + return self.declaration.fields + + def field_annotations(self): + return self.declaration.annotations + + def read_raw(self, name): + return self.declaration.read_raw(self.value, name) + + def stored_child(self, name): + return self.read_raw(name) + + def resolved_child(self, name, owner): + return self.read_raw(name), self.declaration.declaring_type + + def read_path(self, path): + current = self + for part in DottedFieldPath(path).parts: + current = current.read_raw(part) + return current + + def descendant_values(self, prefix, registered): + updates = {} + for declaration in self.declared_fields(): + path = f'{prefix}.{declaration.name}' + member = self.read_raw(declaration.name) + if path in registered: + updates[path] = member + updates.update(self.declaration.field_types[declaration.name].descendants(member, path, registered)) + return updates + + +class DeclaredValue(Value): + __slots__ = ('declaration',) + + def __init__(self, value, declaration): + super().__init__(value) + self.declaration = declaration + + def __deepcopy__(self, memo): + return type(self)(deepcopy(self.value, memo), self.declaration) + + def for_serialization(self, resolver): + return self.declaration.serialize(self, resolver) + + +class CollectionValue(DeclaredValue): + def structural_leaves(self, path): + for key, member in self.declaration.items(self.value): + yield from member.structural_leaves(self.declaration.child_path(path, key)) + + def seed_callable_identity(self, memo, seen): + for key, member in self.declaration.items(self.value): + seed_callable_identity(key, memo, seen) + member.seed_callable_identity(memo, seen) + + def __eq__(self, other): + return (isinstance(other, CollectionValue) + and self.declaration.annotation == other.declaration.annotation + and semantic_values_equal(dict(self.declaration.items(self.value)), + dict(other.declaration.items(other.value)))) + + __hash__ = None + + +class ConfigValue(ConfigurationFields, DeclaredValue): + @property + def parameter_owner(self): + return self.declaration.declaring_type + + def __eq__(self, other): + return (isinstance(other, ConfigValue) + and self.declaration.declaring_type is other.declaration.declaring_type + and all(self.read_raw(item.name) == other.read_raw(item.name) for item in self.declared_fields())) + + __hash__ = None + + def source_equals(self, other): + return (isinstance(other, ConfigValue) + and self.declaration.base_type is other.declaration.base_type + and all(self.read_raw(name).source_equals(other.read_raw(name)) + for name in self.declaration.init_field_names)) + + def render_preview(self, consumer, request): + return consumer.configuration_preview(request) + + def projection_fields(self): + return ((item, self.read_raw(item.name)) for item in self.declared_fields()) + + def extract_parameters(self, state_type, parameters, structure, prefix='', exclusions=()): + for name, info in self.declaration.parameters.items(): + if name in exclusions: + continue + path = f'{prefix}.{name}' if prefix else name + structure.declare(path, info, self.parameter_owner) + member = self.read_raw(name) + parameters[path] = member + self.declaration.field_types[name].extract(member, state_type, parameters, structure, path) + + def payload_types(self, path): + return ((path, self.declaration.base_type),) + + def notification_paths(self, path, structure): + return set() if structure.direct_paths.get(DottedFieldPath(path)) else {path} + + def with_fields(self, values, *, resolved=False): + return self.declaration.rebuild(self.value, values, resolved=resolved) + + def reconstruct(self, prefix, values, structure, target, *, resolved=False): + return self.with_fields(structure.direct_values(prefix, values, target, resolved=resolved), resolved=resolved) + + def overlay_fields(self, values): + return self.with_fields(values) if values else self + + def overlay(self, base): + return base.merge_config(self) + + def merge_config(self, override): + owner = self if self.declaration.base_type is override.declaration.base_type else override + base_names = self.declaration.field_types.keys() + values = {item.name: override.read_raw(item.name).overlay(self.read_raw(item.name)) + for item in override.declared_fields() if item.name in base_names} + return owner.with_fields(values) if values else owner + + def history_changes(self, before, path): + return before.history_to_config(self, path) + + def history_to_config(self, after, path): + return {} if self.declaration.base_type is after.declaration.base_type else {path: (self, after)} + + def structural_leaves(self, path): + for item in self.declared_fields(): + yield from self.read_raw(item.name).structural_leaves(path.child_field(item.name)) + + def seed_callable_identity(self, memo, seen): + for item in self.declared_fields(): + self.read_raw(item.name).seed_callable_identity(memo, seen) + + def placeholder_text(self, *, nested=False): + name = self.declaration.declaring_type.__name__ + if nested: + return f'{name}(...)' + summaries = [f'{item.name}={self.read_raw(item.name).placeholder_text(nested=True)}' + for item in self.declared_fields()] + return ', '.join(summaries) if summaries else f'{name} (default settings)' + + def context_configs(self): + configs = {} + self.contribute_config(configs, source='context object') + for name, declaration in self.declaration.field_types.items(): + declaration.contribute(self.read_raw(name), configs, source=f'{self.parameter_owner.__name__}.{name}') + return configs + + def contribute_config(self, configs, *, source, expected_type=None): + actual = self.declaration.base_type + if expected_type is not None and not self.declaration.accepts(expected_type): + raise TypeError(f'{source} does not declare {expected_type.__name__}.') + if actual in configs and configs[actual].stored is not self.value: + raise ValueError(f'{source} introduces a second {actual.__name__} instance into one configuration context.') + configs[actual] = self diff --git a/src/objectstate/inspection.py b/src/objectstate/inspection.py index 6b929ba..a733e3a 100644 --- a/src/objectstate/inspection.py +++ b/src/objectstate/inspection.py @@ -72,7 +72,7 @@ class FieldInspection: @classmethod def of(cls, field): """Capture one admitted field, never look up a caller-spelled path.""" - resolved = FieldValue.of(field.value) + resolved = field.resolved_member provenance = None match field.provenance: case (source_scope_id, source_type) if source_type is not None: @@ -83,7 +83,7 @@ def of(cls, field): ) return cls( field=field, - raw=FieldValue.of(field.raw), + raw=field.raw_member, resolved=resolved, provenance=provenance, ) diff --git a/src/objectstate/lazy_factory.py b/src/objectstate/lazy_factory.py index be41086..ad6d5c7 100644 --- a/src/objectstate/lazy_factory.py +++ b/src/objectstate/lazy_factory.py @@ -11,9 +11,12 @@ from contextlib import contextmanager from objectstate.field_value import FieldValue, Inherit, Unset from objectstate.field_access import DataclassFieldAccess -from dataclasses import MISSING, dataclass, field, fields, is_dataclass, make_dataclass +from objectstate.declarations import ValueDeclaration, InheritedDefault, ValueDefault, FactoryDefault, admit_root_value +from objectstate.dual_axis_resolver import resolve_with_provenance +from objectstate.construction_binding import admit_target +from dataclasses import MISSING, dataclass, field, fields, make_dataclass from functools import lru_cache -from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union, get_type_hints +from typing import Annotated, Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union, get_type_hints, get_args, get_origin from python_introspect import ( make_optional, @@ -22,7 +25,8 @@ resolve_optional, ) -from objectstate.ui_visibility import mark_ui_hidden_config +from objectstate.ui_visibility import mark_ui_hidden_config, UIParameterVisibilityPolicy +from python_introspect import resolve_declared_type # Note: dual_axis_resolver_recursive and lazy_placeholder imports kept inline to avoid circular imports @@ -34,14 +38,6 @@ _base_to_lazy_registry: Dict[Type, Type] = {} -def _resolved_dataclass_annotations(dataclass_type: type) -> Dict[str, object]: - """Resolve declarations even while a class decorator precedes module binding.""" - - return get_type_hints( - dataclass_type, - localns={dataclass_type.__name__: dataclass_type}, - include_extras=True, - ) def _recreated_dataclass_namespace( @@ -99,8 +95,8 @@ def get_inherited_field_names(cls: Type) -> set: # Get all field names from parent dataclasses parent_fields = set() for base in cls.__mro__[1:]: # Skip cls itself - if dataclasses.is_dataclass(base): - parent_fields.update(base.__dataclass_fields__.keys()) + for owner in ValueDeclaration.of(base).configuration_owners(): + parent_fields.update(item.name for item in owner.declared_fields()) # cls's OWN annotations (not inherited). Python 3.14 (PEP 649) no longer # stores them in cls.__dict__['__annotations__']. @@ -109,26 +105,18 @@ def get_inherited_field_names(cls: Type) -> set: return parent_fields - own_defined -def _inherited_default_metadata(field_definition) -> dict: - """Preserve one concrete dataclass field's standalone fallback values.""" - +def _inheritance_metadata(field_definition) -> dict: + """Preserve a declaration's one inherited-default member.""" metadata = dict(field_definition.metadata) - metadata.setdefault( - "_inherited_default", - ( - field_definition.default - if field_definition.default is not MISSING - else MISSING - ), - ) - metadata.setdefault( - "_inherited_default_factory", - field_definition.default_factory, - ) + if InheritedDefault not in metadata: + if field_definition.default is not MISSING: + metadata[InheritedDefault] = ValueDefault(field_definition.default) + elif field_definition.default_factory is not MISSING: + metadata[InheritedDefault] = FactoryDefault(field_definition.default_factory) return metadata -def rebuild_with_inherited_defaults( +def rebuild_with_inheritance( cls: Type, inherited_field_names: Optional[set] = None, new_name: Optional[str] = None @@ -148,19 +136,19 @@ def rebuild_with_inherited_defaults( Returns: A new class with the same fields but modified defaults """ - if not dataclasses.is_dataclass(cls): - raise ValueError(f"{cls} is not a dataclass") + declaration = ValueDeclaration.of(cls) + declaration.require_config_type() if inherited_field_names is None: # All fields get None (for lazy classes) inherited_field_names = {f.name for f in fields(cls)} - annotations = _resolved_dataclass_annotations(cls) + annotations = ValueDeclaration.of(cls).field_annotations() # Build field definitions field_defs = [] for f in fields(cls): - declared_type = annotations.get(f.name, f.type) + declared_type = annotations[f.name] if f.name in inherited_field_names: # Force None default, but preserve original default in metadata for fallback # This allows standalone usage to fall back to parent's static default @@ -170,7 +158,7 @@ def rebuild_with_inherited_defaults( declared_type, field( default=Inherit(), - metadata=_inherited_default_metadata(f), + metadata=_inheritance_metadata(f), ), ) ) @@ -195,11 +183,7 @@ def rebuild_with_inherited_defaults( bases = cls.__bases__ # Check if any base is a frozen dataclass - if so, new class must also be frozen - is_frozen = any( - dataclasses.is_dataclass(b) and b.__dataclass_fields__ and - getattr(b, '__dataclass_params__', None) and b.__dataclass_params__.frozen - for b in cls.__mro__[1:] - ) + is_frozen = any(owner.frozen_declaration() for base in cls.__mro__[1:] for owner in ValueDeclaration.of(base).configuration_owners()) # Get the original metaclass to preserve it orig_metaclass = type(cls) @@ -243,9 +227,6 @@ def replace_raw(instance, **changes): Returns: A new instance with raw values preserved (not resolved) """ - if not is_dataclass(instance): - raise TypeError(f"replace_raw() should be called on dataclass instances, got {type(instance)}") - # Get all field values using object.__getattribute__ to avoid lazy resolution field_values = {} for f in fields(instance): @@ -383,8 +364,7 @@ def is_lazy_dataclass(obj_or_type) -> bool: def mark_global_config_type(config_type: Type) -> Type: """Register config_type as an ObjectState global-config authority.""" - if not isinstance(config_type, type) or not is_dataclass(config_type): - raise TypeError("Global config authorities must be dataclass types.") + ValueDeclaration.of(config_type).require_config_type() _GLOBAL_CONFIG_TYPES.add(config_type) return config_type @@ -495,34 +475,8 @@ def get_lazy_type_for_base(base_type: Type) -> Optional[Type]: return _base_to_lazy_registry.get(base_type) -def _normalized_config_type(annotation: object) -> Optional[type]: - """Return the nominal config owner declared by one field annotation.""" - - candidate = resolve_optional(resolve_annotated(annotation)) - if not isinstance(candidate, type): - return None - return get_base_type_for_lazy(candidate) or candidate -@lru_cache(maxsize=None) -def _declared_config_field_names( - owner_type: type, - config_type: type, -) -> Tuple[str, ...]: - """Find fields whose resolved declaration owns ``config_type``.""" - - if not is_dataclass(owner_type): - raise TypeError(f"{owner_type!r} is not a dataclass type.") - config_base = get_base_type_for_lazy(config_type) or config_type - annotations = _resolved_dataclass_annotations(owner_type) - return tuple( - field_definition.name - for field_definition in fields(owner_type) - if _normalized_config_type( - annotations.get(field_definition.name, field_definition.type) - ) - is config_base - ) def _single_declared_config_field( @@ -533,12 +487,18 @@ def _single_declared_config_field( ) -> Optional[str]: """Select one annotation-owned config field and reject ambiguity.""" - field_names = _declared_config_field_names(owner_type, config_type) + owner = ValueDeclaration.of(owner_type) + owner.require_config_type() + config_base = resolve_declared_type(config_type) + field_names = tuple( + name for name, declared_type in owner.configuration_fields() + if (resolve_declared_type(declared_type)) is config_base + ) if len(field_names) == 1: return field_names[0] if not field_names and not required: return None - config_base = get_base_type_for_lazy(config_type) or config_type + config_base = resolve_declared_type(config_type) if not field_names: raise TypeError( f"{owner_type.__name__} has no field declared as " @@ -631,7 +591,7 @@ def __getattribute__(self: Any, name: str) -> Any: stack = context_layer_stack.get() if not stack.layers: return declared_default(_cls, name)[0] - return value.resolve(lambda: resolve_in_stack(stack, _cls, name).value) + return value.resolve(lambda: FieldValue.of(resolve_in_stack(stack, _cls, name).value)).stored return __getattribute__ @@ -728,6 +688,21 @@ def create_class_methods() -> Dict[str, Any]: class LazyDataclassFactory: """Generic factory for creating lazy dataclasses with flexible resolution.""" + @classmethod + def configuration_field(cls, owner, declaration, annotation): + declared_type = owner.declaring_type + lazy_type = declared_type if resolve_declared_type(declared_type) is not declared_type else get_lazy_type_for_base(declared_type) + if lazy_type is None: + if not has_lazy_resolution(declared_type): + return ValueDeclaration.lazy_field(owner, declaration, annotation, cls) + lazy_type = cls.make_lazy_simple(declared_type) + transformed = Annotated[lazy_type, *get_args(annotation)[1:]] if get_origin(annotation) is Annotated else lazy_type + return transformed, field(default_factory=lazy_type, metadata=declaration.metadata) + + @staticmethod + def configuration_editing_default(owner): + return owner.admit_data(owner.declaring_type()) if has_lazy_resolution(owner.declaring_type) else Inherit() + @staticmethod def registered_type_pairs() -> tuple[tuple[type, type], ...]: """Project current lazy/public declarations without constructing values.""" @@ -737,94 +712,22 @@ def registered_type_pairs() -> tuple[tuple[type, type], ...]: - @staticmethod - def _introspect_dataclass_fields( - base_class: Type, - debug_template: str, - ) -> List[Tuple[str, Type, None]]: - """ - Introspect dataclass fields for lazy loading. - - Converts nested dataclass fields to lazy equivalents and makes fields Optional - if they lack defaults. Complex logic handles type unwrapping and lazy nesting. - """ - base_fields = fields(base_class) - annotations = _resolved_dataclass_annotations(base_class) - lazy_field_definitions = [] - - for field_definition in base_fields: - # Check if field has default value or factory - has_default = ( - field_definition.default is not MISSING - or field_definition.default_factory is not MISSING - ) - - # Check if field type is a dataclass that should be made lazy - field_type = annotations.get( - field_definition.name, - field_definition.type, - ) - lazy_nested_type = None # Track if we created a lazy nested type - nested_type = resolve_annotated(field_type) - nested_is_registered_lazy = ( - isinstance(nested_type, type) - and ( - get_base_type_for_lazy(nested_type) is not None - or get_lazy_type_for_base(nested_type) is not None - or has_lazy_resolution(nested_type) - ) - ) - if is_dataclass(nested_type) and nested_is_registered_lazy: - if get_base_type_for_lazy(nested_type) is not None: - lazy_nested_type = nested_type - else: - lazy_nested_type = get_lazy_type_for_base(nested_type) - if lazy_nested_type is None: - lazy_nested_type = LazyDataclassFactory.make_lazy_simple(nested_type) - field_type = lazy_nested_type - logger.debug(f"Created lazy class for {field_definition.name}: {nested_type} -> {lazy_nested_type}") - - # CRITICAL FIX: For lazy configs, nested dataclass fields should use default_factory - # to provide lazy instances (e.g., LazyPathPlanningConfig), not None. - # This allows getattr(pipeline_config, 'path_planning_config') to return an instance. - # Non-dataclass fields still default to None for placeholder inheritance. - # CRITICAL: Always preserve metadata from original field (e.g., ui_hidden flag) - if lazy_nested_type is not None: - final_field_type = field_type - # Nested dataclass field: use default_factory so accessing returns an instance - # This matches AbstractStep pattern: napari_streaming_config = LazyNapariStreamingConfig() - field_def = ( - field_definition.name, - final_field_type, - dataclasses.field( - default_factory=lazy_nested_type, - metadata=field_definition.metadata, - ), - ) - else: - final_field_type = field_type - # CRITICAL FIX: For lazy configs, ALL non-dataclass fields should default to None - # This enables proper inheritance from parent configs and placeholder styling - field_def = ( - field_definition.name, - final_field_type, - dataclasses.field( - default=Inherit(), - metadata=_inherited_default_metadata(field_definition), - ), - ) - - lazy_field_definitions.append(field_def) - - # Debug logging with provided template (reduced to DEBUG level to reduce log pollution) + @classmethod + def _introspect_dataclass_fields(cls, base, debug_template): + """Declaration members produce their field definition, not type flags.""" + annotations = base.field_annotations() + definitions = [] + for declaration in base.declared_fields(): + annotation = annotations[declaration.name] + final_type, field_definition = ValueDeclaration.of(resolve_annotated(annotation)).lazy_field(declaration, annotation, cls) + definitions.append((declaration.name, final_type, field_definition)) logger.debug(debug_template.format( - field_name=field_definition.name, - original_type=field_definition.type, - has_default=has_default, - final_type=final_field_type + field_name=declaration.name, + original_type=declaration.type, + has_default=(declaration.default is not MISSING or declaration.default_factory is not MISSING), + final_type=final_type, )) - - return lazy_field_definitions + return definitions @staticmethod def _create_lazy_dataclass_unified( @@ -839,8 +742,8 @@ def _create_lazy_dataclass_unified( binds resolution methods, and registers type mappings. Complex orchestration of field analysis, method binding, and class creation. """ - if not is_dataclass(base_class): - raise ValueError(f"{base_class} must be a dataclass") + declaration = ValueDeclaration.of(base_class) + declaration.require_config_type() registered_type = get_lazy_type_for_base(base_class) if registered_type is not None: @@ -880,17 +783,13 @@ def _create_lazy_dataclass_unified( # Check if base_class is frozen - must match to avoid inheritance error # "cannot inherit frozen dataclass from a non-frozen one" and vice versa - base_is_frozen = ( - is_dataclass(base_class) and - hasattr(base_class, '__dataclass_params__') and - base_class.__dataclass_params__.frozen - ) + base_is_frozen = declaration.frozen_declaration() # Single make_dataclass call - no duplication lazy_class = make_dataclass( lazy_class_name, LazyDataclassFactory._introspect_dataclass_fields( - base_class, + declaration, debug_template, ), bases=bases, @@ -982,80 +881,12 @@ def make_lazy_simple( def resolve_lazy_configurations_for_serialization(data: Any) -> Any: - """ - Recursively resolve lazy dataclass instances to concrete values for serialization. - - CRITICAL: This function must be called WITHIN a config_context() block! - The context provides the hierarchy for lazy resolution. + """Admit one input graph; members own recursive resolution and reconstruction. - How it works: - 1. For lazy dataclasses: Access fields with getattr() to trigger resolution - 2. The lazy __getattribute__ uses the active config_context() to resolve None values - 3. Convert resolved values to base config for pickling - - Example (from README.md): - with config_context(orchestrator.pipeline_config): - # Lazy resolution happens here via context - resolved_steps = resolve_lazy_configurations_for_serialization(steps) + Inheritance requests require an active configuration context. Constructor + failures propagate; serialization never bypasses a domain constructor. """ - # Check if this is a lazy dataclass - base_type = get_base_type_for_lazy(type(data)) - if base_type is not None: - # This is a lazy dataclass - resolve fields using getattr() within the active context - # getattr() triggers lazy __getattribute__ which uses config_context() for resolution - resolved_fields = {} - for f in fields(data): - # CRITICAL: Use getattr() to trigger lazy resolution via context - # The active config_context() provides the hierarchy for resolution - resolved_value = getattr(data, f.name) - resolved_fields[f.name] = resolved_value - - # Create base config instance with resolved values - resolved_data = base_type(**resolved_fields) - else: - # Not a lazy dataclass - resolved_data = data - - # Recursively process nested structures based on type - if is_dataclass(resolved_data) and not isinstance(resolved_data, type): - # Process dataclass fields recursively - logger.debug(f"Resolving fields for {type(resolved_data).__name__}: {[f.name for f in fields(resolved_data)]}") - resolved_fields = {} - for f in fields(resolved_data): - field_value = getattr(resolved_data, f.name) - logger.debug(f"Resolving {type(resolved_data).__name__}.{f.name} = {type(field_value).__name__}") - resolved_fields[f.name] = resolve_lazy_configurations_for_serialization(field_value) - cls = type(resolved_data) - - # Optional hook: allow dataclasses with custom constructors to control rebuild. - rebuild = getattr(cls, "__objectstate_rebuild__", None) - if callable(rebuild): - return rebuild(**resolved_fields) - - # Default: try normal construction, then fall back to field-wise construction. - try: - return cls(**resolved_fields) - except TypeError: - obj = cls.__new__(cls) - for k, v in resolved_fields.items(): - object.__setattr__(obj, k, v) - return obj - - elif isinstance(resolved_data, dict): - # Process dictionary values recursively - return { - key: resolve_lazy_configurations_for_serialization(value) - for key, value in resolved_data.items() - } - - elif isinstance(resolved_data, (list, tuple)): - # Process sequence elements recursively - resolved_items = [resolve_lazy_configurations_for_serialization(item) for item in resolved_data] - return type(resolved_data)(resolved_items) - - else: - # Primitive type or unknown structure - return as-is - return resolved_data + return admit_root_value(data).for_serialization(resolve_with_provenance) # Generic dataclass editing with configurable value preservation @@ -1063,22 +894,19 @@ def resolve_lazy_configurations_for_serialization(data: Any) -> Any: def create_dataclass_for_editing(dataclass_type: Type[T], source_config: Any, preserve_values: bool = False, context_provider: Optional[Callable[[Any], None]] = None) -> T: - """Create dataclass for editing with configurable value preservation.""" - if not is_dataclass(dataclass_type): - raise ValueError(f"{dataclass_type} must be a dataclass") - - # Set up context if provider is given (e.g., thread-local storage) + """Preserve stored meanings, or create inherited drafts without inventing nulls.""" + declaration = ValueDeclaration.of(dataclass_type) + constructor = declaration.require_config_type() if context_provider: context_provider(source_config) - - field_values = { - f.name: (getattr(source_config, f.name) if preserve_values - else f.type() if is_dataclass(f.type) and has_lazy_resolution(f.type) - else None) - for f in fields(dataclass_type) + source = admit_target(source_config) + annotations = declaration.field_annotations() + values = { + field.name: (source.read_raw(field.name) if preserve_values + else ValueDeclaration.of(annotations[field.name]).editing_default(LazyDataclassFactory)) + for field in declaration.declared_fields() if field.init } - - return dataclass_type(**field_values) + return constructor(**{name: member.stored for name, member in values.items()}) @@ -1203,7 +1031,7 @@ def declare_group_abbreviation(config_type: type, name: str) -> None: A lazy class has no abbreviation of its own: the declaration lands on its base class. A second, different declaration for one class raises. """ - declaring_type = get_base_type_for_lazy(config_type) or config_type + declaring_type = resolve_declared_type(config_type) declared = vars(declaring_type).get(GROUP_ABBREVIATION_ATTRIBUTE) if declared is not None and declared != name: raise ValueError( @@ -1224,7 +1052,7 @@ def get_group_abbreviation(config_type: str | type) -> str: if isinstance(config_type, str): return config_type.split('_')[0] if config_type else "root" - declaring_type = get_base_type_for_lazy(config_type) or config_type + declaring_type = resolve_declared_type(config_type) for ancestor in declaring_type.__mro__: declared = vars(ancestor).get(GROUP_ABBREVIATION_ATTRIBUTE) if declared is not None: @@ -1272,7 +1100,7 @@ def decorator(actual_cls): # This replaces the old pre-process setattr + post-process Field patching inherited_fields = get_inherited_field_names(actual_cls) if inherited_fields: - actual_cls = rebuild_with_inherited_defaults(actual_cls, inherited_fields) + actual_cls = rebuild_with_inheritance(actual_cls, inherited_fields) field_name = _camel_to_snake(actual_cls.__name__) @@ -1324,7 +1152,7 @@ def decorator(actual_cls): 'field_name': field_name, 'optional': optional, # Store the optional flag 'inherit_fields': inherit_fields, # Store the inherit_fields flag - 'ui_hidden': ui_hidden # Store the ui_hidden flag for field metadata + UIParameterVisibilityPolicy: ui_hidden }) # Immediately create lazy version of this config (not dependent on injection) @@ -1356,7 +1184,7 @@ def decorator(actual_cls): ALWAYS_VIEWABLE_FIELDS_REGISTRY[lazy_class] = always_viewable_fields # Note: No Stage 3 post-processing needed! - # - Base class: rebuilt via rebuild_with_inherited_defaults() above + # - Base class: rebuilt via rebuild_with_inheritance() above # - Lazy class: _introspect_dataclass_fields() already sets None defaults return actual_cls @@ -1390,9 +1218,9 @@ def _inject_multiple_fields_into_dataclass(target_class: Type, configs: List[Dic """Mathematical simplification: Batch field injection with direct dataclass recreation.""" # Imports moved to top-level - annotations = _resolved_dataclass_annotations(target_class) + annotations = ValueDeclaration.of(target_class).field_annotations() existing_fields = [ - (f.name, annotations.get(f.name, f.type), copy.copy(f)) + (f.name, annotations[f.name], copy.copy(f)) for f in fields(target_class) ] @@ -1411,7 +1239,7 @@ def create_field_definition(config): # CRITICAL: GlobalPipelineConfig needs default_factory to create instances with defaults # PipelineConfig (created by make_lazy_simple) automatically gets default=None # So we use default_factory here for GlobalPipelineConfig fields - default_value = field(default_factory=field_type, metadata={'ui_hidden': is_ui_hidden}) + default_value = field(default_factory=field_type, metadata=UIParameterVisibilityPolicy.field_metadata(is_ui_hidden)) return (config['field_name'], field_type, default_value) diff --git a/src/objectstate/live_context_resolver.py b/src/objectstate/live_context_resolver.py index 28085c0..6d81472 100644 --- a/src/objectstate/live_context_resolver.py +++ b/src/objectstate/live_context_resolver.py @@ -12,7 +12,8 @@ """ import logging -from objectstate.field_access import DataclassFieldAccess +from objectstate.field_value import FieldValue +from objectstate.construction_binding import admit_target from typing import Any from objectstate.context_manager import config_context @@ -107,9 +108,6 @@ def invalidate(self) -> None: self._answers_token = None self._answers_by_attr = {} - def reconstruct_live_values(self, live_values: dict[str, Any]) -> dict[str, Any]: - """Return live values unchanged.""" - return live_values if live_values else {} def _resolve_uncached( self, @@ -154,15 +152,6 @@ def _merge_live_values(self, base_obj: object, live_values: dict[str, Any] | Non Literal null overrides saved data. Only Inherit requests context/MRO selection; neither directive is a nested configuration container. """ - if live_values is None or not DataclassFieldAccess.is_container(base_obj): - return base_obj - - # Reconstruct nested dataclasses recursively - reconstructed_values = self.reconstruct_live_values(live_values) - - # Merge into base object (including None values to override saved concrete values) - # Use replace_raw to preserve None values (dataclasses.replace triggers lazy resolution) - if reconstructed_values: - return replace_raw(base_obj, **reconstructed_values) - else: - return base_obj + member = admit_target(base_obj) + updates = {name: member.declaration.field_types[name].admit(value) for name, value in (live_values or {}).items()} + return member.overlay_fields(updates).stored diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index e842526..94d313d 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -13,9 +13,6 @@ import copy import logging from collections.abc import Callable, Sequence -from dataclasses import fields as dataclass_fields -from dataclasses import is_dataclass -from types import FunctionType from typing import Any, Optional from objectstate.field_access import DataclassFieldAccess, DottedFieldPath, FieldAccessError @@ -24,6 +21,7 @@ DelegatedStateBinding, DirectStateBinding, StateConstructionBinding, + admit_target, ) from objectstate.object_state_metadata import ( ObjectStateMetadataContract, @@ -34,6 +32,7 @@ from objectstate.scope_key import ScopeKey from objectstate.parameter_owner import ParameterOwner from objectstate.parameter_structure import ParameterStructure +from objectstate.declarations import ValueDeclaration from objectstate.resolution_sources import ResolutionSources from objectstate.lazy_factory import is_global_config_type from objectstate.subfield_semantics import ( @@ -43,6 +42,7 @@ ) from objectstate.time_travel_profile import TimeTravelProfiler from objectstate.value_semantics import semantic_values_equal +from python_introspect import resolve_declared_type logger = logging.getLogger(__name__) @@ -119,9 +119,13 @@ def name(self): def annotation(self): return self.declaration.param_type + @property + def scope_available(self): + return ValueDeclaration.of(self.annotation).scope_available(self.resolved_member) + @property def default(self): - return self.declaration.default_value + return self.declaration.default_value.stored @property def description(self): @@ -140,20 +144,29 @@ def _require_present(self): if not self.present: raise FieldAccessError(f"Field {self._path!r} belongs to a retired declaration") + @property + def raw_member(self): + self._require_present() + return self._state._parameters[self._path] + @property def raw(self): + return self.raw_member.stored + + @property + def resolved_member(self): self._require_present() - return self._state._parameters.get(self._path, Unset()) + return self._state._read_field(self) @property def value(self): self._require_present() - return self._state._read_field(self) + return self.resolved_member.stored @property def saved_value(self): self._require_present() - return self._state._read_saved_field(self) + return self._state._read_saved_field(self).stored @property def dirty(self): @@ -163,7 +176,7 @@ def dirty(self): @property def signature_diff(self): - return self.raw != self.default + return self.raw_member != self.declaration.default_value def write(self, value): self._require_present() @@ -210,6 +223,10 @@ def inspect(self): class RootFields(FieldNamespace): """The live root namespace shares field discovery, not a captured schema.""" + @property + def scope_available(self): + return True + def __init__(self, state): self._state = state @@ -238,7 +255,7 @@ def __len__(self): def bind(self, owner, names, field_type=FieldProxy): """Bind an already declared attribute vector to this editing state.""" self._require_present() - target = self._state._extraction_target + target = self._state._target.stored if not ((isinstance(owner, type) and isinstance(target, owner)) or target is owner): raise FieldAccessError(f"{owner!r} does not declare this editing target") if isinstance(names, str) or not names: @@ -318,16 +335,16 @@ def __init__( # extracting editable parameters from a nested config (e.g., pipeline_config). delegate_attr = getattr(type(object_instance), '__objectstate_delegate__', None) if delegate_attr: - self._extraction_target = getattr(object_instance, delegate_attr) + self._target = admit_target(object.__getattribute__(object_instance, delegate_attr)) self._delegate_attr = delegate_attr logger.debug(f"ObjectState delegation: extracting from '{delegate_attr}' attribute") else: - self._extraction_target = object_instance + self._target = admit_target(object_instance) self._delegate_attr = None # === Flat Storage (NEW - for flattened architecture) === self._parameter_structure = ParameterStructure() - self._cached_object: Any | None = None # Cached result of to_object() + self._cached_object: FieldValue | None = None # Cached result of to_object() self._cached_object_applied: bool = False # True if cached delegate was applied to object_instance # UI / integration metadata (never participates in dirty detection). @@ -345,11 +362,11 @@ def __init__( # Extract parameters using FLAT extraction (dotted paths) # This replaces the old UnifiedParameterAnalyzer + _create_nested_states() approach - self._parameters: dict[str, Any] = {} - extraction_target = self._extraction_target + self._parameters: dict[str, FieldValue] = {} + extraction_target = self._target # Flatten parameter extraction - walk nested dataclasses recursively. - # Uses _extraction_target (delegate) instead of object_instance for + # Uses _target (delegate) instead of object_instance for # delegation support. The structure and its immutable path index are # replaced atomically once for this extraction. self._replace_parameter_structure( @@ -498,7 +515,7 @@ def _check_and_sync_delegate(self) -> bool: return False # Use identity check (is) not equality (==) to detect if it's a new instance - if current_delegate is self._extraction_target: + if current_delegate is self._target.stored: # Delegate hasn't changed - no sync needed return False @@ -527,9 +544,7 @@ def _copy_parameters_for_saved_baseline( deepcopy can rebuild callable wrapper instances through ``__reduce__``, making live and saved function specs unequal immediately after load. """ - memo: dict[int, Any] = {} - cls._seed_callable_identity_memo(parameters, memo, set()) - return copy.deepcopy(parameters, memo) + return copy.deepcopy(parameters, cls._parameter_copy_memo(parameters)) @classmethod def copy_parameter_pair_preserving_callable_identity( @@ -544,52 +559,24 @@ def copy_parameter_pair_preserving_callable_identity( can rebuild callable wrappers twice and make a clean state appear dirty. """ - memo: dict[int, Any] = {} - seen: set[int] = set() - cls._seed_callable_identity_memo(parameters, memo, seen) - cls._seed_callable_identity_memo(saved_parameters, memo, seen) + memo = cls._parameter_copy_memo(parameters, saved_parameters) return copy.deepcopy(parameters, memo), copy.deepcopy(saved_parameters, memo) - @classmethod - def _seed_callable_identity_memo( - cls, - value: Any, - memo: dict[int, Any], - seen: set[int], - ) -> None: - value_id = id(value) - if value_id in seen: - return - seen.add(value_id) - - if callable(value): - memo[value_id] = value - return - - if isinstance(value, dict): - for key, item in value.items(): - cls._seed_callable_identity_memo(key, memo, seen) - cls._seed_callable_identity_memo(item, memo, seen) - return - - if isinstance(value, (list, tuple, set, frozenset)): - for item in value: - cls._seed_callable_identity_memo(item, memo, seen) - return + @staticmethod + def _parameter_copy_memo(*snapshots): + memo = {} + seen = set() + for parameters in snapshots: + for member in parameters.values(): + member.seed_callable_identity(memo, seen) + return memo - if is_dataclass(value) and not isinstance(value, type): - for field in dataclass_fields(value): - cls._seed_callable_identity_memo( - object.__getattribute__(value, field.name), - memo, - seen, - ) @property def saved_object(self) -> Any: """Get the saved baseline object with the correct type. - For delegation: returns _extraction_target (the delegate/config) + For delegation: returns _target (the delegate/config) For non-delegation: returns object_instance This is the object that should be used for context resolution when @@ -597,7 +584,7 @@ def saved_object(self) -> Any: """ # Auto-detect delegate changes before returning extraction target self._check_and_sync_delegate() - return self._extraction_target + return self._target.stored @property def fields(self) -> RootFields: @@ -618,13 +605,13 @@ def to_resolved_object(self) -> Any: self._check_and_sync_delegate() self._ensure_live_resolved() assert self._live_resolved is not None - return self._parameter_structure.reconstruct('', self._live_resolved, self._extraction_target, resolved=True) + return self._parameter_structure.reconstruct('', self._live_resolved, self._target, resolved=True) def to_saved_resolved_object(self) -> Any: """Reconstruct this state as an object with saved resolved values.""" self._check_and_sync_delegate() self._ensure_saved_resolved() - return self._parameter_structure.reconstruct('', self._saved_resolved, self._extraction_target, resolved=True) + return self._parameter_structure.reconstruct('', self._saved_resolved, self._target, resolved=True) @classmethod def resolve_saved_object( @@ -647,9 +634,10 @@ def resolve_saved_object( target = ( getattr(object_instance, delegate_attr) if delegate_attr else object_instance ) + target = admit_target(target) structure = ParameterStructure.for_target(target, None) - parameters: dict[str, Any] = {} - cls._extract_all_parameters_flat(target, parameters, structure) + parameters: dict[str, FieldValue] = {} + target.extract_parameters(cls, parameters, structure) saved_parameters = cls._copy_parameters_for_saved_baseline(parameters) snapshot, provenance = cls._resolve_parameter_snapshot( saved_parameters, @@ -887,9 +875,9 @@ def _ensure_live_resolved(self, notify_flash: bool = True) -> set[str]: self._live_resolved = self._compute_resolved_snapshot() self._invalid_fields.clear() changed_values = { - path: (previous.get(path), value) + path: (previous.get(path, Unset()), value) for path, value in self._live_resolved.items() - if previous.get(path) != value + if previous.get(path, Unset()) != value } if changed_values: self._set_last_changed_values(changed_values) @@ -954,9 +942,9 @@ def object_for_resolution(self, use_saved: bool = False, sync_delegate: bool = F first, which must not happen while a snapshot is being recomputed. """ if use_saved: - return self._extraction_target + return self._target.stored if self._cached_object is not None and not sync_delegate: - return self._cached_object + return self._cached_object.stored return self.to_object(update_delegate=False, sync_delegate=sync_delegate) def _fields_with_other_sources( @@ -1043,27 +1031,6 @@ def reset_all_parameters(self) -> None: finally: self._in_reset = False - def _dataclass_parameter_updates(self, prefix: str, value: Any) -> dict[str, Any]: - """Flatten a dataclass container update into registered child parameters.""" - if not DataclassFieldAccess.is_container(value): - return {} - - prefix_dot = f"{prefix}." - if not any( - path.startswith(prefix_dot) - for path in self._parameters - if path != prefix - ): - return {} - - updates: dict[str, Any] = {} - for field in dataclass_fields(value): - field_path = f"{prefix_dot}{field.name}" - field_value = DataclassFieldAccess.raw_value(value, field.name) - if field_path in self._parameters: - updates[field_path] = field_value - updates.update(self._dataclass_parameter_updates(field_path, field_value)) - return updates def _dataclass_ancestor_parameter_updates( self, @@ -1081,7 +1048,7 @@ def _dataclass_ancestor_parameter_updates( prefix = ".".join(path_parts[:part_count]) if prefix not in parameters: continue - rebuilt_value = self._parameter_structure.reconstruct(prefix, parameters, self._extraction_target) + rebuilt_value = self._parameter_structure.reconstruct_member(prefix, parameters, self._target) if parameters.get(prefix) != rebuilt_value: updates[prefix] = rebuilt_value parameters[prefix] = rebuilt_value @@ -1095,10 +1062,9 @@ def inheritance_field_paths( ) -> tuple[str, ...]: """Return flat field paths whose container type can inherit target_type.""" - from objectstate.lazy_factory import get_base_type_for_lazy if isinstance(target_type, type): - target_base_type = get_base_type_for_lazy(target_type) or target_type + target_base_type = resolve_declared_type(target_type) else: target_base_type = target_type @@ -1111,7 +1077,7 @@ def inheritance_field_paths( if isinstance(container_type, type): container_base_type = ( - get_base_type_for_lazy(container_type) or container_type + resolve_declared_type(container_type) ) else: container_base_type = container_type @@ -1123,7 +1089,7 @@ def inheritance_field_paths( and isinstance(target_base_type, type) ): type_matches = any( - (get_base_type_for_lazy(mro_class) or mro_class) + (resolve_declared_type(mro_class)) == target_base_type for mro_class in container_base_type.__mro__ ) @@ -1163,7 +1129,8 @@ def _write_field(self, field: FieldProxy, value: Any) -> set[str]: ) return set() - child_updates = self._dataclass_parameter_updates(param_name, value) + value = self._parameter_structure.admit(param_name, value) + child_updates = self._parameter_structure.descendants(param_name, value, self._parameters) pending_parameters = dict(self._parameters) pending_parameters[param_name] = value for child_path, child_value in child_updates.items(): @@ -1260,15 +1227,10 @@ def _write_field(self, field: FieldProxy, value: Any) -> set[str]: # Leaf edits record directly. Dataclass-container edits record when they # materialize changed flat child fields; those child paths are the UI and # time-travel navigation authority for inline editors. - # A field is a container if there are other params that start with "param_name." param_path = DottedFieldPath(param_name) - is_container = any( - p != param_name and param_path.contains_path(p) - for p in self._parameters.keys() - ) snapshot_field = ( self._select_changed_field(changed_semantic_params) - if is_container and changed_semantic_params + if self._parameter_structure.direct_paths.get(param_path) and changed_semantic_params else param_name ) if snapshot_field: @@ -1280,14 +1242,14 @@ def _read_field(self, field: FieldProxy) -> Any: """Read this projection, preserving scalar terminals and nominal shape.""" self._check_and_sync_delegate() self._ensure_live_resolved() - return self._parameter_structure.reconstruct(field._path, self._live_resolved, self._extraction_target, resolved=True) + return self._parameter_structure.reconstruct_member(field._path, self._live_resolved, self._target, resolved=True) def _read_saved_field(self, field: FieldProxy) -> Any: """The saved projection owns its own payload, independently of live data.""" self._check_and_sync_delegate() self._ensure_saved_resolved() - return self._parameter_structure.reconstruct(field._path, self._saved_resolved, self._extraction_target, resolved=True) + return self._parameter_structure.reconstruct_member(field._path, self._saved_resolved, self._target, resolved=True) def subfield_semantics( @@ -1307,13 +1269,11 @@ def subfield_semantics( self._ensure_saved_resolved() path = owner_field_path - raw_value = self._parameters.get(path.value, Unset()) - resolved_value = self._read_field(self._field_at(path.value)) - saved_resolved_value = self._read_saved_field(self._field_at(path.value)) - declaration = self._parameter_structure.declarations.get(path.value) - signature_default_value = ( - declaration.default_value if declaration is not None else Unset() - ) + field = self._field_at(path.value) + raw_value = field.raw_member + resolved_value = self._read_field(field) + saved_resolved_value = self._read_saved_field(field) + signature_default_value = self._parameter_structure.declarations[path.value].default_value return build_subfield_semantic_index( owner_field_path=path, @@ -1365,21 +1325,20 @@ def find_path_for_type(self, container_type: type) -> str | None: Path prefix for the type, or None if not found. Returns "" (empty string) if type is the root object type. """ - from objectstate.lazy_factory import get_base_type_for_lazy # Normalize the container_type for comparison - container_base = get_base_type_for_lazy(container_type) or container_type + container_base = resolve_declared_type(container_type) # Check if container_type matches the root object type root_type = type(self.object_instance) - root_base = get_base_type_for_lazy(root_type) or root_type + root_base = resolve_declared_type(root_type) if container_base == root_base: return "" # Root type has no prefix # Look for paths where the TYPE matches (normalized comparison) # The path for a nested config is the one WITHOUT a dot suffix that has the type for path, typ in self._parameter_structure.container_types(self._parameters): - typ_base = get_base_type_for_lazy(typ) or typ + typ_base = resolve_declared_type(typ) if not isinstance(typ_base, type): continue if typ_base == container_base and '.' not in path: @@ -1395,10 +1354,9 @@ def project_ui_visible_field_path(self, container_type: type, field_name: str) - method is the authority for remapping hidden source types to a visible field path. """ - from objectstate.lazy_factory import get_base_type_for_lazy from objectstate.ui_visibility import UIVisibilityRegistry - container_base = get_base_type_for_lazy(container_type) or container_type + container_base = resolve_declared_type(container_type) if UIVisibilityRegistry.is_hidden(container_base): return self._find_visible_subclass_field_path(container_base, field_name) @@ -1413,15 +1371,14 @@ def project_ui_visible_field_path(self, container_type: type, field_name: str) - def _find_visible_subclass_field_path(self, hidden_type: type, field_name: str) -> str | None: """Find a rendered field path for a hidden config type via a visible subclass.""" - from objectstate.lazy_factory import get_base_type_for_lazy from objectstate.ui_visibility import UIVisibilityRegistry - hidden_base = get_base_type_for_lazy(hidden_type) or hidden_type + hidden_base = resolve_declared_type(hidden_type) for path, typ in self._parameter_structure.container_types(self._parameters): if "." in path: continue - typ_base = get_base_type_for_lazy(typ) or typ + typ_base = resolve_declared_type(typ) if not isinstance(typ_base, type): continue if UIVisibilityRegistry.is_hidden(typ_base): @@ -1452,10 +1409,10 @@ def resolve_for_type(self, container_type: type, field_name: str) -> Any: path_prefix = self.find_path_for_type(container_type) if path_prefix is None: # Type not found - try the field_name directly (top-level field) - return self._read_field(self._field_at(field_name)) + return self._read_field(self._field_at(field_name)).stored full_path = f'{path_prefix}.{field_name}' - return self._read_field(self._field_at(full_path)) + return self._read_field(self._field_at(full_path)).stored def invalidate_cache(self) -> None: """Invalidate resolved cache - forces full recompute on next access.""" @@ -1518,30 +1475,21 @@ def _changed_parameter_values( for param_path in pending_parameters: if param_path not in changed_params: continue - old_value = self._parameters.get(param_path) - new_value = pending_parameters.get(param_path) + old_value = self._parameters.get(param_path, Unset()) + new_value = pending_parameters.get(param_path, Unset()) if old_value != new_value: changed_values[param_path] = (old_value, new_value) return changed_values - def notification_fields_for_value_changes( - self, - changed_values: dict[str, tuple[Any, Any]], - ) -> set[str]: - """Return ObjectState display paths affected by concrete value changes.""" - - fields: set[str] = set() - for param_path, (old_value, new_value) in changed_values.items(): - if self._parameter_structure.is_container_parameter(param_path, new_value, self._parameters): - continue - fields.add(param_path) - fields.update( - changed_structural_leaf_paths( - owner_field_path=DottedFieldPath(param_path), - old_value=old_value, - new_value=new_value, - ) - ) + def notification_fields_for_value_changes(self, changed_values) -> set[str]: + """The member owns whether its registered descendants carry the change.""" + fields = set() + for path, (before, after) in changed_values.items(): + member = self._parameters.get(path, Unset()) + fields.update(member.notification_paths(path, self._parameter_structure)) + fields.update(changed_structural_leaf_paths( + owner_field_path=DottedFieldPath(path), old_value=before, new_value=after, + )) return fields @staticmethod @@ -1596,7 +1544,7 @@ def construction_binding(self) -> StateConstructionBinding: arguments = dict( state_type=type(self), - target=self._extraction_target, + target=self._target.stored, parent_scope=( self._parent_state.scope_id or "" if self._parent_state is not None else None ), @@ -1634,7 +1582,7 @@ def update_object_instance(self, new_instance: Any) -> None: code mode execution). The ObjectState is updated to point to the new instance and parameters are re-extracted to match the new object's state. - For delegation cases, this updates _extraction_target. For non-delegation cases, + For delegation cases, this updates _target. For non-delegation cases, it updates object_instance directly. Args: @@ -1648,17 +1596,17 @@ def update_object_instance(self, new_instance: Any) -> None: if self._delegate_attr is not None: # Delegation case: verify the new_instance matches the delegate type - if type(new_instance) != type(self._extraction_target): + if type(new_instance) != type(self._target.stored): logger.warning( f"Type mismatch in update_object_instance for delegated ObjectState: " - f"expected {type(self._extraction_target).__name__}, got {type(new_instance).__name__}" + f"expected {type(self._target.stored).__name__}, got {type(new_instance).__name__}" ) - self._extraction_target = new_instance + self._target = admit_target(new_instance) setattr(self.object_instance, self._delegate_attr, new_instance) else: # Non-delegation case: update object_instance directly self.object_instance = new_instance - self._extraction_target = new_instance + self._target = admit_target(new_instance) # Re-extract parameters from new instance self._replace_parameter_structure( @@ -1694,8 +1642,8 @@ def update_object_instance(self, new_instance: Any) -> None: } changed_values = { path: ( - old_live_resolved.get(path), - self._live_resolved.get(path), + old_live_resolved.get(path, Unset()), + self._live_resolved.get(path, Unset()), ) for path in changed_paths } @@ -1733,9 +1681,10 @@ def _recompute_invalid_fields(self, sources: ResolutionSources) -> set[str]: if self._live_resolved is None: return set() selected = self._invalid_fields & self._parameters.keys() + self.to_object(update_delegate=False, sync_delegate=False) snapshot, provenance = self._resolve_parameter_snapshot( self._parameters, self._parameters, self._parameter_structure, - self.to_object(update_delegate=False, sync_delegate=False), + self._cached_object, self.scope_id, sources.ancestors, use_saved=False, field_paths=selected, ) changed = { @@ -1775,13 +1724,13 @@ def get_current_values(self) -> dict[str, Any]: """ # Auto-detect delegate changes before accessing parameters self._check_and_sync_delegate() - return dict(self._parameters) + return {path: member.stored for path, member in self._parameters.items()} def reconstruct_top_level_parameters(self) -> dict[str, Any]: """Project local constructor argument names, not the editing address map.""" self._check_and_sync_delegate() return { - path.field_name: self._parameter_structure.reconstruct(path.value, self._parameters, self._extraction_target) + path.field_name: self._parameter_structure.reconstruct(path.value, self._parameters, self._target) for path in self._parameter_structure.direct_paths.get(DottedFieldPath(''), ()) } @@ -1813,39 +1762,13 @@ def _update_raw_dirty(self) -> bool: return False def _compute_dirty_fields(self) -> set[str]: - """Compute dirty set from live vs saved caches.""" + """Compare admitted effective members, without a second domain normalizer.""" if self._live_resolved is None: return set() - dirty = set() - - def _normalize_callable_value(value: Any) -> Any: - if value is None: - return None - if callable(value): - return value - if isinstance(value, list): - normalized = [] - for item in value: - if callable(item): - normalized.append(item) - continue - if isinstance(item, tuple) and len(item) == 2 and callable(item[0]) and isinstance(item[1], dict): - pruned_kwargs = {k: v for k, v in item[1].items() if FieldValue.of(v).available()} - normalized.append(item[0] if not pruned_kwargs else (item[0], pruned_kwargs)) - if len(normalized) == 1 and callable(normalized[0]): - return normalized[0] - return normalized - return value - for k in (self._live_resolved.keys() | self._saved_resolved.keys()): - live_val = _normalize_callable_value(self._live_resolved.get(k)) - saved_val = _normalize_callable_value(self._saved_resolved.get(k)) - - if live_val != saved_val: - dirty.add(k) - logger.debug(f"🔴 DIRTY_FIELD: scope={self.scope_id!r} field={k!r} live={live_val!r} saved={saved_val!r}") - if dirty: - logger.debug(f"🔴 DIRTY_SUMMARY: scope={self.scope_id!r} dirty_fields={dirty}") - return dirty + return { + path for path in self._live_resolved.keys() | self._saved_resolved.keys() + if self._live_resolved.get(path, Unset()) != self._saved_resolved.get(path, Unset()) + } def _compute_signature_diff_fields(self) -> set[str]: """Compute signature-diff set from parameters vs defaults. @@ -1951,15 +1874,15 @@ def _compute_resolved_snapshot(self, use_saved: bool = False) -> dict[str, Any]: raise TypeError("ObjectState._saved_parameters must remain a canonical flat dictionary.") sources = self._resolution_sources(sync_delegates=True)[use_saved] self._resolved_from[use_saved] = sources - current_obj = ( - self.saved_object if use_saved else self.to_object(update_delegate=False) - ) + if not use_saved: + self.to_object(update_delegate=False) + current_node = self._target if use_saved else self._cached_object parameters = self._saved_parameters if use_saved else self._parameters snapshot, provenance = self._resolve_parameter_snapshot( self._parameters, parameters, self._parameter_structure, - current_obj, + current_node, self.scope_id, sources.ancestors, use_saved=use_saved, @@ -1970,22 +1893,17 @@ def _compute_resolved_snapshot(self, use_saved: bool = False) -> dict[str, Any]: @classmethod def _resolve_parameter_snapshot( - cls, parameters, values, structure, current_obj, scope_id, + cls, parameters, values, structure, current_node, scope_id, ancestor_objects_with_scopes, *, use_saved, field_paths=None, ): - """One resolution algorithm for complete and targeted projections. - - A container entry carries the projection's nominal payload; reads rebuild - its effective children through ParameterStructure.reconstruct. FieldValue - alone decides whether a stored value invokes inheritance. - """ + """Resolve admitted members; terminal parents suppress retained child drafts.""" from objectstate.context_manager import build_context_stack from objectstate.dual_axis_resolver import resolve_with_provenance snapshot = {} provenance = {} with build_context_stack( - object_instance=current_obj, + object_instance=current_node.stored, ancestor_objects_with_scopes=ancestor_objects_with_scopes, current_scope_id=scope_id, use_live=not use_saved, ): @@ -1995,24 +1913,16 @@ def resolve(path): address = DottedFieldPath(path) owner = structure.owner_paths[path] if address.parent.value: - parent = resolve(address.parent.value) - # Read the effective parent, never retained drafts under a - # null, absent or inherited container. Its own nominal type - # supplies inherited defaults for this source's child. - if DataclassFieldAccess.is_container(parent): - raw = DataclassFieldAccess.raw_value(parent, address.field_name) - owner = type(parent) - else: - raw = Unset() + raw, owner = structure.resolved_child(address.parent.value, resolve(address.parent.value), address.field_name, owner) else: - raw = structure.reconstruct(path, values, current_obj) + raw = structure.reconstruct_member(path, values, current_node) def inherited(): if not isinstance(owner, type): - return Unset() # A callable parameter has no config/MRO layer. + return Unset() result = resolve_with_provenance(owner, address.field_name) provenance[path] = (result.source_scope, result.source_type) - return result.value - snapshot[path] = FieldValue.of(raw).resolve(inherited) + return structure.admit(path, result.value) + snapshot[path] = raw.resolve(inherited) return snapshot[path] for path in parameters: if field_paths is None or path in field_paths: @@ -2038,57 +1948,17 @@ def mark_saved(self) -> None: # Ensure live cache is populated for accurate dirty computation post-save self._ensure_live_resolved(notify_flash=False) - # CRITICAL: Extract old values from object_instance BEFORE rebuilding it - # These are the values that descendants might be inheriting from - old_instance_values = {} - if not isinstance(self.object_instance, type): - # Extract raw attribute values from the old object_instance - for param_name in self._parameters.keys(): - # Skip container entries (nested dataclass instances) - if param_name in self._parameters: - raw_value = self._parameters.get(param_name) - if self._parameter_structure.is_container_parameter(param_name, raw_value, self._parameters): - continue - - # Get the old value by navigating dotted path on the extraction target - # For delegation, parameters are on the delegate, not object_instance - try: - # Navigate through nested attributes for dotted paths - obj = self._extraction_target - parts = param_name.split('.') - for part in parts: - obj = object.__getattribute__(obj, part) - old_instance_values[param_name] = obj - except AttributeError: - # Field doesn't exist on extraction target, skip it - pass - - # Find parameters that differ between old object_instance and new live parameters - # These are the fields that changed and need descendant invalidation - changed_params = [] - for param_name in self._parameters.keys(): - # Skip container entries - raw_value = self._parameters.get(param_name) - if self._parameter_structure.is_container_parameter(param_name, raw_value, self._parameters): - continue - - old_value = old_instance_values.get(param_name) - new_value = self._parameters.get(param_name) - if old_value != new_value: - changed_params.append(param_name) - - # CRITICAL: Rebuild extraction target BEFORE invalidating descendants - # Descendants will recompute using parent's extraction target, so it must have new values! - if not isinstance(self.object_instance, type): - if self._delegate_attr is not None: - # DELEGATION: to_object() returns the delegate and updates it on object_instance - # as a side effect. Keep object_instance unchanged (it's the lifecycle object). - # _extraction_target is updated to point to the new delegate. - self._extraction_target = self.to_object(update_delegate=True) - else: - # NON-DELEGATION: to_object() returns the reconstructed object_instance - self.object_instance = self.to_object(update_delegate=True) - self._extraction_target = self.object_instance # Keep in sync + # Compare the saved source before applying the live projection. Each + # member decides whether its registered descendants carry notifications. + saved_members = ParameterStructure.project_target(self._target, type(self)) + self.to_object(update_delegate=False) + current_members = ParameterStructure.project_target(self._cached_object, type(self)) + changed_params = [ + path for path, member in current_members.items() + if path in member.notification_paths(path, self._parameter_structure) + and saved_members.get(path, Unset()) != member + ] + self.to_object(update_delegate=True) # Update saved parameters (after object_instance update, before invalidation) self._saved_parameters = self._copy_parameters_for_saved_baseline(self._parameters) @@ -2214,15 +2084,11 @@ def _restore_saved_impl(self, *, propagate_descendants: bool = True) -> None: # Clear and re-extract from the saved baseline # CRITICAL: For delegation, extract from the delegate (pipeline_config), not the lifecycle object. # This keeps flat parameters aligned with the form's target object after window close/reopen. - # Also refresh _extraction_target in case the delegate attribute was replaced externally. - extraction_target = self._extraction_target - if self._delegate_attr is not None: - try: - extraction_target = getattr(self.object_instance, self._delegate_attr) - self._extraction_target = extraction_target - except Exception: - # Fallback to existing extraction target if delegate access fails - extraction_target = self._extraction_target + # Also refresh _target in case the delegate attribute was replaced externally. + extraction_target = ( + admit_target(object.__getattribute__(self.object_instance, self._delegate_attr)) + if self._delegate_attr is not None else self._target + ) self._replace_parameter_structure( extraction_target, exclude_params=self._exclude_param_names, @@ -2309,99 +2175,23 @@ def should_skip_updates(self) -> bool: # ==================== FLAT STORAGE METHODS (NEW) ==================== - @classmethod - def _extract_all_parameters_flat( - cls, - obj: Any, - parameters: dict[str, Any], - structure: ParameterStructure, - prefix: str = '', - exclude_params: list[str] | None = None, - ) -> None: - """Recursively extract parameters into flat dict with dotted paths. - - Populate raw parameters and the existing declaration structure through - the canonical parameter analyzer, without constructing an editing state. - - Args: - obj: Object to extract from (dataclass instance, callable, or regular object) - prefix: Current path prefix (e.g., 'partition_filter_config') - exclude_params: List of top-level parameter names to exclude - """ - exclude_params = exclude_params or [] - - obj_type = type(obj) - is_function = isinstance(obj, FunctionType) - owner_target: ParameterOwner = obj if is_function else obj_type - - # Delegate signature default extraction to python_introspect (it must derive - # defaults from the type/signature and avoid instance attribute reads). - param_info = cls._analyze_parameters(obj, exclude_params if not prefix else []) - - logger.debug(f"🔧 _extract_all_parameters_flat: obj_type={obj_type.__name__}, prefix={prefix!r}, param_info keys={list(param_info.keys())}") - - for param_name, info in param_info.items(): - # Skip excluded parameters (only at top level) - if not prefix and param_name in exclude_params: - continue - - # Build dotted path - dotted_path = f'{prefix}.{param_name}' if prefix else param_name - - info = structure.declare(dotted_path, info, owner_target) - - # Get current value - if is_function: - # For functions: use signature default from UnifiedParameterAnalyzer - # (functions don't have instance attributes) - current_value = info.default_value - else: - # For class instances: bypass lazy resolution via object.__getattribute__ - try: - current_value = ( - DataclassFieldAccess.raw_value(obj, param_name) - if DataclassFieldAccess.is_container(obj) else getattr(obj, param_name) - ) - except AttributeError: - current_value = info.default_value - - if DataclassFieldAccess.is_container(current_value): - # Store the nested dataclass instance in parameters (needed for UI rendering) - parameters[dotted_path] = current_value - - # Recurse into nested dataclass for child fields - cls._extract_all_parameters_flat( - current_value, parameters, structure, prefix=dotted_path, exclude_params=[] - ) - else: - # Leaf field - store value and container type - parameters[dotted_path] = current_value # Store the owner target that has this field. def _replace_parameter_structure( - self, - obj: Any, - *, - exclude_params: list[str] | None = None, - initial_values: dict[str, Any] | None = None, + self, obj, *, exclude_params=None, initial_values=None, ) -> None: - """Extract one canonical flat parameter structure and index it once.""" - + """Admit one value graph and replace its declaration topology atomically.""" + self._target = admit_target(obj) self._parameters.clear() - self._parameter_structure = ParameterStructure.for_target(obj, exclude_params) - self._extract_all_parameters_flat( - obj, - self._parameters, - self._parameter_structure, - prefix="", - exclude_params=exclude_params, + self._parameter_structure = ParameterStructure.for_target(self._target, exclude_params) + self._target.extract_parameters( + type(self), self._parameters, self._parameter_structure, + exclusions=exclude_params or (), ) - if initial_values: - for param_name, value in initial_values.items(): - self._parameters[param_name] = value - self._parameters.update( - self._dataclass_parameter_updates(param_name, value) - ) + for path, datum in (initial_values or {}).items(): + member = self._parameter_structure.admit(path, datum) + self._parameters[path] = member + self._parameters.update(self._parameter_structure.descendants(path, member, self._parameters)) self._index_parameter_paths() def _index_parameter_paths(self) -> None: @@ -2420,109 +2210,23 @@ def _index_parameter_paths(self) -> None: for owner_path, parameter_paths in paths_by_owner.items() } - def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True) -> Any: - """Reconstruct object from flat parameters with updated nested configs. - - BOUNDARY METHOD - EXPENSIVE - only call at system boundaries: - - Save operation - - Execute operation - - Serialization - - UNIFIED: Works for ANY object_instance type. - - Python functions: can't copy, return original - - Everything else: shallow copy + reconstruct nested dataclass fields - - DELEGATION: If __objectstate_delegate__ was used: - - Reconstructs the delegate (e.g., pipeline_config) - - If update_delegate=True, updates the delegate attribute on object_instance - - Returns the reconstructed delegate (NOT object_instance) - - Callers needing the lifecycle object (orchestrator) should use state.object_instance - - Args: - update_delegate: If True, apply reconstructed delegate to object_instance - sync_delegate: If True (default), check for delegate changes before reconstruction. - Set to False during cache recomputation to avoid re-entrant invalidation. - - Returns: - The reconstructed object that matches the stored parameters. - For delegation, this is the delegate type (config), not the lifecycle object. - """ - # Auto-detect delegate changes before reconstruction (unless explicitly disabled) + def to_object(self, *, update_delegate=False, sync_delegate=True): + """Materialize through the admitted root; apply a cached result only on request.""" if sync_delegate: self._check_and_sync_delegate() - - if self._cached_object is not None: - if not update_delegate: - return self._cached_object - if self._delegate_attr is None or self._cached_object_applied: - return self._cached_object - # Apply cached delegate to lifecycle object when requested - setattr(self.object_instance, self._delegate_attr, self._cached_object) - self._cached_object_applied = True - return self._cached_object - - # UNIFIED: reconstruct nested dataclass fields - # Works for dataclass, non-dataclass class instances, AND functions - import copy - - # For delegation, work with the extraction target (delegate), not object_instance - target = self._extraction_target - - # Collect ALL top-level field updates from self._parameters - # This includes both primitive fields AND nested dataclass fields - field_updates = self.reconstruct_top_level_parameters() - - # Reconstruct the target object (either object_instance or delegate) - reconstructed = None - - # Python functions can't be copied, but we CAN update their attributes - # This is critical for MRO resolution to see edited config values - if isinstance(target, FunctionType): - for field_name, field_value in field_updates.items(): - setattr(target, field_name, field_value) - reconstructed = target - elif DataclassFieldAccess.is_container(target): - # CRITICAL: Use replace_raw to preserve raw None values! - # dataclasses.replace triggers lazy resolution via __getattribute__, - # which resolves None -> concrete defaults and breaks inheritance. - from objectstate.lazy_factory import replace_raw - reconstructed = replace_raw(target, **field_updates) - else: - # Non-dataclass class instance - shallow copy + setattr - obj_copy = copy.copy(target) - obj_type = type(target) - for field_name, field_value in field_updates.items(): - # Skip read-only properties (those without setters) - prop = getattr(obj_type, field_name, None) - if isinstance(prop, property) and prop.fset is None: - continue - setattr(obj_copy, field_name, field_value) - reconstructed = obj_copy - - # DELEGATION: If using delegation, update the delegate attribute on object_instance - # as a side effect, but return the reconstructed delegate (not object_instance). - # This ensures callers get the correct type (config, not orchestrator). - # Callers who need the lifecycle object should access state.object_instance directly. - if self._delegate_attr is not None: - if update_delegate: - setattr(self.object_instance, self._delegate_attr, reconstructed) - self._cached_object_applied = True + if self._cached_object is None: + values = self._parameter_structure.direct_values('', self._parameters, self._target) + self._cached_object = self._target.with_fields(values) + self._cached_object_applied = False + node = self._cached_object + if update_delegate and not self._cached_object_applied: + if self._delegate_attr is None: + self.object_instance = node.stored else: - self._cached_object_applied = False - # Return the reconstructed delegate - this is what the parameters represent - self._cached_object = reconstructed - else: - # NON-DELEGATION: Update object_instance to point to reconstructed object - # This ensures that when to_object() is called (e.g., on window save), - # the ObjectState automatically points to the new instance - if update_delegate: - self.object_instance = reconstructed - self._extraction_target = reconstructed - logger.debug(f"Auto-updated object_instance to new reconstructed object for scope={self.scope_id!r}") - self._cached_object = reconstructed + setattr(self.object_instance, self._delegate_attr, node.stored) + self._target = node self._cached_object_applied = True - - return self._cached_object + return node.stored @@ -2537,10 +2241,11 @@ def _get_changed_params_with_types( """ changed_params = [] - # Get all parameter names from the new extraction target + old_values = ParameterStructure.project_target(old_target, type(self)) + new_values = ParameterStructure.project_target(new_target, type(self)) for param_name in self._parameters.keys(): - old_value = self._get_param_value_from_target(old_target, param_name) - new_value = self._get_param_value_from_target(new_target, param_name) + old_value = old_values.get(param_name, Unset()) + new_value = new_values.get(param_name, Unset()) if old_value != new_value: container_type = self._parameter_structure.owner_paths.get(param_name, type(self.object_instance)) @@ -2548,22 +2253,3 @@ def _get_changed_params_with_types( changed_params.append((param_name, container_type, leaf_field_name)) return changed_params - - def _get_param_value_from_target(self, target: Any, param_name: str) -> Any: - """ - Get a parameter value from an extraction target by dotted path. - - Handles nested dataclass attributes. - """ - if target is None: - return None - - parts = param_name.split('.') - current = target - - for part in parts: - if not hasattr(current, part): - return None - current = getattr(current, part) - - return current diff --git a/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index b6f6d94..207f89a 100644 --- a/src/objectstate/object_state_registry.py +++ b/src/objectstate/object_state_registry.py @@ -26,10 +26,12 @@ from objectstate.object_state_metadata import ObjectStateMetadataStore from objectstate.construction_binding import StateConstructionBinding from objectstate.scope_key import ScopeKey +from objectstate.field_value import Unset from objectstate.parameter_owner import ParameterOwner from objectstate.snapshot_model import Snapshot, StateSnapshot, Timeline from objectstate.time_travel_profile import TimeTravelProfiler from objectstate.transaction_checkpoint import ObjectStateTransactionCheckpoint +from python_introspect import resolve_declared_type if TYPE_CHECKING: from objectstate.object_state import ObjectState @@ -452,7 +454,7 @@ def global_config_state(cls, config_type: type) -> Optional['ObjectState']: where it was not registered) ends that at once. """ state = cls._states.get("") - if state is not None and type(state._extraction_target) is config_type: + if state is not None and type(state._target.stored) is config_type: return state return None @@ -821,12 +823,11 @@ def _invalidate_by_type_and_scope_now( include_origin: bool = True, ) -> None: """Immediate implementation for scope/type/field-aware invalidation.""" - from objectstate.lazy_factory import get_base_type_for_lazy changed_scope = cls._normalize_scope_id(scope_id) # Normalize to base type for comparison (LazyX → X) if isinstance(changed_type, type): - base_changed_type = get_base_type_for_lazy(changed_type) or changed_type + base_changed_type = resolve_declared_type(changed_type) else: base_changed_type = changed_type @@ -1716,12 +1717,9 @@ def _time_travel_changed_param_values( ) -> Dict[str, Tuple[Any, Any]]: changed: Dict[str, Tuple[Any, Any]] = {} for param_key in set(target_params.keys()) | set(current_params.keys()): - before = current_params.get(param_key) - after = target_params.get(param_key) - if DataclassFieldAccess.is_container(before) or DataclassFieldAccess.is_container(after): - continue - if before != after: - changed[param_key] = (before, after) + before = current_params.get(param_key, Unset()) + after = target_params.get(param_key, Unset()) + changed.update(after.history_changes(before, param_key)) return changed @staticmethod @@ -1733,8 +1731,8 @@ def _time_travel_changed_live_values( ) -> Dict[str, Tuple[Any, Any]]: return { path: ( - current_live.get(path), - target_live.get(path), + current_live.get(path, Unset()), + target_live.get(path, Unset()), ) for path in changed_paths } diff --git a/src/objectstate/parameter_structure.py b/src/objectstate/parameter_structure.py index 98a166f..d297d12 100644 --- a/src/objectstate/parameter_structure.py +++ b/src/objectstate/parameter_structure.py @@ -6,10 +6,12 @@ from dataclasses import dataclass, field, fields, replace from typing import Any -from python_introspect import ParameterInfo +from python_introspect import ParameterInfo, resolve_declared_type from objectstate.field_access import DataclassFieldAccess, DottedFieldPath -from objectstate.field_value import Unset +from objectstate.field_value import FieldValue, Unset +from objectstate.declarations import ValueDeclaration +from objectstate.construction_binding import admit_target from objectstate.parameter_owner import ParameterOwner @@ -27,23 +29,28 @@ class ParameterStructure: def declare(self, path: str, info: ParameterInfo, owner: ParameterOwner) -> ParameterInfo: """Admit a declaration and its owner together, never the payload's type.""" - declaration = info._replace(default_value=Unset()) if info.is_required else info + declaration = info._replace(default_value=Unset() if info.is_required else ValueDeclaration.of(info.param_type).admit(info.default_value)) self.declarations[path] = declaration self.owner_paths[path] = owner return declaration def binding_owner(self, path: str) -> ParameterOwner: """Immutable declaration identity survives schema copying and undo.""" - from objectstate.lazy_factory import get_base_type_for_lazy + return resolve_declared_type(self.owner_paths[path]) - owner = self.owner_paths[path] - return (get_base_type_for_lazy(owner) or owner) if isinstance(owner, type) else owner + def admit(self, path, value): + return ValueDeclaration.of(self.declarations[path].param_type).admit(value) + + def descendants(self, path, value, registered): + return ValueDeclaration.of(self.declarations[path].param_type).descendants(value, path, registered) + + def resolved_child(self, parent, value, name, owner): + return ValueDeclaration.of(self.declarations[parent].param_type).resolved_child(value, name, owner) def container_types(self, values: dict[str, Any]): """Nominal payload types come from the projection, not declaration owners.""" - for path, value in values.items(): - if DataclassFieldAccess.is_container(value): - yield path, type(value) + for path, member in values.items(): + yield from member.payload_types(path) def affected_paths(self, changed): """A container's presence/source is a dependency of every declared child.""" @@ -68,16 +75,6 @@ def descriptions(self) -> dict[str, str | None]: return {path: info.description for path, info in self.declarations.items()} - def is_container_parameter( - self, name: str, value: Any, parameters: dict[str, Any] - ) -> bool: - """Identify a nested dataclass entry from its actual extracted children.""" - - if not DataclassFieldAccess.is_container(value): - return False - prefix = f"{name}." - return any(path.startswith(prefix) for path in parameters) - @classmethod def for_target( cls, target: Any, exclusions: list[str] | None @@ -87,11 +84,18 @@ def for_target( names = list(exclusions or []) return cls( exclusions=names, - excluded_values={ - name: getattr(target, name) for name in names if hasattr(target, name) - }, + excluded_values={name: target.read_raw(name) for name in names}, + ) + @classmethod + def project_target(cls, target, state_type): + namespace = admit_target(target) + structure = cls.for_target(namespace, None) + values = {} + namespace.extract_parameters(state_type, values, structure) + return values + def clone(self) -> ParameterStructure: """Detach owned maps without cloning declaration/target identities. @@ -104,37 +108,22 @@ def clone(self) -> ParameterStructure: **{item.name: copy.copy(getattr(self, item.name)) for item in fields(self)}, ) - def reconstruct(self, prefix, values, target, *, resolved=False): - """Build one projection from its own payload, preserving scalar terminals. - - A container's stored None/Inherit/Unset is authoritative even when an - editor retains child drafts. Nominal type comes from that payload, never - from the declaration-owner index. Live and saved projections share this. - """ - from objectstate.lazy_factory import get_base_type_for_lazy, replace_raw - - prototype = values.get(prefix, Unset()) if prefix else target - if prefix and not DataclassFieldAccess.is_container(prototype): - return prototype - prefix_dot = f"{prefix}." if prefix else "" - direct = {} - for path, value in values.items(): - if not path.startswith(prefix_dot): - continue - remainder = path[len(prefix_dot):] - if "." in remainder: - continue - direct[remainder] = ( - self.reconstruct(path, values, target, resolved=resolved) - if DataclassFieldAccess.is_container(value) else value - ) + def direct_values(self, prefix, values, target, *, resolved=False): + """Project topology; each admitted member owns reconstruction behavior.""" + prefix_dot = f'{prefix}.' if prefix else '' + direct = { + path[len(prefix_dot):]: self.reconstruct_member(path, values, target, resolved=resolved) + for path in values + if path.startswith(prefix_dot) and '.' not in path[len(prefix_dot):] + } if not prefix: direct.update(self.excluded_values) - if DataclassFieldAccess.is_container(prototype): - if not resolved: - return replace_raw(prototype, **direct) - constructor = get_base_type_for_lazy(type(prototype)) or type(prototype) - arguments = DataclassFieldAccess.raw_init_values(prototype, constructor) - arguments.update(direct) - return constructor(**arguments) - return type(prototype)(**direct) + return direct + + def reconstruct_member(self, prefix, values, target, *, resolved=False): + prototype = values.get(prefix, Unset()) if prefix else target + return prototype.reconstruct(prefix, values, self, target, resolved=resolved) + + def reconstruct(self, prefix, values, target, *, resolved=False): + """Expose domain data only at the reconstruction boundary.""" + return self.reconstruct_member(prefix, values, target, resolved=resolved).stored diff --git a/src/objectstate/placeholder.py b/src/objectstate/placeholder.py index fcbcf94..6778971 100644 --- a/src/objectstate/placeholder.py +++ b/src/objectstate/placeholder.py @@ -1,154 +1,38 @@ -""" -Generic lazy placeholder service for UI integration. +"""Placeholder presentation over the canonical resolver and admitted members.""" -Provides placeholder text resolution for lazy configuration dataclasses -using contextvars-based context management. -""" - -from typing import Any, Optional -import dataclasses -from objectstate.field_access import DataclassFieldAccess -import logging - -from objectstate.lazy_factory import has_lazy_resolution - -logger = logging.getLogger(__name__) - - -# _has_concrete_field_override moved to dual_axis_resolver_recursive.py -# Placeholder service should not contain inheritance logic +from objectstate.field_value import FieldValue class LazyDefaultPlaceholderService: - """ - Simplified placeholder service using new contextvars system. - - Provides consistent placeholder pattern for lazy configuration classes - using the same resolution mechanism as the compiler. - """ - + """Format actual resolved data without another inheritance/default algorithm.""" + PLACEHOLDER_PREFIX = "Default" - NONE_VALUE_TEXT = "(none)" @staticmethod def get_lazy_resolved_placeholder( dataclass_type: type, field_name: str, - placeholder_prefix: Optional[str] = None, - context_obj: Optional[Any] = None - ) -> Optional[str]: - """ - Get placeholder text using the new contextvars system. - - Args: - dataclass_type: The dataclass type to resolve for - field_name: Name of the field to resolve - placeholder_prefix: Optional prefix for placeholder text - context_obj: Optional context object (orchestrator, step, dataclass instance, etc.) - unused since context should be set externally - - Returns: - Formatted placeholder text or None if no resolution possible - """ - prefix = placeholder_prefix or LazyDefaultPlaceholderService.PLACEHOLDER_PREFIX - - if not has_lazy_resolution(dataclass_type): - # Non-lazy type - use direct class default - return LazyDefaultPlaceholderService._get_class_default_placeholder( - dataclass_type, field_name, prefix - ) - - # Create instance and let lazy __getattribute__ handle context resolution - try: - instance = dataclass_type() - resolved_value = getattr(instance, field_name) - return LazyDefaultPlaceholderService._format_placeholder_text(resolved_value, prefix) - except Exception as e: - logger.debug(f"Failed to resolve {dataclass_type.__name__}.{field_name}: {e}") - # Fallback to class default - class_default = LazyDefaultPlaceholderService._get_class_default_value(dataclass_type, field_name) - return LazyDefaultPlaceholderService._format_placeholder_text(class_default, prefix) - - @staticmethod - def _get_class_default_placeholder(dataclass_type: type, field_name: str, prefix: str) -> Optional[str]: - """Get placeholder for non-lazy dataclasses using class defaults.""" - try: - # Use object.__getattribute__ to avoid triggering lazy __getattribute__ recursion - class_default = object.__getattribute__(dataclass_type, field_name) - if class_default is not None: - return LazyDefaultPlaceholderService._format_placeholder_text(class_default, prefix) - except AttributeError: - pass - return None + placeholder_prefix: str | None = None, + context_obj=None, + ) -> str: + from objectstate.context_manager import config_context + from objectstate.dual_axis_resolver import resolve_with_provenance + + prefix = ( + LazyDefaultPlaceholderService.PLACEHOLDER_PREFIX + if placeholder_prefix is None else placeholder_prefix + ) + from contextlib import nullcontext + + context = config_context(context_obj) if context_obj is not None else nullcontext() + with context: + member = FieldValue.of(resolve_with_provenance(dataclass_type, field_name).value) + return LazyDefaultPlaceholderService.format_placeholder(member, prefix) @staticmethod - def _get_class_default_value(dataclass_type: type, field_name: str) -> Any: - """Get class default value for a field.""" - try: - # Use object.__getattribute__ to avoid triggering lazy __getattribute__ recursion - return object.__getattribute__(dataclass_type, field_name) - except AttributeError: - return None - - @staticmethod - def _format_placeholder_text(resolved_value: Any, prefix: str) -> Optional[str]: - """Format resolved value into placeholder text.""" - if resolved_value is None: - value_text = LazyDefaultPlaceholderService.NONE_VALUE_TEXT - elif hasattr(resolved_value, '__dataclass_fields__'): - value_text = LazyDefaultPlaceholderService._format_nested_dataclass_summary(resolved_value) - else: - # Apply proper formatting for different value types - if hasattr(resolved_value, 'value') and hasattr(resolved_value, 'name'): # Enum - # Format as "EnumName.VALUE" for display - value_text = f"{type(resolved_value).__name__}.{resolved_value.name}" - else: - value_text = str(resolved_value) - - # Apply prefix formatting + def format_placeholder(value, prefix: str) -> str: + text = FieldValue.of(value).placeholder_text() if not prefix: - return value_text - elif prefix.endswith(': '): - return f"{prefix}{value_text}" - elif prefix.endswith(':'): - return f"{prefix} {value_text}" - else: - return f"{prefix}: {value_text}" - - @staticmethod - def _format_nested_dataclass_summary(dataclass_instance) -> str: - """ - Format nested dataclass with all field values for user-friendly placeholders. - """ - - class_name = dataclass_instance.__class__.__name__ - all_fields = [f.name for f in dataclasses.fields(dataclass_instance)] - - field_summaries = [] - for field_name in all_fields: - try: - value = getattr(dataclass_instance, field_name) - - # Skip None values to keep summary concise - if value is None: - continue - - # Format different value types appropriately - if hasattr(value, 'value') and hasattr(value, 'name'): # Enum - # Format as "EnumName.VALUE" for display - formatted_value = f"{type(value).__name__}.{value.name}" - elif isinstance(value, str) and len(value) > 20: # Long strings - formatted_value = f"{value[:17]}..." - elif DataclassFieldAccess.is_container(value): - formatted_value = f"{value.__class__.__name__}(...)" - else: - formatted_value = str(value) - - field_summaries.append(f"{field_name}={formatted_value}") - - except (AttributeError, Exception): - continue - - if field_summaries: - return ", ".join(field_summaries) - else: - return f"{class_name} (default settings)" + return text + separator = "" if prefix.endswith(": ") else " " if prefix.endswith(":") else ": " + return f"{prefix}{separator}{text}" diff --git a/src/objectstate/resolution_sources.py b/src/objectstate/resolution_sources.py index 0a6dc65..5626654 100644 --- a/src/objectstate/resolution_sources.py +++ b/src/objectstate/resolution_sources.py @@ -20,11 +20,9 @@ """ from collections import OrderedDict -from dataclasses import fields, is_dataclass from typing import Any, NamedTuple -from objectstate.field_access import DataclassFieldAccess -from objectstate.field_value import Unset +from objectstate.field_value import FieldValue, Unset FieldKey = tuple[type, str] @@ -104,58 +102,28 @@ def _compare_layer_objects(before: Any, now: Any) -> set[FieldKey]: configs_before = extract_all_configs(before) configs_now = extract_all_configs(now) - held_before = {id(config) for config in configs_before.values()} - held_now = {id(config) for config in configs_now.values()} + held_before = {id(config.stored) for config in configs_before.values()} + held_now = {id(config.stored) for config in configs_now.values()} differing: set[FieldKey] = set() for config_type in configs_before.keys() | configs_now.keys(): - config_before = configs_before.get(config_type) - config_now = configs_now.get(config_type) - if config_before is config_now: + config_before = configs_before.get(config_type, Unset()) + config_now = configs_now.get(config_type, Unset()) + if config_before.stored is config_now.stored: continue names = { - f.name for config in (config_before, config_now) if config is not None - for f in fields(config) + field.name for config in (config_before, config_now) + for field in config.declared_fields() } - if config_before is None or config_now is None: + if not config_before.available() or not config_now.available(): # A layer that starts or stops holding a config type changes where an # unset field is reported to live, whatever the values are. differing.update((config_type, name) for name in names) continue for name in names: - value_before = _stored(config_before, name) - value_now = _stored(config_now, name) - if id(value_before) in held_before and id(value_now) in held_now: + value_before = config_before.read_raw(name) + value_now = config_now.read_raw(name) + if id(value_before.stored) in held_before and id(value_now.stored) in held_now: continue # a nested config on both sides: compared under its own type - if not _same_stored_value(value_before, value_now): + if type(value_before.stored) is not type(value_now.stored) or value_before != value_now: differing.add((config_type, name)) return differing - - -def _stored(config: Any, name: str) -> Any: - """The raw field of a config, without lazy resolution; ``None`` when absent.""" - if config is None: - return Unset() - try: - return DataclassFieldAccess.raw_value(config, name) - except AttributeError: - return Unset() - - -def _same_stored_value(a: Any, b: Any) -> bool: - """Equality of two stored values that never resolves a lazy field. - - Dataclass instances are compared field by field on their stored values: their - own ``__eq__`` reads attributes, which resolves lazy fields in the caller's - context. A value whose comparison fails or is not a plain truth value counts - as different. - """ - if a is b: - return True - if type(a) is not type(b): - return False - if is_dataclass(a) and not isinstance(a, type): - return all(_same_stored_value(_stored(a, f.name), _stored(b, f.name)) for f in fields(a)) - try: - return bool(a == b) - except Exception: - return False diff --git a/src/objectstate/subfield_semantics.py b/src/objectstate/subfield_semantics.py index db6bc31..0b1f999 100644 --- a/src/objectstate/subfield_semantics.py +++ b/src/objectstate/subfield_semantics.py @@ -305,25 +305,8 @@ def _build_leaf( def _leaf_values(value) -> dict: - if not FieldValue.of(value).available(): - return {} - return dict(_iter_leaf_values(value, StructuralValuePath())) - - -def _iter_leaf_values(value, path: StructuralValuePath): - if DataclassFieldAccess.is_container(value): - for dataclass_field in dataclass_fields(type(value)): - child_path = path.child_field(dataclass_field.name) - child_value = DataclassFieldAccess.raw_value(value, dataclass_field.name) - yield from _iter_leaf_values(child_value, child_path) - return - - if isinstance(value, (tuple, list)): - for index, child_value in enumerate(value): - yield from _iter_leaf_values(child_value, path.child_index(index)) - return - - yield path, value + member = FieldValue.of(value) + return dict(member.structural_leaves(StructuralValuePath())) if member.available() else {} def _ordered_paths(*leaf_sets: dict) -> tuple[StructuralValuePath, ...]: diff --git a/src/objectstate/ui_visibility.py b/src/objectstate/ui_visibility.py index f54e81b..d0c4753 100644 --- a/src/objectstate/ui_visibility.py +++ b/src/objectstate/ui_visibility.py @@ -68,7 +68,15 @@ class UIParameterVisibilityRequest: class UIParameterVisibilityPolicy: - """Authority for deciding whether a parameter is omitted from generic UI forms.""" + """Authority for field metadata and omission from generic UI forms.""" + + @classmethod + def field_metadata(cls, hidden: bool) -> dict: + return {cls: hidden} + + @classmethod + def field_hidden(cls, declaration) -> bool: + return cls in declaration.metadata and declaration.metadata[cls] @classmethod def should_hide(cls, request: UIParameterVisibilityRequest) -> bool: diff --git a/src/objectstate/value_semantics.py b/src/objectstate/value_semantics.py index 87142eb..b9bc2cb 100644 --- a/src/objectstate/value_semantics.py +++ b/src/objectstate/value_semantics.py @@ -1,34 +1,36 @@ -"""Shared value comparison semantics for declaration and state reconciliation.""" - +"""Shared plain-data comparison, copy identity and collection projection.""" from __future__ import annotations from collections.abc import Mapping, Sequence def semantic_values_equal(left: object, right: object) -> bool: - """Compare nested values without requiring hashability or scalar equality.""" - if left is right: return True if isinstance(left, Mapping) and isinstance(right, Mapping): return left.keys() == right.keys() and all( semantic_values_equal(left[key], right[key]) for key in left ) - if ( - isinstance(left, Sequence) - and isinstance(right, Sequence) - and not isinstance(left, (str, bytes, bytearray)) - and not isinstance(right, (str, bytes, bytearray)) - ): + if (isinstance(left, Sequence) and isinstance(right, Sequence) + and not isinstance(left, (str, bytes, bytearray)) + and not isinstance(right, (str, bytes, bytearray))): return len(left) == len(right) and all( - semantic_values_equal(left_value, right_value) - for left_value, right_value in zip(left, right) + semantic_values_equal(a, b) for a, b in zip(left, right) ) - try: - comparison = left == right - except Exception: - return False - try: - return bool(comparison) - except (TypeError, ValueError): - return False + return bool(left == right) + + +def seed_callable_identity(value, memo, seen): + identity = id(value) + if identity in seen: + return + seen.add(identity) + if callable(value): + memo[identity] = value + elif isinstance(value, Mapping): + for key, child in value.items(): + seed_callable_identity(key, memo, seen) + seed_callable_identity(child, memo, seen) + elif isinstance(value, (Sequence, set, frozenset)) and not isinstance(value, (str, bytes, bytearray)): + for child in value: + seed_callable_identity(child, memo, seen) diff --git a/tests/test_declarations.py b/tests/test_declarations.py new file mode 100644 index 0000000..da48d8b --- /dev/null +++ b/tests/test_declarations.py @@ -0,0 +1,72 @@ +"""Declaration-owned behavior, meaning and collection traversal.""" +from dataclasses import dataclass +from types import SimpleNamespace + +import pytest + +from objectstate import DottedFieldPath, ObjectState +from objectstate.declarations import FactoryDefault, InheritedDefault, ValueDeclaration, ValueDefault +from objectstate.field_value import ConfigValue, Inherit, Unset, Value +from objectstate.subfield_semantics import StructuralValuePath + + +@dataclass +class Item: + text: str | None = "default" + + +@dataclass +class Rows: + items: tuple[Item, ...] | None = None + + +def test_payload_does_not_choose_a_field_kind(): + payload = Item() + member = ValueDeclaration.of(object).admit(payload) + assert type(member) is Value + assert member.stored is payload + assert not hasattr(member, "read_raw") + assert not hasattr(member, "declared_fields") + assert isinstance(ValueDeclaration.of(Item).admit(payload), ConfigValue) + + +def test_nullable_scope_preserves_null_and_directives(): + declaration = ValueDeclaration.of(Item | None) + assert declaration.admit(None).stored is None + assert declaration.admit(None).available() + assert not declaration.scope_available(declaration.admit(None)) + assert declaration.admit(Inherit()) == Inherit() + assert declaration.admit(Unset()) == Unset() + assert declaration.scope_available(declaration.admit(Item())) + + +def test_collection_serialization_uses_element_declarations(): + declaration = ValueDeclaration.of(tuple[Item, ...]) + member = declaration.admit((Item(Inherit()), Item(None), Item(""))) + assert member.for_serialization(lambda owner, name: SimpleNamespace(value="inherited")) == ( + Item("inherited"), Item(None), Item("") + ) + + +def test_collection_leaves_use_the_bound_declaration(): + state = ObjectState(Rows((Item("before"),))) + state.fields.items.write((Item("after"),)) + index = state.subfield_semantics(DottedFieldPath("items")) + leaf = index.leaf_for(StructuralValuePath().child_index(0).child_field("text")) + assert leaf.raw_value == "after" + assert leaf.saved_resolved_value == "before" + assert leaf.dirty + assert leaf.signature_default_value == Unset() + + +def test_inherited_defaults_have_one_typed_metadata_owner(): + literal = ValueDefault(None) + factory = FactoryDefault(lambda: 7) + assert isinstance(literal, InheritedDefault) + assert literal.value() is None + assert factory.value() == 7 + declaration = ValueDeclaration.of(Item) + with pytest.raises(KeyError): + declaration.own_default("not_declared") + with pytest.raises(KeyError): + declaration.inherited_default("not_declared") diff --git a/tests/test_inspection.py b/tests/test_inspection.py index e9c6952..d18bd21 100644 --- a/tests/test_inspection.py +++ b/tests/test_inspection.py @@ -74,7 +74,7 @@ def test_resolver_failure_propagates_its_original_identity(): failure = RuntimeError("failed real field read") class FailedRead(FieldProxy): @property - def value(self): + def resolved_member(self): raise failure state = ObjectState(Inspected()) with pytest.raises(RuntimeError) as caught: diff --git a/tests/test_value_node_deletion_gates.py b/tests/test_value_node_deletion_gates.py new file mode 100644 index 0000000..ea3af9d --- /dev/null +++ b/tests/test_value_node_deletion_gates.py @@ -0,0 +1,31 @@ +"""Deleted classifiers stay deleted; value admission is the only boundary.""" + +import ast +from pathlib import Path + + +SOURCE = Path(__file__).parents[1] / "src" / "objectstate" +DELETED_CLASSIFIERS = {"is_container", "is_config_type", "is_dataclass"} + + +def test_operations_do_not_reclassify_values(): + violations = [] + for path in SOURCE.rglob("*.py"): + tree = ast.parse(path.read_text(), filename=str(path)) + admitted_calls = set() + if path.name == "declarations.py": + for declaration in tree.body: + if isinstance(declaration, ast.FunctionDef) and declaration.name == "_compile_declaration": + admitted_calls.update(id(node) for node in ast.walk(declaration)) + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + name = node.name + elif isinstance(node, ast.Call): + name = node.func.id if isinstance(node.func, ast.Name) else ( + node.func.attr if isinstance(node.func, ast.Attribute) else "" + ) + else: + continue + if name in DELETED_CLASSIFIERS and id(node) not in admitted_calls: + violations.append(f"{path.relative_to(SOURCE)}:{node.lineno}: {name}") + assert not violations, "\n" + "\n".join(sorted(violations)) From 8642469024c8a1ff41652ac28281d1ffcc821b34 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 16:07:11 -0400 Subject: [PATCH 13/15] Let declared collection behavior normalize data shape --- src/objectstate/declarations.py | 10 ++++++++++ tests/test_declarations.py | 16 ++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/objectstate/declarations.py b/src/objectstate/declarations.py index f853851..21b144e 100644 --- a/src/objectstate/declarations.py +++ b/src/objectstate/declarations.py @@ -76,6 +76,10 @@ def configuration_owners(self): def choose_declaration(self, consumer, **arguments): """Consume the annotation's form capability.""" + def normalize_data(self, value): + """Retain data whose declaration requires no structural conversion.""" + return value + def convert_structure(self, consumer, value, name): return consumer.convert_leaf_structure(value, self.annotation, name) @@ -143,6 +147,9 @@ def __init__(self, annotation, container): def item_declaration(self, index): """Admit this index through its declared element annotation.""" + def normalize_data(self, value): + return self.container(value) + def items(self, value): return ((index, self.item_declaration(index).admit(child)) for index, child in enumerate(value)) @@ -202,6 +209,9 @@ def __init__(self, annotation, inner): def admit_data(self, value): return members.NullValue() if value is None else self.inner.admit_data(value) + def normalize_data(self, value): + return None if value is None else self.inner.normalize_data(value) + def scope_available(self, member): return member.fold(lambda value: value is not None, lambda directive: False) diff --git a/tests/test_declarations.py b/tests/test_declarations.py index da48d8b..79b467e 100644 --- a/tests/test_declarations.py +++ b/tests/test_declarations.py @@ -40,6 +40,22 @@ def test_nullable_scope_preserves_null_and_directives(): assert declaration.scope_available(declaration.admit(Item())) +@pytest.mark.parametrize("annotation, value, expected", [ + (tuple[int, ...], [1, 2], (1, 2)), + (list[int], (1, 2), [1, 2]), + (tuple[int, str], [1, "two"], (1, "two")), + (tuple[int, ...] | None, [1], (1,)), + (tuple[int, ...] | None, None, None), +]) +def test_declared_collection_shape_owns_normalization(annotation, value, expected): + assert ValueDeclaration.of(annotation).normalize_data(value) == expected + + +def test_scalar_declaration_does_not_classify_a_sequence_payload(): + payload = [1, 2] + assert ValueDeclaration.of(object).normalize_data(payload) is payload + + def test_collection_serialization_uses_element_declarations(): declaration = ValueDeclaration.of(tuple[Item, ...]) member = declaration.admit((Item(Inherit()), Item(None), Item(""))) From 78346c9c6d660a8b948f3e04389f809aab173725 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 23:18:23 -0400 Subject: [PATCH 14/15] Checkpoint core-native-runtime before compaction --- .scratch/bridge_handle_journey.py | 43 + .scratch/bridge_handles.py | 52 + .scratch/census.py | 28 + .scratch/consumer_state_cutover.py | 24 + .scratch/core_handle_consumers.py | 32 + .scratch/declaration-checkpoint-report.md | 30 + .scratch/declaration_lifetime_journey.py | 65 + .scratch/declaration_owner.py | 142 + .scratch/dto-member-bridge-journey.py | 127 + .scratch/dto-member-call-sites.py | 12 + .scratch/dto-members-pr-comment.md | 36 + .scratch/field_member_behavior.py | 28 + .scratch/field_namespace.py | 91 + .scratch/field_namespace_capability.py | 30 + .scratch/field_provenance_owner.py | 19 + .scratch/field_proxy.py | 191 + .scratch/field_subscription_owner.py | 36 + .scratch/finish_conversion.py | 31 + .scratch/finish_state.py | 59 + .scratch/form_manager_journey.py | 73 + .scratch/implement_state.py | 173 + .scratch/inspection-spinbox-journey.py | 52 + .scratch/inspection-spinbox-owner-evidence.py | 9 + .scratch/inspection-spinbox-pr-comment.md | 28 + .scratch/migrate-dto-member-fixtures.py | 43 + .scratch/migrate-node-dto-fixtures.py | 66 + .scratch/migrate_constructors.py | 38 + .scratch/native_signal_receivers.py | 17 + .scratch/node-dto-checkpoint.md | 11 + .scratch/node-family-sites.py | 20 + .scratch/normalize-consumer-owners.py | 45 + .scratch/normalize-declared-owners.py | 56 + .scratch/p44/ast-before.err | 0 .scratch/p44/ast-before.json | 85382 ++++++++++++++++ .scratch/p44/flag-sites-before.txt | 179 + .scratch/p44/full.txt | 39 + .scratch/p44/migrate-contracts.py | 27 + .scratch/p44/subfields.txt | 147 + .scratch/path_input_cutover.py | 15 + .scratch/placeholder_owner.py | 37 + .scratch/qt_dispatch_context_members.py | 15 + .scratch/qt_enabled_paint_owner.py | 86 + .scratch/qt_handle_admission.py | 62 + .scratch/qt_mutation_owner.py | 101 + .scratch/reconstruction_owner.py | 63 + .scratch/shared_spinbox_values.py | 89 + .scratch/spinbox-input-order.py | 14 + .scratch/toad_nullable_inputs.py | 94 + .scratch/value-node-context-migration.py | 79 + .scratch/value-node-lazy-migration.py | 76 + .scratch/value-node-resolver-migration.py | 35 + .scratch/value-node-state-migration.py | 135 + .scratch/widget_draft_owner.py | 157 + .scratch/widget_leaf_cutover.py | 75 + src/objectstate/codegen.py | 2 +- src/objectstate/field_value.py | 4 - src/objectstate/lazy_factory.py | 2 +- src/objectstate/object_state.py | 2 +- src/objectstate/subfield_semantics.py | 8 +- tests/test_integration.py | 10 +- tests/test_lazy_counterpart_registry.py | 9 +- tests/test_lazy_factory.py | 16 +- tests/test_subfield_semantics.py | 57 +- 63 files changed, 88653 insertions(+), 71 deletions(-) create mode 100644 .scratch/bridge_handle_journey.py create mode 100644 .scratch/bridge_handles.py create mode 100644 .scratch/census.py create mode 100644 .scratch/consumer_state_cutover.py create mode 100644 .scratch/core_handle_consumers.py create mode 100644 .scratch/declaration-checkpoint-report.md create mode 100644 .scratch/declaration_lifetime_journey.py create mode 100644 .scratch/declaration_owner.py create mode 100644 .scratch/dto-member-bridge-journey.py create mode 100644 .scratch/dto-member-call-sites.py create mode 100644 .scratch/dto-members-pr-comment.md create mode 100644 .scratch/field_member_behavior.py create mode 100644 .scratch/field_namespace.py create mode 100644 .scratch/field_namespace_capability.py create mode 100644 .scratch/field_provenance_owner.py create mode 100644 .scratch/field_proxy.py create mode 100644 .scratch/field_subscription_owner.py create mode 100644 .scratch/finish_conversion.py create mode 100644 .scratch/finish_state.py create mode 100644 .scratch/form_manager_journey.py create mode 100644 .scratch/implement_state.py create mode 100644 .scratch/inspection-spinbox-journey.py create mode 100644 .scratch/inspection-spinbox-owner-evidence.py create mode 100644 .scratch/inspection-spinbox-pr-comment.md create mode 100644 .scratch/migrate-dto-member-fixtures.py create mode 100644 .scratch/migrate-node-dto-fixtures.py create mode 100644 .scratch/migrate_constructors.py create mode 100644 .scratch/native_signal_receivers.py create mode 100644 .scratch/node-dto-checkpoint.md create mode 100644 .scratch/node-family-sites.py create mode 100644 .scratch/normalize-consumer-owners.py create mode 100644 .scratch/normalize-declared-owners.py create mode 100644 .scratch/p44/ast-before.err create mode 100644 .scratch/p44/ast-before.json create mode 100644 .scratch/p44/flag-sites-before.txt create mode 100644 .scratch/p44/full.txt create mode 100644 .scratch/p44/migrate-contracts.py create mode 100644 .scratch/p44/subfields.txt create mode 100644 .scratch/path_input_cutover.py create mode 100644 .scratch/placeholder_owner.py create mode 100644 .scratch/qt_dispatch_context_members.py create mode 100644 .scratch/qt_enabled_paint_owner.py create mode 100644 .scratch/qt_handle_admission.py create mode 100644 .scratch/qt_mutation_owner.py create mode 100644 .scratch/reconstruction_owner.py create mode 100644 .scratch/shared_spinbox_values.py create mode 100644 .scratch/spinbox-input-order.py create mode 100644 .scratch/toad_nullable_inputs.py create mode 100644 .scratch/value-node-context-migration.py create mode 100644 .scratch/value-node-lazy-migration.py create mode 100644 .scratch/value-node-resolver-migration.py create mode 100644 .scratch/value-node-state-migration.py create mode 100644 .scratch/widget_draft_owner.py create mode 100644 .scratch/widget_leaf_cutover.py diff --git a/.scratch/bridge_handle_journey.py b/.scratch/bridge_handle_journey.py new file mode 100644 index 0000000..3326fe4 --- /dev/null +++ b/.scratch/bridge_handle_journey.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from objectstate import ObjectState, ObjectStateRegistry, Inherit, LazyDataclassFactory, set_base_config_type, set_global_config +from formbridge.bridge.dto import UiObjectStateFieldMutationRequest, UiObjectStateScopeListRequest +from formbridge.bridge.object_state import ObjectStateFieldMutationService, ObjectStateScopeProjectionService + +@dataclass +class Settings: + text: str | None = None +set_base_config_type(Settings) +set_global_config(Settings, Settings(text='global text')) +Lazy = LazyDataclassFactory.make_lazy_simple(Settings) +state = ObjectState(Lazy(), scope_id='/declared-bridge-journey') +ObjectStateRegistry.register(state) +field = state.fields.text +service = ObjectStateFieldMutationService() +try: + for value in ('None', '', None): + result = service.mutate(UiObjectStateFieldMutationRequest.from_fields( + object_state_scope_id=state.scope_id, field_path='text', value=value, + )) + assert result.mutated and not result.errors, result + assert field.value == value + assert result.after.raw_value_is_none == (value is None) + assert not result.after.inherited_value + result = service.mutate(UiObjectStateFieldMutationRequest.from_fields( + object_state_scope_id=state.scope_id, field_path='text', reset=True, + )) + assert result.mutated and not result.errors, result + assert isinstance(field.raw, Inherit) and field.value == 'global text' + assert result.after.inherited_value + unknown = service.mutate(UiObjectStateFieldMutationRequest.from_fields( + object_state_scope_id=state.scope_id, field_path='typo', value='bad', + )) + assert not unknown.mutated and unknown.errors[0].code == 'ui_object_state_field_not_found' + summary = ObjectStateScopeProjectionService().summary(state, UiObjectStateScopeListRequest(include_fields=True, include_field_values=True)) + assert summary.fields[0].address.field_path == 'text' + assert summary.fields[0].inherited_value + print('real bridge mutation service preserves None/empty/string None; reset inherits; unknown address is rejected; catalog projects handles',flush=True) + assert ObjectStateRegistry.time_travel_back() + assert field.value is None + print('registered history undo retains the original handle and restores literal None',flush=True) +finally: + ObjectStateRegistry.unregister(state) diff --git a/.scratch/bridge_handles.py b/.scratch/bridge_handles.py new file mode 100644 index 0000000..d8a8674 --- /dev/null +++ b/.scratch/bridge_handles.py @@ -0,0 +1,52 @@ +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +P=Path('/home/ts/wt/formbridge-declared-fields/src/formbridge/bridge/object_state.py');s=P.read_text();s=s.replace('from objectstate.inspection import FieldInspection, type_name, type_qualname','from objectstate.inspection import FieldInspection, type_name, type_qualname\nfrom objectstate.field_access import DottedFieldPath, FieldAccessError') +s=s.replace('parameter_count=len(state.parameters)','parameter_count=len(state.fields)') +idx=module_syntax_index(SourceModule.from_source_path(P,s).parse().module);lines=s.splitlines(keepends=True);edits=[] +for _,c in idx.indexed_nodes_of_type(ast.ClassDef): + for n in c.body: + if not isinstance(n,ast.FunctionDef):continue + old=''.join(lines[n.lineno-1:n.end_lineno]);new=None + if c.name=='ObjectStateFieldMutationService': + if n.name=='_field_summary':new='' + if n.name=='_converted_value':new=''' @staticmethod + def _converted_value(field, value): + return ParameterFormService().convert_value_to_type( + value, field.annotation, field.name, field.declaring_type, + ) +''' + if n.name=='mutate': + new=old.replace(''' if request.field_path not in state.parameters: +''',''' try: + field = DottedFieldPath(request.field_path).bind(state) + except FieldAccessError: +''') + new=new.replace('before = self._field_summary(state, request)','before = ObjectStateFieldSemanticProjection.of(field).to_field_summary(include_values=request.include_field_values)') + new=new.replace('state.reset_parameter(request.field_path)','field.reset()') + new=new.replace('''state.update_parameter( + request.field_path, + self._converted_value(state, request), + )''','''field.write(self._converted_value(field, request.value))''') + new=new.replace('after = self._field_summary(state, request)','after = ObjectStateFieldSemanticProjection.of(field).to_field_summary(include_values=request.include_field_values)') + elif n.name=='_field_page': + a=old.index(' if request.field_paths:');b=old.index(' if request.field_filter',a) + new=old[:a]+''' available = tuple(state.fields) + if request.field_paths: + all_field_paths = tuple(item for item in available if item.address.value in request.field_paths) + else: + all_field_paths = tuple(sorted(available, key=lambda item: item.address.value)) +'''+old[b:] + new=new.replace(''' state, + field_path,''',''' field_path,''').replace(''' state, + field_path,''',''' field_path,''') + new=new.replace('''self._field_summary( + field_path,''','''ObjectStateFieldSemanticProjection.of(field_path).to_field_summary(''').replace('''self._field_summary( + field_path,''','''ObjectStateFieldSemanticProjection.of(field_path).to_field_summary(''') + elif n.name=='_field_summary': + new='' + if new is not None: + start=min([n.lineno]+[d.lineno for d in n.decorator_list])-1 + edits.append((start,n.end_lineno,new+'\n'if new else'')) +for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t] +s=''.join(lines);compile(s,str(P),'exec');P.write_text(s) diff --git a/.scratch/census.py b/.scratch/census.py new file mode 100644 index 0000000..15ec4eb --- /dev/null +++ b/.scratch/census.py @@ -0,0 +1,28 @@ +"""Owned source evidence for ObjectState #32/#46, using NRA's syntax/family owners.""" +import ast,json +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +from nominal_refactor_advisor.class_index import build_class_family_index +ROOTS=[Path('/home/ts/wt/objectstate-declared-fields/src/objectstate'),Path('/home/ts/wt/openhcs-declared-fields/openhcs'),Path('/home/ts/wt/core-doctrine-segment/src/agent_comms'),Path('/home/ts/wt/toad-declared-fields/src/toad'),Path('/home/ts/wt/formbridge-declared-fields/src/formbridge'),Path('/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive'),Path('/home/ts/wt/python-introspect-field-values/src/python_introspect'),Path('/home/ts/code/projects/openhcs/external/pycodify/src/pycodify')] +modules=[];original=[];sites=[];errors=[] +for root in ROOTS: + for path in root.rglob('*.py'): + try: parsed=SourceModule.from_source_path(path,path.read_text()).parse() + except Exception as e:errors.append((str(path),str(e)));continue + modules.append(parsed);syntax=module_syntax_index(parsed.module) + for _,node in syntax.indexed_nodes_of_type(ast.ClassDef): + original.append((str(path),node.lineno,node.name,[ast.unparse(b)for b in node.bases])) + for _,node in syntax.indexed_nodes_of_type(ast.Call): + name=ast.unparse(node.func) + if any(s in name for s in ('update_parameter','reset_parameter','get_resolved_value','on_state_changed','on_resolved_changed','patch_lazy_constructors','global_pipeline_config','rebuild_with_none','_reconstruct_from','reconstruct_top_level_parameters','type_for_path','FieldProxy','FieldInspection','DottedFieldPath','set_placeholder','setSpecialValueText','_write_concrete_value','_write_blank_value')): + sites.append((str(path),node.lineno,'call',ast.unparse(node))) + relevant={'parameters','_parameters','_parameter_structure','declarations','direct_paths','owner_paths','dirty_fields','signature_diff_fields','raw_value','raw_value_is_none','resolved_value_is_none','raw_value_preview','resolved_value_preview','resolved_value','resolved_value_available','resolved','_draft','_write_concrete_value','_write_blank_value','setSpecialValueText','has_value','is_none','_on_state_changed_callbacks','_on_resolved_changed_callbacks'} + for _,node in syntax.indexed_nodes_of_type(ast.Attribute): + if node.attr in relevant:sites.append((str(path),node.lineno,type(node.ctx).__name__,ast.unparse(node))) + for _,node in syntax.indexed_nodes_of_type(ast.ImportFrom): + if (node.module or '').startswith(('objectstate','pycodify','python_introspect')):sites.append((str(path),node.lineno,'import',ast.unparse(node))) + if root.name=='objectstate' or path.name=='widget_adapters.py': + for _,node in syntax.indexed_nodes_of_type(ast.Compare): + if any(isinstance(n,ast.Constant)and n.value is None for n in node.comparators):sites.append((str(path),node.lineno,'None decision',ast.unparse(node))) +index=build_class_family_index(modules) +print(json.dumps({'roots':list(map(str,ROOTS)),'module_count':len(modules),'original_classes':original,'sites':sites,'parse_errors':errors,'index_type':type(index).__name__,'index_members':[n for n in dir(index)if n in ('classes_by_symbol','classes','symbols_by_simple_name')]},indent=2)) diff --git a/.scratch/consumer_state_cutover.py b/.scratch/consumer_state_cutover.py new file mode 100644 index 0000000..d26d065 --- /dev/null +++ b/.scratch/consumer_state_cutover.py @@ -0,0 +1,24 @@ +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +changes={ +'/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/services/widget_service.py':{ +'_apply_context_behavior': [('if value is None:', 'if FieldValue.of(value).inherited:'),('elif value is not None:', 'else:')]}, +'/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/services/parameter_ops_service.py':{ +'_reset_value_widget_parameter':[('if reset_value is None:', 'if FieldValue.of(reset_value).inherited:')], +'refresh_single_placeholder':[('if current_value is not None:', 'if not FieldValue.of(current_value).inherited:')]}, +'/home/ts/wt/formbridge-declared-fields/src/formbridge/forms/parameter_form_service.py':{ +'get_reset_value_for_parameter':None,'_get_actual_dataclass_field_default':None}, +} +for name,methods in changes.items(): + p=Path(name);s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True);edits=[] + for _,n in idx.indexed_nodes_of_type(ast.FunctionDef): + if n.name not in methods:continue + pairs=methods[n.name];text=''.join(lines[n.lineno-1:n.end_lineno])if pairs is not None else'' + for old,new in pairs or []: + assert old in text,(p,n.name,old);text=text.replace(old,new) + edits.append((n.lineno-1,n.end_lineno,text)) + for a,b,text in sorted(edits,reverse=True):lines[a:b]=[text] + s=''.join(lines) + if any(v is not None for v in methods.values()):s='from objectstate.field_value import FieldValue\n'+s + ast.parse(s,filename=str(p));p.write_text(s) diff --git a/.scratch/core_handle_consumers.py b/.scratch/core_handle_consumers.py new file mode 100644 index 0000000..131df83 --- /dev/null +++ b/.scratch/core_handle_consumers.py @@ -0,0 +1,32 @@ +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +ROOT=Path('/home/ts/wt/objectstate-declared-fields/src/objectstate') +for p in ROOT.glob('*.py'): + s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True);offsets=[];at=0 + for line in lines:offsets.append(at);at+=len(line) + edits=[] + for _,n in idx.indexed_nodes_of_type(ast.Attribute): + if n.attr=='parameters'and isinstance(n.value,ast.Name)and n.value.id in {'state','schema'}: + a=offsets[n.lineno-1]+len(lines[n.lineno-1].encode()[:n.col_offset].decode());b=offsets[n.end_lineno-1]+len(lines[n.end_lineno-1].encode()[:n.end_col_offset].decode());edits.append((a,b,ast.unparse(n.value)+'._parameters')) + for a,b,t in sorted(edits,reverse=True):s=s[:a]+t+s[b:] + compile(s,str(p),'exec');p.write_text(s) +p=ROOT/'object_state_registry.py';s=p.read_text();s=s.replace('pipeline_state.update_parameter("step_scope_ids", new_ids)','pipeline_state.fields.step_scope_ids.write(new_ids)');compile(s,str(p),'exec');p.write_text(s) +p=ROOT/'inspection.py';s=p.read_text();s=s.replace(''' def of(cls, state: ObjectState, field_path: str): + """Inspect ``field_path``; a value that fails to resolve is reported unavailable.""" +''',''' def of(cls, field): + """Capture one admitted field, never look up a caller-spelled path.""" + state = field.state + field_path = field._path +''').replace('state.get_resolved_value(field_path)','field.value').replace('raw_value=state._parameters[field_path]','raw_value=field.raw');compile(s,str(p),'exec');p.write_text(s) +p=ROOT/'edit_session.py';s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True);edits=[] +for _,n in idx.indexed_nodes_of_type(ast.FunctionDef): + if n.name in {'has_parameter','parameter_value','update_parameter'}:edits.append((n.lineno-1,n.end_lineno,'')) + if n.name=='require_state':edits.append((n.lineno-1,n.end_lineno,''' def require_state(self) -> ObjectState: + state = self.state + if state is None: + raise RuntimeError("ObjectStateEditSession has no editing state") + return state +''')) +for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t] +s=''.join(lines).rstrip()+'\n';compile(s,str(p),'exec');p.write_text(s) diff --git a/.scratch/declaration-checkpoint-report.md b/.scratch/declaration-checkpoint-report.md new file mode 100644 index 0000000..aceb648 --- /dev/null +++ b/.scratch/declaration-checkpoint-report.md @@ -0,0 +1,30 @@ +## Declaration-owned checkpoint (source-paired, not installed) + +Pushed heads: ObjectState 93eae56; FormBridge b5d3af8; PyQt 8690060; OpenHCS f0c07c411. Dependency python-introspect ffcffaa (PR #25). + +ValueDeclaration compiles annotations once. ParameterOwnerDeclaration owns parameters, field_types and init_field_names (one definition each); constructor and configuration owners share that implementation. FieldValue.of only preserves meaning members or boxes data; it does not classify runtime payloads. Named configuration access is absent on scalar leaves. Declared collection element types own traversal and serialization. DeclaredValue shares metadata/copy/serialization lifecycle between configuration and collection values. + +InheritedDefault is the single frozen class-keyed default record; ValueDefault/FactoryDefault materialize it. own_default and inherited_default now index declared fields and raise KeyError for unknown names. Their direct resolver callers validate against the requested declaration; configuration_mro(field_name) filters ancestors by their declared fields before reading defaults. All touched production roots have zero `_inherited_default`, `_resolve_type` and `__objectstate_rebuild__` references. UIParameterVisibilityPolicy owns field visibility metadata, keyed by the existing policy class. CompactFieldProjection is also its own metadata key; no string-key compatibility reader remains. + +Text-line counts (published baseline -> checkpoint): + +| production root | is_container | is_config_type | literal `| None` | getattr | get_base_type_for_lazy | +| --- | --- | --- | --- | --- | --- | +| ObjectState 1479884 | 27 -> 0 | 2 -> 0 | 94 -> 91 | 37 -> 16 | 46 -> 2 | +| FormBridge bdafb52 | 3 -> 0 | 6 -> 0 | 480 -> 468 | 18 -> 16 | 8 -> 0 | + +The two remaining lazy callback-name lines are its definition and register_type_resolver registration; zero callers, imports or exports remain in the touched production family. The normalization wrapper/cache and per-caller fallback expressions are removed. Archived dirty-before normalization count was 45, now 2. + +FormBridge FieldValue.of: published 3 -> 2; archived dirty-before 15 -> 2. get_base_type_for_lazy: 8 -> 0. metadata.get: 4 -> 2. Combined requested regex: published 15 -> 4; archived dirty-before 27 -> 4. The remaining FieldValue.of sites preserve DTO meaning after mapping JSON data, not payload kind. The remaining metadata.get sites read canonical runtime code-document tokens, not declaration metadata. + +widget_creation_type consumers: WidgetCreationPipeline.__init__ selected an enum by the string; it now calls param_info.creation_type(WidgetCreationType). ParameterFormManager._create_parameter_widget only logged it; it now logs the actual info class. All four string ClassVars are deleted. + +NRA syntax/family evidence: 1,590 production/dependency modules, 9,953 classes, no parse errors. AST calls: ObjectState getattr 28 -> 13, FormBridge 17 -> 15; is_container/is_config_type calls zero, is_dataclass exactly one annotation compiler call. Dynamic resolution is not established by these syntax counts; the actual source-paired journey below checks the affected behavior. + +Exact deleted Python type-name DTO declarations remain: source_type (1), object_state_path_type (2), raw_value_type (2), resolved_value_type (2), object_type (3), help_target_type (1). Genuine mime_type is retained. Deletion gates cover the exact declarations, not all *_type fields. + +Verification: python-introspect public resolver/analyzer 64 passed; producer declaration/handle/inspection/scopeless/codegen/deletion cases 51 passed; bridge DTO/deletion cases 29 passed; Qt declared/numeric cases 66 passed. Actual source-paired native Qt input, main-thread dispatch, authenticated localhost ZMQ, DTO decode, reset, undo and disposal passed with null, empty string, text None, inheritance and absence. No provider and no installation. + +Still unfinished: OpenHCS config startup reaches SourceBindingsConfig.__post_init__ but attempts to iterate Inherit in its source-binding normalization. Earlier removed has_value reads in the shared normalization owner are repaired; the whole remaining post-init normalizer needs a directive-aware owner pass. The old test_subfield_semantics.py still imports deleted MISSING/string APIs and fails collection; this failure is retained, not skipped or xfailed. Full OpenHCS/app acceptance and #32/#46 cutover are not complete. doctrine-segment remains the implementation owner. + +Dependency: python-introspect ffcffaa, draft PR #25. No shared runtime publication/restart performed. diff --git a/.scratch/declaration_lifetime_journey.py b/.scratch/declaration_lifetime_journey.py new file mode 100644 index 0000000..aac3970 --- /dev/null +++ b/.scratch/declaration_lifetime_journey.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass, field +from objectstate import ObjectState, Inherit, Unset, LazyDataclassFactory, set_base_config_type, set_global_config +from objectstate.field_access import FieldAccessError + +@dataclass +class Config: + value: int | None = 8 +@dataclass +class Settings: + text: str | None = None + count: int | None = 4 + config: Config | None = field(default_factory=Config) + +set_base_config_type(Settings) +set_global_config(Settings, Settings(count=77, config=Config(91))) +LazyConfig = LazyDataclassFactory.make_lazy_simple(Config) +LazySettings = LazyDataclassFactory.make_lazy_simple(Settings) +state = ObjectState(LazySettings(text=None, config=LazyConfig()), scope_id='declaration-lifetime-journey') +text = state.field(Settings, 'text') +config = state.field(Settings, 'config') +value = state.fields.config.value +# The actual field named "value" must use explicit declaration binding because +# the owning handle also exposes its effective read as .value. +value = state.fields.bind(Settings, ('config', 'value')) +assert config.declaring_type is Settings +assert value.declaring_type is Config +assert config.value.value == 91 +assert config.saved_value.value == 91 +assert text.value is None +seen=[] +release=text.subscribe(lambda changed:seen.append(text.value)) +text.write('edited') +state.restore_saved() +assert text.value is None +assert not text.dirty +assert seen[-1] is None +state.update_object_instance(LazySettings(text='replacement', config=LazyConfig(value=12))) +assert text.value == 'replacement' +assert config.value.value == 12 +assert value.value == 12 +assert text == state.fields.text +assert hash(text) == hash(state.fields.text) +print('handles survive restore and same-declaration schema replacement; nested owner and payload are distinct',flush=True) +config.write(None) +assert config.value is None +assert config.saved_value.value == 12 +assert state.to_object().config is None +config.write(Inherit()) +assert config.value.value == 91 +config.write(Unset()) +assert isinstance(config.value, Unset) +print('nested None, inherited object and absence survive targeted resolution; saved object remains independent',flush=True) +release() +state.update_object_instance(Settings(text='plain', count=Inherit())) +assert state.fields.count.value == 77 +print('explicit inheritance resolves on a regular declaration, not just a generated lazy class',flush=True) +@dataclass +class Other: + text: str = 'other' +state.update_object_instance(Other()) +assert not text.present +try:text.write('wrong target') +except FieldAccessError:pass +else:raise AssertionError('a handle retargeted an unrelated declaration') +print('same-name field on an unrelated declaration is not silently targeted',flush=True) diff --git a/.scratch/declaration_owner.py b/.scratch/declaration_owner.py new file mode 100644 index 0000000..6f922e1 --- /dev/null +++ b/.scratch/declaration_owner.py @@ -0,0 +1,142 @@ +"""Repair declaration lifetime and remove the mixed owner/payload type index.""" +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +P=Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/object_state.py');s=P.read_text() +s=s.replace('self._structure = state._parameter_structure','self._owner = state._parameter_structure.binding_owner(path)') +s=s.replace('return self._structure.owner_paths[self._path]','return self._owner') +s=s.replace('return self._structure is self._state._parameter_structure and self._path in self._state._parameters','return self._path in self._state._parameter_structure.declarations and self._state._parameter_structure.binding_owner(self._path) is self._owner') +s=s.replace('self._structure is other._structure','self._owner is other._owner').replace('id(self._structure)','id(self._owner)') +s=s.replace('return self._structure.declarations[self._path]','self._require_present()\n return self._structure.declarations[self._path]') +s=s.replace('return self._state._parameters[self._path]', 'return self._state._parameters.get(self._path, Unset())') +s=s.replace('return self._state._read_saved_field(self)','self._require_present()\n return self._state._read_saved_field(self)') +s=s.replace(''' def dirty(self): + self._state._ensure_live_resolved()''',''' def dirty(self): + self._require_present() + self._state._ensure_live_resolved()''') +# All actual owners are admitted at one declaration boundary. No overwrite with +# the contained value's nominal class is permitted. +s=s.replace('structure.declare(dotted_path, info)','structure.declare(dotted_path, info, owner_target)') +s=s.replace(' structure.owner_paths[dotted_path] = nested_type\n','').replace(' structure.owner_paths[dotted_path] = owner_target\n','') +a=s.index(' # Check if this is a nested dataclass\n');b=s.index(' # Store the nested dataclass instance in parameters',a) +s=s[:a]+''' if is_dataclass(type(current_value)): +'''+s[b:] +s=s.replace('local_invalidations = set(changed_semantic_params)','local_invalidations = set(changed_storage_params)') +# Read-time reconstruction consumes the projection's own shape. Declaration +# ownership is never also a payload-type tag. +s=s.replace(''' prefix_type = self._path_to_type.get(prefix) + if not (isinstance(prefix_type, type) and is_dataclass(prefix_type)): + continue + +''','') +s=s.replace('for path, typ in self._path_to_type.items():','for path, typ in self._parameter_structure.container_types(self._parameters):') +s=s.replace('self._path_to_type','self._parameter_structure.owner_paths') +s=s.replace('''self._parameter_structure.owner_paths[path] is owner''','''self._parameter_structure.binding_owner(path) is owner''') +idx=module_syntax_index(SourceModule.from_source_path(P,s).parse().module);lines=s.splitlines(keepends=True);edits=[] +def span(n):return min([n.lineno]+[d.lineno for d in n.decorator_list])-1,n.end_lineno +for _,c in idx.indexed_nodes_of_type(ast.ClassDef): + for n in c.body: + if not isinstance(n,ast.FunctionDef):continue + text=None + if c.name=='FieldProxy'and n.name=='state': + # Insert the shared live-schema projection before the existing property. + a,b=span(n);edits.append((a,a,''' @property + def _structure(self): + return self._state._parameter_structure + +''')) + if c.name=='RootFields'and n.name=='_structure':text='' + if c.name=='RootFields'and n.name=='present': + a,b=span(n);edits.append((b,b,''' + __hash__ = None + + def __eq__(self, other): + return isinstance(other, RootFields) and self._state is other._state +''')) + if c.name=='ObjectState'and n.name in {'_path_to_type','_get_nested_dataclass_type','type_for_path'}:text='' + if c.name=='ObjectState'and n.name=='_read_field':text=''' def _read_field(self, field: FieldProxy) -> Any: + """Read this projection, preserving scalar terminals and nominal shape.""" + self._check_and_sync_delegate() + self._ensure_live_resolved() + return self._parameter_structure.reconstruct(field._path, self._live_resolved, self._extraction_target, resolved=True) +''' + if c.name=='ObjectState'and n.name=='_read_saved_field':text=''' def _read_saved_field(self, field: FieldProxy) -> Any: + """The saved projection owns its own payload, independently of live data.""" + self._check_and_sync_delegate() + self._ensure_saved_resolved() + return self._parameter_structure.reconstruct(field._path, self._saved_resolved, self._extraction_target, resolved=True) +''' + if c.name=='ObjectState'and n.name=='_recompute_invalid_fields':text=''' def _recompute_invalid_fields(self, sources: ResolutionSources) -> set[str]: + """Use the same resolver for admitted invalid fields as for a full snapshot.""" + if self._live_resolved is None: + return set() + selected = self._invalid_fields & self._parameters.keys() + snapshot, provenance = self._resolve_parameter_snapshot( + self._parameters, self._parameters, self._parameter_structure, + self.to_object(update_delegate=False, sync_delegate=False), + self.scope_id, sources.ancestors, use_saved=False, field_paths=selected, + ) + changed = { + path: (self._live_resolved.get(path, Unset()), value) + for path, value in snapshot.items() + if not semantic_values_equal(self._live_resolved.get(path, Unset()), value) + } + self._live_resolved.update(snapshot) + for path in selected: + self._live_provenance.pop(path, None) + self._live_provenance.update(provenance) + if changed: + self._set_last_changed_values(changed) + return self.notification_fields_for_value_changes(changed) +''' + if c.name=='ObjectState'and n.name=='_resolve_parameter_snapshot':text=''' @classmethod + def _resolve_parameter_snapshot( + cls, parameters, values, structure, current_obj, scope_id, + ancestor_objects_with_scopes, *, use_saved, field_paths=None, + ): + """One resolution algorithm for complete and targeted projections. + + A container entry carries the projection's nominal payload; reads rebuild + its effective children through ParameterStructure.reconstruct. FieldValue + alone decides whether a stored value invokes inheritance. + """ + from objectstate.context_manager import build_context_stack + from objectstate.dual_axis_resolver import resolve_with_provenance + + snapshot = {} + provenance = {} + with build_context_stack( + object_instance=current_obj, + ancestor_objects_with_scopes=ancestor_objects_with_scopes, + current_scope_id=scope_id, use_live=not use_saved, + ): + for path in parameters if field_paths is None else field_paths: + raw = values.get(path, Unset()) + def inherited(): + owner = structure.owner_paths[path] + if not isinstance(owner, type): + return Unset() # A callable parameter has no config/MRO layer. + result = resolve_with_provenance(owner, structure.declarations[path].name) + provenance[path] = (result.source_scope, result.source_type) + return result.value + snapshot[path] = FieldValue.of(raw).resolve(inherited) + return snapshot, provenance +''' + if c.name=='ObjectState'and n.name=='reconstruct_top_level_parameters':text=''' def reconstruct_top_level_parameters(self) -> dict[str, Any]: + """Project local constructor argument names, not the editing address map.""" + self._check_and_sync_delegate() + return { + path.field_name: self._parameter_structure.reconstruct(path.value, self._parameters, self._extraction_target) + for path in self._parameter_structure.direct_paths.get(DottedFieldPath(''), ()) + } +''' + if c.name=='ObjectState'and n.name=='to_object': + old=''.join(lines[n.lineno-1:n.end_lineno]);a=old.index(' field_updates = {}');b=old.index(' # Reconstruct the target object',a) + text=old[:a]+''' field_updates = self.reconstruct_top_level_parameters() + +'''+old[b:] + if text is not None: + a,b=span(n);edits.append((a,b,text+'\n'if text else'')) +for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t] +s=''.join(lines);compile(s,str(P),'exec');P.write_text(s) +P=P.with_name('object_state_registry.py');s=P.read_text().replace('state._path_to_type','state._parameter_structure.owner_paths');P.write_text(s) diff --git a/.scratch/dto-member-bridge-journey.py b/.scratch/dto-member-bridge-journey.py new file mode 100644 index 0000000..22e1798 --- /dev/null +++ b/.scratch/dto-member-bridge-journey.py @@ -0,0 +1,127 @@ +"""Production Qt dispatch, authenticated ZMQ bridge, typed DTOs and retained history.""" +import concurrent.futures +import json +import runpy +import tempfile +from dataclasses import dataclass +from pathlib import Path + +from PyQt6.QtCore import QObject, QEventLoop, QTimer, pyqtSignal +from PyQt6.QtTest import QTest +from PyQt6.QtWidgets import QApplication +from objectstate import Inherit, LazyDataclassFactory, ObjectState, ObjectStateRegistry, Unset, set_base_config_type, set_global_config +from objectstate.field_value import Value +from python_introspect import to_jsonable +from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager +from pyqt_reactive.services.ui_thread_dispatch import UiThreadDispatcher +from formbridge.bridge.dto import UiObjectStateFieldMutationRequest, UiObjectStateScopeListRequest +from formbridge.bridge.in_process import UiAgentBridgeService +from formbridge.bridge.object_state import ObjectStateScopeProvider +from formbridge.bridge.server import UiBridgeControlServer, UiBridgeServerConfig +from formbridge.bridge.service import UiBridgeService, UiBridgeListObjectStateScopesOperation, UiBridgeMutateObjectStateFieldOperation +from zmqruntime.config import TransportMode + +runpy.run_path('/home/ts/wt/formbridge-declared-fields/tests/conftest.py') +app = QApplication([]) +@dataclass +class Settings: + text: str | None = None + count: int = 0 +set_base_config_type(Settings) +set_global_config(Settings, Settings(text='global', count=7)) +lazy = LazyDataclassFactory.make_lazy_simple(Settings) +state = ObjectState(lazy(text=None, count=0), scope_id='dto-member-journey') +ObjectStateRegistry.register(state) +field = state.fields.text +manager = ParameterFormManager(state) +build = QEventLoop() +manager.register_form_build_completion_callback(build.quit) +if not manager.form_build_complete: + QTimer.singleShot(2000, build.quit) + build.exec() +assert manager.form_build_complete and manager.form_build_failure is None +manager.show() +app.processEvents() + +class Completion(QObject): + done = pyqtSignal() +completion = Completion() + +def call(pool, operation, request): + loop = QEventLoop() + completion.done.connect(loop.quit) + future = pool.submit(client.invoke, operation, request, connection) + future.add_done_callback(lambda _: completion.done.emit()) + if not future.done(): + QTimer.singleShot(5000, loop.quit) + loop.exec() + completion.done.disconnect(loop.quit) + result = future.result(timeout=0) + assert not result.errors, result.errors + return result + +root = Path('/home/ts/.cache/agent-scratch/objectstate-declared-fields') +with tempfile.TemporaryDirectory(prefix='dto-bridge-', dir=root) as directory: + bridge = UiAgentBridgeService(dispatcher=UiThreadDispatcher()) + bridge.register_object_state_scope_provider(ObjectStateScopeProvider()) + server = UiBridgeControlServer(bridge, UiBridgeServerConfig( + port=None, transport_mode=TransportMode.TCP, + descriptor_directory_path=Path(directory), + )) + binding = server.start() + client = UiBridgeService() + connection = client.connection_from_args(descriptor_file_path=binding.descriptor_file_path) + receipts = [] + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + query = UiObjectStateScopeListRequest(include_fields=True, include_field_values=True) + for datum in (None, '', 'None'): + request = UiObjectStateFieldMutationRequest.from_fields( + object_state_scope_id=state.scope_id, field_path='text', value=datum, + ) + mutation = call(pool, UiBridgeMutateObjectStateFieldOperation, request) + assert mutation.mutated and field.raw == datum + assert mutation.after.raw_value == Value(datum) + assert mutation.after.resolved_value == Value(datum) + catalog = call(pool, UiBridgeListObjectStateScopesOperation, query) + rows = [row for scope in catalog.scopes for row in scope.fields if row.field_name == 'text'] + assert len(rows) == 1 and rows[0].raw_value == Value(datum) + assert rows[0].resolved_value == Value(datum) + assert manager.widgets['text'].get_value() == datum + receipts.append(to_jsonable(rows[0])['raw_value']) + reset = call(pool, UiBridgeMutateObjectStateFieldOperation, + UiObjectStateFieldMutationRequest.from_fields( + object_state_scope_id=state.scope_id, field_path='text', reset=True, + )) + assert reset.mutated and reset.after.raw_value == Inherit() + assert reset.after.resolved_value == Value('global') + assert ObjectStateRegistry.time_travel_back() + app.processEvents() + assert field.raw == 'None' and manager.widgets['text'].get_value() == 'None' + edit = manager.widgets['text'] + edit.selectAll() + QTest.keyClicks(edit, 'native') + wait = QEventLoop() + release = state.fields.subscribe(lambda _: wait.quit() if field.raw == 'native' else None) + if field.raw != 'native': + QTimer.singleShot(1000, wait.quit) + wait.exec() + release() + assert field.raw == 'native' + catalog = call(pool, UiBridgeListObjectStateScopesOperation, query) + row = next(row for scope in catalog.scopes for row in scope.fields if row.field_name == 'text') + assert row.raw_value == Value('native') and row.resolved_value == Value('native') + state.fields.text.write(Unset()) + catalog = call(pool, UiBridgeListObjectStateScopesOperation, query) + row = next(row for scope in catalog.scopes for row in scope.fields if row.field_name == 'text') + assert row.raw_value == Unset() and row.resolved_value == Unset() + assert to_jsonable(row)['raw_value'] == {'Unset': {}} + finally: + server.stop() + bridge.close() + manager.dispose() + manager.close() + manager.deleteLater() + ObjectStateRegistry.unregister(state) + app.processEvents() +print(json.dumps({'journey':'real Qt native input, main-thread dispatch, authenticated ZMQ server/client, DTO decode, reset, history undo and view disposal','raw_wire_members':receipts,'inheritance':True,'absence':True,'source_paired_not_installed':True,'providers':0})) diff --git a/.scratch/dto-member-call-sites.py b/.scratch/dto-member-call-sites.py new file mode 100644 index 0000000..34fd03f --- /dev/null +++ b/.scratch/dto-member-call-sites.py @@ -0,0 +1,12 @@ +import ast,json +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +roots=[Path('/home/ts/wt/'+name+'-declared-fields') for name in ('objectstate','formbridge','pyqt','openhcs','toad')] +rows=[] +for root in roots: + for p in (root/'tests').rglob('*.py'): + s=p.read_text();syntax=module_syntax_index(SourceModule.from_source_path(p,s).parse().module) + for _,n in syntax.indexed_nodes_of_type(ast.Call): + ks={k.arg:ast.unparse(k.value) for k in n.keywords if k.arg in {'raw_value','resolved_value','raw_value_is_none','resolved_value_is_none'}} + if ks: rows.append((str(p),n.lineno,ast.unparse(n.func),ks)) +print(json.dumps(rows,indent=2)) diff --git a/.scratch/dto-members-pr-comment.md b/.scratch/dto-members-pr-comment.md new file mode 100644 index 0000000..e4bafab --- /dev/null +++ b/.scratch/dto-members-pr-comment.md @@ -0,0 +1,36 @@ +## Typed field members across the bridge boundary + +Paired source checkpoints: ObjectState `1479884`, FormBridge `b7c4ad5`, PyQt `f0acb19`, OpenHCS `8d3bac264`. Not shared-installed and not completion of the wider field cutover. + +### Owner and consumers + +- `FieldInspection` retains `raw` and `resolved` as `FieldValue` members. The two null-presence properties are deleted. +- `FieldValue` is dataclass-declared. Unchanged python-introspect `MemberFamily` (main `a9428c3`) already owns tagged abstract-dataclass encoding/decoding. No new codec, wrapper, case roster or registry was added. +- Shared bridge semantic DTO fields carry members, not nullable values plus two booleans. The four compact-presence re-derivations and redundant action-field overrides are deleted. Compact serialization uses the declaration-aware codec before selecting keys, preserving `Value(None)`, empty/false/zero, `Inherit()` and `Unset()` tags even with previews. +- Payload exclusion yields explicit absence for an excluded datum and retains directives; previews remain bounded display metadata. No exclusion fabricates literal null. +- Field-list compaction maps concrete data through the member. OpenHCS help/rendering consume member behavior; the obsolete null-as-inheritance summary counters are deleted. +- `DataclassFieldAccess.is_config_type/is_container` owns configuration classification: meaning members being dataclasses must not make them configuration containers. Extraction, reconstruction, structural leaves, history, context and form selection use that owner. Code-editor updates use it and canonical raw reads; duplicate nested recursion and three replaced/dead helper paths are deleted. + +### Source evidence + +NRA `SourceModule`, `module_syntax_index`, `build_class_family_index`: 1,589 production/dependency modules, 9,923 class rows before/after, zero parse omissions. Evidence uses the producer, FormBridge, PyQt, OpenHCS, Toad, Core, current python-introspect and pycodify roots. Syntax/family evidence does not prove dynamic aliases, decorators, MRO or behavior. + +Exact search after migration: + +`rg -n 'raw_value_is_none|resolved_value_is_none'` across all five owned source and test roots: **0 hits**, exit1, stderr empty. Original two inspection properties, two DTO fields and four re-derivations are gone. + +Evidence root: `/home/ts/.cache/agent-scratch/objectstate-declared-fields/`; `dto-members-{before,after}.json`, `dto-member-call-sites-before.json`, `dto-member-fixture-migrations.json`, `dto-null-flags-after.txt`. + +### Confirmed behavior + +Real Qt form/native typing, production Qt main-thread dispatcher, authenticated localhost ZMQ server/client, declaration-aware DTO decoding, mutation/catalog, reset, original-handle history undo, absence and view/server disposal pass. Explicit wire examples: `{"Value":{"value":null}}`, `{"Value":{"value":""}}`, `{"Value":{"value":"None"}}`, `{"Inherit":{}}`, `{"Unset":{}}`. Final journey also uses the existing metaclass-registry source required by OpenHCS. No provider or original saved input was used. + +- 66 distinct producer cases pass: 64 in the first batch and two old source-generation assertions corrected and confirmed separately. Their former None-as-inheritance expectations contradicted the declared contract; strict literal-null and explicit-empty-config assertions replace them. Initial failure output retained. +- 43 focused FormBridge cases pass, including typed/compact member roundtrips, malformed-member rejection, code-editor updates and real ZMQ transport. +- 68 focused Qt cases pass. + +Receipts: `dto-member-bridge-{journey,paired-final}.txt`, `dto-producer-tests-v1.txt`, `dto-codegen-correction.txt`, `dto-bridge-tests-v1.txt`, `dto-qt-tests-v1.txt`. + +### Remaining application blocker + +OpenHCS tests do not reach collection. The old installed metaclass-registry lacks `testing`; using the unchanged required source clears that import and exposes `TypeError: LazyNapariDisplayConfig.colormap must be str; got Inherit` during configuration startup. This wider lazy-validation cutover remains doctrine-owned under the existing unfinished field work. No skip, timeout increase, alternate test harness or installation was used. Receipts: `dto-openhcs-{tests-v1,paired-tests-v1}.txt`. The OpenHCS consumer source is pushed, not claimed fully application-verified. diff --git a/.scratch/field_member_behavior.py b/.scratch/field_member_behavior.py new file mode 100644 index 0000000..a9cfece --- /dev/null +++ b/.scratch/field_member_behavior.py @@ -0,0 +1,28 @@ +"""Replace source-enumerated FieldValue flags with member behavior using NRA.""" +import ast,json +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +roots=[Path('/home/ts/wt/'+name+'-declared-fields/src') for name in ('objectstate','formbridge','pyqt','toad')] +records=[] +for root in roots: + for p in root.rglob('*.py'): + s=p.read_text();syntax=module_syntax_index(SourceModule.from_source_path(p,s).parse().module) + starts=[0] + for line in s.splitlines(keepends=True):starts.append(starts[-1]+len(line)) + def pos(line,col): + text=s[starts[line-1]:starts[line]];return starts[line-1]+len(text.encode()[:col].decode()) + edits=[] + for _,n in syntax.indexed_nodes_of_type(ast.Attribute): + if n.attr not in {'has_value','inherited'}:continue + receiver=ast.unparse(n.value) + admitted=receiver.startswith('FieldValue.of(') or receiver in {'self._draft','draft','self.resolved'} + if not admitted:raise RuntimeError(f'OPEN receiver: {p}:{n.lineno}: {ast.unparse(n)}') + if n.attr=='has_value':replacement=f'{receiver}.available()' + else: + if isinstance(n.value,ast.Call) and ast.unparse(n.value.func)=='FieldValue.of':replacement=f'FieldValue.is_inherited({ast.unparse(n.value.args[0])})' + else:replacement=f'FieldValue.is_inherited({receiver})' + edits.append((pos(n.lineno,n.col_offset),pos(n.end_lineno,n.end_col_offset),replacement)) + records.append((str(p),n.lineno,ast.unparse(n),replacement)) + for a,b,t in sorted(edits,reverse=True):s=s[:a]+t+s[b:] + if edits:compile(s,str(p),'exec');p.write_text(s) +print(json.dumps(records,indent=2)) diff --git a/.scratch/field_namespace.py b/.scratch/field_namespace.py new file mode 100644 index 0000000..7d72a7f --- /dev/null +++ b/.scratch/field_namespace.py @@ -0,0 +1,91 @@ +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +P=Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/object_state.py');s=P.read_text();idx=module_syntax_index(SourceModule.from_source_path(P,s).parse().module);lines=s.splitlines(keepends=True);edits=[] +c=next(c for _,c in idx.indexed_nodes_of_type(ast.ClassDef)if c.name=='RootFields') +text='''class RootFields(FieldProxy): + """The live root namespace shares field discovery, not a captured schema.""" + def __init__(self, state): + self._state = state + + @property + def _structure(self): + return self._state._parameter_structure + + @property + def _path(self): + return '' + + @property + def present(self): + return True + + @property + def raw(self): + return self._state.to_object() + + @property + def value(self): + return self._state.to_resolved_object() + + def write(self, value): + return self._state.update_object_instance(value) + + def reset(self): + return self._state.reset_all_parameters() + + def __iter__(self): + self._state._check_and_sync_delegate() + return iter(tuple(self._state._field_at(path) for path in self._structure.declarations)) + + def bind(self, owner, names, field_type=FieldProxy): + """Bind an already declared attribute vector to this editing state.""" + target = self._state._extraction_target + if not ((isinstance(owner, type) and isinstance(target, owner)) or target is owner): + raise FieldAccessError(f"{owner!r} does not declare this editing target") + if isinstance(names, str) or not names: + raise TypeError("Binding requires declared attribute segments, not a dotted string") + path = DottedFieldPath('') + for name in names: + candidates = self._structure.direct_paths.get(path, ()) + path = next((child for child in candidates if child.field_name == name), None) + if path is None: + raise FieldAccessError(f"No declared field segment {name!r}") + return field_type(self._state, path.value) + + def subscribe(self, callback): + def changed(paths): + callback(frozenset(self._state._field_at(path) for path in paths if path in self._structure.declarations)) + self._state._listen_state(changed) + def release(): + self._state._unlisten_state(changed) + return release +''' +edits.append((c.lineno-1,c.end_lineno,text+'\n')) +c=next(c for _,c in idx.indexed_nodes_of_type(ast.ClassDef)if c.name=='FieldProxy') +for n in c.body: + if not isinstance(n,ast.FunctionDef):continue + if n.name=='__getattr__':edits.append((n.lineno-1,n.end_lineno,''' def __getattr__(self, name): + self._require_present() + for child in self._structure.direct_paths.get(DottedFieldPath(self._path), ()): + if child.field_name == name: + return self._state._field_at(child.value) + raise AttributeError(f"Field {self._path!r} has no declared child {name!r}") +''')) + if n.name=='_require_present':edits.append((n.lineno-1,n.end_lineno,''' def _require_present(self): + self._state._check_and_sync_delegate() + if not self.present: + raise FieldAccessError(f"Field {self._path!r} belongs to a retired declaration") +''')) +edits.append((c.end_lineno,c.end_lineno,''' + @property + def address(self): + """The existing address codec owns display/wire spelling, not callers.""" + return DottedFieldPath(self._path) + + def inspect(self): + from objectstate.inspection import FieldInspection + return FieldInspection.of(self) +''')) +for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t] +s=''.join(lines);compile(s,str(P),'exec');P.write_text(s) diff --git a/.scratch/field_namespace_capability.py b/.scratch/field_namespace_capability.py new file mode 100644 index 0000000..b97ba74 --- /dev/null +++ b/.scratch/field_namespace_capability.py @@ -0,0 +1,30 @@ +"""A root namespace is not a declared field: share only the actual capability.""" +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +P=Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/object_state.py');s=P.read_text();idx=module_syntax_index(SourceModule.from_source_path(P,s).parse().module);lines=s.splitlines(keepends=True) +classes={c.name:c for _,c in idx.indexed_nodes_of_type(ast.ClassDef)} +leaf=classes['FieldProxy'];root=classes['RootFields'];shared={'state','_structure','children','__getattr__','_subscribe','subscribe','subscribe_value'};methods=[];edits=[] +for n in leaf.body: + if isinstance(n,ast.FunctionDef)and n.name in shared: + a=min([n.lineno]+[d.lineno for d in n.decorator_list])-1;b=n.end_lineno + methods.append(''.join(lines[a:b]));edits.append((a,b,'')) +capability='''class FieldNamespace: + """Declaration discovery and observer lifetime shared by roots and fields. + + A namespace promises neither a field default nor a writable datum. Each + concrete scope owns its admission check and changed-field projection. + """ +'''+ '\n'.join(methods)+'\n\n' +edits.append((leaf.lineno-1,leaf.lineno-1,capability)) +for n in root.body: + if isinstance(n,ast.FunctionDef)and n.name in {'raw','value','write','reset','present'}: + a=min([n.lineno]+[d.lineno for d in n.decorator_list])-1;b=n.end_lineno + edits.append((a,b,'')) +edits.append((root.end_lineno,root.end_lineno,''' + def _require_present(self): + self._state._check_and_sync_delegate() +''')) +for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t] +s=''.join(lines).replace('class FieldProxy:', 'class FieldProxy(FieldNamespace):').replace('class RootFields(FieldProxy):','class RootFields(FieldNamespace):') +compile(s,str(P),'exec');P.write_text(s) diff --git a/.scratch/field_provenance_owner.py b/.scratch/field_provenance_owner.py new file mode 100644 index 0000000..9033547 --- /dev/null +++ b/.scratch/field_provenance_owner.py @@ -0,0 +1,19 @@ +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +P=Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/object_state.py');s=P.read_text();idx=module_syntax_index(SourceModule.from_source_path(P,s).parse().module);lines=s.splitlines(keepends=True) +n=next(n for _,n in idx.indexed_nodes_of_type(ast.FunctionDef)if n.name=='get_provenance') +old=''.join(lines[n.lineno-1:n.end_lineno]);body=old[old.index(' self._ensure_live_resolved()'):];body=body.replace('self._live_provenance.get(param_name)','self._live_provenance.get(field._path)') +new=''' def _read_provenance(self, field: FieldProxy): + """Source of an inherited value; literal None never invents a source.""" +'''+body +lines[n.lineno-1:n.end_lineno]=[new];s=''.join(lines) +s=s.replace(''' def inspect(self): +''',''' @property + def provenance(self): + self._require_present() + return self._state._read_provenance(self) + + def inspect(self): +''');compile(s,str(P),'exec');P.write_text(s) +P=P.with_name('inspection.py');s=P.read_text().replace('match state.get_provenance(field_path):','match field.provenance:');P.write_text(s) diff --git a/.scratch/field_proxy.py b/.scratch/field_proxy.py new file mode 100644 index 0000000..29a7f5e --- /dev/null +++ b/.scratch/field_proxy.py @@ -0,0 +1,191 @@ +"""Declaration-owned live field handles; no caller-built dotted path API.""" +import ast,textwrap +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +P=Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/object_state.py') +s=P.read_text().replace('from objectstate.field_access import DataclassFieldAccess, DottedFieldPath', 'from objectstate.field_access import DataclassFieldAccess, DottedFieldPath, FieldAccessError');idx=module_syntax_index(SourceModule.from_source_path(P,s).parse().module) +n=next(n for _,n in idx.indexed_nodes_of_type(ast.ClassDef)if n.name=='FieldProxy') +source='''class FieldProxy: + """One declaration bound to an editing state. + + The structure, not dataclass reflection repeated by each consumer, admits the + field. The handle owns its reads, reset and subscriptions; ObjectState owns + the complete mutation/invalidation transaction. + """ + def __init__(self, state: 'ObjectState', path: str): + state._check_and_sync_delegate() + if path not in state._parameter_structure.declarations: + raise FieldAccessError(f"No declared field {path!r} on {type(state.saved_object).__name__}") + self._state = state + self._path = path + self._structure = state._parameter_structure + + @property + def state(self): + return self._state + + @property + def declaration(self): + return self._structure.declarations[self._path] + + @property + def name(self): + return self.declaration.name + + @property + def annotation(self): + return self.declaration.param_type + + @property + def default(self): + return self.declaration.default_value + + @property + def description(self): + return self.declaration.description + + @property + def declaring_type(self): + return self._structure.owner_paths[self._path] + + @property + def present(self): + return self._structure is self._state._parameter_structure and self._path in self._state._parameters + + def _require_present(self): + if not self.present: + raise FieldAccessError(f"Field {self._path!r} belongs to a retired declaration") + + @property + def raw(self): + self._require_present() + return self._state._parameters[self._path] + + @property + def value(self): + self._require_present() + return self._state._read_field(self) + + @property + def saved_value(self): + return self._state._read_saved_field(self) + + @property + def dirty(self): + self._state._ensure_live_resolved() + return self._path in self._state._dirty_fields + + @property + def signature_diff(self): + return self.raw != self.default + + def write(self, value): + self._require_present() + return self._state._write_field(self, value) + + def reset(self): + from copy import deepcopy + return self.write(deepcopy(self.default)) + + @property + def children(self): + self._require_present() + return tuple(self._state._field_at(path.value) for path in self._structure.direct_paths.get(DottedFieldPath(self._path), ())) + + def __getattr__(self, name): + for child in self.children: + if child.name == name: + return child + raise AttributeError(f"Field {self._path!r} has no declared child {name!r}") + + def subscribe(self, callback): + """Call a field observer without making it compare path strings. + + Return the one registration's release operation, for the view's existing + resource lifetime. Container observers also see their descendants. + """ + path = DottedFieldPath(self._path) + def changed(paths): + if path.contains_any(paths): + callback() + self._state._listen_state(changed) + def release(): + self._state._unlisten_state(changed) + return release + + def __eq__(self, other): + return isinstance(other, FieldProxy) and self._state is other._state and self._structure is other._structure and self._path == other._path + + def __hash__(self): + return hash((id(self._state), id(self._structure), self._path)) + + def __repr__(self): + return f"{type(self).__name__}({self._path!r})" + + +class RootFields: + """A state's declaration namespace survives replacement of its schema.""" + def __init__(self, state): + self._state = state + + @property + def children(self): + structure = self._state._parameter_structure + return tuple(self._state._field_at(path.value) for path in structure.direct_paths.get(DottedFieldPath(''), ())) + + def __iter__(self): + return iter(tuple(self._state._field_at(path) for path in self._state._parameter_structure.declarations)) + + def __getattr__(self, name): + for child in self.children: + if child.name == name: + return child + raise AttributeError(f"No declared root field {name!r}") + + def subscribe(self, callback): + def changed(paths): + callback(frozenset(self._state._field_at(path) for path in paths if path in self._state._parameter_structure.declarations)) + self._state._listen_state(changed) + def release(): + self._state._unlisten_state(changed) + return release +''' +lines=s.splitlines(keepends=True);lines[n.lineno-1:n.end_lineno]=[source+'\n'];s=''.join(lines) +# Internal storage and transactions are not public string APIs. Consumer ports +# hold FieldProxy values; no compatibility aliases or old entry points remain. +s=s.replace('self.parameters','self._parameters') +idx=module_syntax_index(SourceModule.from_source_path(P,s).parse().module);lines=s.splitlines(keepends=True);edits=[] +renames={'get_resolved_value':'_read_field','get_saved_resolved_value':'_read_saved_field','update_parameter':'_write_field','on_state_changed':'_listen_state','off_state_changed':'_unlisten_state','on_resolved_changed':'_listen_resolved','off_resolved_changed':'_unlisten_resolved'} +for _,node in idx.indexed_nodes_of_type(ast.FunctionDef): + if node.name in {'reset_parameter','signature_default'}: + edits.append((node.lineno-1,node.end_lineno,''));continue + if node.name=='fields': + edits.append((node.lineno-1,node.end_lineno,' def fields(self) -> RootFields:\n return RootFields(self)\n\n def _field_at(self, path: str) -> FieldProxy:\n return FieldProxy(self, path)\n\n def field(self, owner: ParameterOwner, name: str) -> FieldProxy:\n matches = [path for path, declaration in self._parameter_structure.declarations.items() if declaration.name == name and self._parameter_structure.owner_paths[path] is owner]\n if len(matches) != 1:\n raise FieldAccessError(f"Expected one declared {owner!r}.{name}, found {len(matches)}; use the declaration namespace for repeated owners")\n return self._field_at(matches[0])\n'));continue + if node.name in renames: + old=''.join(lines[node.lineno-1:node.end_lineno]);new=old.replace('def '+node.name+'(', 'def '+renames[node.name]+'(',1) + if node.name in {'get_resolved_value','get_saved_resolved_value','update_parameter'}: + new=new.replace('param_name: str','field: FieldProxy',1) + # The existing transaction remains the state owner's work. Resolve its + # canonical declaration key once, not at each consumer. + parsed=ast.parse(textwrap.dedent(new)).body[0];first=parsed.body[0] + insert_after=first.end_lineno if isinstance(first,ast.Expr)and isinstance(first.value,ast.Constant)and isinstance(first.value.value,str)else 1 + parts=new.splitlines(keepends=True);parts.insert(insert_after,' param_name = field._path\n');new=''.join(parts) + edits.append((node.lineno-1,node.end_lineno,new)) +for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t] +s=''.join(lines) +# Migrate ObjectState's own calls while removing the public entry points. +idx=module_syntax_index(SourceModule.from_source_path(P,s).parse().module);lines=s.splitlines(keepends=True);edits=[] +for _,node in idx.indexed_nodes_of_type(ast.Call): + if not isinstance(node.func,ast.Attribute):continue + name=node.func.attr + if name=='reset_parameter': + replacement=ast.Call(func=ast.Attribute(value=ast.Call(func=ast.Attribute(value=node.func.value,attr='_field_at',ctx=ast.Load()),args=[node.args[0]],keywords=[]),attr='reset',ctx=ast.Load()),args=[],keywords=[]) + elif name in renames: + args=list(node.args) + if name in {'get_resolved_value','get_saved_resolved_value','update_parameter'}: + args[0]=ast.Call(func=ast.Attribute(value=node.func.value,attr='_field_at',ctx=ast.Load()),args=[args[0]],keywords=[]) + replacement=ast.Call(func=ast.Attribute(value=node.func.value,attr=renames[name],ctx=ast.Load()),args=args,keywords=node.keywords) + else:continue + before=lines[node.lineno-1][:node.col_offset];after=lines[node.end_lineno-1][node.end_col_offset:];edits.append((node.lineno-1,node.end_lineno,before+ast.unparse(ast.fix_missing_locations(replacement))+after)) +for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t] +s=''.join(lines);compile(s,str(P),'exec');P.write_text(s) diff --git a/.scratch/field_subscription_owner.py b/.scratch/field_subscription_owner.py new file mode 100644 index 0000000..3e23b36 --- /dev/null +++ b/.scratch/field_subscription_owner.py @@ -0,0 +1,36 @@ +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +P=Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/object_state.py');s=P.read_text();idx=module_syntax_index(SourceModule.from_source_path(P,s).parse().module);lines=s.splitlines(keepends=True);edits=[] +for _,c in idx.indexed_nodes_of_type(ast.ClassDef): + if c.name not in {'FieldProxy','RootFields'}:continue + for n in c.body: + if not isinstance(n,ast.FunctionDef)or n.name!='subscribe':continue + if c.name=='FieldProxy':text=''' def _notify_subscriber(self, callback, paths): + owner = DottedFieldPath(self._path) + selected = [path for path in paths if owner.contains_path(path)] + if selected: + callback(frozenset(self._state._field_at(path) for path in selected if path in self._state._parameter_structure.declarations)) + + def _subscribe(self, callback, listen, unlisten): + def changed(paths): + self._notify_subscriber(callback, paths) + listen(changed) + def release(): + unlisten(changed) + return release + + def subscribe(self, callback): + """Observe this field's state; receive changed field handles, never strings.""" + return self._subscribe(callback, self._state._listen_state, self._state._unlisten_state) + + def subscribe_value(self, callback): + """Observe resolved-value changes, including changes inherited from elsewhere.""" + return self._subscribe(callback, self._state._listen_resolved, self._state._unlisten_resolved) +''' + else:text=''' def _notify_subscriber(self, callback, paths): + callback(frozenset(self._state._field_at(path) for path in paths if path in self._structure.declarations)) +''' + edits.append((n.lineno-1,n.end_lineno,text)) +for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t] +s=''.join(lines);compile(s,str(P),'exec');P.write_text(s) diff --git a/.scratch/finish_conversion.py b/.scratch/finish_conversion.py new file mode 100644 index 0000000..2d734a1 --- /dev/null +++ b/.scratch/finish_conversion.py @@ -0,0 +1,31 @@ +import ast,textwrap +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +p=Path('/home/ts/wt/formbridge-declared-fields/src/formbridge/forms/parameter_form_service.py');s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True) +n=next(n for _,n in idx.indexed_nodes_of_type(ast.FunctionDef)if n.name=='convert_value_to_type') +body=''.join(lines[n.lineno-1:n.end_lineno]).replace('def convert_value_to_type(', 'def _convert_domain_value(',1) +body=body.replace(''' # Handle string "None" literal + if isinstance(value, str) and value == CONSTANTS.NONE_STRING_LITERAL: + return None + +''','') +body=body.replace(''' if value == CONSTANTS.EMPTY_STRING: + return None''',''' if value == CONSTANTS.EMPTY_STRING: + return Unset()''') +a=body.index(' # Handle empty strings in lazy context');b=body.index(' return value',a) +# The entire obsolete generic-empty/string-empty policy ends at the final return. +body=body[:a]+' return value\n' +wrapper=''' def convert_value_to_type( + self, value: ParameterValue, param_type: type, param_name: str, obj_type: type = None + ) -> ParameterValue: + """Convert domain input through its annotation; retain field directives.""" + return FieldValue.of(value).map( + lambda raw: self._convert_domain_value(raw, param_type, param_name, obj_type) + ) + +''' +lines[n.lineno-1:n.end_lineno]=[wrapper+body];s=''.join(lines) +# This module has no future import; put the state import after its docstring. +module=ast.parse(s);doc=module.body[0];end=doc.end_lineno if isinstance(doc,ast.Expr)and isinstance(doc.value,ast.Constant)and isinstance(doc.value.value,str)else 0 +lines=s.splitlines(keepends=True);lines.insert(end,'from objectstate.field_value import FieldValue, Unset\n');s=''.join(lines);compile(s,str(p),'exec');p.write_text(s) +p=Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/codegen.py');s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);n=next(n for _,n in idx.indexed_nodes_of_type(ast.FunctionDef)if n.name=='lazy_dataclass_carries_values');lines=s.splitlines(keepends=True);del lines[n.lineno-1:n.end_lineno];s=''.join(lines).replace('registers two formatters','registers the field formatters');compile(s,str(p),'exec');p.write_text(s) diff --git a/.scratch/finish_state.py b/.scratch/finish_state.py new file mode 100644 index 0000000..f07cce6 --- /dev/null +++ b/.scratch/finish_state.py @@ -0,0 +1,59 @@ +"""Complete the stored-value consumers; no compatibility surface remains.""" +import ast,re +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1]/'src/objectstate' +p=ROOT/'subfield_semantics.py';t=p.read_text() +t=t.replace('from objectstate.field_access import DataclassFieldAccess, DottedFieldPath','from objectstate.field_access import DataclassFieldAccess, DottedFieldPath\nfrom objectstate.field_value import FieldValue, Unset') +t=t.replace('''@dataclass(frozen=True, slots=True) +class MissingValue: + """Nominal sentinel for an absent structural leaf.""" + + +MISSING = MissingValue() + + +''','') +t=t.replace('raw_value is not MISSING','FieldValue.of(raw_value).has_value').replace('resolved_value is not MISSING','FieldValue.of(resolved_value).has_value').replace('saved_resolved_value is not MISSING','FieldValue.of(saved_resolved_value).has_value').replace('signature_default_value is not MISSING','FieldValue.of(signature_default_value).has_value') +# Longer names before suffixes: do not turn saved_resolved_value into saved_FieldValue. +t=t.replace('saved_FieldValue.of(resolved_value).has_value','FieldValue.of(saved_resolved_value).has_value') +t=t.replace('None if value_type is MISSING else type(value_type).__qualname__','type(value_type).__qualname__ if FieldValue.of(value_type).has_value else None') +t=t.replace('if value is MISSING:','if not FieldValue.of(value).has_value:') +t=t.replace('owner_inherited_value=raw_value is None and resolved_value is not None','owner_inherited_value=FieldValue.is_inherited(raw_value) and FieldValue.of(resolved_value).has_value') +t=re.sub(r'\bMISSING\b','Unset()',t) +p.write_text(t) +p=ROOT/'object_state.py';t=p.read_text().replace(' MISSING,\n','');t=re.sub(r'\bMISSING\b','Unset()',t);p.write_text(t) +p=ROOT/'__init__.py';t=p.read_text().replace(' MISSING,\n','').replace(' MissingValue,\n','').replace(" 'MISSING',\n",'').replace(" 'MissingValue',\n",'');p.write_text(t) +p=ROOT/'inspection.py';t=p.read_text();t='from objectstate.field_value import FieldValue, Unset\n'+t if 'from __future__'not in t else t.replace('from __future__ import annotations','from __future__ import annotations\nfrom objectstate.field_value import FieldValue, Unset') +t=t.replace('resolved_value = None\n resolved_value_available = False','resolved_value = Unset()\n resolved_value_available = False') +t=t.replace('return self.raw_value is None and self.resolved_value_available and (\n self.resolved_value is not None\n )','return FieldValue.is_inherited(self.raw_value) and self.resolved_value_available and FieldValue.of(self.resolved_value).has_value') +# Literal None remains literal None; inherited state is independently named. +needle=' @property\n def raw_value_is_none(self) -> bool:' +t=t.replace(needle,' @property\n def raw_value_is_inherited(self) -> bool:\n return FieldValue.is_inherited(self.raw_value)\n\n'+needle) +p.write_text(t) +p=ROOT/'resolution_sources.py';t=p.read_text().replace('FieldKey = tuple[type, str]','from objectstate.field_access import DataclassFieldAccess\nfrom objectstate.field_value import Unset\n\nFieldKey = tuple[type, str]') +t=t.replace('return object.__getattribute__(config, name)','return DataclassFieldAccess.raw_value(config, name)').replace(''' if config is None: + return None + try: + return DataclassFieldAccess.raw_value(config, name) + except AttributeError: + return None''',''' if config is None: + return Unset() + try: + return DataclassFieldAccess.raw_value(config, name) + except AttributeError: + return Unset()''');p.write_text(t) +p=ROOT/'codegen.py';t=p.read_text().replace('from objectstate.field_access import DataclassFieldAccess','from objectstate.field_access import DataclassFieldAccess\nfrom objectstate.field_value import FieldValue') +t=t.replace('DataclassFieldAccess.raw_value(value, field.name) is not None','FieldValue.of(DataclassFieldAccess.raw_value(value, field.name)).has_value') +t=t.replace('if current is None:\n return False','if FieldValue.is_inherited(current):\n return False') +t+='''\n\nclass FieldValueFormatter(SourceFormatter): + """Field states render their declared constructor, never a None stand-in.""" + priority = 38 + formats = FieldValue + + def can_format(self, value: object) -> bool: + return isinstance(value, self.formats) + + def format(self, value: FieldValue, context: FormatContext) -> SourceFragment: + return value.source(context) +''';p.write_text(t) +for p in ROOT.glob('*.py'):ast.parse(p.read_text(),filename=str(p)) diff --git a/.scratch/form_manager_journey.py b/.scratch/form_manager_journey.py new file mode 100644 index 0000000..40a19dd --- /dev/null +++ b/.scratch/form_manager_journey.py @@ -0,0 +1,73 @@ +"""Fresh in-memory configuration and real Qt form; no user files or providers.""" +from dataclasses import dataclass +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import QEventLoop, QTimer +from PyQt6.QtTest import QTest +from objectstate import ObjectState, Inherit, LazyDataclassFactory, set_base_config_type, set_global_config +from pyqt_reactive.forms.parameter_form_manager import ParameterFormManager, FormManagerConfig +from pyqt_reactive.theming.color_scheme import ColorScheme + +app = QApplication([]) +@dataclass +class Settings: + text: str | None = None + count: int | None = 4 + +set_base_config_type(Settings) +set_global_config(Settings, Settings(text='global text', count=77)) +Lazy = LazyDataclassFactory.make_lazy_simple(Settings) +state = ObjectState(Lazy(text=None), scope_id='declared-state-journey') +manager = ParameterFormManager(state) +hooks, published = [], [] +manager.before_mutation = lambda: hooks.append('mutation') +manager.parameter_changed.connect(lambda name, value: published.append((name, value))) +loop = QEventLoop() +manager.register_form_build_completion_callback(loop.quit) +if not manager.form_build_complete: + QTimer.singleShot(2000, loop.quit) + loop.exec() +assert manager.form_build_failure is None, manager.form_build_failure +assert manager.form_build_complete +manager.show() +app.processEvents() +try: + print('built', {name: type(widget).__name__ for name, widget in manager.widgets.items()}, flush=True) + assert manager.widgets['text'].get_value() is None + assert isinstance(manager.widgets['count'].get_value(), Inherit) + assert state.fields.count.value == 77 + print('open form preserves literal None and inherited 77 separately', flush=True) + state.fields.count.write(None) + app.processEvents() + assert manager.widgets['count'].get_value() is None + assert state.to_object().count is None + print('existing form receives a backend change to literal None', flush=True) + loop = QEventLoop() + def changed(_fields): + if state.fields.text.raw == 'typed': + loop.quit() + release = state.fields.subscribe(changed) + widget = manager.widgets['text'] + widget.setFocus() + QTest.keyClicks(widget, 'typed') + if state.fields.text.raw != 'typed': + QTimer.singleShot(1000, loop.quit) + loop.exec() + release() + assert state.fields.text.raw == 'typed', state.fields.text.raw + assert state.to_object().text == 'typed' + print('native Qt keystrokes commit through the original debounce and state callbacks', flush=True) + before_hooks, before_published = len(hooks), len(published) + manager.reset_parameter('count') + app.processEvents() + assert isinstance(state.fields.count.raw, Inherit) + assert isinstance(manager.widgets['count'].get_value(), Inherit) + assert state.fields.count.value == 77 + assert len(hooks) == before_hooks + 1, hooks + assert len(published) == before_published + 1, published + assert published[-1][0] == 'count' and isinstance(published[-1][1], Inherit) + print('reset performs one mutation hook and one publication, restores inherited 77', flush=True) +finally: + manager.dispose() + manager.deleteLater() + app.processEvents() +print('view retired', flush=True) diff --git a/.scratch/implement_state.py b/.scratch/implement_state.py new file mode 100644 index 0000000..435140b --- /dev/null +++ b/.scratch/implement_state.py @@ -0,0 +1,173 @@ +"""Authored owner-level migration, with NRA-selected function spans. +Semantic changes are intentionally not claimed as automated equivalence proofs. +""" +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +ROOT=Path(__file__).resolve().parents[1]/'src/objectstate' + +def replace_function(text,path,name,replacement): + parsed=SourceModule.from_source_path(path,text).parse() + matches=[n for _,n in module_syntax_index(parsed.module).indexed_nodes_of_type(ast.FunctionDef) if n.name==name] + assert len(matches)==1,(path,name,len(matches)) + n=matches[0];lines=text.splitlines(keepends=True) + start=min([n.lineno]+[d.lineno for d in n.decorator_list])-1 + return ''.join(lines[:start])+replacement.rstrip()+'\n'+''.join(lines[n.end_lineno:]) + +p=ROOT/'context_manager.py';text=p.read_text() +text=text.replace('from python_introspect import resolve_annotated, resolve_optional','from python_introspect import resolve_annotated, resolve_optional\nfrom objectstate.field_access import DataclassFieldAccess\nfrom objectstate.field_value import FieldValue') +text=text.replace(' ``scope_id`` is ``None`` for a layer entered without one. ``masks_outer_layers``\n is True for a layer entered with ``mask_with_none``: a ``None`` in it is final,\n so no layer outside it supplies a value.',' ``scope_id`` is ``None`` for a layer entered without one.') +text=text.replace('("scope_id", "obj", "masks_outer_layers", "_configs")','("scope_id", "obj", "_configs")').replace('def __init__(self, scope_id: str | None, obj: Any, masks_outer_layers: bool = False):','def __init__(self, scope_id: str | None, obj: Any):').replace(' self.masks_outer_layers = masks_outer_layers\n','') +text=replace_function(text,p,'_merge_nested_dataclass','''def _merge_nested_dataclass(base, override): + """Merge stored values; Inherit delegates, while None is a concrete override.""" + return FieldValue.of(override).overlay(base, _merge_concrete_dataclass) + + +def _merge_concrete_dataclass(base, override): + if not is_dataclass(base) or not is_dataclass(override): + return override + from objectstate.lazy_factory import get_base_type_for_lazy, replace_raw + base_type = get_base_type_for_lazy(type(base)) or type(base) + override_type = get_base_type_for_lazy(type(override)) or type(override) + result_owner = base if base_type is override_type else override + base_fields = DataclassFieldAccess.field_names(base) + values = { + f.name: _merge_nested_dataclass( + DataclassFieldAccess.raw_value(base, f.name), + DataclassFieldAccess.raw_value(override, f.name), + ) + for f in fields(override) if f.name in base_fields + } + return replace_raw(result_owner, **values) if values else result_owner +''') +text=replace_function(text,p,'config_context','''@contextmanager +def config_context(obj, use_live_global: bool = True, scope_id: str | None = None): + """Enter one layer. A stored None always supplies a concrete value.""" + from objectstate.config import get_base_config_type + from objectstate.lazy_factory import replace_raw + current = get_current_temp_global() + base = current if current is not None else get_base_global_config(use_live=use_live_global) + overrides = {} + if obj is not None: + read = DataclassFieldAccess.raw_value if is_dataclass(type(obj)) else getattr + for declaration in fields(get_base_config_type()): + try: + value = read(obj, declaration.name) + except AttributeError: + continue + base_value = DataclassFieldAccess.raw_value(base, declaration.name) + overrides[declaration.name] = _merge_nested_dataclass(base_value, value) + merged = replace_raw(base, **overrides) if overrides else base + types = context_type_stack.get() + stack = context_layer_stack.get() if current is not None else ContextLayerStack((ContextLayer("", base),)) + if obj is not None: + stack = stack.with_layer(ContextLayer(scope_id, obj)) + types += (type(obj),) + with ExitStack() as resources: + for variable, value in ((current_temp_global, merged), (context_type_stack, types), (context_layer_stack, stack)): + resources.callback(variable.reset, variable.set(value)) + yield merged +''') +text=replace_function(text,p,'build_context_stack','''@contextmanager +def build_context_stack( + object_instance: object, + ancestor_objects_with_scopes: Sequence[tuple[str, object]] = (), + current_scope_id: str | None = None, + use_live: bool = True, +): + """Resolve against one explicit baseline and the recorded ancestor layers. + + Editing a global config starts from its declared defaults, not a live object + hidden under a masking mode. Other states start from the selected global. + """ + from objectstate.lazy_factory import GlobalConfigBase + editing_global = isinstance(object_instance, GlobalConfigBase) + baseline = type(object_instance)() if editing_global else get_base_global_config(use_live=use_live) + layers = ContextLayerStack((ContextLayer("", baseline),)) + with ExitStack() as resources: + for variable, value in ((current_temp_global, baseline), (context_type_stack, ()), (context_layer_stack, layers)): + resources.callback(variable.reset, variable.set(value)) + for scope_id, ancestor in ancestor_objects_with_scopes: + if not isinstance(ancestor, GlobalConfigBase): + resources.enter_context(config_context(ancestor, use_live_global=use_live, scope_id=scope_id)) + resources.enter_context(config_context(object_instance, use_live_global=use_live, scope_id=current_scope_id)) + yield +''') +text=text.replace('A ``None`` override is kept: it means "inherit this field from the parent context".','A concrete ``None`` override is kept; only ``Inherit()`` requests inheritance.') +p.write_text(text) + +p=ROOT/'lazy_factory.py';text=p.read_text() +text=text.replace('from contextlib import contextmanager','from contextlib import contextmanager\nfrom objectstate.field_value import FieldValue, Inherit, Unset\nfrom objectstate.field_access import DataclassFieldAccess') +text=text.replace('rebuild_with_none_defaults','rebuild_with_inherited_defaults').replace('field_names_to_none','inherited_field_names').replace('inherit_as_none','inherit_fields') +text=text.replace('make_optional(declared_type),\n field(\n default=None,','declared_type,\n field(\n default=Inherit(),') +text=text.replace('final_field_type = make_optional(field_type)','final_field_type = field_type') +text=text.replace('default=None,\n metadata=_inherited_default_metadata(field_definition)','default=Inherit(),\n metadata=_inherited_default_metadata(field_definition)') +text=replace_function(text,p,'patch_lazy_constructors','') +text=replace_function(text,p,'_get_raw_field_value','') +# Raw reconstruction must not invoke effective reads. +text=text.replace('object.__getattribute__(instance, f.name)','DataclassFieldAccess.raw_value(instance, f.name)') +text=text.replace('object.__getattribute__(self, f.name)','DataclassFieldAccess.raw_value(self, f.name)') +text=text.replace('object.__getattribute__(config, field_definition.name)','DataclassFieldAccess.raw_value(config, field_definition.name)') +text=text.replace('object.__getattribute__(\n inherited,\n field_definition.name,\n )','DataclassFieldAccess.raw_value(inherited, field_definition.name)') +text=text.replace('if value is not None:\n return value','if not FieldValue.is_inherited(value):\n return value') +start=text.index(' resolved_value = resolve_in_stack(stack, _cls, name).value') +end=text.index('\n return __getattribute__',start) +text=text[:start]+''' return value.resolve(lambda: resolve_in_stack(stack, _cls, name).value) +'''+text[end:] +p.write_text(text) + +p=ROOT/'dual_axis_resolver.py';text=p.read_text() +text=text.replace('logger = logging.getLogger(__name__)','from objectstate.field_access import DataclassFieldAccess\nfrom objectstate.field_value import FieldValue, Unset\n\nlogger = logging.getLogger(__name__)') +text=replace_function(text,p,'_direct_owner_field_default','''def _direct_owner_field_default(config_type: type, field_name: str): + """A declared own default, or Unset when this owner delegates the default.""" + if not is_dataclass(config_type): + return Unset() + declaration = config_type.__dataclass_fields__.get(field_name) + if declaration is None or "_inherited_default" in declaration.metadata: + return Unset() + if declaration.default is not MISSING: + return declaration.default + if declaration.default_factory is not MISSING: + return declaration.default_factory() + return Unset() +''') +text=replace_function(text,p,'declared_default','''def declared_default(container_type: type, field_name: str) -> tuple[Any, type | None]: + """Read the declared default, retaining an explicit None as a real value.""" + base = _normalize_to_base(container_type) + own = _direct_owner_field_default(base, field_name) + own_type = base if FieldValue.of(own).has_value else None + if FieldValue.of(own).has_value: + return own, own_type + for owner in base.__mro__: + value = getattr(owner, field_name, Unset()) + if FieldValue.of(value).has_value: + return value, own_type + if is_dataclass(container_type): + for declaration in fields(container_type): + if declaration.name != field_name: + continue + inherited = declaration.metadata.get("_inherited_default", MISSING) + if inherited is not MISSING and FieldValue.of(inherited).has_value: + return inherited, own_type + factory = declaration.metadata.get("_inherited_default_factory", MISSING) + if factory is not MISSING: + return factory(), own_type + return Unset(), own_type +''') +text=text.replace(''' visible_layers = [] + for layer in reversed(layers): + visible_layers.append(layer) + if layer.masks_outer_layers: + break +''',''' visible_layers = tuple(reversed(layers)) +''') +text=text.replace('object.__getattribute__(config_instance, field_name)','DataclassFieldAccess.raw_value(config_instance, field_name)') +text=text.replace('if value is not None:\n return ResolvedField','if FieldValue.of(value).has_value:\n return ResolvedField') +p.write_text(text) + +# Constructor patching no longer exists: every normal constructor owns defaults. +p=ROOT/'__init__.py';text=p.read_text().replace(' patch_lazy_constructors,\n','').replace(" 'patch_lazy_constructors',\n",'') +text+='\nfrom objectstate.field_value import FieldValue, Inherit, Unset\n' +# __all__ is the package export contract, not a fallback alias. +text=text.replace(" 'LazyDataclass',"," 'LazyDataclass',\n 'FieldValue',\n 'Inherit',\n 'Unset',") +p.write_text(text) diff --git a/.scratch/inspection-spinbox-journey.py b/.scratch/inspection-spinbox-journey.py new file mode 100644 index 0000000..879ff85 --- /dev/null +++ b/.scratch/inspection-spinbox-journey.py @@ -0,0 +1,52 @@ +from dataclasses import dataclass +from objectstate import ObjectState, Inherit, Unset, set_base_config_type +from objectstate.field_value import Value +from objectstate.inspection import FieldInspection +from formbridge.bridge.object_state import ObjectStateFieldSemanticProjection +from PyQt6.QtCore import Qt +from PyQt6.QtTest import QTest +from PyQt6.QtWidgets import QApplication +from pyqt_reactive.protocols import SpinBoxAdapter, DoubleSpinBoxAdapter +from pyqt_reactive.widgets.no_scroll_spinbox import NoScrollSpinBox, NoScrollDoubleSpinBox + +@dataclass +class Config: + text: str | None = None +set_base_config_type(Config) +for datum in (None, Unset(), 'None', ''): + state = ObjectState(Config(text=datum)) + field = state.fields.text + inspection = FieldInspection.of(field) + projected = ObjectStateFieldSemanticProjection.of(field) + assert inspection.resolved.project(lambda v: v) == datum + if isinstance(datum, Unset): + assert projected.resolved_json_value == 'Unset()' + assert projected.resolved_value_preview.type_name == 'Unset' + assert projected.resolved_value_preview.text == 'Unset()' + assert not projected.resolved_value_is_none + else: + assert projected.resolved_json_value == datum +for directive in (Unset(), Inherit()): + assert directive.project(lambda v: v) is directive +app = QApplication.instance() or QApplication([]) +for kind in (SpinBoxAdapter, DoubleSpinBoxAdapter, NoScrollSpinBox, NoScrollDoubleSpinBox): + widget = kind(); widget.show(); app.processEvents() + calls=[]; widget.connect_change_signal(calls.append) + for datum in (Inherit(), Unset(), None, Value(None), 0, widget.minimum()): + widget.set_value(datum) + expected = datum.resolve(lambda: Inherit()) if isinstance(datum, Value) else datum + assert widget.get_value() == expected, (kind,datum,widget.get_value()) + if datum is None or datum == Value(None): + assert widget.text() == 'None', (kind,widget.text()) + assert calls == [], calls + widget.configure_range(-10,10) + for datum in (None, Inherit(), Unset()): + widget.set_value(datum) + widget.lineEdit().selectAll(); QTest.keyClicks(widget.lineEdit(), '0') + assert widget.get_value() == 0, (kind,datum,widget.get_value()) + assert calls[-1] == 0, (kind, datum, calls, widget.value(), widget.text()) + widget.set_value(datum) + widget.lineEdit().selectAll(); QTest.keyClicks(widget.lineEdit(), '-10') + assert widget.get_value() == -10, (kind,datum,widget.get_value(),widget.text()) + widget.close(); widget.deleteLater() +print('real Qt numeric controls: distinct literal null/directives/minima, wrapped Values, native zero/minimum input; bridge renders absent member, not null',flush=True) diff --git a/.scratch/inspection-spinbox-owner-evidence.py b/.scratch/inspection-spinbox-owner-evidence.py new file mode 100644 index 0000000..3630286 --- /dev/null +++ b/.scratch/inspection-spinbox-owner-evidence.py @@ -0,0 +1,9 @@ +import ast,json +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +p=Path('/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/protocols/widget_adapters.py') +s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module) +selected=[n for _,n in idx.indexed_nodes_of_type(ast.ClassDef) if n.name in {'SpinBoxValueAdapter','SpinBoxAdapter','DoubleSpinBoxAdapter'}] +text='\n\n'.join(ast.get_source_segment(s,n) for n in selected) +out=Path('/home/ts/.cache/agent-scratch/objectstate-declared-fields/spinbox-members-after.txt');out.write_text(text+'\n') +print(json.dumps({'numeric_owners':[(n.name,n.lineno,[ast.unparse(b) for b in n.bases]) for n in selected],'numeric_none_decisions':[(n.lineno,ast.unparse(n)) for _,n in idx.indexed_nodes_of_type(ast.Compare) if any(c.lineno<=n.lineno<=c.end_lineno for c in selected) and any(isinstance(x,ast.Constant) and x.value is None for x in n.comparators)],'comparison_scope':'numeric classes, not unrelated nullable-text formatting or wire DTO projections'},indent=2)) diff --git a/.scratch/inspection-spinbox-pr-comment.md b/.scratch/inspection-spinbox-pr-comment.md new file mode 100644 index 0000000..73a8700 --- /dev/null +++ b/.scratch/inspection-spinbox-pr-comment.md @@ -0,0 +1,28 @@ +## Inspection/member correction delivered (not completion of the entire cutover) + +Pair: ObjectState `9faba28`, FormBridge `37a0556`, PyQt `02db991`. + +- `FieldInspection` declares exactly one effective value: `resolved: FieldValue`. The parallel `resolved_value`, availability flag and broad exception fallback are deleted. Original resolver exception identity propagates. +- Five original inspection/projection consumer methods migrated: null classification, inherited classification, type projection, JSON projection and preview projection. Inheritance relation is owned by the member and shared with structural-owner projection. Thirty additional AST-selected flag uses now invoke member behavior across resolver, structural projection, construction and Qt services. +- FieldValue has no `inherited`, `has_value` or `is_none` class flags. `project` has no absence default: it passes the real directive or datum through existing polymorphic resolution to the view. Bridge absence is a visible `Unset` preview/member spelling, not fabricated null. Public DTO wire `resolved_value` and its wire null-description fields remain unchanged; their similarly named reads are not FieldInspection aliases. +- `SpinBoxValueAdapter` owns integer/double rendering, placeholder/range behavior and native admission. Leaves retain numeric type/range and floating formatting only. None renders as literal `None`; directives render their preview; real minima are data. No numeric `value is None`/`value is not None` branch or magic one-space remains in the AST-selected numeric class family (scoped rg: zero). +- Native signal declarations include their actual receiver. Qt emits textEdited before updating its number, so unchanged-number text admission is separate from valueChanged; no sender() guess or duplicated publication. Input tracking begins at control construction. Retiring special paint does not repeatedly repaint partial input and erase a minus sign. + +### Source evidence + +NRA ModuleSyntaxIndex/class-family census covers eight production/dependency roots: 1,585 modules; 9,875 before/9,876 after class rows; zero parse errors. Conditional Qt declarations/runtime signal bindings are not claimed statically resolved. Actual C3 MRO and shared implementation identity were checked for base/no-scroll int/double controls. A new numeric leaf supplies its native Qt base, type/range and any real formatter; it does not copy data-state or subscription algorithms. + +Requested raw rg `is_none|has_value|\\.inherited\\b`: **156 -> 115** across the same corpus. Remaining matches are wire DTO/null-description names, unrelated source fields and logging; FieldValue flag declarations/reads are gone. `resolved_value_available` production hits: **0**. Five inspection consumers are not counted as thirty separate callers; the latter number is AST-selected expression sites. + +Owned evidence: `/home/ts/.cache/agent-scratch/objectstate-declared-fields/inspection-spinbox-{before,after}.json`, `meaning-flags-{before,after}.txt`, `meaning-member-migrations.json`, `inspection-spinbox-owners-after.json`, `spinbox-runtime-mro.txt` and `spinbox-members-after.txt`. Transformations remain under the persistent producer checkout `.scratch/`. The first signal tuple selection also selected the outer tuple; that unsupported site was corrected before acceptance, not claimed as semantic proof from parsing. + +### Confirmed behavior + +- **30 producer tests passed**: existing handles plus inspection/scopeless resolution; literal null/absence/inheritance, exact read failure, dirty/provenance and source rules retained. +- **68 real Qt cases passed**: localized float roundtrips, literal/wrapped null and directives, exact minima, one native zero admission, unchanged numeric minimum, range/signal-block restoration, default form, existing/nested refresh and reset. +- **5 bridge projection cases passed**: absent member versus null and literal strings. +- Real source-paired Qt control journey and real bridge mutation/catalog/history journey passed, including original-handle undo. + +Earlier import, signal-ordering, tuple-selection, constructor-tracking and negative-entry failures are retained separately; acceptance is the corrected run, not a silent retry. Test expectation that None means inherited display was replaced by explicit Inherit plus a distinct literal-null assertion; no skip/xfail, weakened count, timeout increase or provider replay. + +This is source-paired acceptance, not shared installation or full OpenHCS/Core/Qt-window cutover. Existing drafts and saved-data migration remain unfinished. diff --git a/.scratch/migrate-dto-member-fixtures.py b/.scratch/migrate-dto-member-fixtures.py new file mode 100644 index 0000000..22d1ee2 --- /dev/null +++ b/.scratch/migrate-dto-member-fixtures.py @@ -0,0 +1,43 @@ +"""Port the eight source-adjudicated DTO fixture constructors, not arbitrary nulls.""" +import ast,json +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +root=Path('/home/ts/wt/openhcs-declared-fields/tests/unit/agent') +records=[] +for filename in ('test_mcp_server.py','test_mcp_dev_client_knowledge_renderers.py','test_object_state_field_help_service.py','test_ui_bridge_service.py'): + p=root/filename;s=p.read_text();module=SourceModule.from_source_path(p,s).parse().module;syntax=module_syntax_index(module) + starts=[0] + for line in s.splitlines(keepends=True): starts.append(starts[-1]+len(line)) + def pos(line,col): + text=s[starts[line-1]:starts[line]];return starts[line-1]+len(text.encode()[:col].decode()) + edits=[] + for _,n in syntax.indexed_nodes_of_type(ast.Call): + if not isinstance(n.func,ast.Name) or n.func.id not in {'UiObjectStateFieldSummary','UiObjectStateFieldProjection','UiWidgetActionSummary'}:continue + ks={k.arg:k for k in n.keywords}; changed=[] + inherited=ast.unparse(ks['inherited_value'].value) if 'inherited_value' in ks else 'False' + for key in ('raw_value','resolved_value'): + if key not in ks:continue + node=ks[key].value; old=s[pos(node.lineno,node.col_offset):pos(node.end_lineno,node.end_col_offset)] + replacement='Inherit()' if key=='raw_value' and inherited=='True' and old=='None' else f'Value({old})' + edits.append((pos(node.lineno,node.col_offset),pos(node.end_lineno,node.end_col_offset),replacement));changed.append(key) + for key in ('raw_value_is_none','resolved_value_is_none'): + if key not in ks:continue + node=ks[key]; value=ast.unparse(node.value); replacement='' + target='raw_value' if key.startswith('raw_') else 'resolved_value' + indent=' '*node.col_offset + if target not in ks and value=='True': + meaning='Inherit()' if target=='raw_value' and inherited=='True' else 'Value(None)' + replacement=f'{indent}{target}={meaning},\n' + elif target=='raw_value' and value=='inherited_value': + replacement=f'{indent}raw_value=Inherit() if inherited_value else Value(False),\n' + elif value not in ('True','False'):raise RuntimeError(f'OPEN fixture {p}:{node.lineno}: {value}') + if node.lineno!=node.end_lineno:raise RuntimeError('Expected adjudicated single-line flag') + edits.append((starts[node.lineno-1],starts[node.lineno],replacement)); changed.append(key) + if changed:records.append((str(p),n.lineno,n.func.id,changed)) + if edits: + future=[node.end_lineno for node in module.body if isinstance(node,ast.ImportFrom) and node.module=='__future__'] + after=max(future,default=(module.body[0].end_lineno if isinstance(module.body[0],ast.Expr) and isinstance(module.body[0].value,ast.Constant) and isinstance(module.body[0].value.value,str) else 0)) + edits.append((starts[after],starts[after],'from objectstate.field_value import Inherit, Unset, Value\n')) + for a,b,text in sorted(edits,reverse=True):s=s[:a]+text+s[b:] + compile(s,str(p),'exec');p.write_text(s) +print(json.dumps(records,indent=2)) diff --git a/.scratch/migrate-node-dto-fixtures.py b/.scratch/migrate-node-dto-fixtures.py new file mode 100644 index 0000000..fcea2c0 --- /dev/null +++ b/.scratch/migrate-node-dto-fixtures.py @@ -0,0 +1,66 @@ +"""One-shot migration of the source-adjudicated DTO fixture constructors.""" +import ast +import json +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule, module_syntax_index + +root = Path('/home/ts/wt/openhcs-declared-fields/tests/unit/agent') +records = [] +for filename in ('test_mcp_server.py', 'test_mcp_dev_client_knowledge_renderers.py', 'test_object_state_field_help_service.py', 'test_ui_bridge_service.py'): + path = root / filename + source = path.read_text() + tree = SourceModule.from_source_path(path, source).parse().module + syntax = module_syntax_index(tree) + lines = source.splitlines(keepends=True) + starts = [0] + for line in lines: + starts.append(starts[-1] + len(line)) + def pos(line, col): + return starts[line - 1] + len(lines[line - 1].encode()[:col].decode()) + def span(node): + return pos(node.lineno, node.col_offset), pos(node.end_lineno, node.end_col_offset) + edits = [] + needs_declaration = False + for _, node in syntax.indexed_nodes_of_type(ast.Call): + if not isinstance(node.func, ast.Name): + continue + name = node.func.id + if name not in {'UiObjectStateFieldSummary', 'UiObjectStateFieldProjection', 'UiObjectStateScopeSummary', 'UiObjectStateFieldScopeProjection', 'UiObjectStateFieldProvenance', 'UiObjectStateFieldHelpResult', 'UiObjectStateValuePreview'}: + continue + for keyword in node.keywords: + if keyword.arg == 'object_state_path_type': + old = ast.unparse(keyword.value) + if isinstance(keyword.value, ast.Constant): + module, _, qualname = keyword.value.value.rpartition('.') + replacement = f'declared_by=UiObjectStateFieldDeclaration(module={module!r}, qualname={qualname!r})' + else: + replacement = f'declared_by=UiObjectStateFieldDeclaration(module={old}.rpartition(".")[0], qualname={old}.rpartition(".")[2])' + edits.append((*span(keyword), replacement)) + needs_declaration = True + elif keyword.arg in {'object_type', 'raw_value_type', 'resolved_value_type', 'source_type', 'help_target_type', 'type_name', 'is_none'}: + if keyword.lineno != keyword.end_lineno: + raise RuntimeError(f'Unadjudicated multi-line keyword {path}:{keyword.lineno}') + edits.append((starts[keyword.lineno - 1], starts[keyword.lineno], '')) + if name == 'UiObjectStateValuePreview': + edits.append((*span(node.func), 'UiObjectStateTextPreview')) + if node.args: + if len(node.args) != 3: + raise RuntimeError('Expected old three-field positional preview') + edits.append((span(node.args[0])[0], span(node.args[2])[0], '')) + if name == 'UiObjectStateFieldProvenance' and node.args: + if len(node.args) != 3: + raise RuntimeError('Expected old three-field positional provenance') + edits.append((span(node.args[1])[0], span(node.args[2])[0], '')) + records.append((filename, node.lineno, name)) + for _, node in syntax.indexed_nodes_of_type(ast.ImportFrom): + for item in node.names: + if item.name == 'UiObjectStateValuePreview': + edits.append((*span(item), 'UiObjectStateTextPreview')) + if needs_declaration: + after = max((node.end_lineno for node in tree.body if isinstance(node, ast.ImportFrom) and node.module == '__future__'), default=0) + edits.append((starts[after], starts[after], 'from formbridge.bridge.dto import UiObjectStateFieldDeclaration\n')) + for start, end, text in sorted(edits, reverse=True): + source = source[:start] + text + source[end:] + compile(source, str(path), 'exec') + path.write_text(source) +print(json.dumps(records, indent=2)) diff --git a/.scratch/migrate_constructors.py b/.scratch/migrate_constructors.py new file mode 100644 index 0000000..3a3904b --- /dev/null +++ b/.scratch/migrate_constructors.py @@ -0,0 +1,38 @@ +"""Delete obsolete constructor patch scopes across their complete consumer family.""" +import ast,textwrap +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +ROOTS=[Path('/home/ts/wt/formbridge-declared-fields/src'),Path('/home/ts/wt/pyqt-declared-fields/src'),Path('/home/ts/wt/openhcs-declared-fields/openhcs'),Path('/home/ts/wt/toad-declared-fields/src')] +for root in ROOTS: + for path in root.rglob('*.py'): + source=path.read_text() + if 'patch_lazy_constructors'not in source and 'inherit_as_none'not in source:continue + source=source.replace('inherit_as_none','inherit_fields') + # Process innermost scopes first. NRA retains original source positions. + while True: + parsed=SourceModule.from_source_path(path,source).parse();syntax=module_syntax_index(parsed.module) + matches=[n for _,n in syntax.indexed_nodes_of_type(ast.With) if any('patch_lazy_constructors'in ast.unparse(i.context_expr) for i in n.items)] + if not matches:break + node=max(matches,key=lambda n:n.lineno);lines=source.splitlines(keepends=True) + assert len(node.items)==1,(path,node.lineno,'multi-context scope requires semantic review') + body=''.join(lines[node.body[0].lineno-1:node.end_lineno]);body=textwrap.dedent(body) + replacement=textwrap.indent(body,' '*node.col_offset) + source=''.join(lines[:node.lineno-1])+replacement+''.join(lines[node.end_lineno:]) + parsed=SourceModule.from_source_path(path,source).parse();syntax=module_syntax_index(parsed.module);edits=[] + for _,node in syntax.indexed_nodes_of_type(ast.ImportFrom): + if any(n.name=='patch_lazy_constructors' for n in node.names): + names=[n for n in node.names if n.name!='patch_lazy_constructors'] + replacement=ast.unparse(ast.ImportFrom(module=node.module,names=names,level=node.level))if names else'' + edits.append((node.lineno-1,node.end_lineno,' '*node.col_offset+replacement+'\n')) + for _,node in syntax.indexed_nodes_of_type(ast.FunctionDef): + if node.name=='_patch_lazy_constructors':edits.append((min([node.lineno]+[d.lineno for d in node.decorator_list])-1,node.end_lineno,'')) + for _,node in syntax.indexed_nodes_of_type(ast.AnnAssign): + if isinstance(node.target,ast.Name)and node.target.id=='patch_lazy_constructors':edits.append((node.lineno-1,node.end_lineno,'')) + lines=source.splitlines(keepends=True) + for start,end,replacement in sorted(edits,reverse=True):lines[start:end]=[replacement] + source=''.join(lines) + # Constructor keywords are whole, line-owned dependencies at the witnessed sites. + lines=source.splitlines(keepends=True) + lines=[line for line in lines if 'patch_lazy_constructors=patch_lazy_constructors'not in line] + source=''.join(lines) + ast.parse(source,filename=str(path));path.write_text(source);print(path) diff --git a/.scratch/native_signal_receivers.py b/.scratch/native_signal_receivers.py new file mode 100644 index 0000000..e4d922f --- /dev/null +++ b/.scratch/native_signal_receivers.py @@ -0,0 +1,17 @@ +"""NRA-selected native signal declarations include their receiver, not sender guesses.""" +import ast,json +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +rows=[] +for p in Path('/home/ts/wt/pyqt-declared-fields/src').rglob('*.py'): + s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True);starts=[0] + for line in lines:starts.append(starts[-1]+len(line)) + def pos(line,col):return starts[line-1]+len(lines[line-1].encode()[:col].decode()) + methods=[n for _,n in idx.indexed_nodes_of_type(ast.FunctionDef) if n.name=='_signal_sources'];edits=[] + for method in methods: + for _,n in idx.indexed_nodes_of_type(ast.Tuple): + if method.lineno= 3: +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/theming/color_scheme.py:376: if isinstance(color_value, tuple) and len(color_value) >= 3: +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/log_viewer.py:281: if isinstance(item, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/log_viewer.py:283: elif isinstance(item, tuple) and len(item) == 2: +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/log_viewer.py:1961: if isinstance(value, list) and len(value) == 3: +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/protocols/widget_adapters.py:164: return FieldValue.is_inherited(self._draft) +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/protocols/widget_adapters.py:170: if FieldValue.is_inherited(self._draft): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/protocols/widget_adapters.py:496: elif isinstance(value, list): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_pane.py:479: initial_values = dict(self.kwargs or {}) if isinstance(self.kwargs, dict) else None +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/shared/abstract_manager_widget.py:715: config = self.PREVIEW_FORMATTING_CONFIG.default_factory() +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/shared/manager_item_display_builder.py:200: if not is_dataclass(config_type): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/forms/parameter_form_manager.py:219: return ParameterDefaultsByName((field.name, field.default) for field in self.fields.children) +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/shared/tabbed_form_widget.py:218: if is_dataclass(obj): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/shared/tabbed_form_widget.py:222: if isinstance(obj, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/forms/widget_strategies.py:356: if isinstance(value, list): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/forms/widget_strategies.py:358: if isinstance(value, tuple): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/forms/widget_strategies.py:360: if isinstance(value, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/forms/widget_strategies.py:939: if not isinstance(value, list): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/forms/widget_strategies.py:1690: if not dataclasses.is_dataclass(global_config): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/forms/parameter_form_chrome_sync.py:368: skip_context_behavior = FieldValue.is_inherited(value) +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/forms/parameter_form_chrome_sync.py:372: if FieldValue.is_inherited(value): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/forms/form_init_service.py:579: services[fld.name] = fld.default +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/services/scope_color_service.py:160: cached_scope = cache_key[0] if isinstance(cache_key, tuple) else cache_key +src/objectstate/object_state.py:187: return self.write(deepcopy(self.default)) +src/objectstate/object_state.py:971: if FieldValue.is_inherited(own_values.get(path, Unset())) +src/objectstate/object_state.py:1871: if not isinstance(self._parameters, dict): +src/objectstate/object_state.py:1873: if not isinstance(self._saved_parameters, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/services/window_manager.py:400: if lookup.is_present: +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/services/window_manager.py:509: if not lookup.is_present: +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/services/window_manager.py:732: if existing.is_present and existing.window is not window: +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/services/window_manager.py:807: return lookup.is_present +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/services/window_manager.py:820: if lookup.is_present: +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/services/window_manager.py:835: if not lookup.is_present: +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/services/widget_service.py:355: if isinstance(value, list): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/structural_table.py:342: if not is_dataclass(self.row_value_type): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/shared/zmq_server_browser_widget.py:124: self._tree_state_adapter = TreeStateAdapter.default() +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/forms/inline_dataclass_context.py:110: default_value = DottedFieldPath(identity.object_state_path.value).bind(self.state).default +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/forms/inline_dataclass_context.py:128: default_value = DottedFieldPath(identity.object_state_path.value).bind(self.state).default +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:229: if not isinstance(entry, (tuple, list)) or len(entry) < 1: +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:308: if not self.is_dict_mode or not isinstance(self.pattern_data, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:334: if isinstance(raw, list): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:337: if isinstance(raw, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:340: if not isinstance(tokens, list): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:360: if self.is_dict_mode and isinstance(self._pattern_tokens, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:363: if isinstance(self._pattern_tokens, list): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:370: if not isinstance(self._pattern_tokens, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:385: if not self.is_dict_mode or not isinstance(self.pattern_data, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:405: if isinstance(self._pattern_tokens, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:457: if self.is_dict_mode and isinstance(self.pattern_data, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:464: if isinstance(self._pattern_tokens, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:476: and isinstance(self.pattern_data, dict) +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:481: if isinstance(self._pattern_tokens, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:537: if isinstance(initial_functions, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:542: existing_tokens = self._pattern_tokens if isinstance(self._pattern_tokens, dict) else {} +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:571: seed_tokens = self._pattern_tokens if isinstance(self._pattern_tokens, list) else [] +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:724: if not isinstance(self.pattern_data, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1339: if isinstance(pattern, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1351: if isinstance(pattern, list): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1522: if self.is_dict_mode and isinstance(self.pattern_data, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1599: if self.is_dict_mode and isinstance(self.pattern_data, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1643: old_pattern = self.pattern_data if isinstance(self.pattern_data, dict) else {} +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1644: old_tokens = self._pattern_tokens if isinstance(self._pattern_tokens, dict) else {} +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1663: if isinstance(old_tokens, dict) +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1685: if isinstance(self._pattern_tokens, list) +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1701: self.pattern_data.copy() if isinstance(self.pattern_data, dict) else {} +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1704: self._pattern_tokens.copy() if isinstance(self._pattern_tokens, dict) else {} +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1773: if isinstance(self._pattern_tokens, dict) and self.selected_pattern_key is not None: +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1811: and isinstance(self.pattern_data, dict) +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1823: if not self.is_dict_mode or not isinstance(self.pattern_data, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1846: if not self.is_dict_mode or not isinstance(self.pattern_data, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1865: if not token or not self.is_dict_mode or not isinstance(self.pattern_data, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1867: if not isinstance(self._pattern_tokens, dict): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1891: clean_kwargs = self._sanitize_pattern_kwargs(kwargs if isinstance(kwargs, dict) else {}) +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/function_list_editor.py:1906: old_pattern = self.pattern_data if isinstance(self.pattern_data, dict) else {} +src/objectstate/reified_generics.py:194: if not isinstance(params, tuple): +src/objectstate/reified_generics.py:217: if not isinstance(params, tuple): +src/objectstate/reified_generics.py:232: if not isinstance(params, tuple): +src/objectstate/reified_generics.py:244: if not isinstance(params, tuple): +src/objectstate/reified_generics.py:304: if not isinstance(params, tuple): +src/objectstate/codegen.py:61: if field.default is not MISSING: +src/objectstate/codegen.py:62: return field.default +src/objectstate/codegen.py:63: if field.default_factory is not MISSING: +src/objectstate/codegen.py:64: return field.default_factory() +src/objectstate/codegen.py:106: if FieldValue.is_inherited(current): +src/objectstate/declarations.py:32: return self.default +src/objectstate/declarations.py:91: if declaration.default is not MISSING: +src/objectstate/declarations.py:92: metadata[InheritedDefault] = ValueDefault(declaration.default) +src/objectstate/declarations.py:93: elif declaration.default_factory is not MISSING: +src/objectstate/declarations.py:94: metadata[InheritedDefault] = FactoryDefault(declaration.default_factory) +src/objectstate/declarations.py:335: if not isinstance(value, dict): +src/objectstate/declarations.py:346: if declaration.default is not MISSING: +src/objectstate/declarations.py:347: return self.context_field_types[name].admit(declaration.default) +src/objectstate/declarations.py:348: if declaration.default_factory is not MISSING: +src/objectstate/declarations.py:349: return self.context_field_types[name].admit(declaration.default_factory()) +src/objectstate/declarations.py:439: if is_dataclass(annotation): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/shared/config_hierarchy_tree.py:567: if not is_dataclass(root_dataclass): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/shared/config_hierarchy_tree.py:626: if not is_dataclass(field_type): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/shared/config_hierarchy_tree.py:752: if not is_dataclass(root_config): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/shared/config_hierarchy_tree.py:808: if not is_dataclass(cls): +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/bridge/windows.py:1583: if kind.item_action is not None and kind.item_action.default +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/bridge/windows.py:1845: if isinstance(value, (frozenset, list, set, tuple)): +src/objectstate/lazy_factory.py:112: if field_definition.default is not MISSING: +src/objectstate/lazy_factory.py:113: metadata[InheritedDefault] = ValueDefault(field_definition.default) +src/objectstate/lazy_factory.py:114: elif field_definition.default_factory is not MISSING: +src/objectstate/lazy_factory.py:115: metadata[InheritedDefault] = FactoryDefault(field_definition.default_factory) +src/objectstate/lazy_factory.py:575: if not FieldValue.is_inherited(value): +src/objectstate/lazy_factory.py:727: has_default=(declaration.default is not MISSING or declaration.default_factory is not MISSING), +/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/shared/tree_rebuild_coordinator.py:21: return cls(TreeStateAdapter.default()) +/home/ts/wt/formbridge-declared-fields/src/formbridge/flash_trace.py:55: if isinstance(value, (list, tuple, set, frozenset)): +/home/ts/wt/formbridge-declared-fields/src/formbridge/contracts.py:81: and declared_field.default_factory is not MISSING +/home/ts/wt/formbridge-declared-fields/src/formbridge/contracts.py:82: and kwargs.get(declared_field.name) is parameters[declared_field.name].default +/home/ts/wt/formbridge-declared-fields/src/formbridge/contracts.py:175: return errors if isinstance(errors, list) else None +src/objectstate/docs/context_system.rst:154: if is_dataclass(context_obj): +src/objectstate/docs/context_system.rst:157: if is_dataclass(value): +/home/ts/wt/formbridge-declared-fields/src/formbridge/forms/raw_display_value.py:70: FieldValue.is_inherited(self.raw_field(field_name)) +/home/ts/wt/formbridge-declared-fields/src/formbridge/forms/raw_display_value.py:71: and not FieldValue.is_inherited(self.display_field(field_name)) +/home/ts/wt/formbridge-declared-fields/src/formbridge/forms/raw_display_value.py:78: item.default +/home/ts/wt/formbridge-declared-fields/src/formbridge/forms/parameter_form_service.py:243: if isinstance(value, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/forms/parameter_form_service.py:324: if isinstance(value, (list, tuple)): +/home/ts/wt/formbridge-declared-fields/src/formbridge/forms/parameter_form_service.py:370: if not isinstance(value, (list, tuple)): +/home/ts/wt/formbridge-declared-fields/src/formbridge/forms/parameter_form_service.py:404: if not isinstance(value, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/forms/parameter_form_service.py:419: if not isinstance(value, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/forms/parameter_form_service.py:531: if isinstance(resolved_info, dict) and full_param_path: +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/pattern_data_manager.py:80: if not isinstance(pattern, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/pattern_data_manager.py:97: if not isinstance(pattern, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/pattern_data_manager.py:114: if isinstance(func_item, tuple) and len(func_item) == 2 and callable(func_item[0]): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/pattern_data_manager.py:140: if isinstance(pattern, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/pattern_data_manager.py:149: elif isinstance(pattern, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/pattern_data_manager.py:152: if not isinstance(value, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/pattern_data_manager.py:175: if is_dict and isinstance(pattern, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/pattern_data_manager.py:179: elif not is_dict and isinstance(pattern, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/pattern_data_manager.py:202: if is_dict and isinstance(pattern, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/pattern_data_manager.py:206: elif not is_dict and isinstance(pattern, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:177: if isinstance(value, tuple): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:295: if isinstance(func_item, tuple) and len(func_item) == 2 and callable(func_item[0]): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:371: if isinstance(tokens, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:373: elif isinstance(tokens, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:390: if isinstance(pattern, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:416: if isinstance(next_pattern, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:417: previous_by_key = previous_pattern if isinstance(previous_pattern, dict) else {} +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:418: tokens_by_key = existing_tokens if isinstance(existing_tokens, dict) else {} +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:429: previous_pattern if not isinstance(previous_pattern, dict) else [], +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:430: existing_tokens if isinstance(existing_tokens, list) else [], +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:462: isinstance(func_list, tuple) +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:465: and isinstance(func_list[1], Mapping) +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:470: elif isinstance(func_list, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:531: item_list = func_list if isinstance(func_list, list) else [func_list] +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:563: item_list = func_list if isinstance(func_list, list) else [func_list] +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:623: if isinstance(pattern, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:624: token_map = tokens if isinstance(tokens, dict) else {} +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:626: channel_items = items if isinstance(items, list) else [items] +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:646: item_list = pattern if isinstance(pattern, list) else [pattern] +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:647: token_list = tokens if isinstance(tokens, list) else [] +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:995: if isinstance(pattern, list) and len(pattern) == 1: +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:1041: if isinstance(tokens, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:1043: if isinstance(tokens, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:1077: if isinstance(tokens, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:1079: if isinstance(pattern, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:1097: if isinstance(tokens, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:1098: if not isinstance(pattern, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/function_pattern_code_document.py:1111: if not isinstance(items, list): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/widget_tree_projection_config.py:35: if isinstance(value, (str, bytes, tuple, list, dict, set, frozenset)): +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/widget_tree_projection_config.py:47: required = declared_field.default is MISSING and declared_field.default_factory is MISSING +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/widget_tree_projection_config.py:60: declared_field.default is not MISSING +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/widget_tree_projection_config.py:61: and type(field_value) is type(declared_field.default) +/home/ts/wt/formbridge-declared-fields/src/formbridge/services/widget_tree_projection_config.py:62: and member.source_equals(node.declaration.field_types[declared_field.name].admit(declared_field.default)) +/home/ts/wt/formbridge-declared-fields/src/formbridge/bridge/live_overview.py:162: if not isinstance(payload, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/bridge/service.py:1328: if not isinstance(payload, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/bridge/dto.py:1681: if not isinstance(payload, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/bridge/field_projection.py:147: if isinstance(value, (list, tuple)) and len(value) > query.max_value_items: +/home/ts/wt/formbridge-declared-fields/src/formbridge/bridge/server.py:323: if not isinstance(payload, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/bridge/server.py:333: if not isinstance(payload, dict): +/home/ts/wt/formbridge-declared-fields/src/formbridge/bridge/server.py:536: if not isinstance(request_payload, dict): diff --git a/.scratch/p44/full.txt b/.scratch/p44/full.txt new file mode 100644 index 0000000..b477d93 --- /dev/null +++ b/.scratch/p44/full.txt @@ -0,0 +1,39 @@ +/home/ts/code/projects/openhcs-sequential/.venv/lib/python3.12/site-packages/pytest_asyncio/plugin.py:208: PytestDeprecationWarning: The configuration option "asyncio_default_fixture_loop_scope" is unset. +The event loop scope for asynchronous fixtures will default to the fixture caching scope. Future versions of pytest-asyncio will default the loop scope for asynchronous fixtures to function scope. Set the default fixture loop scope explicitly in order to avoid unexpected behavior in the future. Valid fixture loop scopes are: "function", "class", "module", "package", "session" + + warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET)) + +==================================== ERRORS ==================================== +__________________ ERROR collecting tests/test_integration.py __________________ +ImportError while importing test module '/home/ts/wt/objectstate-declared-fields/tests/test_integration.py'. +Hint: make sure your test modules/packages have valid Python names. +Traceback: +../../.pyenv/versions/3.12.3/lib/python3.12/importlib/__init__.py:90: in import_module + return _bootstrap._gcd_import(name[level:], package, level) +tests/test_integration.py:8: in + from objectstate import ( +E ImportError: cannot import name 'get_base_type_for_lazy' from 'objectstate' (/home/ts/wt/objectstate-declared-fields/src/objectstate/__init__.py). Did you mean: 'get_lazy_type_for_base'? +___________ ERROR collecting tests/test_lazy_counterpart_registry.py ___________ +ImportError while importing test module '/home/ts/wt/objectstate-declared-fields/tests/test_lazy_counterpart_registry.py'. +Hint: make sure your test modules/packages have valid Python names. +Traceback: +../../.pyenv/versions/3.12.3/lib/python3.12/importlib/__init__.py:90: in import_module + return _bootstrap._gcd_import(name[level:], package, level) +tests/test_lazy_counterpart_registry.py:7: in + from objectstate import LazyDataclassFactory, abbreviation, get_base_type_for_lazy, lazy_factory +E ImportError: cannot import name 'get_base_type_for_lazy' from 'objectstate' (/home/ts/wt/objectstate-declared-fields/src/objectstate/__init__.py). Did you mean: 'get_lazy_type_for_base'? +_________________ ERROR collecting tests/test_lazy_factory.py __________________ +ImportError while importing test module '/home/ts/wt/objectstate-declared-fields/tests/test_lazy_factory.py'. +Hint: make sure your test modules/packages have valid Python names. +Traceback: +../../.pyenv/versions/3.12.3/lib/python3.12/importlib/__init__.py:90: in import_module + return _bootstrap._gcd_import(name[level:], package, level) +tests/test_lazy_factory.py:14: in + from objectstate import ( +E ImportError: cannot import name 'get_base_type_for_lazy' from 'objectstate' (/home/ts/wt/objectstate-declared-fields/src/objectstate/__init__.py). Did you mean: 'get_lazy_type_for_base'? +=========================== short test summary info ============================ +ERROR tests/test_integration.py +ERROR tests/test_lazy_counterpart_registry.py +ERROR tests/test_lazy_factory.py +!!!!!!!!!!!!!!!!!!! Interrupted: 3 errors during collection !!!!!!!!!!!!!!!!!!!! +3 errors in 2.08s diff --git a/.scratch/p44/migrate-contracts.py b/.scratch/p44/migrate-contracts.py new file mode 100644 index 0000000..2c27bea --- /dev/null +++ b/.scratch/p44/migrate-contracts.py @@ -0,0 +1,27 @@ +import ast,tokenize,io +from pathlib import Path +root=Path('/home/ts/wt/objectstate-declared-fields') +p=root/'tests/test_subfield_semantics.py';s=p.read_text();tree=ast.parse(s);lines=s.splitlines(keepends=True);starts=[0] +for line in lines:starts.append(starts[-1]+len(line)) +edits=[] +for node in ast.walk(tree): + if isinstance(node,ast.Call) and isinstance(node.func,ast.Attribute) and isinstance(node.func.value,ast.Name) and node.func.value.id=='state': + method=node.func.attr + if method in ('update_parameter','get_resolved_value'): + path=ast.get_source_segment(s,node.args[0]);field=f'DottedFieldPath({path}).bind(state)' + replacement=field+'.value' if method=='get_resolved_value' else field+'.write('+ast.get_source_segment(s,node.args[1])+')' + edits.append((starts[node.lineno-1]+node.col_offset,starts[node.end_lineno-1]+node.end_col_offset,replacement)) +for a,b,v in sorted(edits,reverse=True):s=s[:a]+v+s[b:] +s=s.replace('state.on_resolved_changed(', 'state._listen_resolved(').replace('state.on_state_changed(', 'state._listen_state(').replace('state.parameters["bindings"]','state.fields.bindings.raw') +p.write_text(s) +for name in ('test_integration.py','test_lazy_factory.py','test_lazy_counterpart_registry.py'): + p=root/'tests'/name;s=p.read_text();tree=ast.parse(s);lines=s.splitlines(keepends=True);starts=[0] + for line in lines:starts.append(starts[-1]+len(line)) + for node in reversed(tree.body): + if isinstance(node,ast.ImportFrom) and node.module=='objectstate' and any(a.name=='get_base_type_for_lazy' for a in node.names): + node.names=[a for a in node.names if a.name!='get_base_type_for_lazy'] + a=starts[node.lineno-1]+node.col_offset;b=starts[node.end_lineno-1]+node.end_col_offset + s=s[:a]+'from python_introspect import resolve_declared_type\n'+ast.unparse(node)+s[b:] + tokens=[t._replace(string='resolve_declared_type') if t.type==tokenize.NAME and t.string=='get_base_type_for_lazy' else t for t in tokenize.generate_tokens(io.StringIO(s).readline)] + s=tokenize.untokenize(tokens).replace('resolve_declared_type(Unregistered) is None','get_lazy_type_for_base(Unregistered) is None') + p.write_text(s) diff --git a/.scratch/p44/subfields.txt b/.scratch/p44/subfields.txt new file mode 100644 index 0000000..38f539e --- /dev/null +++ b/.scratch/p44/subfields.txt @@ -0,0 +1,147 @@ +/home/ts/code/projects/openhcs-sequential/.venv/lib/python3.12/site-packages/pytest_asyncio/plugin.py:208: PytestDeprecationWarning: The configuration option "asyncio_default_fixture_loop_scope" is unset. +The event loop scope for asynchronous fixtures will default to the fixture caching scope. Future versions of pytest-asyncio will default the loop scope for asynchronous fixtures to function scope. Set the default fixture loop scope explicitly in order to avoid unexpected behavior in the future. Valid fixture loop scopes are: "function", "class", "module", "package", "session" + + warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET)) +.FFFFFF [100%] +=================================== FAILURES =================================== +_________ test_tuple_dataclass_leaf_semantics_detect_only_changed_leaf _________ + + def test_tuple_dataclass_leaf_semantics_detect_only_changed_leaf() -> None: + state = ObjectState( + Holder(clauses=(Clause("file", "contains", "DAPI"),)), + scope_id="subfield", + ) + ObjectStateRegistry.register(state, _skip_snapshot=True) + +> state.update_parameter( + "clauses", + (Clause("file", "contains", "GFP"),), + ) +E AttributeError: 'ObjectState' object has no attribute 'update_parameter'. Did you mean: '_saved_parameters'? + +tests/test_subfield_semantics.py:67: AttributeError +________ test_container_replacement_reports_child_semantic_change_only _________ + + def test_container_replacement_reports_child_semantic_change_only() -> None: + @dataclass + class RootConfig: + bindings: EnableableHolder = field( + default_factory=lambda: EnableableHolder( + enabled=True, + clauses=(Clause("file", "contains", "DAPI"),), + ) + ) + + state = ObjectState(RootConfig(), scope_id="subfield") + ObjectStateRegistry.register(state, _skip_snapshot=True) + resolved_events: list[set[str]] = [] + state_events: list[set[str]] = [] +> state.on_resolved_changed(lambda paths: resolved_events.append(set(paths))) +E AttributeError: 'ObjectState' object has no attribute 'on_resolved_changed'. Did you mean: '_notify_resolved_changed'? + +tests/test_subfield_semantics.py:102: AttributeError +____ test_missing_default_leaf_marks_signature_diff_without_confusing_none _____ + + def test_missing_default_leaf_marks_signature_diff_without_confusing_none() -> None: + state = ObjectState(Holder(), scope_id="subfield") + ObjectStateRegistry.register(state, _skip_snapshot=True) + +> state.update_parameter( + "clauses", + (Clause("file", "is_image", None),), + ) +E AttributeError: 'ObjectState' object has no attribute 'update_parameter'. Did you mean: '_saved_parameters'? + +tests/test_subfield_semantics.py:124: AttributeError +_______________ test_inherited_tuple_cells_are_marked_inherited ________________ + + def test_inherited_tuple_cells_are_marked_inherited() -> None: + @dataclass + class ParentConfig: + clauses: tuple[Clause, ...] | None = None + + @dataclass + class ChildConfig(ParentConfig): + clauses: tuple[Clause, ...] | None = None + + LazyParentConfig = LazyDataclassFactory.make_lazy_simple(ParentConfig) + LazyChildConfig = LazyDataclassFactory.make_lazy_simple(ChildConfig) + + @dataclass + class RootConfig: + parent_config: LazyParentConfig = field(default_factory=LazyParentConfig) + child_config: LazyChildConfig = field(default_factory=LazyChildConfig) + + state = ObjectState(RootConfig(), scope_id="subfield") + ObjectStateRegistry.register(state, _skip_snapshot=True) +> state.update_parameter( + "parent_config.clauses", + (Clause("file", "contains", "DAPI"),), + ) +E AttributeError: 'ObjectState' object has no attribute 'update_parameter'. Did you mean: '_saved_parameters'? + +tests/test_subfield_semantics.py:160: AttributeError +________ test_inherited_tuple_recompute_notifies_structural_leaf_paths _________ + + def test_inherited_tuple_recompute_notifies_structural_leaf_paths() -> None: + @dataclass + class ParentConfig: + clauses: tuple[Clause, ...] | None = None + + @dataclass + class ChildConfig(ParentConfig): + clauses: tuple[Clause, ...] | None = None + + LazyParentConfig = LazyDataclassFactory.make_lazy_simple(ParentConfig) + LazyChildConfig = LazyDataclassFactory.make_lazy_simple(ChildConfig) + + @dataclass + class RootConfig: + parent_config: LazyParentConfig = field(default_factory=LazyParentConfig) + child_config: LazyChildConfig = field(default_factory=LazyChildConfig) + + state = ObjectState(RootConfig(), scope_id="subfield") + ObjectStateRegistry.register(state, _skip_snapshot=True) +> state.update_parameter( + "parent_config.clauses", + (Clause("file", "contains", "DAPI"),), + ) +E AttributeError: 'ObjectState' object has no attribute 'update_parameter'. Did you mean: '_saved_parameters'? + +tests/test_subfield_semantics.py:203: AttributeError +_____ test_time_travel_navigation_field_ignores_inherited_resolved_fanout ______ + + def test_time_travel_navigation_field_ignores_inherited_resolved_fanout() -> None: + @dataclass + class ParentConfig: + clauses: tuple[Clause, ...] | None = None + + @dataclass + class ChildConfig(ParentConfig): + clauses: tuple[Clause, ...] | None = None + + LazyParentConfig = LazyDataclassFactory.make_lazy_simple(ParentConfig) + LazyChildConfig = LazyDataclassFactory.make_lazy_simple(ChildConfig) + + @dataclass + class RootConfig: + parent_config: LazyParentConfig = field(default_factory=LazyParentConfig) + child_config: LazyChildConfig = field(default_factory=LazyChildConfig) + + state = ObjectState(RootConfig(), scope_id="subfield") + ObjectStateRegistry.register(state, _skip_snapshot=True) +> state.update_parameter( + "parent_config.clauses", + (Clause("file", "contains", "DAPI"),), + ) +E AttributeError: 'ObjectState' object has no attribute 'update_parameter'. Did you mean: '_saved_parameters'? + +tests/test_subfield_semantics.py:251: AttributeError +=========================== short test summary info ============================ +FAILED tests/test_subfield_semantics.py::test_tuple_dataclass_leaf_semantics_detect_only_changed_leaf +FAILED tests/test_subfield_semantics.py::test_container_replacement_reports_child_semantic_change_only +FAILED tests/test_subfield_semantics.py::test_missing_default_leaf_marks_signature_diff_without_confusing_none +FAILED tests/test_subfield_semantics.py::test_inherited_tuple_cells_are_marked_inherited +FAILED tests/test_subfield_semantics.py::test_inherited_tuple_recompute_notifies_structural_leaf_paths +FAILED tests/test_subfield_semantics.py::test_time_travel_navigation_field_ignores_inherited_resolved_fanout +6 failed, 1 passed in 0.61s diff --git a/.scratch/path_input_cutover.py b/.scratch/path_input_cutover.py new file mode 100644 index 0000000..7c78326 --- /dev/null +++ b/.scratch/path_input_cutover.py @@ -0,0 +1,15 @@ +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +p=Path('/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/widgets/enhanced_path_widget.py');s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True);edits=[] +c=next(n for _,n in idx.indexed_nodes_of_type(ast.ClassDef)if n.name=='EnhancedPathWidget') +for n in c.body: + if not isinstance(n,ast.FunctionDef):continue + if n.name in {'get_value','set_value','connect_change_signal','disconnect_change_signal'}:edits.append((n.lineno-1,n.end_lineno,'')) + elif n.name in {'get_path','set_path'}: + text=''.join(lines[n.lineno-1:n.end_lineno]).replace('def get_path(', 'def _read_concrete_value(',1).replace('def set_path(', 'def _write_concrete_value(',1) + text=text.replace('self.path_input.blockSignals(True)','previous = self.path_input.blockSignals(True)').replace('self.path_input.blockSignals(False)','self.path_input.blockSignals(previous)') + edits.append((n.lineno-1,n.end_lineno,text)) +edits.append((c.end_lineno,c.end_lineno,'\n def _signal_sources(self):\n return ((self, self.path_changed),)\n')) +for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t] +s=''.join(lines).replace('self.set_path(current_value)','self.set_value(current_value)').replace('self.set_path(path_obj)','self.set_value(path_obj)');compile(s,str(p),'exec');p.write_text(s) diff --git a/.scratch/placeholder_owner.py b/.scratch/placeholder_owner.py new file mode 100644 index 0000000..95d7565 --- /dev/null +++ b/.scratch/placeholder_owner.py @@ -0,0 +1,37 @@ +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +ROOT=Path('/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive') +p=ROOT/'services/widget_service.py';s=p.read_text();s=s.replace(''' if value is None: + self.show_resolved_value_as_placeholder(widget, param_name, manager) + elif value is not None: + if self.widget_enhancer.has_placeholder_state(widget): + logger.debug("Clearing placeholder state for concrete value") + self.widget_enhancer._clear_placeholder_state(widget) +''','') # Original source is already migrated; replace that owned block instead. +old=''' if FieldValue.of(value).inherited: + self.show_resolved_value_as_placeholder(widget, param_name, manager) + else: + if self.widget_enhancer.has_placeholder_state(widget): + logger.debug("Clearing placeholder state for concrete value") + self.widget_enhancer._clear_placeholder_state(widget) +''';assert old in s;s=s.replace(old,' self.show_resolved_value_as_placeholder(widget, param_name, manager)\n') +old=''' full_path = f"{manager.field_id}.{param_name}" if manager.field_id else param_name + resolved_value = manager.state.get_resolved_value(full_path)''';new=''' full_path = f"{manager.field_id}.{param_name}" if manager.field_id else param_name + raw_value = manager.state.parameters[full_path] + if not FieldValue.of(raw_value).inherited: + if self.widget_enhancer.has_placeholder_state(widget): + self._dispatch_widget_update(widget, raw_value) + return False + resolved_value = manager.state.get_resolved_value(full_path)''';assert old in s;s=s.replace(old,new);compile(s,str(p),'exec');p.write_text(s) +p=ROOT/'services/parameter_ops_service.py';s=p.read_text();s=s.replace(''' if FieldValue.of(reset_value).inherited: + self.refresh_single_placeholder(manager, param_name) +''','') +a=s.index(' # Only refresh if value is None (needs placeholder)');b=s.index(' if manager._widget_service.show_resolved_value_as_placeholder',a);s=s[:a]+s[b:] +a=s.index(' full_path = f"{manager.field_id}.{param_name}"');s=s[:a]+''' with monitor.measure(): + manager._widget_service.show_resolved_value_as_placeholder( + widget, param_name, manager + ) +'''+s[s.index('\n',s.index(' PyQt6WidgetEnhancer._clear_placeholder_state(widget)',a))+1:] +s=s.replace('from objectstate.field_value import FieldValue\n','');compile(s,str(p),'exec');p.write_text(s) +p=ROOT/'services/field_change_dispatcher.py';s=p.read_text();a=s.index(' # FIX: Check current value instead of _user_set_fields.');b=s.index(' if DEBUG_DISPATCHER:\n logger.info(f" ✅ Refreshing placeholder',a);s=s[:a]+s[b:];s=s.replace('from objectstate import FieldValue\n','');compile(s,str(p),'exec');p.write_text(s) diff --git a/.scratch/qt_dispatch_context_members.py b/.scratch/qt_dispatch_context_members.py new file mode 100644 index 0000000..1bc85f8 --- /dev/null +++ b/.scratch/qt_dispatch_context_members.py @@ -0,0 +1,15 @@ +"""Project UI/log addresses from the admitted field; keep no path mirrors.""" +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +p=Path('/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/services/field_change_dispatcher.py');s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module) +lines=s.splitlines(keepends=True);offsets=[];at=0 +for line in lines:offsets.append(at);at+=len(line) +edits=[] +for _,n in idx.indexed_nodes_of_type(ast.Attribute): + if not isinstance(n.value,ast.Name)or n.value.id!='context':continue + target={'source':'context.event.source_manager','source_path':'context.field.address.value','root_path':'context.field.address.value'}.get(n.attr) + if target is None:continue + a=offsets[n.lineno-1]+len(lines[n.lineno-1].encode()[:n.col_offset].decode());b=offsets[n.end_lineno-1]+len(lines[n.end_lineno-1].encode()[:n.end_col_offset].decode());edits.append((a,b,target)) +for a,b,t in sorted(edits,reverse=True):s=s[:a]+t+s[b:] +compile(s,str(p),'exec');p.write_text(s) diff --git a/.scratch/qt_enabled_paint_owner.py b/.scratch/qt_enabled_paint_owner.py new file mode 100644 index 0000000..b5be4c6 --- /dev/null +++ b/.scratch/qt_enabled_paint_owner.py @@ -0,0 +1,86 @@ +"""Enabled paint derives from admitted state, never checkbox paint or None policy.""" +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +ROOT=Path('/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive') +p=ROOT/'services/enabled_field_styling_service.py';s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True);edits=[] +methods={ +'apply_initial_enabled_styling':''' @staticmethod + def _enabled_field(manager): + return next((field for field in manager.fields.children if Enableable.is_parameter_key(field.name)), None) + + def apply_initial_enabled_styling(self, manager) -> None: + """Paint this materialized scope from its canonical effective value.""" + if self._should_skip_optional_dataclass_styling(manager, "INITIAL ENABLED STYLING"): + return + self._last_enabled_values.pop(manager, None) + field = self._enabled_field(manager) + if field is not None: + self._paint_enabled_scope(manager, field) +''', +'refresh_enabled_styling':''' def refresh_enabled_styling(self, manager) -> None: + """Own the single enabled-paint traversal for this form subtree.""" + if self._should_skip_optional_dataclass_styling(manager, "REFRESH ENABLED STYLING"): + return + field = self._enabled_field(manager) + if field is not None: + self._paint_enabled_scope(manager, field) + for nested_manager in manager.nested_managers.values(): + self.refresh_enabled_styling(nested_manager) +''', +'on_enabled_field_changed':''' def on_enabled_field_changed(self, manager, field: FieldProxy) -> None: + """A changed enable declaration invalidates its scope and descendants.""" + if Enableable.is_parameter_key(field.name): + self.refresh_enabled_styling(manager) + + @staticmethod + def _is_enabled(field: FieldProxy) -> bool: + value = field.value + return FieldValue.of(value).has_value and bool(value) + + def _paint_enabled_scope(self, manager, field: FieldProxy) -> None: + effective = self._is_enabled(field) and not self._is_any_ancestor_disabled(manager) + if manager in self._last_enabled_values and self._last_enabled_values[manager] == effective: + return + self._last_enabled_values[manager] = effective + direct_widgets = self._get_direct_widgets(manager) + if manager.form_tree.owning_groupbox(manager) is not None: + self._apply_nested_config_styling(manager, effective) + else: + self._apply_top_level_styling(manager, effective, direct_widgets) +''', +'_is_any_ancestor_disabled':''' def _is_any_ancestor_disabled(self, manager) -> bool: + current = manager._parent_manager + while current is not None: + field = self._enabled_field(current) + if field is not None and not self._is_enabled(field): + return True + current = current._parent_manager + return False +''', +'_apply_top_level_styling':''' def _apply_top_level_styling(self, manager, resolved_value: bool, direct_widgets: list) -> None: + """Paint only this scope; refresh_enabled_styling owns descendant work.""" + self._set_widgets_dimmed(direct_widgets, not resolved_value, skip_labels=not resolved_value) +'''} +for _,n in idx.indexed_nodes_of_type(ast.FunctionDef): + if n.name in methods: + a=min([n.lineno]+[d.lineno for d in n.decorator_list])-1;edits.append((a,n.end_lineno,methods[n.name])) +for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t+'\n'] +s=''.join(lines).replace('from objectstate import DottedFieldPath','from objectstate import FieldProxy, FieldValue\nfrom python_introspect import Enableable') +s=s.replace(''' # Check if ANY ancestor has enabled=False + ancestor_is_disabled = self._is_any_ancestor_disabled(manager) + logger.debug(f"[ENABLED HANDLER] field_id={manager.field_id}, ancestor_is_disabled={ancestor_is_disabled}") + + should_dim = ancestor_is_disabled or not resolved_value''',''' should_dim = not resolved_value''') +compile(s,str(p),'exec');p.write_text(s) +# Delete forwarding-only enabled stages: the existing paint owner does the work. +p=ROOT/'services/field_change_dispatcher.py';s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True) +for _,n in idx.indexed_nodes_of_type(ast.ClassDef): + if n.name=='EnabledFieldStyleStage':lines[n.lineno-1:n.end_lineno]=[] +s=''.join(lines).replace(' self._enabled_field_style = EnabledFieldStyleStage()\n','').replace(' self._enabled_field_style.run(context)',' source._enabled_field_styling_service.on_enabled_field_changed(source, event.field)') +compile(s,str(p),'exec');p.write_text(s) +for relative,method in [('forms/parameter_form_manager.py','sync_enabled_field_visuals'),('forms/parameter_form_chrome_sync.py','enabled_field_visuals')]: + p=ROOT/relative;s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True) + for _,n in idx.indexed_nodes_of_type(ast.FunctionDef): + if n.name==method:lines[n.lineno-1:n.end_lineno]=[] + s=''.join(lines);compile(s,str(p),'exec');p.write_text(s) diff --git a/.scratch/qt_handle_admission.py b/.scratch/qt_handle_admission.py new file mode 100644 index 0000000..b45be20 --- /dev/null +++ b/.scratch/qt_handle_admission.py @@ -0,0 +1,62 @@ +"""Port actual ObjectState field operations, not similarly named UI operations. + +NRA indexes source; admission remains DottedFieldPath/FieldProxy's work. +The state receiver set is source-adjudicated, not a dynamic binding proof. +""" +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule, module_syntax_index +ROOT=Path('/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive') +owner=Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/object_state.py') +owner_index=module_syntax_index(SourceModule.from_source_path(owner,owner.read_text()).parse().module) +RESERVED={n.name for _,c in owner_index.indexed_nodes_of_type(ast.ClassDef) if c.name in {'FieldNamespace','FieldProxy','RootFields'} for n in c.body if isinstance(n,ast.FunctionDef)} +OPS={'get_resolved_value':'value','get_saved_resolved_value':'saved_value','signature_default':'default','update_parameter':'write','reset_parameter':'reset','get_provenance':'inspect().provenance'} +def state_expr(n): + s=ast.unparse(n) + return s in {'state','func_state','self._state','self._subscribed_state'} or s.endswith('.state') +def field(state,path): + if isinstance(path,ast.Constant)and isinstance(path.value,str)and all(p.isidentifier()for p in path.value.split('.')) and not any(p in RESERVED for p in path.value.split('.')): + # Namespace traversal is declaration admission, not string-based data access. + return f'{ast.unparse(state)}.fields.{path.value}' + return f'DottedFieldPath({ast.unparse(path)}).bind({ast.unparse(state)})' +for p in ROOT.rglob('*.py'): + s=p.read_text();changed=False + for stage in ('subscript','call'): + idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True);offsets=[];at=0 + for line in lines:offsets.append(at);at+=len(line) + edits=[] + def add(n,text): + a=offsets[n.lineno-1]+len(lines[n.lineno-1].encode()[:n.col_offset].decode());b=offsets[n.end_lineno-1]+len(lines[n.end_lineno-1].encode()[:n.end_col_offset].decode());edits.append((a,b,text)) + if stage=='subscript': + for _,n in idx.indexed_nodes_of_type(ast.Subscript): + if isinstance(n.ctx,ast.Load)and isinstance(n.value,ast.Attribute)and n.value.attr=='parameters'and state_expr(n.value.value):add(n,field(n.value.value,n.slice)+'.raw') + else: + for _,n in idx.indexed_nodes_of_type(ast.Call): + if not isinstance(n.func,ast.Attribute):continue + f=n.func + if f.attr in OPS and state_expr(f.value): + assert n.args and not n.keywords,(p,n.lineno,ast.unparse(n)) + bound=field(f.value,n.args[0]);op=OPS[f.attr] + if op in {'write','reset'}:text=f'{bound}.{op}('+', '.join(ast.unparse(a)for a in n.args[1:])+')' + else:text=f'{bound}.{op}' + add(n,text) + elif f.attr=='get'and isinstance(f.value,ast.Attribute)and f.value.attr=='parameters'and state_expr(f.value.value): + assert n.args and not n.keywords,(p,n.lineno) + if len(n.args)>1: + print('OPEN explicit missing-declaration policy',p,n.lineno,ast.unparse(n));continue + add(n,field(f.value.value,n.args[0])+'.raw') + # An outer operation would need to consume the already rewritten inner input. + # Keep that uncertainty visible instead of applying overlapping edits. + ordered=sorted(edits) + assert all(a[1]<=b[0]for a,b in zip(ordered,ordered[1:])),(p,'overlapping source operations') + for a,b,t in reversed(ordered):s=s[:a]+t+s[b:] + changed|=bool(edits) + if not changed:continue + if 'DottedFieldPath('in s: + tree=SourceModule.from_source_path(p,s).parse().module + top=[n for n in tree.body if isinstance(n,ast.ImportFrom)] + if not any(n.module in {'objectstate','objectstate.field_access'}and any(a.name=='DottedFieldPath'and a.asname is None for a in n.names)for n in top): + lines=s.splitlines(keepends=True);future=[n.end_lineno for n in top if n.module=='__future__'];at=max(future,default=0) + if not at and tree.body and isinstance(tree.body[0],ast.Expr)and isinstance(tree.body[0].value,ast.Constant)and isinstance(tree.body[0].value.value,str):at=tree.body[0].end_lineno + lines[at:at]=['\nfrom objectstate import DottedFieldPath\n'];s=''.join(lines) + compile(s,str(p),'exec');p.write_text(s);print(p.relative_to(ROOT)) diff --git a/.scratch/qt_mutation_owner.py b/.scratch/qt_mutation_owner.py new file mode 100644 index 0000000..c60cae0 --- /dev/null +++ b/.scratch/qt_mutation_owner.py @@ -0,0 +1,101 @@ +"""One admitted field and one operation per UI mutation; delete reset copies.""" +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +ROOT=Path('/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive') +p=ROOT/'services/field_change_dispatcher.py';s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True);edits=[] +bodies={ +'FieldChangeEvent':'''@dataclass(frozen=True) +class FieldChangeEvent(ABC): + """An admitted field and its UI source; each operation owns its mutation.""" + field: FieldProxy + source_manager: "ParameterFormManager" + is_reset: ClassVar[bool] = False + + def __post_init__(self): + if self.field.state is not self.source_manager.state: + raise FieldAccessError("The field belongs to a different editing state") + + @property + def field_name(self): + return self.field.name + + @abstractmethod + def apply(self) -> set[str]: + """Perform the sole canonical model transaction for this operation.""" + + +@dataclass(frozen=True) +class FieldWriteEvent(FieldChangeEvent): + value: Any + + def apply(self): + from objectstate import ObjectStateRegistry + with ObjectStateRegistry.defer_live_invalidations(): + return self.field.write(self.value) + + +@dataclass(frozen=True) +class FieldResetEvent(FieldChangeEvent): + is_reset: ClassVar[bool] = True + + def apply(self): + return self.field.reset() +''', +'FieldDispatchContext':'''@dataclass(frozen=True) +class FieldDispatchContext: + """One prepared UI route; the event retains the field and source facts.""" + event: FieldChangeEvent + root: "ParameterFormManager" + + @classmethod + def for_event(cls, event): + return cls(event, event.source_manager.form_tree.root()) +''', +'FieldDispatchContextFactory':'', +'SourceStateUpdateStage':'''class SourceStateUpdateStage: + """Apply the operation once, then synchronize the source view.""" + + def run(self, context: FieldDispatchContext) -> set[str]: + source = context.event.source_manager + if source.before_mutation is not None: + source.before_mutation() + changed_paths = context.event.apply() + source.sync_after_model_field_change(context.event.field_name, changed_paths=changed_paths) + return changed_paths +'''} +for _,n in idx.indexed_nodes_of_type(ast.ClassDef): + if n.name in bodies: + a=min([n.lineno]+[d.lineno for d in n.decorator_list])-1;edits.append((a,n.end_lineno,bodies[n.name])) +for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t+'\n'] +s=''.join(lines).replace('from contextlib import nullcontext\nfrom objectstate import DottedFieldPath, FieldProxy','from abc import ABC, abstractmethod\nfrom objectstate import FieldAccessError, FieldProxy').replace('from typing import TYPE_CHECKING, Any','from typing import TYPE_CHECKING, Any, ClassVar').replace(' self._context_factory = FieldDispatchContextFactory()\n','').replace('context = self._context_factory.build(event)','context = FieldDispatchContext.for_event(event)') +idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True);offsets=[];at=0 +for line in lines:offsets.append(at);at+=len(line) +edits=[] +for _,n in idx.indexed_nodes_of_type(ast.Attribute): + text=None + if isinstance(n.value,ast.Name)and n.value.id=='context': + text={'source':'context.event.source_manager','field':'context.event.field','source_path':'context.event.field.address.value','root_path':'context.event.field.address.value'}.get(n.attr) + elif isinstance(n.value,ast.Name)and n.value.id=='event'and n.attr=='value':text='event.field.raw' + elif isinstance(n.value,ast.Attribute)and isinstance(n.value.value,ast.Name)and n.value.value.id=='context'and n.value.attr=='event'and n.attr=='value':text='context.event.field.raw' + if text is not None: + a=offsets[n.lineno-1]+len(lines[n.lineno-1].encode()[:n.col_offset].decode());b=offsets[n.end_lineno-1]+len(lines[n.end_lineno-1].encode()[:n.end_col_offset].decode());edits.append((a,b,text)) +ordered=sorted(edits);assert all(a[1]<=b[0]for a,b in zip(ordered,ordered[1:])), 'overlapping admitted projections' +for a,b,t in reversed(ordered):s=s[:a]+t+s[b:] +compile(s,str(p),'exec');p.write_text(s) +# Reset policy no longer belongs to per-widget rendering services. +p=ROOT/'services/parameter_ops_service.py';s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True);edits=[] +for _,n in idx.indexed_nodes_of_type(ast.FunctionDef): + if n.name in {'__init__','_get_effective_context_obj','_get_handler_prefix','reset_parameter'}or n.name.startswith('_reset_'): + a=min([n.lineno]+[d.lineno for d in n.decorator_list])-1;edits.append((a,n.end_lineno,'')) +for _,n in idx.indexed_nodes_of_type(ast.If): + if isinstance(n.test,ast.Name)and n.test.id=='TYPE_CHECKING':edits.append((n.lineno-1,n.end_lineno,'')) +for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t] +s=''.join(lines).replace('class ParameterOpsService(ParameterServiceABC):','class ParameterOpsService:').replace('from objectstate import DottedFieldPath\n','').replace('from typing import TYPE_CHECKING\n','').replace('from formbridge.services.parameter_service_abc import ParameterServiceABC\n','') +s=s.replace(' # DELETED: _get_reset_value - ObjectState owns defaults\n # DELETED: _update_reset_tracking - ObjectState.reset_parameter handles tracking\n','') +compile(s,str(p),'exec');p.write_text(s) +# The manager now issues one reset operation; no second reset publication path. +p=ROOT/'forms/parameter_form_chrome_sync.py';s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);lines=s.splitlines(keepends=True) +for _,n in idx.indexed_nodes_of_type(ast.FunctionDef): + if n.name=='dispatch_reset':lines[n.lineno-1:n.end_lineno]=[] +s=''.join(lines);compile(s,str(p),'exec');p.write_text(s) diff --git a/.scratch/reconstruction_owner.py b/.scratch/reconstruction_owner.py new file mode 100644 index 0000000..d6e1245 --- /dev/null +++ b/.scratch/reconstruction_owner.py @@ -0,0 +1,63 @@ +import ast,textwrap +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +root=Path('/home/ts/wt/objectstate-declared-fields/src/objectstate') +p=root/'parameter_structure.py';s=p.read_text();s+=''' + def reconstruct(self, prefix, values, target, *, resolved=False): + """Build one projection from its own payload, preserving scalar terminals. + + A container's stored None/Inherit/Unset is authoritative even when an + editor retains child drafts. Nominal type comes from that payload, never + from the declaration-owner index. Live and saved projections share this. + """ + from objectstate.field_access import DataclassFieldAccess + from objectstate.lazy_factory import get_base_type_for_lazy, replace_raw + + prototype = values[prefix] if prefix else target + if prefix and not is_dataclass(prototype): + return prototype + prefix_dot = f"{prefix}." if prefix else "" + direct = {} + for path, value in values.items(): + if not path.startswith(prefix_dot): + continue + remainder = path[len(prefix_dot):] + if "." in remainder: + continue + direct[remainder] = ( + self.reconstruct(path, values, target, resolved=resolved) + if is_dataclass(value) else value + ) + if not prefix: + direct.update(self.excluded_values) + if is_dataclass(prototype): + if not resolved: + return replace_raw(prototype, **direct) + constructor = get_base_type_for_lazy(type(prototype)) or type(prototype) + arguments = DataclassFieldAccess.raw_init_values(prototype, constructor) + arguments.update(direct) + return constructor(**arguments) + return type(prototype)(**direct) +''';compile(s,str(p),'exec');p.write_text(s) +p=root/'object_state.py';s=p.read_text();idx=module_syntax_index(SourceModule.from_source_path(p,s).parse().module);edits=[];deleted=[] +for _,n in idx.indexed_nodes_of_type(ast.FunctionDef): + if n.name in {'_reconstruct_from_resolved','_reconstruct_from_parameter_snapshot','_reconstruct_from_prefix'}: + start=min([n.lineno]+[d.lineno for d in n.decorator_list])-1;deleted.append((start,n.end_lineno));edits.append((start,n.end_lineno,'')) +lines=s.splitlines(keepends=True) +for _,n in idx.indexed_nodes_of_type(ast.Call): + if any(a<=n.lineno-1 str: + """How a value of this field reads.""" + return self.field.placeholder if value is None else str(value) +''',''' def value_text(self, value: object) -> str: + """A native entry projects absence without turning it into input data.""" + return self.field.placeholder if value is None or not FieldValue.of(value).has_value else str(value) + + def read_value(self) -> object: + raise NotImplementedError + + def commit(self) -> None: + """An explicit commit reads this control, not a manufactured type default.""" + try: + value = self.read_value() + except (TypeError, ValueError) as error: + self.refuse(error) + else: + self.send(value) +''') +s=s.replace(''' super().__init__(value=bool(field.value))''',''' value = field.value + super().__init__(value=bool(FieldValue.of(value).has_value and value))''') +s=s.replace(''' def paint(self) -> None: + with self.prevent(Checkbox.Changed): + self.value = bool(self.field.value) +''',''' def read_value(self) -> object: + return self.value + + def paint(self) -> None: + value = self.field.value + with self.prevent(Checkbox.Changed): + self.value = bool(FieldValue.of(value).has_value and value) + self.tooltip = None if FieldValue.of(value).has_value else repr(value) +''') +a=s.index(' try:\n value = self.read(self.value)',s.index('class LineEditor'));b=s.index(' def paint(self)',a) +s=s[:a]+''' self.commit() + + def read_value(self) -> object: + return self.read(self.value) + +'''+s[b:] +s=s.replace('super().__init__(str(field.value or ""))','super().__init__(self.value_text(field.value))').replace('value = str(self.field.value or "")','value = self.value_text(self.field.value)') +s=s.replace(''' self.send(self.text) + + def paint(self)''',''' self.send(self.text) + + def read_value(self) -> object: + return self.text + + def paint(self)''') +s=s.replace(''' def options(self) -> Iterable[tuple[str, object]]: + """Each value on offer, with how it reads."""''',''' def read_value(self) -> object: + if self.value is Select.NULL: + raise ValueError("Choose a value before committing this field") + return self.value + + def options(self) -> Iterable[tuple[str, object]]: + """Each value on offer, with how it reads."""''') +s=s.replace(''' """A field that may hold nothing: a box says whether it holds a value, and the + editor of that value is shown while it does."""''',''' """A nullable field; controls edit data and the box projects non-nullness. + + Keep the member editable while null so a numeric value can be entered before + committing it. Enabling never leaves a checked box over backend None. + """''') +s=s.replace(''' self.holds_value = Checkbox("Set", value=field.value is not None) + self.member = ValueEditor.declared_for(field.member) + self.member.display = field.value is not None''',''' value = field.value + self.holds_value = Checkbox("Not None", value=FieldValue.of(value).has_value and value is not None) + self.member = ValueEditor.declared_for(field.member)''') +s=s.replace(''' self.member.display = message.value + if not message.value: + self.send(None) +''',''' if message.value: + self.member.commit() + else: + self.send(None) + self.paint() +''') +s=s.replace(''' return "not set" if value is None else self.member.value_text(value)''',''' return "None" if value is None else self.member.value_text(value)''') +s=s.replace(''' held = self.field.value is not None + with self.prevent(Checkbox.Changed): + self.holds_value.value = held + self.member.display = held + self.member.paint()''',''' value = self.field.value + held = FieldValue.of(value).has_value and value is not None + with self.prevent(Checkbox.Changed): + self.holds_value.value = held + self.holds_value.tooltip = None if FieldValue.of(value).has_value else repr(value) + self.member.paint() + + def read_value(self) -> object: + return self.member.read_value() if self.holds_value.value else None''') +compile(s,str(P),'exec');P.write_text(s) diff --git a/.scratch/value-node-context-migration.py b/.scratch/value-node-context-migration.py new file mode 100644 index 0000000..1b6f2b6 --- /dev/null +++ b/.scratch/value-node-context-migration.py @@ -0,0 +1,79 @@ +"""Move context operations onto admitted values; remove competing algorithms.""" +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule, module_syntax_index +p = Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/context_manager.py') +s = p.read_text() +tree = SourceModule.from_source_path(p, s).parse().module +syntax = module_syntax_index(tree) +lines = s.splitlines(keepends=True) +starts = [0] +for line in lines: starts.append(starts[-1] + len(line)) +def span(n): + first = min([n.lineno] + [d.lineno for d in n.decorator_list]) + return starts[first - 1], starts[n.end_lineno] +replacements = { +'_merge_nested_dataclass': '', +'_merge_concrete_dataclass': '', +'_record_config_instance': '', +'_extract_from_object_attributes_typed': '', +'_is_compatible_config_type': '', +'extract_all_configs': '''def extract_all_configs(context_obj): + """Admit an external context and return its typed configuration members.""" + return FieldValue.of(context_obj).context_configs() +''', +'get_context_info': '''def get_context_info() -> Dict[str, Any]: + current = get_current_temp_global() + if current is None: + return {'active': False} + node = FieldValue.of(current) + declarations = node.declared_fields() + return { + 'active': True, + 'type': type(current).__name__, + 'field_count': len(declarations), + 'non_none_fields': sum(node.read_raw(field.name).stored is not None for field in declarations), + } +''', +'config_context': '''@contextmanager +def config_context(obj, use_live_global: bool = True, scope_id: str | None = None): + """Enter one admitted layer; field members own merge and raw-read behavior.""" + from objectstate.config import get_base_config_type + + current = get_current_temp_global() + base = FieldValue.of(current if current is not None else get_base_global_config(use_live=use_live_global)) + incoming = FieldValue.of(obj) + overrides = {} + if obj is not None: + for declaration in FieldValue.of(get_base_config_type()).declared_fields(): + try: + member = incoming.read_raw(declaration.name) + except AttributeError: + continue + overrides[declaration.name] = member.overlay(base.read_raw(declaration.name)) + merged = base.with_fields(overrides).stored if overrides else base.stored + types = context_type_stack.get() + stack = context_layer_stack.get() if current is not None else ContextLayerStack((ContextLayer('', base),)) + if obj is not None: + stack = stack.with_layer(ContextLayer(scope_id, incoming)) + types += (type(obj),) + with ExitStack() as resources: + for variable, value in ((current_temp_global, merged), (context_type_stack, types), (context_layer_stack, stack)): + resources.callback(variable.reset, variable.set(value)) + yield merged +''', +'_declared_config_fields': '''@functools.cache +def _declared_config_fields(dataclass_type: type) -> tuple[tuple[str, type], ...]: + """Immutable declaration metadata; the annotation member owns slot admission.""" + annotations = get_type_hints(dataclass_type, include_extras=True) + return tuple( + slot + for field in FieldValue.of(dataclass_type).declared_fields() + for slot in FieldValue.of(resolve_optional(resolve_annotated(annotations.get(field.name, field.type)))).declared_config_slot(field.name) + ) +''', +} +edits = [(*span(n), replacements[n.name]) for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in replacements] +for a, b, replacement in sorted(edits, reverse=True): s = s[:a] + replacement + s[b:] +compile(s, str(p), 'exec') +p.write_text(s) diff --git a/.scratch/value-node-lazy-migration.py b/.scratch/value-node-lazy-migration.py new file mode 100644 index 0000000..f6bd26f --- /dev/null +++ b/.scratch/value-node-lazy-migration.py @@ -0,0 +1,76 @@ +"""Move declaration/data operations into their admitted family in one source batch.""" +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule, module_syntax_index +p = Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/lazy_factory.py') +s = p.read_text() +tree = SourceModule.from_source_path(p, s).parse().module +syntax = module_syntax_index(tree) +lines = s.splitlines(keepends=True) +starts = [0] +for line in lines: starts.append(starts[-1] + len(line)) +def span(n): + first = min([n.lineno] + [d.lineno for d in n.decorator_list]) + return starts[first - 1], starts[n.end_lineno] +replacements = { +'_resolved_dataclass_annotations': '', +'_normalized_config_type': '', +'_declared_config_field_names': '', +'resolve_lazy_configurations_for_serialization': '''def resolve_lazy_configurations_for_serialization(data: Any) -> Any: + """Admit one input graph; members own recursive resolution and reconstruction. + + Inheritance requests require an active configuration context. Constructor + failures propagate; serialization never bypasses a domain constructor. + """ + return FieldValue.of(data).for_serialization() +''', +'create_dataclass_for_editing': '''def create_dataclass_for_editing(dataclass_type: Type[T], source_config: Any, preserve_values: bool = False, context_provider: Optional[Callable[[Any], None]] = None) -> T: + """Preserve stored meanings, or create inherited drafts without inventing nulls.""" + declaration = FieldValue.of(dataclass_type) + constructor = declaration.require_config_type() + if context_provider: + context_provider(source_config) + source = FieldValue.of(source_config) + annotations = declaration.annotations_for(constructor) + values = { + field.name: (source.read_raw(field.name) if preserve_values + else FieldValue.of(annotations.get(field.name, field.type)).editing_default()) + for field in declaration.declared_fields() if field.init + } + return constructor(**{name: member.stored for name, member in values.items()}) +''', +} +edits = [(*span(n), replacements[n.name]) for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in replacements] +owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'LazyDataclassFactory') +method = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == '_introspect_dataclass_fields') +edits.append((*span(method), ''' @staticmethod + def _introspect_dataclass_fields(base, debug_template): + """Declaration members produce their field definition, not type flags.""" + annotations = base.annotations_for(base.stored) + definitions = [] + for declaration in base.declared_fields(): + annotation = annotations.get(declaration.name, declaration.type) + final_type, field_definition = FieldValue.of(resolve_annotated(annotation)).lazy_field(declaration, annotation) + definitions.append((declaration.name, final_type, field_definition)) + logger.debug(debug_template.format( + field_name=declaration.name, + original_type=declaration.type, + has_default=(declaration.default is not MISSING or declaration.default_factory is not MISSING), + final_type=final_type, + )) + return definitions +''')) +for a,b,text in sorted(edits, reverse=True):s=s[:a]+text+s[b:] +s=s.replace('_resolved_dataclass_annotations(', 'DataclassFieldAccess.annotations_for(') +compile(s,str(p),'exec') +p.write_text(s) + +# The old context-specific declaration cache is replaced by the shared capability. +p = Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/context_manager.py') +s=p.read_text() +tree=SourceModule.from_source_path(p,s).parse().module +lines=s.splitlines(keepends=True);starts=[0] +for line in lines:starts.append(starts[-1]+len(line)) +n=next(n for n in tree.body if isinstance(n,ast.FunctionDef) and n.name=='_declared_config_fields') +a,b=span(n);s=s[:a]+s[b:] +compile(s,str(p),'exec');p.write_text(s) diff --git a/.scratch/value-node-resolver-migration.py b/.scratch/value-node-resolver-migration.py new file mode 100644 index 0000000..825d3e9 --- /dev/null +++ b/.scratch/value-node-resolver-migration.py @@ -0,0 +1,35 @@ +"""Adjudicated resolver migration using NRA source positions, not a new scanner.""" +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule, module_syntax_index +p = Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/dual_axis_resolver.py') +s = p.read_text() +tree = SourceModule.from_source_path(p, s).parse().module +syntax = module_syntax_index(tree) +lines = s.splitlines(keepends=True) +starts = [0] +for line in lines: starts.append(starts[-1] + len(line)) +replacements = { +'_direct_owner_field_default': '', +'declared_default': '''def declared_default(container_type: type, field_name: str) -> tuple[Any, type | None]: + """Defaults come from declarations, never slot descriptors or guessed attributes.""" + base = FieldValue.of(_normalize_to_base(container_type)) + own = base.own_default(field_name) + own_type = base.stored if own.available() else None + if own.available(): + return own.stored, own_type + for declaration in base.configuration_mro(): + value = declaration.own_default(field_name) + if value.available(): + return value.stored, own_type + return FieldValue.of(container_type).inherited_default(field_name).stored, own_type +''', +} +edits = [] +for n in tree.body: + if isinstance(n, ast.FunctionDef) and n.name in replacements: + first = min([n.lineno] + [d.lineno for d in n.decorator_list]) + edits.append((starts[first - 1], starts[n.end_lineno], replacements[n.name])) +for a,b,text in sorted(edits, reverse=True): s=s[:a]+text+s[b:] +compile(s, str(p), 'exec') +p.write_text(s) diff --git a/.scratch/value-node-state-migration.py b/.scratch/value-node-state-migration.py new file mode 100644 index 0000000..59d0768 --- /dev/null +++ b/.scratch/value-node-state-migration.py @@ -0,0 +1,135 @@ +"""One-shot authored owner migration; NRA supplies original syntax positions.""" +import ast +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule, module_syntax_index + +path = Path('/home/ts/wt/objectstate-declared-fields/src/objectstate/object_state.py') +source = path.read_text() +tree = SourceModule.from_source_path(path, source).parse().module +syntax = module_syntax_index(tree) +lines = source.splitlines(keepends=True) +starts = [0] +for line in lines: + starts.append(starts[-1] + len(line)) +def pos(line, column): + return starts[line - 1] + len(lines[line - 1].encode()[:column].decode()) +def method_span(method): + first = min([method.lineno] + [d.lineno for d in method.decorator_list]) + return starts[first - 1], starts[method.end_lineno] +owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'ObjectState') +methods = {n.name: n for n in owner.body if isinstance(n, ast.FunctionDef)} +replacements = { +'_seed_callable_identity_memo': '', +'_extract_all_parameters_flat': '', +'_dataclass_parameter_updates': '', +'_compute_dirty_fields': ''' def _compute_dirty_fields(self) -> set[str]: + """Compare admitted effective members, without a second domain normalizer.""" + if self._live_resolved is None: + return set() + return { + path for path in self._live_resolved.keys() | self._saved_resolved.keys() + if self._live_resolved.get(path, Unset()) != self._saved_resolved.get(path, Unset()) + } +''', +'_replace_parameter_structure': ''' def _replace_parameter_structure( + self, obj, *, exclude_params=None, initial_values=None, + ) -> None: + """Admit one value graph and replace its declaration topology atomically.""" + self._target = FieldValue.of(obj) + self._parameters.clear() + self._parameter_structure = ParameterStructure.for_target(self._target, exclude_params) + self._target.extract_parameters( + type(self), self._parameters, self._parameter_structure, + exclusions=exclude_params or (), + ) + for path, datum in (initial_values or {}).items(): + member = FieldValue.of(datum) + self._parameters[path] = member + self._parameters.update(member.descendant_values(path, self._parameters)) + self._index_parameter_paths() +''', +'_resolve_parameter_snapshot': ''' @classmethod + def _resolve_parameter_snapshot( + cls, parameters, values, structure, current_node, scope_id, + ancestor_objects_with_scopes, *, use_saved, field_paths=None, + ): + """Resolve admitted members; terminal parents suppress retained child drafts.""" + from objectstate.context_manager import build_context_stack + from objectstate.dual_axis_resolver import resolve_with_provenance + + snapshot = {} + provenance = {} + with build_context_stack( + object_instance=current_node.stored, + ancestor_objects_with_scopes=ancestor_objects_with_scopes, + current_scope_id=scope_id, use_live=not use_saved, + ): + def resolve(path): + if path in snapshot: + return snapshot[path] + address = DottedFieldPath(path) + owner = structure.owner_paths[path] + if address.parent.value: + raw, owner = resolve(address.parent.value).resolved_child(address.field_name, owner) + else: + raw = structure.reconstruct_member(path, values, current_node) + def inherited(): + if not isinstance(owner, type): + return Unset() + result = resolve_with_provenance(owner, address.field_name) + provenance[path] = (result.source_scope, result.source_type) + return FieldValue.of(result.value) + snapshot[path] = raw.resolve(inherited) + return snapshot[path] + for path in parameters: + if field_paths is None or path in field_paths: + resolve(path) + return snapshot, provenance +''', +'notification_fields_for_value_changes': ''' def notification_fields_for_value_changes(self, changed_values) -> set[str]: + """The member owns whether its registered descendants carry the change.""" + fields = set() + for path, (before, after) in changed_values.items(): + member = self._parameters.get(path, Unset()) + fields.update(member.notification_paths(path, self._parameter_structure)) + fields.update(changed_structural_leaf_paths( + owner_field_path=DottedFieldPath(path), old_value=before, new_value=after, + )) + return fields +''', +'_get_param_value_from_target': ''' def _get_param_value_from_target(self, target, param_name): + """A missing descendant under a terminal parent is absent, never null.""" + current = target + for part in DottedFieldPath(param_name).parts: + try: + current = current.read_raw(part) + except AttributeError: + return Unset() + return current +''', + 'to_object': ''' def to_object(self, *, update_delegate=False, sync_delegate=True): + """Materialize through the admitted root; apply a cached result only on request.""" + if sync_delegate: + self._check_and_sync_delegate() + if self._cached_object is None: + values = self._parameter_structure.direct_values('', self._parameters, self._target) + self._cached_object = self._target.with_fields(values) + self._cached_object_applied = False + node = self._cached_object + if update_delegate and not self._cached_object_applied: + if self._delegate_attr is None: + self.object_instance = node.stored + else: + setattr(self.object_instance, self._delegate_attr, node.stored) + self._target = node + self._cached_object_applied = True + return node.stored +''', +} +edits = [(*method_span(methods[name]), replacement) for name, replacement in replacements.items()] +for start, end, replacement in sorted(edits, reverse=True): + source = source[:start] + replacement + source[end:] +# The selected methods above no longer carry the source's old datatype switch. +source = source.replace('_extraction_target', '_target') +compile(source, str(path), 'exec') +path.write_text(source) diff --git a/.scratch/widget_draft_owner.py b/.scratch/widget_draft_owner.py new file mode 100644 index 0000000..9233825 --- /dev/null +++ b/.scratch/widget_draft_owner.py @@ -0,0 +1,157 @@ +"""Move Qt adapter field-value meaning and signal lifetime into its existing mixin.""" +import ast,textwrap +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +P=Path('/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive/protocols/widget_adapters.py') +s=P.read_text();s=s.replace('from typing import Any','from typing import Any\nfrom objectstate.field_value import FieldValue, Inherit, Unset, Value') +MIXIN='''class WidgetValue(Value): + """A concrete draft read from its control, not a second stored model value.""" + __slots__ = ('reader',) + + def __init__(self, reader): + self.reader = reader + + @property + def value(self): + return self.reader() + + +class PlaceholderStateMixin(PlaceholderStateTrackable): + """Own one input draft and its native signal subscriptions. + + Inherit and Unset are retained as distinct input states. Concrete input lives + in the Qt control and is read lazily, so incomplete text is not parsed while + the user is typing. Model paints never publish input events. + """ + widget_capabilities = frozenset({WidgetCapability.PLACEHOLDER_STATE}) + + def __init__(self, *args, **kwargs): + self._draft = Unset() + self._input_callbacks = [] + self._native_connections = {} + self._cached_placeholder_text = None + self._cached_placeholder_resolved_value = None + super().__init__(*args, **kwargs) + + def _signal_sources(self): + return () + + def _ensure_input_tracking(self): + for owner, signal in self._signal_sources(): + if owner not in self._native_connections: + signal.connect(self._native_input) + self._native_connections[owner] = signal + + def _native_input(self, *arguments): + self._prepare_input() + self._draft = WidgetValue(self._read_concrete_value) + self.clear_placeholder_cache() + self._apply_draft_style() + for callback in tuple(self._input_callbacks): + callback(self.get_value()) + + def _prepare_input(self): + pass + + def _apply_draft_style(self): + pass + + def _write_blank_value(self, value): + self._write_concrete_value(None) + + def get_value(self): + self._ensure_input_tracking() + return self._draft.resolve(lambda: Inherit()) + + def set_value(self, value): + self._ensure_input_tracking() + draft = FieldValue.of(value) + previous = self.blockSignals(True) + try: + self._draft = draft + if draft.has_value: + self._write_concrete_value(value) + if value is not None: + self._draft = WidgetValue(self._read_concrete_value) + else: + self._write_blank_value(value) + self._apply_draft_style() + finally: + self.blockSignals(previous) + + def connect_change_signal(self, callback): + self._ensure_input_tracking() + if callback not in self._input_callbacks: + self._input_callbacks.append(callback) + + def disconnect_change_signal(self, callback): + if callback in self._input_callbacks: + self._input_callbacks.remove(callback) + + def has_placeholder_state(self): + return self._draft.inherited + + def mark_placeholder_state(self): + self._draft = Inherit() + + def clear_placeholder_state(self): + if self._draft.inherited: + self._draft = WidgetValue(self._read_concrete_value) + self.clear_placeholder_cache() + + def cached_placeholder_text(self): + return self._cached_placeholder_text + + def set_cached_placeholder_text(self, text): + self._cached_placeholder_text = text + + def cached_placeholder_resolved_value(self): + return self._cached_placeholder_resolved_value + + def set_cached_placeholder_resolved_value(self, value): + self._cached_placeholder_resolved_value = value + + def clear_placeholder_cache(self): + self._cached_placeholder_text = None + self._cached_placeholder_resolved_value = None +''' +parsed=SourceModule.from_source_path(P,s).parse();nodes=[n for _,n in module_syntax_index(parsed.module).indexed_nodes_of_type(ast.ClassDef)];edits=[] +for c in nodes: + if c.name=='PlaceholderStateMixin': + edits.append((c.lineno-1,c.end_lineno,textwrap.indent(MIXIN,' '*c.col_offset)+'\n'));continue + if c.name not in {'LineEditAdapter','KeySequenceEditAdapter','SpinBoxAdapter','DoubleSpinBoxAdapter','ComboBoxAdapter','CheckBoxAdapter','CheckboxGroupAdapter'}:continue + methods={n.name:n for n in c.body if isinstance(n,ast.FunctionDef)} + for name in ('get_value','set_value'): + n=methods[name];lines=s.splitlines(keepends=True);original=''.join(lines[n.lineno-1:n.end_lineno]);newname='_read_concrete_value'if name=='get_value'else'_write_concrete_value' + replacement=original.replace('def '+name+'(', 'def '+newname+'(',1) + if name=='get_value' and c.name=='LineEditAdapter':replacement=replacement.replace('return None if text == "" else text','return text') + if name=='get_value' and c.name in {'SpinBoxAdapter','DoubleSpinBoxAdapter'}: + replacement=' '*n.col_offset+'def _read_concrete_value(self):\n'+' '*(n.col_offset+4)+'return self.value()\n' + if name=='get_value' and c.name=='ComboBoxAdapter':replacement=replacement.replace('return None','return Unset()') + if c.name=='CheckboxGroupAdapter': + if name=='get_value':replacement=' '*n.col_offset+'def _read_concrete_value(self):\n'+' '*(n.col_offset+4)+'return [value for value, checkbox in self.checkbox_items() if checkbox.isChecked()]\n' + else:replacement=' '*n.col_offset+'def _write_concrete_value(self, value):\n'+' '*(n.col_offset+4)+'for member, checkbox in self.checkbox_items():\n'+' '*(n.col_offset+8)+'checkbox.set_value(member in value if value is not None else False)\n' + edits.append((n.lineno-1,n.end_lineno,replacement)) + for name in ('connect_change_signal','disconnect_change_signal','_make_checkbox_group_handler','has_placeholder_state'): + if name in methods: + n=methods[name];edits.append((n.lineno-1,n.end_lineno,'')) + if c.name=='KeySequenceEditAdapter': + n=methods['__init__'];edits.append((n.lineno-1,n.end_lineno,'')) + signal={'LineEditAdapter':'textChanged','KeySequenceEditAdapter':'editingFinished','SpinBoxAdapter':'valueChanged','DoubleSpinBoxAdapter':'valueChanged','ComboBoxAdapter':'currentIndexChanged','CheckBoxAdapter':'stateChanged'}.get(c.name) + indent=' '*(c.col_offset+4) + if signal:hook=f'\n{indent}def _signal_sources(self):\n{indent} return ((self, self.{signal}),)\n' + else:hook=f'''\n{indent}def _signal_sources(self): +{indent} return tuple((checkbox, checkbox.stateChanged) for checkbox in self.checkbox_widgets()) + +{indent}def _prepare_input(self): +{indent} for checkbox in self.checkbox_widgets(): +{indent} checkbox.convert_placeholder_to_concrete() + +{indent}def _write_blank_value(self, value): +{indent} for checkbox in self.checkbox_widgets(): +{indent} checkbox.set_value(value) +''' + edits.append((c.end_lineno,c.end_lineno,hook)) +lines=s.splitlines(keepends=True) +for start,end,text in sorted(edits,reverse=True):lines[start:end]=[text] +s=''.join(lines);ast.parse(s);P.write_text(s) diff --git a/.scratch/widget_leaf_cutover.py b/.scratch/widget_leaf_cutover.py new file mode 100644 index 0000000..b8a6e26 --- /dev/null +++ b/.scratch/widget_leaf_cutover.py @@ -0,0 +1,75 @@ +import ast,textwrap +from pathlib import Path +from nominal_refactor_advisor.ast_tools import SourceModule,module_syntax_index +ROOT=Path('/home/ts/wt/pyqt-declared-fields/src/pyqt_reactive') +def transform(path,visitor): + s=path.read_text();parsed=SourceModule.from_source_path(path,s).parse();idx=module_syntax_index(parsed.module);edits=visitor(s,idx);lines=s.splitlines(keepends=True) + for a,b,t in sorted(edits,reverse=True):lines[a:b]=[t] + out=''.join(lines);ast.parse(out,filename=str(path));path.write_text(out) +def whole(n,text):return(n.lineno-1,n.end_lineno,textwrap.indent(textwrap.dedent(text).strip()+'\n',' '*n.col_offset)) +def no_scroll(s,idx): + changes=[] + for _,c in idx.indexed_nodes_of_type(ast.ClassDef): + if c.name=='CheckboxValueState':changes.append(whole(c,''));continue + if c.name=='NoScrollComboBox': + for n in c.body: + if isinstance(n,ast.FunctionDef)and n.name in {'get_value','set_value'}:changes.append(whole(n,'')) + if c.name!='NoneAwareCheckBox':continue + for n in c.body: + if not isinstance(n,ast.FunctionDef):continue + if n.name in {'connect_change_signal','disconnect_change_signal'}:changes.append(whole(n,'')) + elif n.name=='get_value':changes.append(whole(n,'''def _read_concrete_value(self): + return self.isChecked() +''')) + elif n.name=='set_value':changes.append(whole(n,'''def _write_concrete_value(self, value): + self.setChecked(bool(value)) + +def _signal_sources(self): + return ((self, self.stateChanged),) + +def _apply_draft_style(self): + if self.has_placeholder_state(): + self._apply_placeholder_palette() + else: + self._apply_concrete_palette() +''')) + elif n.name=='is_placeholder':changes.append(whole(n,'''def is_placeholder(self): + return self.has_placeholder_state() +''')) + elif n.name=='convert_placeholder_to_concrete':changes.append(whole(n,'''def convert_placeholder_to_concrete(self): + self.clear_placeholder_state() + self._apply_draft_style() +''')) + for _,n in idx.indexed_nodes_of_type(ast.Assign): + if 'CheckboxValueState.' in ast.unparse(n):changes.append(whole(n,'')) + return changes +transform(ROOT/'widgets/no_scroll_spinbox.py',no_scroll) +def text_owner(s,idx): + changes=[];lines=s.splitlines(keepends=True) + for _,c in idx.indexed_nodes_of_type(ast.ClassDef): + if c.name=='NoneAwareTextValueMixin': + header=lines[c.lineno-1].replace('(DebouncedTextSignalMixin)', '(DebouncedTextSignalMixin, PlaceholderStateMixin)');changes.append((c.lineno-1,c.lineno,header)) + for n in c.body: + if isinstance(n,ast.FunctionDef)and n.name=='set_value': + original=''.join(lines[n.lineno-1:n.end_lineno]).replace('def set_value(', 'def _write_concrete_value(',1) + changes.append((n.lineno-1,n.end_lineno,original)) + changes.append((c.end_lineno,c.end_lineno,'\n def _signal_sources(self):\n return ((self, self.textChanged),)\n')) + if any(ast.unparse(base) == 'NoneAwareTextValueMixin' for base in c.bases): + for n in c.body: + if not isinstance(n,ast.FunctionDef):continue + if n.name=='get_value': + original=''.join(lines[n.lineno-1:n.end_lineno]).replace('def get_value(', 'def _read_concrete_value(',1) + if c.name=='NoneAwareLineEdit':original=' '*n.col_offset+'def _read_concrete_value(self):\n'+' '*(n.col_offset+4)+'return self.text().strip()\n' + changes.append((n.lineno-1,n.end_lineno,original)) + if n.name=='set_value' and c.name=='TypedLiteralContainerEdit': + original=''.join(lines[n.lineno-1:n.end_lineno]);body=original.splitlines(keepends=True) + body.insert(1,' '*(n.col_offset+4)+'if not FieldValue.of(value).has_value:\n'+' '*(n.col_offset+8)+'super().set_value(value)\n'+' '*(n.col_offset+8)+'return\n') + changes.append((n.lineno-1,n.end_lineno,''.join(body))) + return changes +transform(ROOT/'forms/widget_strategies.py',text_owner) +p=ROOT/'forms/widget_strategies.py';s=p.read_text();s=s.replace('from objectstate import ', 'from objectstate.field_value import FieldValue\nfrom objectstate import ',1);p.write_text(s) +# Names describe the capability, not the domain null value that used to request it. +for root in [ROOT,Path('/home/ts/wt/openhcs-declared-fields/openhcs'),Path('/home/ts/wt/formbridge-declared-fields/src')]: + for p in root.rglob('*.py'): + s=p.read_text();new=s.replace('NoneAwareCheckBox','InheritedCheckBox').replace('NoneAwareTextValueMixin','FieldTextValueMixin').replace('NoneAwareLineEdit','InheritedLineEdit').replace('NoneAwareIntEdit','InheritedIntEdit') + if new!=s:ast.parse(new,filename=str(p));p.write_text(new) diff --git a/src/objectstate/codegen.py b/src/objectstate/codegen.py index 9ed214f..85d04e8 100644 --- a/src/objectstate/codegen.py +++ b/src/objectstate/codegen.py @@ -103,7 +103,7 @@ class LazyDataclassFormatter(LazyResolutionFormatter): formats = LazyDataclass def clean_field_needs_source(self, value: object, field: Field, current: object) -> bool: - if FieldValue.is_inherited(current): + if FieldValue.of(current).inherits(): return False if field.name in _set_field_names(value): return True diff --git a/src/objectstate/field_value.py b/src/objectstate/field_value.py index ec2d0d1..baf1842 100644 --- a/src/objectstate/field_value.py +++ b/src/objectstate/field_value.py @@ -20,10 +20,6 @@ class FieldValue(ABC): def of(cls, value: Any) -> FieldValue: return value if isinstance(value, cls) else Value(value) - @staticmethod - def is_inherited(value): - return FieldValue.of(value).inherits() - @abstractmethod def available(self): """Whether domain data is supplied, including literal null.""" diff --git a/src/objectstate/lazy_factory.py b/src/objectstate/lazy_factory.py index ad6d5c7..8289482 100644 --- a/src/objectstate/lazy_factory.py +++ b/src/objectstate/lazy_factory.py @@ -572,7 +572,7 @@ def create_getattribute() -> Callable[[Any, str], Any]: def __getattribute__(self: Any, name: str) -> Any: value = object.__getattribute__(self, name) # Fast path: non-None values never need resolution - if not FieldValue.is_inherited(value): + if not FieldValue.of(value).inherits(): return value # PERFORMANCE: Use pre-computed frozenset (O(1) lookup) instead of # rebuilding {f.name for f in fields(self.__class__)} (O(n)) on every access. diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 94d313d..b23b9c2 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -968,7 +968,7 @@ def _fields_with_other_sources( path for config_type, field_name in differing for path in self.inheritance_field_paths(config_type, field_name) - if FieldValue.is_inherited(own_values.get(path, Unset())) + if own_values.get(path, Unset()).inherits() } def _resolve_saved_again_if_sources_changed(self, sources: ResolutionSources) -> bool: diff --git a/src/objectstate/subfield_semantics.py b/src/objectstate/subfield_semantics.py index 0b1f999..5aaa599 100644 --- a/src/objectstate/subfield_semantics.py +++ b/src/objectstate/subfield_semantics.py @@ -158,10 +158,10 @@ class ObjectStateSubfieldSemantic(Generic[SubfieldValueT]): display_path: str value_type_name: str | None - raw_value: SubfieldValueT | MissingValue - resolved_value: SubfieldValueT | MissingValue - saved_resolved_value: SubfieldValueT | MissingValue - signature_default_value: SubfieldValueT | MissingValue + raw_value: SubfieldValueT | FieldValue + resolved_value: SubfieldValueT | FieldValue + saved_resolved_value: SubfieldValueT | FieldValue + signature_default_value: SubfieldValueT | FieldValue raw_present: bool resolved_present: bool diff --git a/tests/test_integration.py b/tests/test_integration.py index fe8436f..35cfc0a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -5,12 +5,8 @@ import pytest from dataclasses import dataclass -from objectstate import ( - set_base_config_type, - LazyDataclassFactory, - config_context, - get_base_type_for_lazy, -) +from python_introspect import resolve_declared_type +from objectstate import set_base_config_type, LazyDataclassFactory, config_context def test_readme_quick_start_example(): @@ -53,7 +49,7 @@ class MyConfig: assert LazyConfig.__name__ == "LazyMyConfig" # Type mapping should be registered - assert get_base_type_for_lazy(LazyConfig) == MyConfig + assert resolve_declared_type(LazyConfig) == MyConfig def test_lazy_instantiation(): diff --git a/tests/test_lazy_counterpart_registry.py b/tests/test_lazy_counterpart_registry.py index a0d868d..7958365 100644 --- a/tests/test_lazy_counterpart_registry.py +++ b/tests/test_lazy_counterpart_registry.py @@ -4,7 +4,8 @@ import pytest -from objectstate import LazyDataclassFactory, abbreviation, get_base_type_for_lazy, lazy_factory +from python_introspect import resolve_declared_type +from objectstate import LazyDataclassFactory, abbreviation, lazy_factory from objectstate.lazy_factory import ( create_global_default_decorator, get_group_abbreviation, @@ -55,7 +56,7 @@ def test_class_inside_function_gets_abbreviation_on_base_and_lazy(define, global config = define(global_default, "wfc") lazy = get_lazy_type_for_base(config) - assert get_base_type_for_lazy(lazy) is config + assert resolve_declared_type(lazy) is config assert get_group_abbreviation(config) == "wfc" assert get_group_abbreviation(lazy) == "wfc" @@ -159,8 +160,8 @@ class Unregistered: with pytest.raises(ValueError, match="already owns lazy type"): LazyDataclassFactory.make_lazy_simple(First, "OtherLazyFirst") assert get_lazy_type_for_base(First) is lazy_first - assert get_base_type_for_lazy(lazy_second) is Second - assert get_base_type_for_lazy(Unregistered) is None + assert resolve_declared_type(lazy_second) is Second + assert get_lazy_type_for_base(Unregistered) is None def test_no_lazy_class_is_looked_up_by_constructed_name(): diff --git a/tests/test_lazy_factory.py b/tests/test_lazy_factory.py index c7f846b..6e6508f 100644 --- a/tests/test_lazy_factory.py +++ b/tests/test_lazy_factory.py @@ -11,12 +11,8 @@ optional_member_type, ) -from objectstate import ( - LazyDataclassFactory, - get_base_type_for_lazy, - patch_lazy_constructors, - register_lazy_type_mapping, -) +from python_introspect import resolve_declared_type +from objectstate import LazyDataclassFactory, patch_lazy_constructors, register_lazy_type_mapping def test_make_lazy_simple(): @@ -33,7 +29,7 @@ class SimpleConfig: assert LazySimpleConfig.__name__ == "LazySimpleConfig" # Check that type mapping was registered - assert get_base_type_for_lazy(LazySimpleConfig) == SimpleConfig + assert resolve_declared_type(LazySimpleConfig) == SimpleConfig def test_make_lazy_simple_reuses_the_registered_nominal_type() -> None: @@ -330,7 +326,7 @@ class LazyConfig: register_lazy_type_mapping(LazyConfig, BaseConfig) # Verify mapping - assert get_base_type_for_lazy(LazyConfig) == BaseConfig + assert resolve_declared_type(LazyConfig) == BaseConfig def test_patch_lazy_constructors_uses_registered_type_mapping(): @@ -351,7 +347,7 @@ class BaseConfig: assert object.__getattribute__(candidate, "inherited") is None nested = object.__getattribute__(candidate, "nested") - assert get_base_type_for_lazy(type(nested)) is NestedConfig + assert resolve_declared_type(type(nested)) is NestedConfig assert object.__getattribute__(nested, "value") is None @@ -504,7 +500,7 @@ class ParentConfig: parent = LazyParentConfig.from_config(ChildConfig(value="explicit")) child = object.__getattribute__(parent, "payload") - assert get_base_type_for_lazy(type(child)) is ChildConfig + assert resolve_declared_type(type(child)) is ChildConfig assert object.__getattribute__(child, "value") == "explicit" diff --git a/tests/test_subfield_semantics.py b/tests/test_subfield_semantics.py index 71ffe02..0ddc178 100644 --- a/tests/test_subfield_semantics.py +++ b/tests/test_subfield_semantics.py @@ -6,7 +6,7 @@ from objectstate import ( DottedFieldPath, - MISSING, + Unset, LazyDataclassFactory, ObjectState, ObjectStateRegistry, @@ -64,10 +64,7 @@ def test_tuple_dataclass_leaf_semantics_detect_only_changed_leaf() -> None: ) ObjectStateRegistry.register(state, _skip_snapshot=True) - state.update_parameter( - "clauses", - (Clause("file", "contains", "GFP"),), - ) + DottedFieldPath("clauses").bind(state).write((Clause("file", "contains", "GFP"),)) index = state.subfield_semantics(DottedFieldPath("clauses")) subject = index.leaf_for(_path(0, "subject")) @@ -99,13 +96,10 @@ class RootConfig: ObjectStateRegistry.register(state, _skip_snapshot=True) resolved_events: list[set[str]] = [] state_events: list[set[str]] = [] - state.on_resolved_changed(lambda paths: resolved_events.append(set(paths))) - state.on_state_changed(lambda paths: state_events.append(set(paths))) + state._listen_resolved(lambda paths: resolved_events.append(set(paths))) + state._listen_state(lambda paths: state_events.append(set(paths))) - changed_paths = state.update_parameter( - "bindings", - replace_raw(state.parameters["bindings"], enabled=None), - ) + changed_paths = DottedFieldPath("bindings").bind(state).write(replace_raw(state.fields.bindings.raw, enabled=None)) assert "bindings.enabled" in state._last_changed_concrete_paths assert "bindings" not in state._last_changed_concrete_paths @@ -121,10 +115,7 @@ def test_missing_default_leaf_marks_signature_diff_without_confusing_none() -> N state = ObjectState(Holder(), scope_id="subfield") ObjectStateRegistry.register(state, _skip_snapshot=True) - state.update_parameter( - "clauses", - (Clause("file", "is_image", None),), - ) + DottedFieldPath("clauses").bind(state).write((Clause("file", "is_image", None),)) leaf = state.subfield_semantics(DottedFieldPath("clauses")).leaf_for( _path(0, "value") @@ -134,7 +125,7 @@ def test_missing_default_leaf_marks_signature_diff_without_confusing_none() -> N assert leaf.raw_present is True assert leaf.raw_value is None assert leaf.signature_default_present is False - assert leaf.signature_default_value is MISSING + assert leaf.signature_default_value == Unset() assert leaf.signature_diff is True @@ -157,13 +148,10 @@ class RootConfig: state = ObjectState(RootConfig(), scope_id="subfield") ObjectStateRegistry.register(state, _skip_snapshot=True) - state.update_parameter( - "parent_config.clauses", - (Clause("file", "contains", "DAPI"),), - ) + DottedFieldPath("parent_config.clauses").bind(state).write((Clause("file", "contains", "DAPI"),)) state.mark_saved() - assert state.get_resolved_value("child_config.clauses") == ( + assert DottedFieldPath("child_config.clauses").bind(state).value == ( Clause("file", "contains", "DAPI"), ) index = state.subfield_semantics(DottedFieldPath("child_config.clauses")) @@ -171,7 +159,7 @@ class RootConfig: assert leaf is not None assert leaf.raw_present is False - assert leaf.raw_value is MISSING + assert leaf.raw_value == Unset() assert leaf.resolved_value == "DAPI" assert leaf.saved_resolved_value == "DAPI" assert leaf.dirty is False @@ -200,23 +188,17 @@ class RootConfig: state = ObjectState(RootConfig(), scope_id="subfield") ObjectStateRegistry.register(state, _skip_snapshot=True) - state.update_parameter( - "parent_config.clauses", - (Clause("file", "contains", "DAPI"),), - ) - assert state.get_resolved_value("child_config.clauses") == ( + DottedFieldPath("parent_config.clauses").bind(state).write((Clause("file", "contains", "DAPI"),)) + assert DottedFieldPath("child_config.clauses").bind(state).value == ( Clause("file", "contains", "DAPI"), ) changed_notifications: list[set[str]] = [] - state.on_resolved_changed( + state._listen_resolved( lambda changed_paths: changed_notifications.append(set(changed_paths)) ) - state.update_parameter( - "parent_config.clauses", - (Clause("file", "contains", "GFP"),), - ) - assert state.get_resolved_value("child_config.clauses") == ( + DottedFieldPath("parent_config.clauses").bind(state).write((Clause("file", "contains", "GFP"),)) + assert DottedFieldPath("child_config.clauses").bind(state).value == ( Clause("file", "contains", "GFP"), ) @@ -248,12 +230,9 @@ class RootConfig: state = ObjectState(RootConfig(), scope_id="subfield") ObjectStateRegistry.register(state, _skip_snapshot=True) - state.update_parameter( - "parent_config.clauses", - (Clause("file", "contains", "DAPI"),), - ) + DottedFieldPath("parent_config.clauses").bind(state).write((Clause("file", "contains", "DAPI"),)) - assert state.get_resolved_value("child_config.clauses") == ( + assert DottedFieldPath("child_config.clauses").bind(state).value == ( Clause("file", "contains", "DAPI"), ) concrete_parent_change = state.last_changed_field @@ -262,7 +241,7 @@ class RootConfig: assert not concrete_parent_change.startswith("child_config.") changed_notifications: list[set[str]] = [] - state.on_resolved_changed( + state._listen_resolved( lambda changed_paths: changed_notifications.append(set(changed_paths)) ) From 9c7f05a88108f23951a4953a43f43c738f221db0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 16 Oct 2026 23:31:48 -0400 Subject: [PATCH 15/15] Exercise lazy construction through its declared owner --- tests/test_lazy_factory.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/tests/test_lazy_factory.py b/tests/test_lazy_factory.py index 6e6508f..c031e46 100644 --- a/tests/test_lazy_factory.py +++ b/tests/test_lazy_factory.py @@ -12,7 +12,8 @@ ) from python_introspect import resolve_declared_type -from objectstate import LazyDataclassFactory, patch_lazy_constructors, register_lazy_type_mapping +from objectstate import LazyDataclassFactory, Inherit, register_lazy_type_mapping +from objectstate.declarations import ValueDeclaration def test_make_lazy_simple(): @@ -342,13 +343,12 @@ class BaseConfig: LazyDataclassFactory.make_lazy_simple(NestedConfig) LazyConfig = LazyDataclassFactory.make_lazy_simple(BaseConfig) - with patch_lazy_constructors(): - candidate = LazyConfig() + candidate = ValueDeclaration.of(LazyConfig).construct({}) - assert object.__getattribute__(candidate, "inherited") is None + assert isinstance(object.__getattribute__(candidate, "inherited"), Inherit) nested = object.__getattribute__(candidate, "nested") assert resolve_declared_type(type(nested)) is NestedConfig - assert object.__getattribute__(nested, "value") is None + assert isinstance(object.__getattribute__(nested, "value"), Inherit) def test_patch_lazy_constructors_does_not_hide_default_factory_failure(): @@ -359,9 +359,8 @@ def fail_default(): class LazyConfig: value: str = field(default_factory=fail_default) - with patch_lazy_constructors(types=[LazyConfig]): - with pytest.raises(RuntimeError, match="default factory failed"): - LazyConfig() + with pytest.raises(RuntimeError, match="default factory failed"): + ValueDeclaration.of(LazyConfig).construct({}) def test_patch_lazy_constructors_preserves_nominal_validation_and_authored_fields(): @@ -372,13 +371,13 @@ class BaseConfig(AnnotatedDataclassValidationMixin): lazy_config_type = LazyDataclassFactory.make_lazy_simple(BaseConfig) - with patch_lazy_constructors(types=[lazy_config_type]): - candidate = lazy_config_type(count=3) - with pytest.raises(TypeError, match="LazyBaseConfig.count"): - lazy_config_type(count="invalid") + declaration = ValueDeclaration.of(lazy_config_type) + candidate = declaration.construct({'count': 3}) + with pytest.raises(TypeError, match="LazyBaseConfig.count"): + declaration.construct({'count': 'invalid'}) assert object.__getattribute__(candidate, "count") == 3 - assert object.__getattribute__(candidate, "label") is None + assert isinstance(object.__getattribute__(candidate, "label"), Inherit) assert candidate.__dict__["_explicitly_set_fields"] == {"count"}