Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/objectstate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@
'ObjectStateEditSession',
]

__version__ = "3.0.1"
__version__ = "3.0.2"
__author__ = 'OpenHCS Team'
__description__ = 'Generic configuration framework for lazy dataclass resolution'

Expand Down
2 changes: 1 addition & 1 deletion src/objectstate/docs/configuration_framework.rst
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ Usage Example
)
)

The lazy configs resolve through dual-axis algorithm: check ``AppConfig`` context first, then ``GlobalAppConfig`` context, walking MRO at each level.
The lazy configs resolve class first: ``DatabaseConfig`` is looked up in the ``AppConfig`` layer, then in the ``GlobalAppConfig`` layer, and only if neither holds a value is a base class of ``DatabaseConfig`` tried in those layers. A value on the more specific class in the global config therefore beats a value on a base class in the app config. :ref:`precedence-order` states the order with an example.

Framework Modules
----------------
Expand Down
35 changes: 31 additions & 4 deletions src/objectstate/docs/context_system.rst
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,37 @@ The dual-axis resolver reads the layer stack of the active context:

value, source_scope, source_type = resolve_with_provenance(LazyDatabaseConfig, "port")

For each type in the container type's MRO, most specific first, it asks every layer from
innermost to outermost for a config of that type and returns the first value that is not
``None``, together with the layer's scope id and the type it was read from. Attribute access
on a lazy config returns the ``value`` of this same result.
Attribute access on a lazy config returns the ``value`` of this same result.

.. _precedence-order:

Precedence Order
~~~~~~~~~~~~~~~~

Resolution is class first. For each class in the MRO of the type being read, most specific
first, the resolver asks every layer from the innermost scope to the outermost for a config
of that class and returns the first value that is not ``None``, together with the layer's
scope id and the class it was read from. Only when no layer holds a value for a class is
the next, less specific class tried.

So the class decides before the scope does. Take ``Leaf(Base)``, a field ``value`` read on
``Leaf`` in a step, and two layers that hold a value:

========================== ====================== =====================
Layer (scope) ``Base.value`` ``Leaf.value``
========================== ====================== =====================
plate (inner) ``"base at plate"`` ``None``
global (outer) ``None`` ``"leaf at global"``
========================== ====================== =====================

The read returns ``"leaf at global"`` with source ``("", Leaf)``: ``Leaf`` is tried in
every scope before ``Base`` is tried in any, although the plate is the nearer scope. A
value on the same class in a nearer scope does win: with ``Leaf.value`` also set on the
plate, the plate's value is returned.

This is the only statement of the order. ``objectstate.dual_axis_resolver`` implements it
in ``_resolve_in_layers``; ``tests/test_resolution_precedence_order.py`` checks it for
every set of (class, scope) sites.

Usage Pattern
------------
Expand Down
13 changes: 9 additions & 4 deletions src/objectstate/dual_axis_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,16 @@ def resolve_in_stack(stack, container_type: type, field_name: str) -> ResolvedFi


def _resolve_in_layers(layers: tuple, container_type: type, field_name: str) -> ResolvedField:
"""Walk the container type's MRO and, for each type, the layers from inner to outer.
"""Resolve class first: the MRO is the outer loop, the layers the inner one.

MRO order dominates layer order: ``None`` in an inner layer means "inherit", so
every outer layer is asked for the same type before a less specific type is
tried. A layer that masks the layers outside it ends the walk outward.
For each class in the container type's MRO, most specific first, every layer is
asked from the innermost scope to the outermost; the first value that is not
``None`` wins. ``None`` in an inner layer means "inherit", so a less specific
class is tried only after every layer was asked for the more specific one.
A layer that masks the layers outside it ends the walk outward.

The order is stated with an example in ``docs/context_system.rst`` under
"Precedence Order"; this function is its implementation.
"""
container_base = _normalize_to_base(container_type)

Expand Down
75 changes: 68 additions & 7 deletions src/objectstate/object_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,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, ...]] = {}

# === Saved baseline (2 attributes) ===
self._saved_resolved: dict[str, Any] = {}
Expand Down Expand Up @@ -476,8 +480,7 @@ def to_resolved_object(self) -> Any:
def to_saved_resolved_object(self) -> Any:
"""Reconstruct this state as an object with saved resolved values."""
self._check_and_sync_delegate()
if not self._saved_resolved:
self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
self._ensure_saved_resolved()
return self._reconstruct_from_resolved(
"", self._saved_resolved, self._extraction_target, self._parameter_structure
)
Expand Down Expand Up @@ -742,6 +745,12 @@ def _ensure_live_resolved(self, notify_flash: bool = True) -> set[str]:
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)}")
Expand All @@ -759,6 +768,60 @@ def _ensure_live_resolved(self, notify_flash: bool = True) -> set[str]:

return changed_paths

def _registered_ancestors(self) -> tuple[ObjectState, ...]:
"""The other states this state's resolved values are computed from.

