From b5b06abf212a61c6d91e83a38d07c3bd45e2f651 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 12:17:13 -0400 Subject: [PATCH 1/2] Resolved values follow every object they were resolved from A resolved snapshot now keeps the objects it was computed from (ResolutionSources): the global config instance of the resolving thread and the object of each state registered above. Every resolved read compares them by identity with the objects the state would be given now. When one differs, the two objects are compared field by field the way field resolution reads a layer, and only the unset fields that can inherit a differing (config type, field) are resolved again. The answer for a pair of objects is kept, so it is computed once per edit, not once per descendant. This replaces the comparison of registered ancestor states from 3.0.2 and covers two inputs it did not (issue #53, cases 1 and 2): a state that is not registered follows edits, saves and restores of its ancestors, and replacing the global config through the setters reaches existing states. The saved snapshot is resolved in full when its sources change (on save, restore, replacement), as the registry already did. The registry's field invalidation no longer resolves the saved snapshot a second time. Case 3 of #53 (a state resolved inside an entered config_context keeps that context's values) is unchanged and stays an expected failure. Co-Authored-By: Claude Opus 5.5 --- docs/state_propagation.rst | 39 +++ src/objectstate/object_state.py | 241 ++++++++----- src/objectstate/object_state_registry.py | 48 +-- src/objectstate/resolution_sources.py | 176 ++++++++++ src/objectstate/scope_key.py | 16 +- src/objectstate/transaction_checkpoint.py | 3 + ...lved_values_follow_registered_ancestors.py | 321 +++++++++++++++++- 7 files changed, 695 insertions(+), 149 deletions(-) create mode 100644 src/objectstate/resolution_sources.py diff --git a/docs/state_propagation.rst b/docs/state_propagation.rst index 629cd69..72c2675 100644 --- a/docs/state_propagation.rst +++ b/docs/state_propagation.rst @@ -94,3 +94,42 @@ Use parent forwarding when all of the following are true: For a nested dataclass field in the same state, call ``update_parameter()`` on the dotted path instead. + +What a resolved value is computed from +-------------------------------------- + +A resolved value of a state is computed from three things: which states are +registered at the scopes above it, what each of them stores for the field's config +type and field name, and what the global config stores. Each resolved snapshot (live +and saved) keeps the objects it was computed from (``ResolutionSources``): the global +config instance of the thread that resolved it and the object of every state +registered above. Every resolved read (``get_resolved_value``, +``get_saved_resolved_value``, ``get_provenance``, ``to_resolved_object``, +``state.fields``) first compares those objects with the ones the state would be given +now. When they are the same objects the read answers from the snapshot. When one is +another object, the two are compared field by field, the way field resolution reads a +context layer, and only the fields of the state that are unset and can inherit a +differing ``(config type, field)`` are resolved again. When other scopes have a +registered state than before, every field is. + +So no caller announces a change. Registering, unregistering or replacing an ancestor, +editing, saving, restoring, undoing or redoing an ancestor's value, and replacing the +global config with ``set_live_global_config`` / ``set_saved_global_config`` all reach +a state on its next read, whether the state is registered or not. A registered state +is also told at once, so its subscribers hear of the change without a read; a state +that is not registered is referenced by nothing and tells its subscribers on its next +read. + +Limits: + +- Objects are compared by identity. A source that is changed in place is not seen. + ``ObjectState.to_object`` returns the same function object for a state that wraps a + plain function, so states registered below a function-backed state follow its edits + only through the registry (that is, when they are registered themselves). +- The global config is stored per thread. A snapshot follows the global config of the + thread that resolved it; a read from another thread answers from the snapshot and + does not resolve it again for that thread's global config. +- A state built or resolved inside a ``config_context`` the caller entered takes that + context's layers into its snapshot and keeps them after the block + (`issue #53 `_). + diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 7a64505..b39110e 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -12,6 +12,7 @@ import copy import logging +import threading from collections.abc import Callable, Sequence from dataclasses import fields as dataclass_fields from dataclasses import is_dataclass @@ -33,6 +34,7 @@ from objectstate.scope_key import ScopeKey from objectstate.parameter_owner import ParameterOwner from objectstate.parameter_structure import ParameterStructure +from objectstate.resolution_sources import ResolutionSources from objectstate.global_config import GlobalContextValues from objectstate.lazy_factory import is_global_config_type from objectstate.subfield_semantics import ( @@ -226,10 +228,10 @@ def __init__( # Maps dotted_path → (source_scope_id, source_type) for inherited fields # source_type may differ from local container_type due to MRO inheritance self._live_provenance: dict[str, tuple[str | None, type | None]] = {} - # The registered ancestors each resolved snapshot was computed from, keyed by - # ``use_saved``. A snapshot is current only while ``_registered_ancestors()`` - # still returns the same states. - self._ancestors_resolved_from: dict[bool, tuple[ObjectState, ...]] = {} + # What each resolved snapshot was computed from, keyed by ``use_saved``. A + # snapshot is current only while ``_resolution_sources()`` still returns the + # same objects; a snapshot without an entry is resolved again in full. + self._resolved_from: dict[bool, ResolutionSources] = {} # === Saved baseline (2 attributes) === self._saved_resolved: dict[str, Any] = {} @@ -268,9 +270,8 @@ def __init__( # === Time-Travel === # Instance-level time-travel removed - use ObjectStateRegistry class-level DAG instead - # Initialize baselines (suppress flash during init) - self._ensure_live_resolved(notify_flash=False) - assert self._live_resolved is not None # Guaranteed by _ensure_live_resolved + # Initialize baselines (nothing to tell anyone during init) + self._live_resolved = self._compute_resolved_snapshot() # CRITICAL: Initialize _saved_parameters BEFORE _compute_resolved_snapshot(use_saved=True) # because that method reads from _saved_parameters to get raw values. @@ -723,91 +724,153 @@ def on_child_changed(changed_paths): self._forwarding_to_parent = False def _ensure_live_resolved(self, notify_flash: bool = True) -> set[str]: - """Ensure _live_resolved cache is populated. - - PERFORMANCE: Field-level invalidation only. - - First access: full compute to populate cache - - After update_parameter(): only recompute invalid fields - - Cross-window access: return cached values directly (no work) + """Bring both resolved snapshots up to date; return the changed live paths. + + Two things make a field out of date: it was invalidated on this state + (``_invalid_fields``), or the snapshot was computed from other sources than + the state has now (``_fields_with_other_sources``): an ancestor registered, + unregistered or replaced, an ancestor's value edited, saved or restored, the + global config replaced. Both are found here, on every resolved read, so no + caller has to announce a change and a state that is not registered follows + its ancestors like a registered one. Only the fields that can be affected are + resolved again; when nothing changed the cost is the comparison of the + sources. Args: - notify_flash: If True, fire on_resolved_changed callbacks for flash animations. - Set to False during initialization to suppress flash. - - Returns: - Set of paths that changed (for flash). Empty if no changes. - - NOTE: This method handles CACHE + FLASH only. Caller handles _sync_materialized_state(). + notify_flash: True (reads): when anything was resolved again, dirty and + signature fields are updated and subscribers are told here. False: + the caller does that with the returned paths + (``_sync_materialized_state``). """ - # First access - populate cache if self._live_resolved is None: + # First access - populate cache; there is no earlier value to compare with. self._live_resolved = self._compute_resolved_snapshot() self._invalid_fields.clear() - return set() # First populate - no "changes" to flash - - if not self._resolved_from_registered_ancestors(): - # Registration changed above this state: every inherited field may have - # another source, so both snapshots are resolved again in full. Pending - # field invalidations are covered by the full pass. - return self._resolve_again_from_registered_ancestors(notify=notify_flash) - - # Partial recompute for invalid fields only - if self._invalid_fields: - logger.debug(f"🔄 _ensure_live_resolved: scope={self.scope_id}, recomputing {len(self._invalid_fields)} invalid fields: {list(self._invalid_fields)}") - changed_paths = self._recompute_invalid_fields() + return set() + + sources, saved_sources = self._resolution_sources() + saved_resolved_again = self._resolve_saved_again_if_sources_changed(saved_sources) + if saved_resolved_again: + sources, _ = self._resolution_sources() + + stale = self._fields_with_other_sources(sources, use_saved=False) + if stale is None: + # Other states are registered above this one: every inherited field may + # have another source. Pending field invalidations are covered. + previous = self._live_resolved + self._live_resolved = self._compute_resolved_snapshot() self._invalid_fields.clear() + changed_values = { + path: (previous.get(path), value) + for path, value in self._live_resolved.items() + if previous.get(path) != value + } + if changed_values: + self._set_last_changed_values(changed_values) + changed_paths = self.notification_fields_for_value_changes(changed_values) + live_resolved_again = True else: - logger.debug(f"🔄 _ensure_live_resolved: scope={self.scope_id}, no invalid fields to recompute") - changed_paths = set() + self._invalid_fields.update(stale) + live_resolved_again = bool(self._invalid_fields) + if live_resolved_again: + changed_paths = self._recompute_invalid_fields(sources) + self._invalid_fields.clear() + else: + changed_paths = set() + self._resolved_from[False] = sources.recorded_after(self._resolved_from.get(False)) - if notify_flash and changed_paths: - self._notify_resolved_changed( - changed_paths, - context="_ensure_live_resolved", - ) + if notify_flash and (live_resolved_again or saved_resolved_again): + self._sync_materialized_state(changed_value_fields=changed_paths) return changed_paths - def _registered_ancestors(self) -> tuple[ObjectState, ...]: - """The other states this state's resolved values are computed from. + def _resolution_sources( + self, *, sync_delegates: bool = False + ) -> tuple[ResolutionSources, ResolutionSources]: + """What this state's fields are resolved from today, live then saved (so the + pair is indexed by ``use_saved``): the global config stored for the running + thread and the objects of the other states registered above. - This state is left out: registering it adds no value it does not hold. + This state's own object is left out (its own parameters are not an outside + source), and so are global config objects of ancestors, for which + ``build_context_stack`` enters the global config itself. """ - return tuple( - state - for _, state in ObjectStateRegistry.registered_ancestors(self.scope_id) - if state is not self - ) + from objectstate.config import get_base_config_type + from objectstate.global_config import get_live_global_config, get_saved_global_config - def _resolved_from_registered_ancestors(self) -> bool: - """Whether both resolved snapshots were computed from today's ancestors.""" - ancestors = self._registered_ancestors() - return all( - self._ancestors_resolved_from.get(use_saved) == ancestors - for use_saved in (False, True) + live: list[tuple[str, Any]] = [] + saved: list[tuple[str, Any]] = [] + for scope, state in ObjectStateRegistry.registered_ancestors(self.scope_id): + if state is self: + continue + saved_object = state.object_for_resolution(use_saved=True) + if is_global_config_type(type(saved_object)): + continue + saved.append((scope, saved_object)) + live.append( + (scope, state.object_for_resolution(sync_delegate=sync_delegates)) + ) + thread = threading.current_thread() + global_config_type = get_base_config_type() + return ( + ResolutionSources(thread, get_live_global_config(global_config_type), tuple(live)), + ResolutionSources(thread, get_saved_global_config(global_config_type), tuple(saved)), ) - def _resolve_again_from_registered_ancestors(self, *, notify: bool) -> set[str]: - """Resolve the live and saved snapshots in full; return the changed live paths.""" - previous = self._live_resolved or {} - self._live_resolved = self._compute_resolved_snapshot() - self._invalid_fields.clear() - self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) - changed_values = { - path: (previous.get(path), value) - for path, value in self._live_resolved.items() - if previous.get(path) != value + def object_for_resolution(self, use_saved: bool = False, sync_delegate: bool = False) -> Any: + """The object a state below this one enters as a context layer. + + Saved: the object as last saved, without delegate sync or reconstruction. + Live: rebuilt from the current parameters when they changed since the last + call; ``sync_delegate`` decides whether a replaced delegate is taken over + first, which must not happen while a snapshot is being recomputed. + """ + if use_saved: + return self._extraction_target + if self._cached_object is not None and not sync_delegate: + return self._cached_object + return self.to_object(update_delegate=False, sync_delegate=sync_delegate) + + def _fields_with_other_sources( + self, sources: ResolutionSources, *, use_saved: bool + ) -> set[str] | None: + """Fields of one snapshot that ``sources`` may resolve differently. + + These are the fields this state leaves unset whose config type inherits a + ``(config type, field)`` that an ancestor or the global config stores + differently than when the snapshot was computed. A field this state sets + itself has no outside source. ``None`` means every field. + """ + resolved_from = self._resolved_from.get(use_saved) + if sources.same_objects_as(resolved_from): + return set() + differing = sources.fields_that_differ(resolved_from) + if differing is None: + return None + own_values = self._saved_parameters if use_saved else self.parameters + return { + 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 changed_values: - self._set_last_changed_values(changed_values) - changed_paths = self.notification_fields_for_value_changes(changed_values) - self._sync_materialized_state( - changed_value_fields=changed_paths, emit_notifications=notify - ) - return changed_paths - def resolve_again_if_ancestors_changed(self) -> None: - """Resolve again when other states are registered above this one than before. + def _resolve_saved_again_if_sources_changed(self, sources: ResolutionSources) -> bool: + """Resolve the saved snapshot again when its sources changed; say whether it was. + + The saved sources change when an ancestor is saved, restored or replaced and + when the saved global config is replaced, not while values are edited, so the + snapshot is resolved in full. + """ + stale = self._fields_with_other_sources(sources, use_saved=True) + if stale is not None and not stale: + self._resolved_from[True] = sources.recorded_after(self._resolved_from.get(True)) + return False + self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) + return True + + def resolve_again_if_sources_changed(self) -> None: + """Resolve again now, and tell subscribers, if this state's sources changed. Every resolved read makes this check itself. ``ObjectStateRegistry`` calls it when registration changes so subscribers are told at once. @@ -816,9 +879,9 @@ def resolve_again_if_ancestors_changed(self) -> None: self._ensure_live_resolved() def _ensure_saved_resolved(self) -> None: - """Make ``_saved_resolved`` hold every field, resolved from today's ancestors.""" - if self._live_resolved is not None and not self._resolved_from_registered_ancestors(): - self._resolve_again_from_registered_ancestors(notify=True) + """Make ``_saved_resolved`` hold every field, resolved from today's sources.""" + if self._live_resolved is not None: + self._ensure_live_resolved() elif not self._saved_resolved: self._saved_resolved = self._compute_resolved_snapshot(use_saved=True) @@ -1046,7 +1109,7 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]: ) # Establish the pre-mutation resolved authority even for a state that # has not yet been displayed. Raw edits can preserve the resolved value. - self._ensure_live_resolved(notify_flash=False) + changed_before_the_edit = self._ensure_live_resolved(notify_flash=False) ObjectStateRegistry.ensure_baseline_snapshot() # Update state directly (no type conversion - that's VIEW responsibility) @@ -1114,7 +1177,7 @@ def update_parameter(self, param_name: str, value: Any) -> set[str]: # Materialize the origin once after its live context is authoritative, # then cascade only to descendants. - changed_paths = self._ensure_live_resolved(notify_flash=False) + changed_paths = self._ensure_live_resolved(notify_flash=False) | changed_before_the_edit for changed_param in changed_semantic_params: # SCOPE + TYPE + FIELD AWARE INVALIDATION: # Get the CONTAINER type for this field (e.g., PartitionFilterConfig for @@ -1790,8 +1853,8 @@ def update_object_instance(self, new_instance: Any) -> None: f"Updated ObjectState(scope={self.scope_id!r}) to new instance of type {type(new_instance).__name__}" ) - def _recompute_invalid_fields(self) -> set[str]: - """Recompute only the invalid fields, not the entire snapshot. + 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. @@ -1845,14 +1908,6 @@ def _recompute_invalid_fields(self) -> set[str]: from objectstate.dual_axis_resolver import resolve_with_provenance from objectstate.lazy_factory import has_lazy_resolution - # Get ancestor objects for context stack building - # CRITICAL: Skip delegate sync to avoid re-entrant invalidate_cache() calls - # that would destroy _live_resolved while we're computing it. - ancestor_objects_with_scopes = ObjectStateRegistry.get_ancestor_objects_with_scopes( - self.scope_id, - skip_delegate_sync=True - ) - # 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 @@ -1861,7 +1916,7 @@ def _recompute_invalid_fields(self) -> set[str]: with build_context_stack( object_instance=current_obj, - ancestor_objects_with_scopes=ancestor_objects_with_scopes, + ancestor_objects_with_scopes=sources.ancestors, current_scope_id=self.scope_id, ): # For each inherited field, resolve using dual-axis resolution with provenance @@ -2152,10 +2207,8 @@ def _compute_resolved_snapshot(self, use_saved: bool = False) -> dict[str, Any]: 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.") - self._ancestors_resolved_from[use_saved] = self._registered_ancestors() - ancestors = ObjectStateRegistry.get_ancestor_objects_with_scopes( - self.scope_id, use_saved=use_saved - ) + 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) ) @@ -2166,7 +2219,7 @@ def _compute_resolved_snapshot(self, use_saved: bool = False) -> dict[str, Any]: self._parameter_structure, current_obj, self.scope_id, - ancestors, + sources.ancestors, use_saved=use_saved, ) if not use_saved: diff --git a/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index f29ba68..1c77e73 100644 --- a/src/objectstate/object_state_registry.py +++ b/src/objectstate/object_state_registry.py @@ -447,16 +447,16 @@ def unregister(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None: def _resolve_again_below(cls, changed_scopes: Iterable[ScopeKey]) -> None: """Tell registered states at or below ``changed_scopes`` that registration changed. - Each state compares the ancestors registered now with the ones its resolved - values were computed from (``ObjectState.resolve_again_if_ancestors_changed``), - so a state whose ancestors are the same does no work. This call only makes - the comparison happen now, so subscribers hear of changed values without - waiting for the next read. + Each state compares what it is resolved from now with what its resolved values + were computed from (``ObjectState.resolve_again_if_sources_changed``), so a + state whose ancestors are the same does no work. This call only makes the + comparison happen now, so subscribers hear of changed values without waiting + for the next read. """ changed_scopes = tuple(changed_scopes) for state in tuple(cls._states.values()): if any(scope.contains(state.scope_id) for scope in changed_scopes): - state.resolve_again_if_ancestors_changed() + state.resolve_again_if_sources_changed() @classmethod def registered_ancestors( @@ -737,17 +737,10 @@ def get_ancestor_objects_with_scopes( Returns: List of (scope_id, object) tuples from ancestor scopes, ordered least→most specific. """ - # Saved: the object as last saved, without delegate sync or reconstruction. - # Live: rebuilt from the current parameters; ``skip_delegate_sync`` decides - # whether the delegate is synchronised first. return [ ( ancestor_key, - state._extraction_target - if use_saved - else state.to_object( - update_delegate=False, sync_delegate=not skip_delegate_sync - ), + state.object_for_resolution(use_saved, sync_delegate=not skip_delegate_sync), ) for ancestor_key, state in cls.registered_ancestors(scope_id) ] @@ -882,29 +875,14 @@ def _invalidate_field_in_matching_states( state.invalidate_field(dotted_path) invalidated_paths.add(dotted_path) - # If invalidating saved baseline, remove from saved_resolved so it recomputes - if invalidate_saved and dotted_path in state._saved_resolved: - del state._saved_resolved[dotted_path] - if _debug: - logger.debug(f"Invalidated saved_resolved cache for {dotted_path}") - - # Trigger recompute immediately to detect if resolved values actually changed. - # This ensures callbacks fire only when values change, not just when fields are invalidated. - # Prevents false flashes when Reset is clicked on already-reset fields. if invalidated_paths: - changed_paths = state._ensure_live_resolved(notify_flash=False) - - # If we invalidated saved baseline, recompute it now with fresh ancestor values if invalidate_saved: - # Recompute entire saved_resolved snapshot to pick up new ancestor saved values - old_saved_resolved = state._saved_resolved - state._saved_resolved = state._compute_resolved_snapshot(use_saved=True) - logger.debug(f"Recomputed saved_resolved baseline after invalidation") - - if old_saved_resolved != state._saved_resolved: - state._sync_materialized_state(changed_value_fields=changed_paths) - else: - state._sync_materialized_state(changed_value_fields=changed_paths) + # Without recorded sources the saved snapshot is resolved again in full. + state._resolved_from.pop(True, None) + # Resolve now, so callbacks fire only for values that really changed (no + # flash when Reset is clicked on a field that is already reset). + changed_paths = state._ensure_live_resolved(notify_flash=False) + state._sync_materialized_state(changed_value_fields=changed_paths) # ========== REGISTRY-LEVEL TIME-TRAVEL (DAG Model) ========== diff --git a/src/objectstate/resolution_sources.py b/src/objectstate/resolution_sources.py new file mode 100644 index 0000000..eac3446 --- /dev/null +++ b/src/objectstate/resolution_sources.py @@ -0,0 +1,176 @@ +"""The objects a state's resolved values are computed from. + +A resolved value of a state is a function of three things: which states are +registered above it, what each of them holds for the field's config type and +field, and what the global config holds. ``ResolutionSources`` is that input as +one value: the global config and the ancestors' objects, in the order the context +layers are entered. A resolved snapshot keeps the sources it was computed from; +it is current exactly while the state would be given the same objects today. + +When the objects are not the same, ``fields_that_differ`` names the +``(config type, field name)`` pairs whose stored value is another one, read the +way field resolution reads a layer (``extract_all_configs``). Only fields that +can inherit one of those pairs need to be resolved again. + +Objects are compared by identity. ``ObjectState.to_object`` builds a new object +whenever a parameter changed, and the global config setters store another +instance, so another identity is the signal that values may differ. Every state +below an edited ancestor asks about the same pair of objects, so the answer for +a pair is kept and computed once per edit. +""" + +import threading +from collections import OrderedDict +from dataclasses import fields, is_dataclass +from typing import Any, NamedTuple + +FieldKey = tuple[type, str] + +# How many (old object, new object) answers are kept. Each entry holds both +# objects, so their ids cannot be given to other objects while it is kept. +_KEPT_PAIRS = 64 +_differing_fields_of_pair: "OrderedDict[tuple[int, int], tuple[Any, Any, frozenset[FieldKey]]]" = ( + OrderedDict() +) + + +class ResolutionSources(NamedTuple): + """What one resolved snapshot of a state is computed from. + + ``global_config`` is the instance stored for the running thread, ``None`` when + none was set (the declared defaults are used then), and ``thread`` is that + thread: the global config is stored per thread, so two sources read in + different threads say nothing about each other's global config and are not + compared on it. A snapshot therefore follows a replaced global config in the + thread that resolved it, and a read from another thread never resolves it again + for that thread's global config. ``ancestors`` are the ``(scope id, object)`` + pairs of the states registered above the state, least specific first, without + global config objects: the global config stands for them. + """ + + thread: threading.Thread + global_config: Any | None + ancestors: tuple[tuple[str, Any], ...] + + def same_objects_as(self, other: "ResolutionSources | None") -> bool: + """Whether ``other`` holds the same objects at the same scopes.""" + if other is None: + return False + if self.thread is other.thread and self.global_config is not other.global_config: + return False + if len(self.ancestors) != len(other.ancestors): + return False + for mine, theirs in zip(self.ancestors, other.ancestors): # on every resolved read + if mine[1] is not theirs[1] or mine[0] != theirs[0]: + return False + return True + + def recorded_after(self, earlier: "ResolutionSources | None") -> "ResolutionSources": + """What a snapshot is recorded as computed from once it follows these sources. + + A snapshot brought up to date in another thread than the one that resolved it + was not compared on the global config, so it keeps the global config, and the + thread, it was resolved with. + """ + if earlier is None or earlier.thread is self.thread: + return self + return self._replace(thread=earlier.thread, global_config=earlier.global_config) + + def fields_that_differ(self, earlier: "ResolutionSources | None") -> frozenset[FieldKey] | None: + """The fields whose stored value differs between ``earlier`` and these sources. + + ``None`` means every field may differ: there is no ``earlier``, other scopes + have a registered state, or a global config was set where none was (or the + reverse). + """ + if earlier is None: + return None + same_thread = self.thread is earlier.thread + if same_thread and (earlier.global_config is None) != (self.global_config is None): + return None + if tuple(scope for scope, _ in earlier.ancestors) != tuple( + scope for scope, _ in self.ancestors + ): + return None + differing: frozenset[FieldKey] = frozenset() + if same_thread and self.global_config is not earlier.global_config: + differing |= _fields_that_differ(earlier.global_config, self.global_config) + for (_, before), (_, now) in zip(earlier.ancestors, self.ancestors): + if before is not now: + differing |= _fields_that_differ(before, now) + return differing + + +def _fields_that_differ(before: Any, now: Any) -> frozenset[FieldKey]: + """``(config type, field name)`` pairs stored differently in two layer objects.""" + pair = (id(before), id(now)) + kept = _differing_fields_of_pair.get(pair) + if kept is not None and kept[0] is before and kept[1] is now: + return kept[2] + differing = frozenset(_compare_layer_objects(before, now)) + _differing_fields_of_pair[pair] = (before, now, differing) + while len(_differing_fields_of_pair) > _KEPT_PAIRS: + _differing_fields_of_pair.popitem(last=False) + return differing + + +def _compare_layer_objects(before: Any, now: Any) -> set[FieldKey]: + from objectstate.context_manager import extract_all_configs + + 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()} + 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: + continue + names = { + f.name for config in (config_before, config_now) if config is not None + for f in fields(config) + } + if config_before is None or config_now is None: + # 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: + continue # a nested config on both sides: compared under its own type + if not _same_stored_value(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 None + try: + return object.__getattribute__(config, name) + except AttributeError: + return None + + +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/scope_key.py b/src/objectstate/scope_key.py index f3f8ffd..003c4ed 100644 --- a/src/objectstate/scope_key.py +++ b/src/objectstate/scope_key.py @@ -2,6 +2,7 @@ from __future__ import annotations +from functools import lru_cache from typing import ClassVar @@ -69,10 +70,7 @@ def child(self, segment: str) -> ScopeKey: def ancestors(self) -> tuple[ScopeKey, ...]: """The global scope, then each prefix of this key, ending with the key itself.""" - parts = self.parts - return (ScopeKey(),) + tuple( - ScopeKey(self.SEPARATOR.join(parts[: index + 1])) for index in range(len(parts)) - ) + return _ancestors(self) def contains(self, other: str | None) -> bool: """Whether ``other`` is this scope or below it.""" @@ -86,3 +84,13 @@ def strictly_contains(self, other: str | None) -> bool: """Whether ``other`` is below this scope and not the scope itself.""" return ScopeKey(other) != self and self.contains(other) + + +@lru_cache(maxsize=4096) +def _ancestors(scope: ScopeKey) -> tuple[ScopeKey, ...]: + """``ScopeKey.ancestors``, kept per key: every resolved read of a state asks for it.""" + + parts = scope.parts + return (ScopeKey(),) + tuple( + ScopeKey(scope.SEPARATOR.join(parts[: index + 1])) for index in range(len(parts)) + ) diff --git a/src/objectstate/transaction_checkpoint.py b/src/objectstate/transaction_checkpoint.py index b7e69d3..79864da 100644 --- a/src/objectstate/transaction_checkpoint.py +++ b/src/objectstate/transaction_checkpoint.py @@ -39,6 +39,7 @@ class ObjectStateTransactionCheckpoint: cached_object: Any cached_object_applied: bool invalid_fields: frozenset[str] + resolved_from: dict[bool, Any] raw_dirty: bool dirty_fields: frozenset[str] signature_diff_fields: frozenset[str] @@ -83,6 +84,7 @@ def capture( cached_object=state._cached_object, cached_object_applied=state._cached_object_applied, invalid_fields=frozenset(state._invalid_fields), + resolved_from=dict(state._resolved_from), raw_dirty=state._raw_dirty, dirty_fields=frozenset(state._dirty_fields), signature_diff_fields=frozenset(state._signature_diff_fields), @@ -134,6 +136,7 @@ def restore(self, state: ObjectState, *, emit_notifications: bool = True) -> Non state._cached_object = self.cached_object state._cached_object_applied = self.cached_object_applied state._invalid_fields = set(self.invalid_fields) + state._resolved_from = dict(self.resolved_from) state._raw_dirty = self.raw_dirty state._dirty_fields = set(self.dirty_fields) state._signature_diff_fields = set(self.signature_diff_fields) diff --git a/tests/test_resolved_values_follow_registered_ancestors.py b/tests/test_resolved_values_follow_registered_ancestors.py index 41e28b5..8559738 100644 --- a/tests/test_resolved_values_follow_registered_ancestors.py +++ b/tests/test_resolved_values_follow_registered_ancestors.py @@ -13,11 +13,17 @@ Mutations covered: an ancestor registered after the descendant was built (in every order of registering the two), unregistered (alone, with its descendants, by clearing the registry), registered again with another value, replaced at its scope, -registered with unsaved edits; an ancestor's value edited, saved and restored; the -descendant's ``scope_id`` moved under another ancestor; a context entered after -the build. The three cases at the end of the file are known to fail and say why. +registered with unsaved edits; an ancestor's value edited, saved, restored, undone +and redone, one and two levels up, with the descendant registered or not; the +global config replaced through its setters; the descendant's ``scope_id`` moved +under another ancestor; a context entered after the build. Only the fields a change +can reach are resolved again. The case at the end of the file is known to fail and +says why. """ +import gc +import threading +import weakref from dataclasses import dataclass, field from typing import NamedTuple @@ -29,10 +35,12 @@ ObjectStateRegistry, build_context_stack, config_context, + dual_axis_resolver, mark_global_config_type, set_base_config_type, set_global_config_for_editing, set_live_global_config, + set_saved_global_config, ) @@ -303,33 +311,314 @@ def test_context_entered_after_the_build_changes_no_read(step_and_plate): assert _reads(step) == expected -# --- Known to fail -------------------------------------------------------------- -# The three cases below are the same kind of fault (a resolved snapshot outliving -# something it was computed from) with another input than registration. Comparing -# registered ancestors does not cover them; issue #53 says why each is open. - -OPEN = "open, see https://github.com/OpenHCSDev/ObjectState/issues/53: " +# --- Sources other than registration -------------------------------------------- +# A resolved snapshot is computed from the global config and from the objects of +# the states registered above the state. The cases below change one of those +# without changing which states are registered. No registered state has to be told: +# the state finds the difference on its next read, registered or not, and resolves +# only the fields that the difference can reach. -@pytest.mark.xfail(strict=True, reason=OPEN + "only registered states are told of an ancestor edit") -def test_unregistered_descendant_follows_an_edit_of_its_ancestor(step_and_plate): +def test_unregistered_descendant_follows_edit_save_and_restore_of_its_ancestor(step_and_plate): step, plate = step_and_plate ObjectStateRegistry.unregister(step) assert _reads(step) == _inherited("plate", PLATE) plate.update_parameter(PATH, "edited") - assert _reads(step) == _inherited("edited", PLATE, saved="plate") + plate.mark_saved() + assert _reads(step) == _inherited("edited", PLATE) + + plate.update_parameter(PATH, "again") + assert _reads(step) == _inherited("again", PLATE, saved="edited") + + plate.restore_saved() + assert _reads(step) == _inherited("edited", PLATE) + + +def test_undo_and_redo_of_an_ancestor_edit(step_and_plate): + step, plate = step_and_plate + with ObjectStateRegistry.atomic("edit plate", scope_id=PLATE): + plate.update_parameter(PATH, "first") + with ObjectStateRegistry.atomic("edit plate", scope_id=PLATE): + plate.update_parameter(PATH, "second") + assert _reads(step) == _inherited("second", PLATE, saved="plate") + + assert ObjectStateRegistry.time_travel_back() + assert _reads(step) == _inherited("first", PLATE, saved="plate") -@pytest.mark.xfail(strict=True, reason=OPEN + "no state is told when the global config is replaced") -def test_global_config_replaced_after_the_build(): + assert ObjectStateRegistry.time_travel_forward() + assert _reads(step) == _inherited("second", PLATE, saved="plate") + + +def test_unregistered_descendant_is_told_on_its_next_read(step_and_plate): + step, plate = step_and_plate + ObjectStateRegistry.unregister(step) + resolved: list[set[str]] = [] + state: list[set[str]] = [] + step.on_resolved_changed(resolved.append) + step.on_state_changed(state.append) + + plate.update_parameter(PATH, "edited") + assert resolved == [] and state == [] # nothing holds the state to tell it + + assert step.get_resolved_value(PATH) == "edited" + assert resolved == [{PATH}] and state == [{PATH}] + assert step.dirty_fields == {PATH} + + assert step.get_resolved_value(PATH) == "edited" + assert resolved == [{PATH}] and state == [{PATH}] + + +def test_unregistered_descendant_is_not_kept_alive(): + plate = _plate_state("plate") + ObjectStateRegistry.register(plate) step = _step_state() - ObjectStateRegistry.register(step) + plate.update_parameter(PATH, "edited") + assert step.get_resolved_value(PATH) == "edited" + plate.update_parameter(PATH, "again") + gone = weakref.ref(step) - set_live_global_config(GlobalConfig, GlobalConfig(settings=Settings(value="replaced"))) + del step + gc.collect() + assert gone() is None + + +@pytest.fixture(params=["registered", "not registered"]) +def step_under_plate(request): + """A plate that sets ``value`` and ``other``, and a step below it that sets neither.""" + plate = ObjectState( + Holder(settings=LazySettings(value="plate", other="plate other")), scope_id=PLATE + ) + ObjectStateRegistry.register(plate) + step = _step_state() + if request.param == "registered": + ObjectStateRegistry.register(step) + return step, plate + + +@pytest.fixture +def resolved_fields(monkeypatch): + """The ``(config type, field)`` of every field resolution, in order.""" + calls: list[tuple[type, str]] = [] + resolve = dual_axis_resolver.resolve_with_provenance + + def counting(container_type, field_name): + calls.append((container_type, field_name)) + return resolve(container_type, field_name) + + monkeypatch.setattr(dual_axis_resolver, "resolve_with_provenance", counting) + return calls + + +def test_only_the_edited_field_is_resolved_again(step_under_plate, resolved_fields): + step, plate = step_under_plate + assert step.get_saved_resolved_value(PATH) == "plate" + resolved_fields.clear() + + plate.update_parameter("settings.other", "edited other") + assert step.get_resolved_value("settings.other") == "edited other" + assert step.get_resolved_value(PATH) == "plate" + assert step.get_saved_resolved_value("settings.other") == "plate other" + assert step.dirty_fields == {"settings.other"} + + assert [name for _, name in resolved_fields] == ["other"] + assert _reads(step) == _inherited("plate", PLATE)._replace(dirty={"settings.other"}) + + +def test_a_field_the_descendant_sets_itself_is_not_resolved_again(resolved_fields): + plate = _plate_state("plate") + ObjectStateRegistry.register(plate) + step = ObjectState(Holder(settings=LazySettings(value="own")), scope_id=STEP) + resolved_fields.clear() + + plate.update_parameter(PATH, "edited") + plate.mark_saved() + plate.update_parameter(PATH, "again") + + assert step.get_resolved_value(PATH) == "own" + assert step.get_saved_resolved_value(PATH) == "own" + assert step.get_provenance(PATH) is None + assert step.dirty_fields == set() + # ``mark_saved`` changes the saved sources and the saved snapshot is resolved in + # full, but a field with its own value is never asked of the context. + assert [call for call in resolved_fields if call[1] == "value"] == [] + + +def test_reading_an_unchanged_state_resolves_nothing(step_under_plate, resolved_fields): + step, _ = step_under_plate + live_snapshot, saved_snapshot = step._live_resolved, step._saved_resolved + resolved_fields.clear() + + assert _reads(step)._replace(uncached=None) == _inherited("plate", PLATE)._replace( + uncached=None + ) + + assert resolved_fields == [] + assert step._live_resolved is live_snapshot + assert step._saved_resolved is saved_snapshot + + +INNER = f"{STEP}::inner" + + +@pytest.mark.parametrize("registered", [(), (STEP,), (INNER,), (STEP, INNER)], ids=str) +def test_edit_two_levels_up(registered): + plate = _plate_state("plate") + ObjectStateRegistry.register(plate) + step, inner = _step_state(), _step_state_at(INNER) + for state in (step, inner): + if state.scope_id in registered: + ObjectStateRegistry.register(state) + assert _reads(inner) == _inherited("plate", PLATE) + + plate.update_parameter(PATH, "edited") + assert _reads(inner) == _inherited("edited", PLATE, saved="plate") + + # The state between them takes the value over, then gives it back. + step.update_parameter(PATH, "step") + expected_source = STEP if STEP in registered else PLATE + expected_value = "step" if STEP in registered else "edited" + assert _reads(inner) == _inherited(expected_value, expected_source, saved="plate") + + step.update_parameter(PATH, None) + plate.mark_saved() + assert _reads(inner) == _inherited("edited", PLATE) + + +def _replace_global(value: str, *, live: bool = True, saved: bool = False) -> None: + config = GlobalConfig(settings=Settings(value=value)) + if live: + set_live_global_config(GlobalConfig, config) + if saved: + set_saved_global_config(GlobalConfig, config) + + +@pytest.mark.parametrize("registered", [True, False], ids=["registered", "not registered"]) +def test_global_config_replaced_after_the_build(registered): + step = _step_state() + if registered: + ObjectStateRegistry.register(step) + changes: list[set[str]] = [] + step.on_resolved_changed(changes.append) + + _replace_global("replaced") assert _reads(step) == _inherited("replaced", GLOBAL, saved="global") + assert changes == [{PATH}] + + _replace_global("saved too", saved=True) + assert _reads(step) == _inherited("saved too", GLOBAL) + + _replace_global("only saved", live=False, saved=True) + assert _reads(step) == _inherited("saved too", GLOBAL, saved="only saved") + + set_global_config_for_editing(GlobalConfig, GlobalConfig(settings=Settings(value="both"))) + assert _reads(step) == _inherited("both", GLOBAL) + assert changes == [{PATH}, {PATH}, {PATH}] + + +def test_global_config_replaced_with_equal_values_resolves_nothing(resolved_fields): + step = _step_state() + live_snapshot = step._live_resolved + resolved_fields.clear() + + set_global_config_for_editing(GlobalConfig, GlobalConfig(settings=Settings(value="global"))) + + assert step.get_resolved_value(PATH) == "global" + assert step.get_saved_resolved_value(PATH) == "global" + assert resolved_fields == [] + assert step._live_resolved is live_snapshot + + +def test_global_config_replaced_then_an_ancestor_edited(step_under_plate, resolved_fields): + step, plate = step_under_plate + plate.update_parameter(PATH, None) # the plate inherits ``value`` too + assert _reads(step) == _inherited("global", GLOBAL, saved="plate") + plate.mark_saved() + assert _reads(step) == _inherited("global", GLOBAL) + resolved_fields.clear() + + _replace_global("replaced") + plate.update_parameter("settings.other", "edited other") + + assert step.get_resolved_value(PATH) == "replaced" + assert step.get_resolved_value("settings.other") == "edited other" + assert step.get_provenance(PATH) == (GLOBAL, Settings) + assert step.get_provenance("settings.other") == (PLATE, Settings) + assert step.dirty_fields == {PATH, "settings.other"} + + plate.update_parameter(PATH, "plate again") + assert _reads(step) == _inherited("plate again", PLATE, saved="global")._replace( + dirty={PATH, "settings.other"} + ) + + +def test_global_state_edit_reaches_states_two_levels_down(step_under_plate): + step, plate = step_under_plate + plate.update_parameter(PATH, None) + plate.mark_saved() + global_state = ObjectState(GlobalConfig(settings=Settings(value="global")), scope_id=GLOBAL) + ObjectStateRegistry.register(global_state) + + global_state.update_parameter(PATH, "typed") + assert _reads(step) == _inherited("typed", GLOBAL, saved="global") + + # Saving the global state does not store the saved global config; the + # application does, with the setter. + global_state.mark_saved() + set_saved_global_config(GlobalConfig, global_state.to_object()) + assert _reads(step) == _inherited("typed", GLOBAL) + + +def test_another_thread_does_not_make_a_state_resolve_again(step_under_plate, resolved_fields): + """The global config is stored per thread; a snapshot follows the thread it was resolved in. + + A thread started without one (``threading.Thread`` copies no thread-local value) + reads the values the state has, and a thread that sets its own does not change + them. The registry's stores are one per process and not synchronised, so the + threads here run one after the other. + """ + step, plate = step_under_plate + plate.update_parameter(PATH, None) + expected = (step.get_resolved_value(PATH), step.get_saved_resolved_value(PATH)) + assert expected == ("global", "plate") + changes: list[set[str]] = [] + step.on_resolved_changed(changes.append) + resolved_fields.clear() + seen: list[tuple] = [] + + def read_without_a_global_config(): + seen.append((step.get_resolved_value(PATH), step.get_saved_resolved_value(PATH))) + + def read_with_its_own_global_config(): + set_global_config_for_editing( + GlobalConfig, GlobalConfig(settings=Settings(value="other thread")) + ) + read_without_a_global_config() + + for target in (read_without_a_global_config, read_with_its_own_global_config): + thread = threading.Thread(target=target) + thread.start() + thread.join() + + assert seen == [expected, expected] + assert (step.get_resolved_value(PATH), step.get_saved_resolved_value(PATH)) == expected + assert resolved_fields == [] and changes == [] + + # An ancestor edit is followed from any thread: ancestors are one per process. + thread = threading.Thread(target=lambda: plate.update_parameter(PATH, "from a thread")) + thread.start() + thread.join() + assert step.get_resolved_value(PATH) == "from a thread" + + +# --- Known to fail -------------------------------------------------------------- +# Open in issue #53: an owner decision on whether a state's values may depend on a +# context the caller has entered. + +OPEN = "open, see https://github.com/OpenHCSDev/ObjectState/issues/53: " @pytest.mark.xfail(strict=True, reason=OPEN + "a state resolved inside a context keeps its values") From 9a85990428f2712df3c7f52565557993b11360fc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 12:17:13 -0400 Subject: [PATCH 2/2] 3.0.3 Co-Authored-By: Claude Opus 5.5 --- src/objectstate/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index af5c2be..41c14ab 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -210,7 +210,7 @@ 'ObjectStateEditSession', ] -__version__ = "3.0.2" +__version__ = "3.0.3" __author__ = 'OpenHCS Team' __description__ = 'Generic configuration framework for lazy dataclass resolution'