Skip to content

Commit 74da47d

Browse files
trissimclaude
andcommitted
feat: Auto-detect delegate changes and provide clean API for object replacement
Implements option 3 (auto-detection) from architectural discussion to eliminate manual ObjectState synchronization when delegate attributes change. Key changes: 1. _check_and_sync_delegate(): Auto-detects when delegate attribute is replaced with new instance and automatically re-extracts parameters. Called before all parameter access points (update_parameter, get_resolved_value, etc.) 2. to_object() auto-update: For non-delegation cases, automatically updates object_instance to point to reconstructed object when update_delegate=True. This makes config window saves fully automatic with zero manual code. 3. update_object_instance() API: Clean public method for replacing object_instance when code execution creates brand new config instances. Replaces ~30 lines of internal manipulation with single API call. Architecture: - Delegation auto-detection: orchestrator.pipeline_config = new_config → next access auto-syncs ✓ - Window save auto-update: to_object(update_delegate=True) → auto-updates object_instance ✓ - Code mode clean API: update_object_instance(new_config) → single call ✓ This implements true separation of concerns where ObjectState is responsible for maintaining sync with its delegate/object without external intervention. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 13b0fcf commit 74da47d

1 file changed

Lines changed: 163 additions & 0 deletions

File tree

src/objectstate/object_state.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)