diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f2e696..6ab77db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 5.0.0 + +Field addresses own their structure (#61). `DottedFieldPath` gains `parent` and +`lineage`; `ParameterStructure` gains `path`, `leaf_paths` and `ancestors`. +Readers that split dotted strings for the parent or leaf name now use these. + +Removed: `ObjectState.signature_default`. Read the default from the admitted +declaration in `parameter_structure.declarations` instead. + ## 4.0.1 Requires `python-introspect>=0.3.0,<0.4` (was `<0.3`). ObjectState does not call diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index 5e7ef44..bf43984 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -206,7 +206,7 @@ 'ObjectStateEditSession', ] -__version__ = "4.0.1" +__version__ = "5.0.0" __author__ = 'OpenHCS Team' __description__ = 'Generic configuration framework for lazy dataclass resolution' diff --git a/src/objectstate/field_access.py b/src/objectstate/field_access.py index 44d2c2f..5946be3 100644 --- a/src/objectstate/field_access.py +++ b/src/objectstate/field_access.py @@ -29,6 +29,23 @@ def field_name(self) -> str: return self.parts[-1] if self.parts else "" + @property + def parent(self) -> "DottedFieldPath": + """The immediate container; a root field and the root both have an empty parent.""" + + return DottedFieldPath(self.value.rpartition(".")[0]) + + @property + def lineage(self) -> tuple["DottedFieldPath", ...]: + """This field followed by its parents, nearest first, excluding the empty root.""" + + paths = [] + path = self + while path.value: + paths.append(path) + path = path.parent + return tuple(paths) + def child(self, field_name: str) -> "DottedFieldPath": if self.value == "": return DottedFieldPath(field_name) @@ -59,12 +76,10 @@ def direct_child_name(self, candidate: str | "DottedFieldPath") -> str | None: """Return the direct dotted child field owned by this path, when one exists.""" candidate_value = candidate.value if isinstance(candidate, DottedFieldPath) else candidate - if candidate_value == self.value or not self.contains_path(candidate_value): - return None - remainder = candidate_value[len(self.value):] - if not remainder.startswith("."): + prefix = f"{self.value}." if self.value else "" + if not candidate_value.startswith(prefix): return None - child_remainder = remainder[1:] + child_remainder = candidate_value[len(prefix):] child_name = child_remainder.split(".", 1)[0].split("[", 1)[0] return child_name or None diff --git a/src/objectstate/inspection.py b/src/objectstate/inspection.py index cc5df51..7ae8e63 100644 --- a/src/objectstate/inspection.py +++ b/src/objectstate/inspection.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass +from objectstate.field_access import DottedFieldPath from objectstate.object_state import ObjectState from objectstate.object_state_registry import ObjectStateRegistry @@ -96,7 +97,7 @@ def of(cls, state: ObjectState, field_path: str): provenance = FieldProvenance( source_scope_id=source_scope_id, source_type=source_type, - field_name=field_path.rsplit(".", 1)[-1], + field_name=DottedFieldPath(field_path).field_name, ) return cls( state=state, @@ -141,11 +142,11 @@ def semantic_markers(self) -> tuple[str, ...]: @property def field_name(self) -> str: - return self.field_path.rsplit(".", 1)[-1] + return DottedFieldPath(self.field_path).field_name @property def container_path(self) -> str: - return self.field_path.rsplit(".", 1)[0] if "." in self.field_path else "" + return DottedFieldPath(self.field_path).parent.value @property def raw_value_type(self) -> str: diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 59eb28d..1a69c4c 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -183,8 +183,8 @@ def __init__( self._extraction_target = object_instance self._delegate_attr = None - # === Flat Storage (NEW - for flattened architecture) === - self._parameter_structure = ParameterStructure() + # Canonical declarations/topology, supplied by the extraction below. + self.parameter_structure: ParameterStructure self._cached_object: Any | None = None # Cached result of to_object() self._cached_object_applied: bool = False # True if cached delegate was applied to object_instance @@ -473,7 +473,7 @@ def to_resolved_object(self) -> Any: 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 + "", self._live_resolved, self._extraction_target, self.parameter_structure ) def to_saved_resolved_object(self) -> Any: @@ -481,7 +481,7 @@ def to_saved_resolved_object(self) -> Any: self._check_and_sync_delegate() self._ensure_saved_resolved() return self._reconstruct_from_resolved( - "", self._saved_resolved, self._extraction_target, self._parameter_structure + "", self._saved_resolved, self._extraction_target, self.parameter_structure ) @classmethod @@ -532,7 +532,7 @@ def parameter_descriptions(self) -> dict[str, str | None]: Dictionary mapping dotted parameter paths to their descriptions (value may be None). E.g., {'partition_filter_config.partition_filter': 'Filter partitions by...'} """ - return self._parameter_structure.descriptions + return self.parameter_structure.descriptions def type_for_path(self, field_path: str) -> ParameterOwner: """Return the ObjectState-recorded type for a dotted field path. @@ -541,9 +541,9 @@ def type_for_path(self, field_path: str) -> ParameterOwner: field. Nested dataclass paths return the nested dataclass type itself. """ if field_path == "": - return type(self.object_instance) + return type(self._extraction_target) try: - return self._path_to_type[field_path] + return self.parameter_structure.owner_paths[field_path] except KeyError as exc: raise KeyError( f"ObjectState path is not registered: {field_path!r}" @@ -560,7 +560,7 @@ def direct_parameter_paths( never cached in this projection. """ - return self._direct_parameter_paths.get(DottedFieldPath(field_path), ()) + return self.parameter_structure.direct_paths.get(DottedFieldPath(field_path), ()) def has_parameter_descendants(self, field_path: str) -> bool: """Return whether an authoritative flat parameter owns child paths.""" @@ -974,16 +974,12 @@ def _dataclass_ancestor_parameter_updates( ) -> dict[str, Any]: """Rebuild dataclass container parameters that own a changed child path.""" - if "." not in param_name: - return {} - updates: dict[str, Any] = {} - path_parts = param_name.split(".") - for part_count in range(len(path_parts) - 1, 0, -1): - prefix = ".".join(path_parts[:part_count]) + for path in DottedFieldPath(param_name).parent.lineage: + prefix = path.value if prefix not in parameters: continue - prefix_type = self._path_to_type.get(prefix) + prefix_type = self.parameter_structure.owner_paths.get(prefix) if not (isinstance(prefix_type, type) and is_dataclass(prefix_type)): continue @@ -1012,7 +1008,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): @@ -1122,8 +1118,8 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]: # SELF-INVALIDATION: Mark changed fields and same-state inherited siblings. local_invalidations = set(changed_semantic_params) for changed_param in changed_semantic_params: - container_type = self._path_to_type.get(changed_param, type(self.object_instance)) - leaf_field_name = changed_param.split('.')[-1] if '.' in changed_param else changed_param + container_type = self.parameter_structure.owner_paths.get(changed_param, type(self.object_instance)) + leaf_field_name = DottedFieldPath(changed_param).field_name local_invalidations.update( self.inheritance_field_paths(container_type, leaf_field_name) ) @@ -1141,10 +1137,10 @@ def update_parameter(self, param_name: str, 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 + leaf_field_name = DottedFieldPath(changed_param).field_name ObjectStateRegistry.invalidate_by_type_and_scope( @@ -1207,10 +1203,10 @@ def get_resolved_value(self, param_name: str) -> Any: if has_subfields: # This is a container field - reconstruct's dataclass from live resolved values - field_type = self._path_to_type.get(param_name) + field_type = self.parameter_structure.owner_paths.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 + param_name, self._live_resolved, self._extraction_target, self.parameter_structure ) result = self._live_resolved.get(param_name) @@ -1247,10 +1243,10 @@ def get_saved_resolved_value(self, param_name: str) -> Any: if has_subfields: # This is a container field - reconstruct's dataclass from saved resolved values - field_type = self._path_to_type.get(param_name) + field_type = self.parameter_structure.owner_paths.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 + param_name, self._saved_resolved, self._extraction_target, self.parameter_structure ) # Return the simple value (or None if not found) @@ -1276,7 +1272,7 @@ def subfield_semantics( raw_value = self.parameters.get(path.value, MISSING) 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) + declaration = self.parameter_structure.declarations.get(path.value) signature_default_value = ( declaration.default_value if declaration is not None else MISSING ) @@ -1432,7 +1428,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.owner_paths.items(): typ_base = get_base_type_for_lazy(typ) or typ if not isinstance(typ_base, type): continue @@ -1461,7 +1457,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 @@ -1471,7 +1467,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.owner_paths.items(): if "." in path: continue @@ -1484,7 +1480,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 @@ -1584,7 +1580,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( @@ -1615,40 +1611,6 @@ 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, ...]]: - return self._parameter_structure.direct_paths - - @_direct_parameter_paths.setter - def _direct_parameter_paths( - self, value: dict[DottedFieldPath, tuple[DottedFieldPath, ...]] - ) -> None: - self._parameter_structure.direct_paths = value - - @property - def _exclude_param_names(self) -> list[str]: - return self._parameter_structure.exclusions - - @_exclude_param_names.setter - def _exclude_param_names(self, value: list[str]) -> None: - self._parameter_structure.exclusions = value - - @property - def _excluded_params(self) -> dict[str, Any]: - return self._parameter_structure.excluded_values - - @_excluded_params.setter - def _excluded_params(self, value: dict[str, Any]) -> None: - self._parameter_structure.excluded_values = value - def construction_binding(self) -> StateConstructionBinding: """Capture construction from this state, not a parallel factory registry.""" @@ -1658,7 +1620,7 @@ def construction_binding(self) -> StateConstructionBinding: parent_scope=( self._parent_state.scope_id or "" if self._parent_state is not None else None ), - exclusions=tuple(self._exclude_param_names), + exclusions=tuple(self.parameter_structure.exclusions), parent_field=self._parent_field_name, ) if self._delegate_attr is not None: @@ -1680,7 +1642,7 @@ def apply_prepared_construction( binding.attach(self) self._parent_state = parent self._parent_field_name = binding.parent_field - self._parameter_structure = prepared._parameter_structure.clone() + self.parameter_structure = prepared.parameter_structure.clone() self._cached_object = None self._cached_object_applied = False self._invalid_fields.clear() @@ -1721,7 +1683,7 @@ def update_object_instance(self, new_instance: Any) -> None: # Re-extract parameters from new instance self._replace_parameter_structure( new_instance, - exclude_params=self._exclude_param_names + exclude_params=self.parameter_structure.exclusions ) # Update saved parameters to match @@ -1768,8 +1730,8 @@ 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)) - leaf_field_name = path.split(".")[-1] + container_type = self.parameter_structure.owner_paths.get(path, type(self.object_instance)) + leaf_field_name = DottedFieldPath(path).field_name ObjectStateRegistry.invalidate_by_type_and_scope( scope_id=self.scope_id, changed_type=container_type, @@ -1813,7 +1775,7 @@ def _recompute_invalid_fields(self, sources: ResolutionSources) -> set[str]: # 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): + if self.parameter_structure.is_container_parameter(name, raw_value, self.parameters): continue if raw_value is not None: explicit_fields.append(name) @@ -1854,7 +1816,7 @@ def _recompute_invalid_fields(self, sources: ResolutionSources) -> set[str]: ): # 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) + container_type = self.parameter_structure.owner_paths.get(dotted_path) if container_type is None: logger.debug(f"⚠️ _recompute: {dotted_path} has no container_type in _path_to_type") continue @@ -1870,8 +1832,7 @@ def _recompute_invalid_fields(self, sources: ResolutionSources) -> set[str]: changed_values[dotted_path] = (old_val, raw_val) self._live_resolved[dotted_path] = raw_val continue - parts = dotted_path.split('.') - field_name = parts[-1] + field_name = DottedFieldPath(dotted_path).field_name # 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) @@ -1920,18 +1881,10 @@ 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) + declaration = self.parameter_structure.declarations.get(param_name) default_value = declaration.default_value if declaration is not None else None 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]: """ Get current parameter values from state. @@ -1965,7 +1918,7 @@ def reconstruct_top_level_parameters(self) -> dict[str, Any]: } for param_name in top_level_params: - param_type = self._path_to_type.get(param_name) + param_type = self.parameter_structure.owner_paths.get(param_name) nested_dataclass = ( param_type is not None and is_dataclass(param_type) @@ -2048,8 +2001,8 @@ def _compute_signature_diff_fields(self) -> set[str]: """ result = set() for k, v in self.parameters.items(): - if k in self._parameter_structure.declarations: - sig_default = self._parameter_structure.declarations[k].default_value + if k in self.parameter_structure.declarations: + sig_default = self.parameter_structure.declarations[k].default_value is_diff = v != sig_default if is_diff: result.add(k) @@ -2150,7 +2103,7 @@ def _compute_resolved_snapshot(self, use_saved: bool = False) -> dict[str, Any]: snapshot, provenance = self._resolve_parameter_snapshot( self.parameters, parameters, - self._parameter_structure, + self.parameter_structure, current_obj, self.scope_id, sources.ancestors, @@ -2191,7 +2144,6 @@ def _resolve_parameter_snapshot( for dotted_path in parameters.keys(): raw_value = values.get(dotted_path) 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) @@ -2213,9 +2165,9 @@ def _resolve_parameter_snapshot( # 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] + # - Nested fields (processing_config.group_by) + # - Top-level fields on root (num_workers on PipelineConfig) + field_name = DottedFieldPath(dotted_path).field_name if raw_value is None: # Field needs resolution - use combined resolve + provenance walk @@ -2267,7 +2219,7 @@ def mark_saved(self) -> None: # 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 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 @@ -2289,7 +2241,7 @@ def mark_saved(self) -> None: 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): + if self.parameter_structure.is_container_parameter(param_name, raw_value, self.parameters): continue old_value = old_instance_values.get(param_name) @@ -2318,8 +2270,8 @@ 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)) - leaf_field_name = param_name.split('.')[-1] if '.' in param_name else param_name + container_type = self.parameter_structure.owner_paths.get(param_name, type(self.object_instance)) + leaf_field_name = DottedFieldPath(param_name).field_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}") ObjectStateRegistry.invalidate_by_type_and_scope( @@ -2427,8 +2379,8 @@ 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)) - leaf_field_name = param_name.split('.')[-1] if '.' in param_name else param_name + container_type = self.parameter_structure.owner_paths.get(param_name, type(self.object_instance)) + leaf_field_name = DottedFieldPath(param_name).field_name changed_params_with_types.append((param_name, container_type, leaf_field_name)) # Clear and re-extract from the saved baseline @@ -2445,7 +2397,7 @@ def _restore_saved_impl(self, *, propagate_descendants: bool = True) -> None: extraction_target = self._extraction_target self._replace_parameter_structure( extraction_target, - exclude_params=self._exclude_param_names, + exclude_params=self.parameter_structure.exclusions, ) # CRITICAL: Also restore _saved_parameters to match current parameters @@ -2619,11 +2571,11 @@ def _replace_parameter_structure( """Extract one canonical flat parameter structure and index it once.""" self.parameters.clear() - self._parameter_structure = ParameterStructure.for_target(obj, exclude_params) + self.parameter_structure = ParameterStructure.for_target(obj, exclude_params) self._extract_all_parameters_flat( obj, self.parameters, - self._parameter_structure, + self.parameter_structure, prefix="", exclude_params=exclude_params, ) @@ -2640,13 +2592,9 @@ def _index_parameter_paths(self) -> None: paths_by_owner: dict[DottedFieldPath, list[DottedFieldPath]] = {} for dotted_path in self.parameters: - owner_path, separator, _field_name = dotted_path.rpartition(".") - if not separator: - owner_path = "" - paths_by_owner.setdefault(DottedFieldPath(owner_path), []).append( - DottedFieldPath(dotted_path) - ) - self._direct_parameter_paths = { + path = DottedFieldPath(dotted_path) + paths_by_owner.setdefault(path.parent, []).append(path) + self.parameter_structure.direct_paths = { owner_path: tuple(parameter_paths) for owner_path, parameter_paths in paths_by_owner.items() } @@ -2703,13 +2651,13 @@ def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True # This includes both primitive fields AND nested dataclass fields field_updates = {} root_type = type(target) - for field_name in self._path_to_type: + for field_name in self.parameter_structure.owner_paths: 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) + field_type = self.parameter_structure.owner_paths.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) @@ -2810,7 +2758,7 @@ def _reconstruct_from_parameter_snapshot( obj_type = type(self._extraction_target) else: # Nested level - look up type from _path_to_type - obj_type = self._path_to_type.get(prefix) + obj_type = self.parameter_structure.owner_paths.get(prefix) if obj_type is None: raise ValueError(f"No type mapping for prefix: {prefix}") @@ -2852,7 +2800,7 @@ def _reconstruct_from_parameter_snapshot( # At root level, include constructor params excluded from editing. if not prefix: - direct_fields.update(self._excluded_params) + direct_fields.update(self.parameter_structure.excluded_values) # Instantiate the dataclass with ALL fields including None values @@ -2877,8 +2825,8 @@ 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)) - leaf_field_name = param_name.split('.')[-1] if '.' in param_name else param_name + container_type = self.parameter_structure.owner_paths.get(param_name, type(self.object_instance)) + leaf_field_name = DottedFieldPath(param_name).field_name changed_params.append((param_name, container_type, leaf_field_name)) return changed_params diff --git a/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index e23bf77..bf7dd33 100644 --- a/src/objectstate/object_state_registry.py +++ b/src/objectstate/object_state_registry.py @@ -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 ce26de8..c075eee 100644 --- a/src/objectstate/parameter_structure.py +++ b/src/objectstate/parameter_structure.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +from collections.abc import Iterator from dataclasses import dataclass, field, fields, is_dataclass, replace from typing import Any @@ -36,6 +37,29 @@ def descriptions(self) -> dict[str, str | None]: return {path: info.description for path, info in self.declarations.items()} + def path(self, name: str) -> DottedFieldPath: + """Resolve a name to its admitted path, without analyzing declarations.""" + + requested = DottedFieldPath(name) + for path in self.direct_paths.get(requested.parent, ()): + if path.value == name: + return path + raise KeyError(f"ObjectState path is not registered: {name!r}") + + def ancestors(self, path: DottedFieldPath) -> tuple[DottedFieldPath, ...]: + """The admitted fields down to this one, itself last.""" + + return tuple(self.path(step.value) for step in reversed(path.lineage)) + + def leaf_paths(self, parent: str = "") -> Iterator[DottedFieldPath]: + """Enumerate leaves through the extracted topology, never the source type.""" + + for path in self.direct_paths.get(DottedFieldPath(parent), ()): + if self.direct_paths.get(path): + yield from self.leaf_paths(path.value) + else: + yield path + def is_container_parameter( self, name: str, value: Any, parameters: dict[str, Any] ) -> bool: diff --git a/src/objectstate/transaction_checkpoint.py b/src/objectstate/transaction_checkpoint.py index 79864da..e25fcb6 100644 --- a/src/objectstate/transaction_checkpoint.py +++ b/src/objectstate/transaction_checkpoint.py @@ -73,7 +73,7 @@ def capture( object_instance=state.object_instance, construction=state.construction_binding(), parent_state=state._parent_state, - structure=state._parameter_structure.clone(), + structure=state.parameter_structure.clone(), preserve_value_identity=preserve_value_identity, parameters=copied["parameters"], saved_parameters=copied["saved_parameters"], @@ -122,7 +122,7 @@ def restore(self, state: ObjectState, *, emit_notifications: bool = True) -> Non self.construction.attach(state) state._parent_state = self.parent_state state._parent_field_name = self.construction.parent_field - state._parameter_structure = self.structure.clone() + state.parameter_structure = self.structure.clone() state.parameters = copied["parameters"] state._index_parameter_paths() state._saved_parameters = copied["saved_parameters"] diff --git a/tests/test_callable_baseline.py b/tests/test_callable_baseline.py index 8161e4e..3833972 100644 --- a/tests/test_callable_baseline.py +++ b/tests/test_callable_baseline.py @@ -93,7 +93,7 @@ def test_initial_values_flatten_nested_dataclass_overrides(): assert state.parameters["options.threshold"] == 7.0 assert state.parameters["options.bounds"] == options.bounds assert state.parameters["options.bounds.minimum"] == 2.0 - assert state.signature_default("options") == NestedOptions() + assert state.parameter_structure.declarations["options"].default_value == NestedOptions() assert state.reconstruct_top_level_parameters() == { "image": None, "options": options, diff --git a/tests/test_construction_history.py b/tests/test_construction_history.py index dd148a1..898bcd1 100644 --- a/tests/test_construction_history.py +++ b/tests/test_construction_history.py @@ -121,7 +121,7 @@ def test_fresh_load_constructs_absent_scopes_and_exact_parent_exclusions( restored_parent = ObjectStateRegistry.get_by_scope("z-parent") restored_child = ObjectStateRegistry.get_by_scope("a-child") assert restored_child._parent_state is restored_parent - assert restored_child._exclude_param_names == ["excluded"] + assert restored_child.parameter_structure.exclusions == ["excluded"] assert restored_child.to_object().excluded == child.to_object().excluded assert restored_child.parameters["value"] is None assert restored_child._saved_parameters["value"] is None @@ -148,8 +148,8 @@ def test_same_scope_callable_owner_and_schema_restore_without_replacing_state(): assert state.object_instance is function assert set(state.parameters) == parameters for name in parameters: - state.signature_default(name) - assert set(state._path_to_type) == parameters + state.parameter_structure.declarations[name].default_value + assert set(state.parameter_structure.owner_paths) == parameters def test_equal_signature_owner_change_records_and_notifies_without_fake_value_changes(): diff --git a/tests/test_field_access.py b/tests/test_field_access.py index f6f5d14..20431f6 100644 --- a/tests/test_field_access.py +++ b/tests/test_field_access.py @@ -35,6 +35,43 @@ class SlottedAbstractConfig(AbstractFieldConfig): value: int = 11 +@pytest.mark.parametrize( + ("value", "parent", "lineage", "field_name"), + [ + ("", "", (), ""), + ("name", "", ("name",), "name"), + ("child.value", "child", ("child.value", "child"), "value"), + ("outer.child.value", "outer.child", ("outer.child.value", "outer.child", "outer"), "value"), + ], +) +def test_dotted_path_owns_parent_lineage_and_leaf_name(value, parent, lineage, field_name) -> None: + path = DottedFieldPath(value) + + assert path.field_name == field_name + assert path.parent == DottedFieldPath(parent) + assert tuple(step.value for step in path.lineage) == lineage + assert path.parent.child(path.field_name) == path + + +@pytest.mark.parametrize( + ("owner", "candidate", "child"), + [ + ("", "", None), + ("", "value", "value"), + ("", "source_bindings.source_filters[0].match_type", "source_bindings"), + ("processing_config", "processing_config.variable_components[1]", "variable_components"), + ("processing_config", "processing_config", None), + ("processing_config", "processing_config[0].value", None), + ("processing_config", "unrelated.value", None), + ("processing_config", "processing_config_extra.value", None), + ], +) +def test_dotted_path_owns_direct_child_selection(owner, candidate, child) -> None: + path = DottedFieldPath(owner) + assert path.direct_child_name(candidate) == child + assert path.direct_child_name(DottedFieldPath(candidate)) == child + + def test_raw_value_reads_regular_dataclass_field() -> None: config = RegularConfig(child=NestedConfig()) diff --git a/tests/test_object_state_update_instance.py b/tests/test_object_state_update_instance.py index cb115b0..e893533 100644 --- a/tests/test_object_state_update_instance.py +++ b/tests/test_object_state_update_instance.py @@ -170,6 +170,17 @@ def test_direct_parameter_paths_project_canonical_flat_topology_without_values() assert state.direct_parameter_paths("nested.threshold") == () assert state.has_parameter_descendants("nested") is True assert state.has_parameter_descendants("nested.threshold") is False + structure = state.parameter_structure + nested = state.direct_parameter_paths()[1] + threshold = state.direct_parameter_paths("nested")[0] + assert structure.path("nested.threshold") is threshold + assert structure.ancestors(threshold) == (nested, threshold) + assert structure.ancestors(DottedFieldPath("")) == () + assert tuple(structure.leaf_paths()) == ( + DottedFieldPath("enabled"), threshold, DottedFieldPath("nested.label") + ) + with pytest.raises(KeyError, match="not registered"): + structure.path("nested.unknown") state.update_parameter("nested.threshold", 7) @@ -178,6 +189,7 @@ def test_direct_parameter_paths_project_canonical_flat_topology_without_values() DottedFieldPath("nested.label"), ) assert state.parameters["nested.threshold"] == 7 + assert state.parameters["nested"].threshold == 7 def test_resolved_snapshot_rejects_partial_parameter_state_without_recovery(): diff --git a/tests/test_resolved_values_follow_registered_ancestors.py b/tests/test_resolved_values_follow_registered_ancestors.py index 1f99eb7..0c353bb 100644 --- a/tests/test_resolved_values_follow_registered_ancestors.py +++ b/tests/test_resolved_values_follow_registered_ancestors.py @@ -52,6 +52,7 @@ ) from objectstate.context_manager import get_context_layer_stack +from objectstate.inspection import FieldInspection @dataclass @@ -175,6 +176,9 @@ def step_and_plate(request): def test_ancestor_registered_after_the_descendant_was_built_supplies_the_value(step_and_plate): step, _ = step_and_plate assert _reads(step) == _inherited("plate", PLATE) + inspection = FieldInspection.of(step, PATH) + assert inspection.field_name == "value" + assert inspection.provenance.field_name == "value" def test_both_orders_of_building_give_the_same_reads(step_and_plate):