@@ -1564,6 +1564,62 @@ def context_obj(self) -> Optional[Any]:
15641564 """Derive context_obj from parent_state (no separate attribute needed)."""
15651565 return self ._parent_state .object_instance if self ._parent_state else None
15661566
1567+ def _check_and_sync_delegate (self ) -> bool :
1568+ """Check if delegate attribute has changed and sync extraction target if needed.
1569+
1570+ This implements auto-detection of delegate changes (Option 3 from architectural discussion).
1571+ When object_instance's delegate attribute is replaced with a new instance (e.g., after
1572+ rebuild_lazy_config_with_new_global_reference()), this method detects the change and
1573+ automatically re-extracts parameters from the new delegate.
1574+
1575+ Returns:
1576+ True if delegate was detected as changed and re-extraction occurred, False otherwise.
1577+ """
1578+ if self ._delegate_attr is None :
1579+ # Not using delegation - nothing to check
1580+ return False
1581+
1582+ try :
1583+ current_delegate = getattr (self .object_instance , self ._delegate_attr )
1584+ except AttributeError :
1585+ # Delegate attribute no longer exists - this is unexpected but handle gracefully
1586+ logger .warning (
1587+ f"Delegate attribute '{ self ._delegate_attr } ' no longer exists on "
1588+ f"{ type (self .object_instance ).__name__ } . Keeping current extraction target."
1589+ )
1590+ return False
1591+
1592+ # Use identity check (is) not equality (==) to detect if it's a new instance
1593+ if current_delegate is self ._extraction_target :
1594+ # Delegate hasn't changed - no sync needed
1595+ return False
1596+
1597+ # Delegate has changed to a new instance - sync extraction target and re-extract
1598+ logger .debug (
1599+ f"Auto-detected delegate change for ObjectState(scope={ self .scope_id !r} ): "
1600+ f"'{ self ._delegate_attr } ' attribute was replaced with new instance. Re-extracting parameters."
1601+ )
1602+
1603+ self ._extraction_target = current_delegate
1604+
1605+ # Re-extract parameters from new delegate (same logic as refresh_state())
1606+ self .parameters .clear ()
1607+ self ._path_to_type .clear ()
1608+ self ._extract_all_parameters_flat (
1609+ current_delegate ,
1610+ prefix = '' ,
1611+ exclude_params = self ._exclude_param_names
1612+ )
1613+
1614+ # Update saved parameters to match
1615+ import copy
1616+ self ._saved_parameters = copy .deepcopy (self .parameters )
1617+
1618+ # Invalidate caches since parameters changed
1619+ self .invalidate_cache ()
1620+
1621+ return True
1622+
15671623 @property
15681624 def saved_object (self ) -> Any :
15691625 """Get the saved baseline object with the correct type.
@@ -1574,6 +1630,8 @@ def saved_object(self) -> Any:
15741630 This is the object that should be used for context resolution when
15751631 use_saved=True. It represents the "saved" state of the editable object.
15761632 """
1633+ # Auto-detect delegate changes before returning extraction target
1634+ self ._check_and_sync_delegate ()
15771635 return self ._extraction_target
15781636
15791637 @property
@@ -1809,6 +1867,9 @@ def update_parameter(self, param_name: str, value: Any) -> None:
18091867 param_name: Name of parameter to update
18101868 value: New value
18111869 """
1870+ # Auto-detect delegate changes before parameter access
1871+ self ._check_and_sync_delegate ()
1872+
18121873 if param_name not in self .parameters :
18131874 logger .warning (
18141875 f"⚠️ update_parameter({ param_name !r} ) called on ObjectState(scope={ self .scope_id !r} ) "
@@ -1925,6 +1986,8 @@ def get_resolved_value(self, param_name: str) -> Any:
19251986 Returns:
19261987 Resolved value from _live_resolved snapshot
19271988 """
1989+ # Auto-detect delegate changes before resolving values
1990+ self ._check_and_sync_delegate ()
19281991 self ._ensure_live_resolved ()
19291992 assert self ._live_resolved is not None # Guaranteed by _ensure_live_resolved
19301993 result = self ._live_resolved .get (param_name )
@@ -2047,6 +2110,53 @@ def invalidate_field(self, field_name: str) -> None:
20472110 if field_name in self .parameters :
20482111 self ._invalid_fields .add (field_name )
20492112
2113+ def update_object_instance (self , new_instance : Any ) -> None :
2114+ """Replace object_instance with a new instance and re-extract parameters.
2115+
2116+ This is used when the object being edited is replaced externally (e.g., from
2117+ code mode execution). The ObjectState is updated to point to the new instance
2118+ and parameters are re-extracted to match the new object's state.
2119+
2120+ For delegation cases, this updates _extraction_target. For non-delegation cases,
2121+ it updates object_instance directly.
2122+
2123+ Args:
2124+ new_instance: The new object instance to extract parameters from
2125+ """
2126+ if self ._delegate_attr is not None :
2127+ # Delegation case: verify the new_instance matches the delegate type
2128+ if type (new_instance ) != type (self ._extraction_target ):
2129+ logger .warning (
2130+ f"Type mismatch in update_object_instance for delegated ObjectState: "
2131+ f"expected { type (self ._extraction_target ).__name__ } , got { type (new_instance ).__name__ } "
2132+ )
2133+ self ._extraction_target = new_instance
2134+ # Don't update object_instance for delegation - it's the parent object
2135+ else :
2136+ # Non-delegation case: update object_instance directly
2137+ self .object_instance = new_instance
2138+ self ._extraction_target = new_instance
2139+
2140+ # Re-extract parameters from new instance
2141+ self .parameters .clear ()
2142+ self ._path_to_type .clear ()
2143+ self ._extract_all_parameters_flat (
2144+ new_instance ,
2145+ prefix = '' ,
2146+ exclude_params = self ._exclude_param_names
2147+ )
2148+
2149+ # Update saved parameters to match
2150+ import copy
2151+ self ._saved_parameters = copy .deepcopy (self .parameters )
2152+
2153+ # Invalidate caches
2154+ self .invalidate_cache ()
2155+
2156+ logger .debug (
2157+ f"Updated ObjectState(scope={ self .scope_id !r} ) to new instance of type { type (new_instance ).__name__ } "
2158+ )
2159+
20502160 def _recompute_invalid_fields (self ) -> Set [str ]:
20512161 """Recompute only the invalid fields, not the entire snapshot.
20522162
@@ -2178,6 +2288,8 @@ def get_current_values(self) -> Dict[str, Any]:
21782288 For ObjectState, this reads directly from self.parameters.
21792289 PFM overrides this to also read from widgets.
21802290 """
2291+ # Auto-detect delegate changes before accessing parameters
2292+ self ._check_and_sync_delegate ()
21812293 return dict (self .parameters )
21822294
21832295 # ==================== MATERIALIZED DIFFS ====================
@@ -2672,6 +2784,9 @@ def to_object(self, *, update_delegate: bool = False) -> Any:
26722784 The reconstructed object that matches the stored parameters.
26732785 For delegation, this is the delegate type (config), not the lifecycle object.
26742786 """
2787+ # Auto-detect delegate changes before reconstruction
2788+ self ._check_and_sync_delegate ()
2789+
26752790 if self ._cached_object is not None :
26762791 if not update_delegate :
26772792 return self ._cached_object
@@ -2758,6 +2873,13 @@ def to_object(self, *, update_delegate: bool = False) -> Any:
27582873 # Return the reconstructed delegate - this is what the parameters represent
27592874 self ._cached_object = reconstructed
27602875 else :
2876+ # NON-DELEGATION: Update object_instance to point to reconstructed object
2877+ # This ensures that when to_object() is called (e.g., on window save),
2878+ # the ObjectState automatically points to the new instance
2879+ if update_delegate :
2880+ self .object_instance = reconstructed
2881+ self ._extraction_target = reconstructed
2882+ logger .debug (f"Auto-updated object_instance to new reconstructed object for scope={ self .scope_id !r} " )
27612883 self ._cached_object = reconstructed
27622884 self ._cached_object_applied = True
27632885
@@ -2836,3 +2958,44 @@ def _reconstruct_from_prefix(self, prefix: str) -> Any:
28362958 logger .debug (f"🔍 _reconstruct_from_prefix: Reconstructed { prefix } with well_filter={ raw_well_filter } " )
28372959
28382960 return result
2961+
2962+ def _get_changed_params_with_types (
2963+ self , old_target : Any , new_target : Any
2964+ ) -> List [Tuple [str , type , str ]]:
2965+ """
2966+ Compare old and new extraction targets to find changed parameters.
2967+
2968+ Returns a list of tuples: (param_name, container_type, leaf_field_name)
2969+ """
2970+ changed_params = []
2971+
2972+ # Get all parameter names from the new extraction target
2973+ for param_name in self .parameters .keys ():
2974+ old_value = self ._get_param_value_from_target (old_target , param_name )
2975+ new_value = self ._get_param_value_from_target (new_target , param_name )
2976+
2977+ if old_value != new_value :
2978+ container_type = self ._path_to_type .get (param_name , type (self .object_instance ))
2979+ leaf_field_name = param_name .split ('.' )[- 1 ] if '.' in param_name else param_name
2980+ changed_params .append ((param_name , container_type , leaf_field_name ))
2981+
2982+ return changed_params
2983+
2984+ def _get_param_value_from_target (self , target : Any , param_name : str ) -> Any :
2985+ """
2986+ Get a parameter value from an extraction target by dotted path.
2987+
2988+ Handles nested dataclass attributes.
2989+ """
2990+ if target is None :
2991+ return None
2992+
2993+ parts = param_name .split ('.' )
2994+ current = target
2995+
2996+ for part in parts :
2997+ if not hasattr (current , part ):
2998+ return None
2999+ current = getattr (current , part )
3000+
3001+ return current
0 commit comments