This state is left out: registering it adds no value it does not hold.
"""
return tuple(
state
for _, state in ObjectStateRegistry.registered_ancestors(self.scope_id)
if state is not self
)

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)
)

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
}
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.

Every resolved read makes this check itself. ``ObjectStateRegistry`` calls it
when registration changes so subscribers are told at once.
"""
if self._live_resolved is not 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)
elif not self._saved_resolved:
self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)

# DELETED: _create_nested_states() - No longer needed with flat storage
# Nested ObjectStates are no longer created - flat storage handles all parameters

Expand Down Expand Up @@ -1157,9 +1220,7 @@ def get_saved_resolved_value(self, param_name: str) -> Any:
# Auto-detect delegate changes before resolving values
self._check_and_sync_delegate()

# Ensure saved resolved cache is populated
if not self._saved_resolved:
self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
self._ensure_saved_resolved()

# Check if this is a container/dataclass field (has subfields in _saved_resolved)
prefix = f"{param_name}."
Expand Down Expand Up @@ -1190,8 +1251,7 @@ def subfield_semantics(
self._check_and_sync_delegate()
self._ensure_live_resolved()
assert self._live_resolved is not None
if not self._saved_resolved:
self._saved_resolved = self._compute_resolved_snapshot(use_saved=True)
self._ensure_saved_resolved()

path = owner_field_path
raw_value = self.parameters.get(path.value, MISSING)
Expand Down Expand Up @@ -2092,6 +2152,7 @@ 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
)
Expand Down
93 changes: 54 additions & 39 deletions src/objectstate/object_state_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
Tuple,
TYPE_CHECKING,
Generator,
Iterable,
TypeAlias,
)
import copy
Expand Down Expand Up @@ -409,6 +410,7 @@ def register(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None:
cls._states[key] = state
cls.mark_snapshot_dirty_scope(key)
logger.debug(f"Registered ObjectState: scope={key}, type={type(state.object_instance).__name__}")
cls._resolve_again_below((key,))

# Fire callbacks for UI binding
cls._fire_register_callbacks(key, state)
Expand All @@ -433,13 +435,44 @@ def unregister(cls, state: 'ObjectState', _skip_snapshot: bool = False) -> None:
# that reference this ObjectState. Graveyard persists for session lifetime.
cls._graveyard[key] = removed_state
logger.debug(f"Unregistered ObjectState: scope={key} (moved to graveyard)")
cls._resolve_again_below((key,))

# Fire callbacks for UI binding
cls._fire_unregister_callbacks(key, state)

# Structural unregistration is not a user-edit boundary. The
# graveyard preserves state for explicit operation snapshots.

@classmethod
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.
"""
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()

@classmethod
def registered_ancestors(
cls, scope_id: str | None
) -> tuple[tuple[ScopeKey, 'ObjectState'], ...]:
"""The states registered at the global scope, each prefix of ``scope_id`` and
``scope_id`` itself, least specific first.

Resolved values of a state at ``scope_id`` are computed from these states.
"""
return tuple(
(ancestor_key, cls._states[ancestor_key])
for ancestor_key in cls._normalize_scope_id(scope_id).ancestors()
if ancestor_key in cls._states
)

@classmethod
def unregister_scope_and_descendants(cls, scope_id: Optional[str], _skip_snapshot: bool = False) -> int:
"""Unregister an ObjectState and all its descendants from the registry.
Expand Down Expand Up @@ -474,8 +507,9 @@ def unregister_scope_and_descendants(cls, scope_id: Optional[str], _skip_snapsho
# NOTE: Unlike plain unregister(), this is often used for hierarchical deletes.
# When history is enabled we must preserve removed states for time-travel (undo/redo),
# otherwise snapshots may reference scopes that can no longer be resurrected.
for key in scopes_to_delete:
state = cls._states.pop(key)
removed = [(key, cls._states.pop(key)) for key in scopes_to_delete]
cls._resolve_again_below(scopes_to_delete)
for key, state in removed:
if cls._history_enabled:
cls._graveyard[key] = state
logger.debug(f"Unregistered ObjectState (cascade): scope={key} (moved to graveyard)")
Expand Down Expand Up @@ -669,21 +703,10 @@ def get_ancestor_objects(cls, scope_id: Optional[str], use_saved: bool = False)
List of objects from ancestor scopes, ordered least→most specific.
Each object is from state.object_instance (saved) or state.to_object() (live).
"""
scope_key = cls._normalize_scope_id(scope_id)

# Get objects from ancestor scopes, least-specific to most-specific
objects = []
for ancestor_key in scope_key.ancestors():
state = cls._states.get(ancestor_key)
if state:
if use_saved:
# Return saved baseline (saved_object handles delegation correctly)
objects.append(state.saved_object)
else:
# Return live state with current edits
objects.append(state.to_object(update_delegate=False))

return objects
return [
state.saved_object if use_saved else state.to_object(update_delegate=False)
for _, state in cls.registered_ancestors(scope_id)
]

@classmethod
def get_ancestor_objects_with_scopes(
Expand Down Expand Up @@ -714,28 +737,20 @@ def get_ancestor_objects_with_scopes(
Returns:
List of (scope_id, object) tuples from ancestor scopes, ordered least→most specific.
"""
scope_key = cls._normalize_scope_id(scope_id)

# Get (scope_id, object) tuples from ancestor scopes
results: List[Tuple[str, Any]] = []
for ancestor_key in scope_key.ancestors():
state = cls._states.get(ancestor_key)
if state:
# Get object - either with or without delegate sync
logger.debug(f"🐛 get_ancestor_objects_with_scopes: Getting object for ancestor_key={ancestor_key!r}, skip_delegate_sync={skip_delegate_sync}, use_saved={use_saved}")

if use_saved:
# For saved baseline, we want object_instance (the original saved state)
# Not reconstructed, no delegate sync needed
obj = state._extraction_target
else:
# For live state, reconstruct from current parameters
# Control whether delegate sync happens
obj = state.to_object(update_delegate=False, sync_delegate=not skip_delegate_sync)

results.append((ancestor_key, obj))

return results
# 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
),
)
for ancestor_key, state in cls.registered_ancestors(scope_id)
]

# ========== SCOPE + TYPE + FIELD AWARE INVALIDATION ==========

Expand Down
24 changes: 9 additions & 15 deletions tests/test_resolution_precedence_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@

Which axis is the outer loop only shows on a crossed pair: a value on a less
specific class in a more specific scope against a value on a more specific class in
a less specific scope, with no site most specific on both. The crossed tests here
are a CHARACTERISATION OF TODAY'S BEHAVIOUR (class first), not a statement of
intended policy: the documentation states both orders. See
a less specific scope, with no site most specific on both. The policy is class
first: the more specific class wins such a pair. It is stated in
``docs/context_system.rst`` under "Precedence Order" and checked by
``test_crossed_pair_is_won_by_the_more_specific_class``.
"""

Expand Down Expand Up @@ -244,16 +244,10 @@ def test_winner_is_the_first_site_of_one_strict_total_order():
ids=[f"{_value_at(a)} vs {_value_at(b)}" for a, b in CROSSED_PAIRS],
)
def test_crossed_pair_is_won_by_the_more_specific_class(inner_scope_site, specific_class_site):
"""CHARACTERISATION of today's behaviour, not a statement of intended policy.

``_resolve_in_layers`` walks the MRO outside and the layers inside, so a value on
``Leaf`` in the global config beats a value on ``Base`` set on the plate. Its
docstring and ``docs/context_system.rst`` describe that order;
``docs/configuration_framework.rst`` describes the opposite one ("check
``AppConfig`` context first, then ``GlobalAppConfig`` context, walking MRO at each
level"). Until the owner settles which is intended, this test only records what
the resolver does. If the scope is meant to be the outer loop, flip the expected
winner here and in ``test_all_sites_are_ordered_class_first_then_scope``.
"""The policy: class first.

The MRO is the outer loop and the layers the inner one, so a value on ``Leaf``
in the global config beats a value on ``Base`` set on the plate.
"""
assert inner_scope_site[0] is not specific_class_site[0]
assert inner_scope_site[1] != specific_class_site[1]
Expand All @@ -268,7 +262,7 @@ def test_crossed_pair_is_won_by_the_more_specific_class(inner_scope_site, specif


def test_all_sites_are_ordered_class_first_then_scope():
"""CHARACTERISATION: the one order is the product order with the class outer."""
"""The one order is the product order with the class outer."""
for sites in ALL_SITE_SETS:
if sites:
assert _winner(sites) == min(sites, key=ALL_SITES.index), sites
Expand All @@ -279,7 +273,7 @@ def test_read_kinds_of_an_editing_state_stay_distinct_and_agree_on_a_crossed_pai

The stored value stays ``None``; the resolved value and the source are the
resolver's answer for the same crossed pair. Which site that is, is the
characterised class-first order (see the crossed-pair test above).
class-first order (see the crossed-pair test above).
"""
crossed = frozenset({(Base, PLATE), (Leaf, GLOBAL)})
plate_only = frozenset({(Base, PLATE)})
Expand Down
Loading
Loading