From f372fc5d0e9d43db8421c37f41b3ebdf02fce0af Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 00:24:44 -0400 Subject: [PATCH 1/8] Build context stacks loudly: with-only build_context_stack, no layer skipped or substituted build_context_stack is a context manager and takes scoped ancestors only. config_context, merge_configs, build_context_stack, resolve_with_provenance and the lazy resolver method raise where they used to continue with a layer missing. Uncalled helpers that substituted layers are deleted. Refs #34 (items 1, 2), #31. Co-Authored-By: Claude Opus 5.5 --- src/objectstate/context_manager.py | 363 +++--------------------- src/objectstate/docs/context_system.rst | 70 ++--- src/objectstate/dual_axis_resolver.py | 6 +- src/objectstate/lazy_factory.py | 25 +- src/objectstate/object_state.py | 20 +- tests/test_context_failures_surface.py | 129 +++++++++ 6 files changed, 209 insertions(+), 404 deletions(-) create mode 100644 tests/test_context_failures_surface.py diff --git a/src/objectstate/context_manager.py b/src/objectstate/context_manager.py index cf023f8..8c6362a 100644 --- a/src/objectstate/context_manager.py +++ b/src/objectstate/context_manager.py @@ -13,7 +13,6 @@ Key components: - current_temp_global: ContextVar holding current merged global config - config_context(): Context manager for creating context scopes -- extract_config_overrides(): Extract config values from any object type - merge_configs(): Merge overrides into base config """ @@ -21,13 +20,18 @@ import functools import logging import threading -from contextlib import contextmanager -from typing import Any, Dict, get_type_hints +from contextlib import ExitStack, contextmanager +from typing import Any, Dict, Sequence, get_type_hints from dataclasses import fields, is_dataclass from python_introspect import resolve_annotated, resolve_optional logger = logging.getLogger(__name__) + +class NoActiveContextError(RuntimeError): + """A field was resolved against the active context while no context was active.""" + + # Core contextvar for current merged global config # This holds the current context state that resolution functions can access current_temp_global = contextvars.ContextVar('current_temp_global') @@ -215,16 +219,10 @@ def config_context(obj, mask_with_none: bool = False, use_live_global: bool = Tr # Create merged config if we have overrides # Use replace_raw to preserve None values (dataclasses.replace triggers lazy resolution) if overrides: - try: - from objectstate.lazy_factory import replace_raw - merged_config = replace_raw(base_config, **overrides) - logger.debug(f"Creating config context with {len(overrides)} field overrides from {type(obj).__name__}") - except Exception as e: - logger.warning(f"Failed to merge config overrides from {type(obj).__name__}: {e}") - merged_config = base_config + from objectstate.lazy_factory import replace_raw + merged_config = replace_raw(base_config, **overrides) else: merged_config = base_config - logger.debug(f"Creating config context with no overrides from {type(obj).__name__}") # Track the type in the context type stack current_types = context_type_stack.get() @@ -533,348 +531,73 @@ def is_same_type_in_context(type_a, type_b): # ============================================================================ -def _inject_context_layer( - stack, - t: type | None, - values: dict | None, - stored: object | None, -) -> None: - """ - Inject a context layer into the stack. - - Handles dataclass instantiation with SimpleNamespace fallback. - For types that can't be reconstructed from editable values alone, - injects stored object + SimpleNamespace so type tracking works and live values win. - - Args: - stack: ExitStack to inject into - t: Type to instantiate (None = use SimpleNamespace) - values: Dict of values to use (None = use stored only) - stored: Stored object to fall back to (None = no fallback) - """ - from types import SimpleNamespace - - if values is None: - if stored is not None: - stack.enter_context(config_context(stored)) - return - - # Have values - try to instantiate as dataclass, fall back to SimpleNamespace - if t is not None and is_dataclass(t): - try: - stack.enter_context(config_context(t(**values))) - return - except Exception: - # Dataclass ctor failed (e.g., missing required args not in form) - # Inject stored (for type tracking) + SimpleNamespace (for values) - if stored is not None: - stack.enter_context(config_context(stored)) - stack.enter_context(config_context(SimpleNamespace(**values))) - return - - # Non-dataclass or no type - use SimpleNamespace - stack.enter_context(config_context(SimpleNamespace(**values))) - - +@contextmanager def build_context_stack( object_instance: object, - ancestor_objects: list[object] | None = None, - ancestor_objects_with_scopes: list[tuple[str, object]] | None = None, + ancestor_objects_with_scopes: Sequence[tuple[str, object]] = (), current_scope_id: str | None = None, use_live: bool = True, ): """ - Build a complete context stack for placeholder resolution. + Enter every context layer needed to resolve ``object_instance``; leave them on exit. - SINGLE SOURCE OF TRUTH: Uses ancestor_objects from ObjectStateRegistry as the - sole mechanism for parent hierarchy. No separate context_obj parameter. + Layers, outermost first: + 1. The global layer, under scope ``""``. When ``object_instance`` is itself a global + config, this is a fresh instance of its type holding the declared defaults. + 2. Each ancestor, under its own scope id. Global-config ancestors are left out + because layer 1 already stands for them. + 3. ``object_instance``, under ``current_scope_id``. - Layer order (innermost to outermost when entered): - 1. Global context layer (from ancestors or thread-local) - 2. Ancestor objects (from ObjectStateRegistry.get_ancestor_objects()) - 3. Current object_instance + Nothing is set until the ``with`` block is entered. Any layer that cannot be + built raises, and the layers entered before it are left again. Args: - object_instance: The object being edited (type used to infer global editing mode) - ancestor_objects: List of ancestor objects from least→most specific (from ObjectStateRegistry). - This is the SINGLE SOURCE OF TRUTH for parent hierarchy. - DEPRECATED: Use ancestor_objects_with_scopes for provenance tracking. - ancestor_objects_with_scopes: List of (scope_id, object) tuples from least→most specific. - Enables provenance tracking via context_layer_stack. - current_scope_id: Scope ID for the current object_instance. Used for provenance tracking. - - Returns: - ExitStack with all context layers entered. Caller must manage the stack lifecycle. + object_instance: The object whose fields are being resolved. + ancestor_objects_with_scopes: ``(scope_id, object)`` pairs, least specific first. + current_scope_id: Scope id of ``object_instance``. + use_live: Read the live global config (True) or the saved one (False). """ - from contextlib import ExitStack from objectstate.lazy_factory import GlobalConfigBase - stack = ExitStack() - obj_type = type(object_instance) - - # Infer global editing mode from object_instance type - is_global_config_editing = isinstance(object_instance, GlobalConfigBase) - - obj_type_name = obj_type.__name__ - - # PERFORMANCE: Only build ancestor list for logging when DEBUG is enabled - if logger.isEnabledFor(logging.DEBUG): - if ancestor_objects_with_scopes: - ancestor_types = [type(o).__name__ for _, o in ancestor_objects_with_scopes] - elif ancestor_objects: - ancestor_types = [type(o).__name__ for o in ancestor_objects] + with ExitStack() as stack: + if isinstance(object_instance, GlobalConfigBase): + declared_defaults = type(object_instance)() + stack.enter_context( + config_context(declared_defaults, mask_with_none=True, scope_id="") + ) else: - ancestor_types = [] - logger.debug(f"🔧 build_context_stack: obj={obj_type_name}, ancestors={ancestor_types}") - - # 1. Global context layer (least specific) - # ALWAYS use LIVE thread-local for global config - it's the SINGLE SOURCE OF TRUTH - # Don't use ancestor_objects for GlobalConfigBase since that comes from to_object() - # which can be stale relative to the LIVE thread-local we just updated. - if is_global_config_editing: - try: - global_layer = obj_type() # Fresh instance with static defaults - stack.enter_context(config_context(global_layer, mask_with_none=True, scope_id="")) - except Exception: - pass # Couldn't create global layer - else: - # Use LIVE or SAVED thread-local based on use_live parameter - global_layer = get_base_global_config(use_live=use_live) - if global_layer: - stack.enter_context(config_context(global_layer, scope_id="")) - - # 2. Ancestor objects (intermediate layers, excluding GlobalConfigBase already handled) - if ancestor_objects_with_scopes: - # New format with scope_ids for provenance tracking + global_layer = get_base_global_config(use_live=use_live) + if global_layer: + stack.enter_context(config_context(global_layer, scope_id="")) + for scope_id, ancestor_obj in ancestor_objects_with_scopes: if isinstance(ancestor_obj, GlobalConfigBase): continue stack.enter_context(config_context(ancestor_obj, scope_id=scope_id)) - elif ancestor_objects: - # Legacy format without scope_ids - for ancestor_obj in ancestor_objects: - if isinstance(ancestor_obj, GlobalConfigBase): - continue - stack.enter_context(config_context(ancestor_obj)) - - # 3. Current object overlay (most specific) - stack.enter_context(config_context(object_instance, scope_id=current_scope_id)) - - return stack - - -def _get_global_context_layer( - live_context: dict | None, - is_global_config_editing: bool, - global_config_type: type | None, -) -> object | None: - """ - Get the global context layer for the stack. - - Priority: - 1. If editing global config, use static defaults (mask_with_none will mask thread-local) - 2. If live_context has a global config, use that (from another open editor) - 3. Fall back to thread-local global config - - PERFORMANCE OPTIMIZATION: Within a dispatch cycle, the GLOBAL layer is cached - since it's the same for all sibling refreshes. - - Args: - live_context: Dict mapping types to their live values - is_global_config_editing: True if editing a global config - global_config_type: The global config type - - Returns: - Global config instance to use, or None if not available - """ - layer = None - - # 1) Global config editing → fresh instance (mask thread-local when entering) - if is_global_config_editing and global_config_type is not None: - try: - layer = global_config_type() - except Exception: - layer = None - - # 2) Live global from other window - if layer is None: - layer = _find_live_global(live_context) - - # 3) Fallback to thread-local base - if layer is None: - layer = get_base_global_config() - - return layer - - -def _find_live_global(live_context: dict | None) -> object | None: - """Find and instantiate a global config from live_context if present.""" - if not live_context: - return None - - from objectstate.lazy_factory import is_global_config_type - - for config_type, config_values in live_context.items(): - if is_global_config_type(config_type): - try: - return config_type(**config_values) - except Exception: - continue - return None - - - - -def _find_live_values_for_type(target_type: type, live_context: dict) -> dict | None: - """ - Find live values for a target type in live_context. - - Handles type normalization (lazy vs base types) AND inheritance. - For sibling inheritance, a StepPartitionFilterConfig's values should be - usable when resolving PartitionFilterConfig's placeholders. - - IMPORTANT: Prefers subclass matches over exact matches. - This ensures StepPartitionFilterConfig values (with concrete value) are used - for PartitionFilterConfig resolution, not PartitionFilterConfig values (with None). - - Args: - target_type: The type to find values for - live_context: Dict mapping types to their live values - - Returns: - Dict of field values, or None if not found - """ - target_base = _normalize_type(target_type) - logger.debug(f"_find_live_values_for_type: target={target_type.__name__} -> base={target_base.__name__}") - logger.debug(f"_find_live_values_for_type: live_context has {len(live_context)} types") - - # Pass 0: exact type match without normalization (prefer most specific) - for config_type, config_values in live_context.items(): - if config_type == target_type: - logger.debug(f"_find_live_values_for_type: ✅ exact type match for {config_type.__name__}") - return config_values - - # First pass: look for subclass match (more specific wins) after normalization - # e.g., StepPartitionFilterConfig values for PartitionFilterConfig resolution - for config_type, config_values in live_context.items(): - config_base = _normalize_type(config_type) - try: - if config_base != target_base and issubclass(config_base, target_base): - logger.debug(f"_find_live_values_for_type: ✅ using {config_base.__name__} values for {target_base.__name__} (subclass)") - return config_values - except TypeError: - pass # Not a class - - # Second pass: exact type match (after normalization) - for config_type, config_values in live_context.items(): - config_base = _normalize_type(config_type) - if config_base == target_base: - logger.debug(f"_find_live_values_for_type: ✅ exact match for {target_base.__name__}") - return config_values - - logger.debug(f"_find_live_values_for_type: ❌ no match for {target_base.__name__}") - return None - - -# Removed: extract_config_overrides - no longer needed with field matching approach - - -def extract_from_dataclass_fields(obj) -> Dict[str, Any]: - """ - Get non-None fields as config overrides. - - This extracts concrete values from dataclass instances, ignoring None values - which represent fields that should inherit from context. - - Args: - obj: Dataclass instance to extract field values from - - Returns: - Dict of field_name -> value for non-None fields - """ - if not is_dataclass(obj): - return {} - - overrides = {} - - for field in fields(obj): - value = getattr(obj, field.name) - if value is not None: - overrides[field.name] = value - - logger.debug(f"Extracted {len(overrides)} overrides from dataclass {type(obj).__name__}") - return overrides - - -def extract_from_object_attributes(obj) -> Dict[str, Any]: - """ - Extract config attributes from step/pipeline objects. - - This handles orchestrators, steps, and other objects that have *_config attributes. - It flattens the config hierarchy into a single dict of field overrides. - - Args: - obj: Object to extract config attributes from - - Returns: - Dict of field_name -> value for all non-None config fields - """ - overrides = {} - - try: - for attr_name in dir(obj): - if attr_name.endswith('_config'): - attr_value = getattr(obj, attr_name) - if attr_value is not None and is_dataclass(attr_value): - # Extract all non-None fields from this config - config_overrides = extract_from_dataclass_fields(attr_value) - overrides.update(config_overrides) - - logger.debug(f"Extracted {len(overrides)} overrides from object {type(obj).__name__}") - - except Exception as e: - logger.debug(f"Error extracting from object {obj}: {e}") - - return overrides + stack.enter_context(config_context(object_instance, scope_id=current_scope_id)) + yield def merge_configs(base, overrides: Dict[str, Any]): """ Merge overrides into base config, creating new immutable instance. - - This creates a new config instance with override values merged in, - preserving immutability of the original base config. - + + A ``None`` override is kept: it means "inherit this field from the parent context". + Raises whatever the config's constructor raises when the overrides are not accepted. + Args: base: Base config instance (base config type) overrides: Dict of field_name -> value to override - + Returns: New config instance with overrides applied """ if not base or not overrides: return base - try: - # CRITICAL: Do NOT filter out None values! - # None can be a semantic value such as "inherit from parent context". - # When an override dict contains None, it means "reset this field to None" - # which should override the base value with None for lazy resolution. - - if not overrides: - return base - - # Use replace_raw to preserve None values (dataclasses.replace triggers lazy resolution) - from objectstate.lazy_factory import replace_raw - merged = replace_raw(base, **overrides) - - logger.debug(f"Merged {len(overrides)} overrides into {type(base).__name__}") - return merged - - except Exception as e: - logger.warning(f"Failed to merge configs: {e}") - return base + from objectstate.lazy_factory import replace_raw + return replace_raw(base, **overrides) def get_base_global_config(use_live: bool = True): diff --git a/src/objectstate/docs/context_system.rst b/src/objectstate/docs/context_system.rst index 68e2935..482d298 100644 --- a/src/objectstate/docs/context_system.rst +++ b/src/objectstate/docs/context_system.rst @@ -249,66 +249,28 @@ No manual cleanup needed - Python's context manager protocol handles it. Framework-Agnostic Context Stack Building ----------------------------------------- -For UI placeholder resolution, the ``build_context_stack()`` function provides a -framework-agnostic way to build complete context stacks: +``build_context_stack()`` is a context manager that enters every layer needed to +resolve one object, and leaves them again when the block ends: .. code-block:: python - from objectstate import build_context_stack + from objectstate import ObjectStateRegistry, build_context_stack - # Build context stack for placeholder resolution - stack = build_context_stack( - context_obj=parent_config, # Parent context - overlay=current_parameters, # Current form values - dataclass_type=config_type, # Type being edited - live_context=live_context_dict, # Live values from other forms - root_form_values=root_values, # Root form's values (for sibling inheritance) - root_form_type=root_type, # Root form's dataclass type - ) + ancestors = ObjectStateRegistry.get_ancestor_objects_with_scopes(scope_id) - with stack: - # Context layers are active + with build_context_stack( + object_instance=current_object, + ancestor_objects_with_scopes=ancestors, # (scope_id, object), least specific first + current_scope_id=scope_id, + ): placeholder = resolve_placeholder(field_name) -The stack builds layers in order: - -1. **Global context layer** - Thread-local global config or live editor values -2. **Intermediate layers** - Ancestors from ``get_types_before_in_stack()`` -3. **Parent context** - The ``context_obj`` parameter -4. **Root form layer** - For sibling inheritance (see below) -5. **Overlay** - Current form values - -Sibling Inheritance via Root Form -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -When nested configs need to inherit from siblings, the root form's values enable this: - -.. code-block:: python - - # Root form contains both sibling configs - root_values = { - 'base_config': LazyBaseConfig(shared_value=123), - 'child_config': LazyChildConfig(shared_value=None), # Inherits from base - # Additional sibling configs may follow. - } - - # When resolving child_config.shared_value: - # 1. stack includes root_values - # 2. LazyChildConfig.shared_value resolution walks MRO - # 3. Finds BaseConfig (superclass) in context - # 4. Uses base_config.shared_value = 123 - -For non-dataclass roots, the function wraps values in ``SimpleNamespace`` to maintain a unified code path: - -.. code-block:: python - - if root_form_type and is_dataclass(root_form_type): - root_instance = root_form_type(**root_form_values) - else: - # Non-dataclass root - wrap in SimpleNamespace - from types import SimpleNamespace - root_instance = SimpleNamespace(**root_form_values) +Layers are entered outermost first: - stack.enter_context(config_context(root_instance)) +1. **Global layer** - the global config, under scope ``""`` +2. **Ancestors** - each under its own scope id +3. **Current object** - under ``current_scope_id`` -This enables sibling inheritance for any root type. +Every layer carries a scope id, so ``resolve_with_provenance()`` can name the scope +that supplied a value. Nothing is set until the ``with`` block is entered. A layer +that cannot be built raises; the block never runs with a layer missing. diff --git a/src/objectstate/dual_axis_resolver.py b/src/objectstate/dual_axis_resolver.py index 69cec7d..0bf4c61 100644 --- a/src/objectstate/dual_axis_resolver.py +++ b/src/objectstate/dual_axis_resolver.py @@ -284,11 +284,7 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any, for scope_id, layer_obj in layers: if layer_obj is None: continue - try: - layer_configs = extract_all_configs(layer_obj) - all_layer_configs.append((scope_id, layer_configs)) - except Exception: - continue + all_layer_configs.append((scope_id, extract_all_configs(layer_obj))) # PHASE 1: Hierarchy walk - check ONLY same-type config at each scope (inner to outer) # This gives hierarchy precedence: inner/child scopes override outer/parent scopes diff --git a/src/objectstate/lazy_factory.py b/src/objectstate/lazy_factory.py index e45675e..2271ae5 100644 --- a/src/objectstate/lazy_factory.py +++ b/src/objectstate/lazy_factory.py @@ -608,21 +608,20 @@ class LazyMethodBindings: def create_resolver() -> Callable[[Any, str], Any]: """Create field resolver method using new pure function interface.""" from objectstate.dual_axis_resolver import resolve_field_inheritance - from objectstate.context_manager import current_temp_global, extract_all_configs + from objectstate.context_manager import ( + NoActiveContextError, + extract_all_configs, + get_current_temp_global, + ) def _resolve_field_value(self, field_name: str) -> Any: - # Get current context from contextvars - try: - current_context = current_temp_global.get() - # Extract available configs from current context - available_configs = extract_all_configs(current_context) - - # Use pure function for resolution - return resolve_field_inheritance(self, field_name, available_configs) - except LookupError: - # No context available - return None (fail-loud approach) - logger.debug(f"No context available for resolving {type(self).__name__}.{field_name}") - return None + current_context = get_current_temp_global() + if current_context is None: + raise NoActiveContextError( + f"Cannot resolve {type(self).__name__}.{field_name}: no config context is active." + ) + available_configs = extract_all_configs(current_context) + return resolve_field_inheritance(self, field_name, available_configs) return _resolve_field_value diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 0a0402e..e87b207 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -1783,13 +1783,11 @@ def _recompute_invalid_fields(self) -> set[str]: # without triggering delegate sync that would invalidate our cache mid-computation. current_obj = self.to_object(update_delegate=False, sync_delegate=False) - stack = build_context_stack( + with build_context_stack( object_instance=current_obj, ancestor_objects_with_scopes=ancestor_objects_with_scopes, current_scope_id=self.scope_id, - ) - - with stack: + ): # 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) @@ -2115,19 +2113,17 @@ def _resolve_parameter_snapshot( from objectstate.dual_axis_resolver import resolve_with_provenance from objectstate.lazy_factory import has_lazy_resolution - stack = build_context_stack( - object_instance=current_obj, - ancestor_objects_with_scopes=ancestor_objects_with_scopes, - current_scope_id=scope_id, - use_live=not use_saved, - ) - snapshot: dict[str, Any] = {} provenance: dict[str, tuple[str | None, type | None]] = {} # UNIFIED: Resolve ALL fields in single context stack # For each path, check if it has a lazy dataclass container type - with stack: + with build_context_stack( + object_instance=current_obj, + ancestor_objects_with_scopes=ancestor_objects_with_scopes, + current_scope_id=scope_id, + use_live=not use_saved, + ): for dotted_path in parameters.keys(): raw_value = values.get(dotted_path) container_type = structure.owner_paths.get(dotted_path) diff --git a/tests/test_context_failures_surface.py b/tests/test_context_failures_surface.py new file mode 100644 index 0000000..b24cd44 --- /dev/null +++ b/tests/test_context_failures_surface.py @@ -0,0 +1,129 @@ +"""A context that cannot be built must fail; it must never run with a layer missing.""" + +from dataclasses import dataclass, field + +import pytest + +from objectstate import ( + LazyDataclassFactory, + build_context_stack, + config_context, + mark_global_config_type, + merge_configs, + set_base_config_type, + set_global_config_for_editing, +) +from objectstate.context_manager import ( + NoActiveContextError, + context_layer_stack, + get_current_temp_global, +) +from objectstate.dual_axis_resolver import resolve_with_provenance + + +@dataclass +class Policy: + value: int | None = 1 + + +@dataclass +class CheckedGlobal: + """Global config whose constructor rejects a negative worker count.""" + + workers: int = 1 + policy: Policy = field(default_factory=Policy) + + def __post_init__(self): + if self.workers < 0: + raise ValueError("workers must not be negative") + + +@dataclass +class Pipeline: + workers: int = 1 + + +@pytest.fixture +def checked_global(): + set_base_config_type(CheckedGlobal) + saved = CheckedGlobal() + set_global_config_for_editing(CheckedGlobal, saved) + return saved + + +def test_build_context_stack_sets_nothing_until_entered_and_resets_on_exit(checked_global): + pipeline = Pipeline(workers=5) + before = (get_current_temp_global(), context_layer_stack.get()) + + pending = build_context_stack( + Policy(), ancestor_objects_with_scopes=[("plate", pipeline)], current_scope_id="step" + ) + assert (get_current_temp_global(), context_layer_stack.get()) == before + + with pending: + assert get_current_temp_global().workers == 5 + assert [scope for scope, _ in context_layer_stack.get()] == ["", "plate", "step"] + + assert (get_current_temp_global(), context_layer_stack.get()) == before + + +def test_build_context_stack_resets_when_the_block_raises(checked_global): + before = (get_current_temp_global(), context_layer_stack.get()) + with pytest.raises(KeyError): + with build_context_stack(Pipeline(workers=5), current_scope_id="step"): + raise KeyError("block failed") + assert (get_current_temp_global(), context_layer_stack.get()) == before + + +def test_build_context_stack_rejects_ancestors_without_scopes(checked_global): + with pytest.raises(TypeError): + build_context_stack(Pipeline(), ancestor_objects=[Pipeline()]) + + +def test_layer_that_cannot_be_merged_raises(checked_global): + with pytest.raises(ValueError, match="workers must not be negative"): + with config_context(Pipeline(workers=-1)): + pytest.fail("the block ran without the layer's overrides") + + +def test_merge_configs_that_cannot_be_applied_raises(checked_global): + with pytest.raises(ValueError, match="workers must not be negative"): + merge_configs(checked_global, {"workers": -1}) + + +def test_global_type_that_cannot_be_constructed_raises(): + @dataclass + class RequiredGlobal: + workers: int + + mark_global_config_type(RequiredGlobal) + set_base_config_type(RequiredGlobal) + set_global_config_for_editing(RequiredGlobal, RequiredGlobal(workers=2)) + + with pytest.raises(TypeError, match="workers"): + with build_context_stack(RequiredGlobal(workers=3), current_scope_id=""): + pytest.fail("the block ran without the global layer") + + +def test_layer_whose_configs_cannot_be_extracted_raises(checked_global): + @dataclass + class Unrelated: + value: int = 9 + + @dataclass + class Step: + step_policy: Policy + + lazy_policy = LazyDataclassFactory.make_lazy_simple(Policy) + mistyped_step = Step(step_policy=Unrelated()) + + with config_context(mistyped_step, scope_id="plate::step"): + with pytest.raises(TypeError, match="Step.step_policy declares Policy"): + resolve_with_provenance(lazy_policy, "value") + + +def test_resolver_method_without_active_context_raises(): + lazy_policy = LazyDataclassFactory.make_lazy_simple(Policy) + assert get_current_temp_global() is None + with pytest.raises(NoActiveContextError): + lazy_policy(value=None)._resolve_field_value("value") From c8568add6e867e5df6ecdabdb12d9248bd677936 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 00:25:08 -0400 Subject: [PATCH 2/8] Fit added lines to the ruff limits Co-Authored-By: Claude Opus 5.5 --- src/objectstate/context_manager.py | 3 ++- src/objectstate/lazy_factory.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/objectstate/context_manager.py b/src/objectstate/context_manager.py index 8c6362a..5c57f19 100644 --- a/src/objectstate/context_manager.py +++ b/src/objectstate/context_manager.py @@ -20,8 +20,9 @@ import functools import logging import threading +from collections.abc import Sequence from contextlib import ExitStack, contextmanager -from typing import Any, Dict, Sequence, get_type_hints +from typing import Any, Dict, get_type_hints from dataclasses import fields, is_dataclass from python_introspect import resolve_annotated, resolve_optional diff --git a/src/objectstate/lazy_factory.py b/src/objectstate/lazy_factory.py index 2271ae5..1472892 100644 --- a/src/objectstate/lazy_factory.py +++ b/src/objectstate/lazy_factory.py @@ -618,7 +618,8 @@ def _resolve_field_value(self, field_name: str) -> Any: current_context = get_current_temp_global() if current_context is None: raise NoActiveContextError( - f"Cannot resolve {type(self).__name__}.{field_name}: no config context is active." + f"Cannot resolve {type(self).__name__}.{field_name}: " + "no config context is active." ) available_configs = extract_all_configs(current_context) return resolve_field_inheritance(self, field_name, available_configs) From dd34842e74c0560e59d0c82d7a80b2abda4084f7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 00:25:54 -0400 Subject: [PATCH 3/8] Keep atomic and deferral block state per context; name the navigation payload atomic, atomic_success and defer_live_invalidations kept their nesting in class attributes shared by the process, while config_context keeps its scope in context variables. The open block is now an AtomicBlock held in a ContextVar and the deferral nesting is a ContextVar count, so a thread outside any block no longer sees another thread's block (#30). TimeTravelChangeSet.legacy_entries is the only completion-callback payload, so it is renamed states_needing_navigation (#34 item 3). Co-Authored-By: Claude Opus 5.5 --- src/objectstate/object_state_registry.py | 179 +++++++++++------- tests/test_atomic_block_context.py | 139 ++++++++++++++ tests/test_callable_baseline.py | 3 - tests/test_object_state_metadata_contracts.py | 3 - tests/test_object_state_update_instance.py | 3 - tests/test_undo_redo_cascade.py | 7 +- 6 files changed, 248 insertions(+), 86 deletions(-) create mode 100644 tests/test_atomic_block_context.py diff --git a/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index 3c765e0..8620fc5 100644 --- a/src/objectstate/object_state_registry.py +++ b/src/objectstate/object_state_registry.py @@ -4,6 +4,7 @@ """ from contextlib import contextmanager +from contextvars import ContextVar, Token from dataclasses import dataclass, is_dataclass, replace import logging from typing import ( @@ -136,8 +137,10 @@ class TimeTravelChangeSet: triggering_scope: Optional[str] scope_changes: Dict[str, TimeTravelScopeChange] - def legacy_entries(self, states: Dict[str, 'ObjectState']) -> List[TimeTravelStateEntry]: - """Project to the existing callback payload shape.""" + def states_needing_navigation( + self, states: dict[str, 'ObjectState'] + ) -> list[TimeTravelStateEntry]: + """Return the registered states this restore changed, as completion callbacks take them.""" return [ (scope_key, states[scope_key]) for scope_key, change in self.scope_changes.items() @@ -156,6 +159,35 @@ class DeferredFieldInvalidation: include_origin: bool = True +@dataclass +class AtomicBlock: + """One open ``atomic`` / ``atomic_success`` block: the undo step being coalesced. + + The block belongs to the context that opened it, exactly as a + ``config_context`` layer does: a new thread starts outside every block, and + an asyncio task started inside a block inherits it. + """ + + label: str + triggering_scope: str | None + entry_signature: RegistryMutationSignature + depth: int = 1 + snapshot_requested: bool = False + + +# Which block, and how many deferrals, the running context is inside. These +# answer "whose block is this", so they live where ``config_context`` keeps its +# scope (``contextvars``, see ``context_manager``). What the blocks act on, the +# registered states, the history and the queue of owed invalidations, is one +# per process and stays on ``ObjectStateRegistry``. +_atomic_block: ContextVar[AtomicBlock | None] = ContextVar( + "objectstate_atomic_block", default=None +) +_live_invalidation_deferrals: ContextVar[int] = ContextVar( + "objectstate_live_invalidation_deferrals", default=0 +) + + class ObjectStateRegistry: """Singleton registry of all ObjectState instances. @@ -167,7 +199,16 @@ class ObjectStateRegistry: - ImageBrowser: registers when opened, unregisters when closed - Config window: registers PipelineConfig/GlobalPipelineConfig - Thread safety: Not thread-safe (all operations expected on main thread). + One registry per process, by design: the registered states, the snapshot + history, the callbacks and the queue of owed live-cache invalidations are + class attributes, so two independent registries cannot exist. + + Per context (thread or asyncio task), like ``config_context``: the open + ``atomic`` / ``atomic_success`` block and the ``defer_live_invalidations`` + nesting. A thread outside any block never sees another thread's block. + + Thread safety: the process-wide stores above are not synchronized; callers + that mutate the registry from several threads must serialize those calls. """ _states: Dict[str, 'ObjectState'] = {} # Keys are always strings (None normalized to "") @@ -175,7 +216,6 @@ class ObjectStateRegistry: # Callbacks receive (scope_id: str, object_state: ObjectState) _on_register_callbacks: List[Callable[[str, 'ObjectState'], None]] = [] _on_unregister_callbacks: List[Callable[[str, 'ObjectState'], None]] = [] - _deferred_invalidation_depth: int = 0 _deferred_invalidations: Set[DeferredFieldInvalidation] = set() _snapshot_dirty_scopes: Set[str] = set() @@ -290,11 +330,11 @@ def _fire_history_changed_callbacks(cls) -> None: @contextmanager def defer_live_invalidations(cls) -> Generator[None, None, None]: """Queue descendant live-cache invalidations for an explicit later flush.""" - cls._deferred_invalidation_depth += 1 + token = _live_invalidation_deferrals.set(_live_invalidation_deferrals.get() + 1) try: yield finally: - cls._deferred_invalidation_depth -= 1 + _live_invalidation_deferrals.reset(token) @classmethod def has_deferred_invalidations(cls) -> bool: @@ -309,8 +349,7 @@ def flush_deferred_invalidations(cls) -> None: requests = tuple(cls._deferred_invalidations) cls._deferred_invalidations.clear() - previous_depth = cls._deferred_invalidation_depth - cls._deferred_invalidation_depth = 0 + token = _live_invalidation_deferrals.set(0) try: for request in requests: cls._invalidate_by_type_and_scope_now( @@ -321,7 +360,7 @@ def flush_deferred_invalidations(cls) -> None: include_origin=request.include_origin, ) finally: - cls._deferred_invalidation_depth = previous_depth + _live_invalidation_deferrals.reset(token) @classmethod def _fire_callbacks(cls, callbacks: List[Callable], role: str, *args: Any) -> None: @@ -498,17 +537,13 @@ def clear(cls) -> None: cls._time_travel_limbo.clear() cls._graveyard.clear() cls._deferred_invalidations.clear() - cls._deferred_invalidation_depth = 0 + _live_invalidation_deferrals.set(0) cls._snapshot_dirty_scopes.clear() cls._snapshots.clear() cls._timelines.clear() cls._current_head = None cls._current_timeline = cls.MAIN_BRANCH - cls._atomic_depth = 0 - cls._atomic_label = None - cls._atomic_triggering_scope = None - cls._atomic_snapshot_requested = False - cls._atomic_entry_signature = None + _atomic_block.set(None) logger.debug("Cleared ObjectState registry, history, limbo, and graveyard") # ========== TOKEN MANAGEMENT AND CHANGE NOTIFICATION ========== @@ -735,7 +770,7 @@ def invalidate_by_type_and_scope( well as its descendants. """ changed_scope = cls._normalize_scope_id(scope_id) - if cls._deferred_invalidation_depth > 0: + if _live_invalidation_deferrals.get() > 0: cls._deferred_invalidations.add( DeferredFieldInvalidation( scope_id=changed_scope, @@ -877,13 +912,6 @@ def _invalidate_field_in_matching_states( _max_history_size: int = 1000 # Max snapshots in DAG (increased since we don't truncate branches) _in_time_travel: bool = False # Flag for PFM to know to refresh widget values - # Atomic operation state - when >0, snapshots are deferred until operation completes - _atomic_depth: int = 0 - _atomic_label: Optional[str] = None # Label for the coalesced snapshot - _atomic_triggering_scope: Optional[str] = None - _atomic_snapshot_requested: bool = False - _atomic_entry_signature: Optional[RegistryMutationSignature] = None - # Limbo: ObjectStates temporarily removed during time-travel # When traveling to a snapshot, ObjectStates not in that snapshot are moved here. # When traveling forward or to head, they're restored from here. @@ -926,28 +954,12 @@ def atomic(cls, label: str, scope_id: Optional[str] = None) -> Generator[None, N the first scoped snapshot request inside the atomic block becomes the owner. """ - cls._atomic_depth += 1 - if cls._atomic_depth == 1: - cls._atomic_label = label - cls._atomic_triggering_scope = scope_id - cls._atomic_snapshot_requested = False - cls._atomic_entry_signature = cls._registry_mutation_signature() - + block, token = cls._enter_atomic_block(label, scope_id) try: yield finally: - cls._atomic_depth -= 1 - if cls._atomic_depth == 0: - # Outermost atomic block - record the coalesced snapshot - final_label = cls._atomic_label or label - triggering_scope = cls._atomic_triggering_scope - should_record = cls._atomic_should_record() - cls._atomic_label = None - cls._atomic_triggering_scope = None - cls._atomic_snapshot_requested = False - cls._atomic_entry_signature = None - if should_record: - cls.record_snapshot(final_label, triggering_scope) + if cls._leave_atomic_block(block, token) and cls._atomic_should_record(block): + cls.record_snapshot(block.label, block.triggering_scope) @classmethod @contextmanager @@ -963,7 +975,7 @@ def atomic_success( raises, the outermost success-only operation unwinds without recording a partial snapshot. """ - outermost = cls._atomic_depth == 0 + outermost = cls.current_atomic_block() is None snapshots_before = set(cls._snapshots) if outermost else None timelines_before = copy.deepcopy(cls._timelines) if outermost else None current_head_before = cls._current_head if outermost else None @@ -973,30 +985,16 @@ def atomic_success( set(cls._snapshot_dirty_scopes) if outermost else None ) - cls._atomic_depth += 1 - if cls._atomic_depth == 1: - cls._atomic_label = label - cls._atomic_triggering_scope = scope_id - cls._atomic_snapshot_requested = False - cls._atomic_entry_signature = cls._registry_mutation_signature() - + block, token = cls._enter_atomic_block(label, scope_id) succeeded = False try: yield succeeded = True finally: - cls._atomic_depth -= 1 - if cls._atomic_depth == 0: - final_label = cls._atomic_label or label - triggering_scope = cls._atomic_triggering_scope - should_record = cls._atomic_should_record() - cls._atomic_label = None - cls._atomic_triggering_scope = None - cls._atomic_snapshot_requested = False - cls._atomic_entry_signature = None + if cls._leave_atomic_block(block, token): if succeeded: - if should_record: - cls.record_snapshot(final_label, triggering_scope) + if cls._atomic_should_record(block): + cls.record_snapshot(block.label, block.triggering_scope) elif snapshots_before is not None and timelines_before is not None: for snapshot_id in set(cls._snapshots) - snapshots_before: del cls._snapshots[snapshot_id] @@ -1007,6 +1005,44 @@ def atomic_success( if snapshot_dirty_scopes_before is not None: cls._snapshot_dirty_scopes = snapshot_dirty_scopes_before + @classmethod + def current_atomic_block(cls) -> AtomicBlock | None: + """Return the block the running context is inside, or None outside any block.""" + block = _atomic_block.get() + if block is None or block.depth == 0: + return None + return block + + @classmethod + def _enter_atomic_block( + cls, label: str, scope_id: str | None + ) -> tuple[AtomicBlock, Token[AtomicBlock | None] | None]: + """Join the running context's block, opening one when it is outside any.""" + block = cls.current_atomic_block() + if block is not None: + block.depth += 1 + return block, None + block = AtomicBlock( + label=label, + triggering_scope=scope_id, + entry_signature=cls._registry_mutation_signature(), + ) + return block, _atomic_block.set(block) + + @classmethod + def _leave_atomic_block( + cls, block: AtomicBlock, token: Token[AtomicBlock | None] | None + ) -> bool: + """Leave ``block``; return whether that closed it, so its step is now due. + + A block dropped by ``clear()`` while open is not due: its states are gone. + """ + block.depth -= 1 + still_current = _atomic_block.get() is block + if token is not None: + _atomic_block.reset(token) + return block.depth == 0 and still_current + @classmethod def _registry_mutation_signature( cls, @@ -1027,12 +1063,12 @@ def _registry_mutation_signature( return tuple(entries) @classmethod - def _atomic_should_record(cls) -> bool: - """Return whether the outer atomic scope produced a user-visible state change.""" + def _atomic_should_record(cls, block: AtomicBlock) -> bool: + """Return whether the closed block produced a user-visible state change.""" - if cls._atomic_snapshot_requested: + if block.snapshot_requested: return True - return cls._registry_mutation_signature() != cls._atomic_entry_signature + return cls._registry_mutation_signature() != block.entry_signature @classmethod def ensure_baseline_snapshot(cls, label: str = "init") -> None: @@ -1120,11 +1156,12 @@ def record_snapshot(cls, label: str = "", scope_id: Optional[str] = None) -> Non # ATOMIC OPERATIONS: Defer snapshot until atomic block exits # The atomic() context manager will call record_snapshot() when it completes - if cls._atomic_depth > 0: - cls._atomic_snapshot_requested = True - if scope_id is not None and cls._atomic_triggering_scope is None: - cls._atomic_triggering_scope = scope_id - logger.debug(f"⏱️ ATOMIC: Deferring snapshot '{label}' (depth={cls._atomic_depth})") + block = cls.current_atomic_block() + if block is not None: + block.snapshot_requested = True + if scope_id is not None and block.triggering_scope is None: + block.triggering_scope = scope_id + logger.debug(f"⏱️ ATOMIC: Deferring snapshot '{label}' (depth={block.depth})") return import time @@ -1886,7 +1923,7 @@ def _fire_time_travel_complete_callbacks( if not cls._on_time_travel_complete_callbacks: return - states_with_changes = change_set.legacy_entries(cls._states) + states_with_changes = change_set.states_needing_navigation(cls._states) logger.debug( "⏱️ TIME_TRAVEL: Firing %d callback(s) with %d changed state(s)", len(cls._on_time_travel_complete_callbacks), diff --git a/tests/test_atomic_block_context.py b/tests/test_atomic_block_context.py new file mode 100644 index 0000000..9777811 --- /dev/null +++ b/tests/test_atomic_block_context.py @@ -0,0 +1,139 @@ +"""Block state belongs to the context that opened the block (issue #30).""" + +import asyncio +import threading +from dataclasses import dataclass + +import pytest + +from objectstate import ObjectState +from objectstate.object_state_registry import ObjectStateRegistry + + +@dataclass +class Dummy: + x: int = 1 + + +@pytest.fixture(autouse=True) +def empty_registry(): + ObjectStateRegistry.clear() + yield + ObjectStateRegistry.clear() + + +class HeldOpen: + """Run a body on another thread and hold it inside until released.""" + + def __init__(self, body) -> None: + self._inside = threading.Event() + self._release = threading.Event() + self._errors: list[BaseException] = [] + self._thread = threading.Thread(target=self._run, args=(body,)) + + def _run(self, body) -> None: + try: + with body(): + self._inside.set() + self._release.wait(timeout=10) + except BaseException as error: # reported to the test thread + self._errors.append(error) + self._inside.set() + + def __enter__(self) -> "HeldOpen": + self._thread.start() + assert self._inside.wait(timeout=10) + return self + + def __exit__(self, *exc_info) -> None: + self._release.set() + self._thread.join(timeout=10) + assert not self._errors, self._errors + + +def _block_facts() -> tuple[int, str | None]: + block = ObjectStateRegistry.current_atomic_block() + return (0, None) if block is None else (block.depth, block.label) + + +def test_thread_outside_any_block_does_not_see_another_threads_block() -> None: + """The reproducer of issue #30.""" + with HeldOpen(lambda: ObjectStateRegistry.atomic("worker edit")): + assert _block_facts() == (0, None) + with ObjectStateRegistry.atomic("main edit"): + assert _block_facts() == (1, "main edit") + assert _block_facts() == (0, None) + + +def test_snapshot_is_not_absorbed_into_another_threads_block() -> None: + state = ObjectState(Dummy(), scope_id="/plate::step_0") + ObjectStateRegistry.register(state, _skip_snapshot=True) + + with HeldOpen(lambda: ObjectStateRegistry.atomic("worker edit")): + ObjectStateRegistry.record_snapshot("main snapshot") + assert [s.label for s in ObjectStateRegistry.get_branch_history()] == ["main snapshot"] + + with ObjectStateRegistry.atomic("main edit", scope_id="/plate::step_0"): + state.update_parameter("x", 2) + labels = [s.label for s in ObjectStateRegistry.get_branch_history()] + assert "main edit [/plate::step_0]" in labels + + +def test_atomic_success_in_another_thread_is_outermost_for_its_own_context() -> None: + state = ObjectState(Dummy(), scope_id="/plate::step_0") + ObjectStateRegistry.register(state, _skip_snapshot=True) + + with HeldOpen(lambda: ObjectStateRegistry.atomic_success("worker edit")): + with ObjectStateRegistry.atomic_success("main edit", scope_id="/plate::step_0"): + assert _block_facts() == (1, "main edit") + state.update_parameter("x", 2) + labels = [s.label for s in ObjectStateRegistry.get_branch_history()] + assert "main edit [/plate::step_0]" in labels + + +def test_nested_blocks_in_one_context_share_the_outer_block() -> None: + with ObjectStateRegistry.atomic("outer"): + with ObjectStateRegistry.atomic_success("inner"): + assert _block_facts() == (2, "outer") + assert _block_facts() == (1, "outer") + assert _block_facts() == (0, None) + + +def test_block_follows_the_same_owner_as_config_context_across_tasks() -> None: + """A task started inside a block inherits it; a sibling task does not.""" + + async def facts() -> tuple[int, str | None]: + return _block_facts() + + async def scenario() -> tuple[tuple[int, str | None], tuple[int, str | None]]: + sibling = asyncio.ensure_future(facts()) + with ObjectStateRegistry.atomic("task edit"): + child = asyncio.ensure_future(facts()) + inherited = await child + return inherited, await sibling + + inherited, sibling = asyncio.run(scenario()) + assert inherited == (1, "task edit") + assert sibling == (0, None) + + +def test_deferral_in_one_thread_does_not_queue_another_threads_invalidation() -> None: + state = ObjectState(Dummy(), scope_id="/plate::step_0") + ObjectStateRegistry.register(state, _skip_snapshot=True) + + def invalidate() -> None: + ObjectStateRegistry.invalidate_by_type_and_scope( + scope_id="/plate::step_0", + changed_type=Dummy, + field_name="x", + ) + + with HeldOpen(ObjectStateRegistry.defer_live_invalidations): + invalidate() + assert not ObjectStateRegistry.has_deferred_invalidations() + + with ObjectStateRegistry.defer_live_invalidations(): + invalidate() + assert ObjectStateRegistry.has_deferred_invalidations() + ObjectStateRegistry.flush_deferred_invalidations() + assert not ObjectStateRegistry.has_deferred_invalidations() diff --git a/tests/test_callable_baseline.py b/tests/test_callable_baseline.py index 67b930e..206e164 100644 --- a/tests/test_callable_baseline.py +++ b/tests/test_callable_baseline.py @@ -21,9 +21,6 @@ def _reset_registry_and_history() -> None: ObjectStateRegistry._current_timeline = "main" ObjectStateRegistry._current_head = None ObjectStateRegistry._in_time_travel = False - ObjectStateRegistry._atomic_depth = 0 - ObjectStateRegistry._atomic_label = None - ObjectStateRegistry._atomic_triggering_scope = None class RebuiltCallable: diff --git a/tests/test_object_state_metadata_contracts.py b/tests/test_object_state_metadata_contracts.py index f983377..375ed1e 100644 --- a/tests/test_object_state_metadata_contracts.py +++ b/tests/test_object_state_metadata_contracts.py @@ -21,9 +21,6 @@ def _reset_registry_and_history() -> None: ObjectStateRegistry._current_timeline = "main" ObjectStateRegistry._current_head = None ObjectStateRegistry._in_time_travel = False - ObjectStateRegistry._atomic_depth = 0 - ObjectStateRegistry._atomic_label = None - ObjectStateRegistry._atomic_triggering_scope = None @dataclass diff --git a/tests/test_object_state_update_instance.py b/tests/test_object_state_update_instance.py index 6060d25..0eab449 100644 --- a/tests/test_object_state_update_instance.py +++ b/tests/test_object_state_update_instance.py @@ -28,9 +28,6 @@ def _reset_registry() -> None: ObjectStateRegistry._current_timeline = "main" ObjectStateRegistry._current_head = None ObjectStateRegistry._in_time_travel = False - ObjectStateRegistry._atomic_depth = 0 - ObjectStateRegistry._atomic_label = None - ObjectStateRegistry._atomic_triggering_scope = None ObjectStateRegistry._token = 0 ObjectStateRegistry._resolved_changed_callbacks.clear() diff --git a/tests/test_undo_redo_cascade.py b/tests/test_undo_redo_cascade.py index f980a60..6d052fb 100644 --- a/tests/test_undo_redo_cascade.py +++ b/tests/test_undo_redo_cascade.py @@ -25,11 +25,6 @@ def _reset_registry_and_history() -> None: ObjectStateRegistry._current_timeline = "main" ObjectStateRegistry._current_head = None ObjectStateRegistry._in_time_travel = False - ObjectStateRegistry._atomic_depth = 0 - ObjectStateRegistry._atomic_label = None - ObjectStateRegistry._atomic_triggering_scope = None - ObjectStateRegistry._atomic_snapshot_requested = False - ObjectStateRegistry._atomic_entry_signature = None @dataclass @@ -236,7 +231,7 @@ def test_time_travel_callback_entries_include_nested_resolved_path_changes() -> }, ) - assert change_set.legacy_entries(ObjectStateRegistry._states) == [ + assert change_set.states_needing_navigation(ObjectStateRegistry._states) == [ (step_scope, step_state) ] From e487f8bac38ac537a4cba85548a2f78b0d0f599a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 00:27:12 -0400 Subject: [PATCH 4/8] Find a class's lazy counterpart through the base/lazy registry The lazy class of a config class was found by building its name from a prefix and reading that name off the module. The registry that already maps lazy to base now answers both directions, and nothing looks a lazy class up by name. A class's group abbreviation is declared once, on the class, and read on demand; a lazy class answers with its base's declaration. The @abbreviation decorator and the abbreviation= parameter write the same declaration, so decorator order no longer matters and two different declarations for one class raise. GROUP_ABBREVIATIONS_REGISTRY and LAZY_CONFIG_PREFIX are removed. The factory alone names a lazy class. Fixes #35 Co-Authored-By: Claude Opus 5.5 --- src/objectstate/lazy_factory.py | 135 +++++++------------ tests/test_lazy_counterpart_registry.py | 171 ++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 87 deletions(-) create mode 100644 tests/test_lazy_counterpart_registry.py diff --git a/src/objectstate/lazy_factory.py b/src/objectstate/lazy_factory.py index e45675e..9685ba6 100644 --- a/src/objectstate/lazy_factory.py +++ b/src/objectstate/lazy_factory.py @@ -890,10 +890,7 @@ def _introspect_dataclass_fields( else: lazy_nested_type = get_lazy_type_for_base(nested_type) if lazy_nested_type is None: - lazy_nested_type = LazyDataclassFactory.make_lazy_simple( - base_class=nested_type, - lazy_class_name=f"Lazy{nested_type.__name__}" - ) + lazy_nested_type = LazyDataclassFactory.make_lazy_simple(nested_type) field_type = lazy_nested_type logger.debug(f"Created lazy class for {field_definition.name}: {nested_type} -> {lazy_nested_type}") @@ -942,7 +939,7 @@ def _introspect_dataclass_fields( @staticmethod def _create_lazy_dataclass_unified( base_class: Type, - lazy_class_name: str, + lazy_class_name: str | None, debug_template: str, ) -> Type: """ @@ -957,13 +954,15 @@ def _create_lazy_dataclass_unified( registered_type = get_lazy_type_for_base(base_class) if registered_type is not None: - if registered_type.__name__ != lazy_class_name: + if lazy_class_name not in (None, registered_type.__name__): raise ValueError( f"{base_class.__name__} already owns lazy type " f"{registered_type.__name__}; cannot also create " f"{lazy_class_name}." ) return registered_type + if lazy_class_name is None: + lazy_class_name = f"{LAZY_CLASS_NAME_PREFIX}{base_class.__name__}" # Create lazy dataclass with introspected fields # CRITICAL FIX: Avoid inheriting from classes with custom metaclasses to prevent descriptor conflicts @@ -1055,7 +1054,7 @@ def __init_with_tracking__(self, **kwargs): @staticmethod def make_lazy_simple( base_class: Type, - lazy_class_name: str = None + lazy_class_name: str | None = None ) -> Type: """ Create lazy dataclass using new contextvars system. @@ -1065,14 +1064,12 @@ def make_lazy_simple( Args: base_class: Base dataclass to make lazy - lazy_class_name: Optional name for the lazy class + lazy_class_name: Name for the lazy class. When omitted the factory + names it, or returns the lazy class the base already owns. Returns: Generated lazy dataclass with contextvars-based resolution """ - # Generate class name if not provided - lazy_class_name = lazy_class_name or f"Lazy{base_class.__name__}" - return LazyDataclassFactory._create_lazy_dataclass_unified( base_class=base_class, lazy_class_name=lazy_class_name, @@ -1352,7 +1349,6 @@ def process_field_value(field_obj): # Naming configuration GLOBAL_CONFIG_PREFIX = "Global" -LAZY_CONFIG_PREFIX = "Lazy" # Registry to accumulate all decorations before injection _pending_injections = {} @@ -1365,9 +1361,9 @@ def process_field_value(field_obj): # Used by UI to display compact field names in list item previews FIELD_ABBREVIATIONS_REGISTRY: Dict[Type, Dict[str, str]] = {} -# Group abbreviations registry: Type -> abbreviation string -# Used by UI to display compact config class names in grouped previews -GROUP_ABBREVIATIONS_REGISTRY: Dict[Type, str] = {} +# Class attribute holding a config class's own group abbreviation. The class +# is the only owner; get_group_abbreviation reads it on demand. +GROUP_ABBREVIATION_ATTRIBUTE = "_abbreviation" # Always viewable fields registry: Type -> List[field_name] # Used by UI to auto-discover which fields should always be shown in list item previews @@ -1398,7 +1394,7 @@ class PartitionFilterConfig: name: The abbreviation string Returns: - When used as class decorator: the class with _abbreviation attribute set + When used as class decorator: the class, declaring its group abbreviation When used in Annotated: a marker object storing the abbreviation """ @@ -1407,21 +1403,7 @@ def __init__(self, name: str): def __call__(self, cls: Type) -> Type: """Class decorator usage: @abbreviation('wfc')""" - cls._abbreviation = self.name - # CRITICAL: Register immediately so @global_pipeline_config detects the correct value. - # Decorators apply bottom-up, so @abbreviation runs AFTER @global_pipeline_config. - # Without this, @global_pipeline_config would see the parent's _abbreviation. - GROUP_ABBREVIATIONS_REGISTRY[cls] = self.name - - # Also update the lazy wrapper class if it exists - # The lazy wrapper is created by @global_pipeline_config before @abbreviation runs, - # so we need to update its abbreviation here to keep them in sync. - lazy_class_name = f"{LAZY_CONFIG_PREFIX}{cls.__name__}" - lazy_class = getattr(sys.modules[cls.__module__], lazy_class_name, None) - if lazy_class is not None: - GROUP_ABBREVIATIONS_REGISTRY[lazy_class] = self.name - logger.debug(f"🔍 @abbreviation: Updated lazy wrapper {lazy_class_name} -> {self.name}") - + declare_group_abbreviation(cls, self.name) return cls def __repr__(self) -> str: @@ -1475,42 +1457,39 @@ def _extract_abbreviations_from_annotations(cls: Type) -> Dict[str, str]: return field_abbreviations -def get_group_abbreviation(config_type: Union[str, type]) -> str: - """Look up group abbreviation from GROUP_ABBREVIATIONS_REGISTRY. +def declare_group_abbreviation(config_type: type, name: str) -> None: + """Declare a config class's own group abbreviation. - Handles both type objects and type name strings. + A lazy class has no abbreviation of its own: the declaration lands on its + base class. A second, different declaration for one class raises. + """ + declaring_type = get_base_type_for_lazy(config_type) or config_type + declared = vars(declaring_type).get(GROUP_ABBREVIATION_ATTRIBUTE) + if declared is not None and declared != name: + raise ValueError( + f"{declaring_type.__qualname__} already declares abbreviation " + f"{declared!r}; cannot also declare {name!r}." + ) + setattr(declaring_type, GROUP_ABBREVIATION_ATTRIBUTE, name) - Args: - config_type: Config class or type name to get abbreviation for - Returns: - Abbreviation string if found, otherwise falls back to class name prefix +def get_group_abbreviation(config_type: str | type) -> str: + """Read a config class's group abbreviation from its declaration. + + A lazy class answers with its base class's declaration. A class with no + declaration of its own answers with its nearest declaring ancestor, then + with the prefix of its class name. A type name string answers with its + prefix. """ - import logging - logger = logging.getLogger(__name__) - if isinstance(config_type, str): return config_type.split('_')[0] if config_type else "root" - # Debug: Log lookup attempt - logger.debug(f"🔍 get_group_abbreviation: looking up {config_type.__name__}") - logger.debug(f"🔍 MRO: {[c.__name__ for c in config_type.__mro__]}") - logger.debug(f"🔍 Registry keys: {[k.__name__ if hasattr(k, '__name__') else str(k) for k in GROUP_ABBREVIATIONS_REGISTRY.keys()]}") - - if config_type in GROUP_ABBREVIATIONS_REGISTRY: - abbr = GROUP_ABBREVIATIONS_REGISTRY[config_type] - logger.debug(f"🔍 Found direct: {abbr}") - return abbr - - for base in config_type.__mro__[1:]: - if base in GROUP_ABBREVIATIONS_REGISTRY: - abbr = GROUP_ABBREVIATIONS_REGISTRY[base] - logger.debug(f"🔍 Found in MRO {base.__name__}: {abbr}") - return abbr - - fallback = config_type.__name__.split('_')[0] - logger.debug(f"🔍 Fallback: {fallback}") - return fallback + declaring_type = get_base_type_for_lazy(config_type) or config_type + for ancestor in declaring_type.__mro__: + declared = vars(ancestor).get(GROUP_ABBREVIATION_ATTRIBUTE) + if declared is not None: + return declared + return config_type.__name__.split('_')[0] def create_global_default_decorator(target_config_class: Type): @@ -1539,7 +1518,7 @@ def global_default_decorator(cls=None, *, optional: bool = False, inherit_as_non preview_label: Short label for list item previews (e.g., "NAP", "FIJI", "MAT"). If set, config will appear in preview when enabled. Registered in PREVIEW_LABEL_REGISTRY. abbreviation: Short abbreviation for config class name used in grouped previews (e.g., "pp" for PathPlanningConfig). - Registered in GROUP_ABBREVIATIONS_REGISTRY. + Declared on the class; see declare_group_abbreviation. field_abbreviations: Dict mapping field names to abbreviations for compact display. E.g., {'partition_filter': 'pf', 'num_workers': 'W'}. Registered in FIELD_ABBREVIATIONS_REGISTRY. @@ -1555,9 +1534,7 @@ def decorator(actual_cls): if inherited_fields: actual_cls = rebuild_with_none_defaults(actual_cls, inherited_fields) - # Generate field and class names field_name = _camel_to_snake(actual_cls.__name__) - lazy_class_name = f"{LAZY_CONFIG_PREFIX}{actual_cls.__name__}" # Mark class with typed ui_hidden metadata for UI layer to check # This allows the config to remain in the context (for lazy resolution) @@ -1570,14 +1547,10 @@ def decorator(actual_cls): if preview_label is not None: PREVIEW_LABEL_REGISTRY[actual_cls] = preview_label - # Register group abbreviation for config class name in grouped previews - # Priority: explicit parameter > @abbreviation decorator > auto-generated - detected_class_abbr = getattr(actual_cls, '_abbreviation', None) - final_class_abbr = abbreviation or detected_class_abbr - logger.debug(f"🔍 @global_pipeline_config: {actual_cls.__name__} - detected={detected_class_abbr}, explicit={abbreviation}, final={final_class_abbr}") - if final_class_abbr is not None: - GROUP_ABBREVIATIONS_REGISTRY[actual_cls] = final_class_abbr - logger.debug(f"🔍 Registered {actual_cls.__name__} -> {final_class_abbr}") + # The class owns its group abbreviation; @abbreviation writes the + # same declaration, so the two decorators may come in either order. + if abbreviation is not None: + declare_group_abbreviation(actual_cls, abbreviation) # Register field abbreviations for compact preview display # Priority: explicit parameter > @abbreviation in Annotated types @@ -1609,7 +1582,6 @@ def decorator(actual_cls): _pending_injections[target_class_name]['configs_to_inject'].append({ 'config_class': actual_cls, 'field_name': field_name, - 'lazy_class_name': lazy_class_name, 'optional': optional, # Store the optional flag 'inherit_as_none': inherit_as_none, # Store the inherit_as_none flag 'ui_hidden': ui_hidden # Store the ui_hidden flag for field metadata @@ -1623,25 +1595,17 @@ def decorator(actual_cls): # is the capability contract used by the factory and its consumers. bind_lazy_resolution_to_class(actual_cls) - lazy_class = LazyDataclassFactory.make_lazy_simple( - base_class=actual_cls, - lazy_class_name=lazy_class_name - ) + lazy_class = LazyDataclassFactory.make_lazy_simple(actual_cls) # Export lazy class to config module immediately config_module = sys.modules[actual_cls.__module__] - setattr(config_module, lazy_class_name, lazy_class) + setattr(config_module, lazy_class.__name__, lazy_class) # Copy metadata to lazy class for UI compatibility if ui_hidden: mark_ui_hidden_config(lazy_class) if preview_label is not None: PREVIEW_LABEL_REGISTRY[lazy_class] = preview_label - # Copy class abbreviation to lazy class - # Priority: 1. explicit param, 2. registry lookup (set by @abbreviation), 3. detected from class - class_abbr_to_copy = abbreviation or GROUP_ABBREVIATIONS_REGISTRY.get(actual_cls) or detected_class_abbr - if class_abbr_to_copy is not None: - GROUP_ABBREVIATIONS_REGISTRY[lazy_class] = class_abbr_to_copy # Copy field abbreviations to lazy class (from explicit param or detected from Annotated) field_abbr_to_copy = field_abbreviations or detected_field_abbrs if field_abbr_to_copy: @@ -1746,11 +1710,8 @@ def _register_lazy_class(lazy_class, class_name, module_name): # Create lazy classes and recreate PipelineConfig inline for config in configs: - lazy_class = LazyDataclassFactory.make_lazy_simple( - base_class=config['config_class'], - lazy_class_name=config['lazy_class_name'] - ) - _register_lazy_class(lazy_class, config['lazy_class_name'], config['config_class'].__module__) + lazy_class = LazyDataclassFactory.make_lazy_simple(config['config_class']) + _register_lazy_class(lazy_class, lazy_class.__name__, config['config_class'].__module__) # Create lazy version of the updated global config itself with proper naming # Global configs must start with GLOBAL_CONFIG_PREFIX - fail-loud if not diff --git a/tests/test_lazy_counterpart_registry.py b/tests/test_lazy_counterpart_registry.py new file mode 100644 index 0000000..a0d868d --- /dev/null +++ b/tests/test_lazy_counterpart_registry.py @@ -0,0 +1,171 @@ +"""The base/lazy relation is owned by one registry, not by a class-name convention.""" + +from dataclasses import dataclass + +import pytest + +from objectstate import LazyDataclassFactory, abbreviation, get_base_type_for_lazy, lazy_factory +from objectstate.lazy_factory import ( + create_global_default_decorator, + get_group_abbreviation, + get_lazy_type_for_base, + register_lazy_type_mapping, +) + + +@pytest.fixture +def global_default(): + """A global-default decorator whose pending injections are dropped afterwards.""" + + @dataclass(frozen=True) + class GlobalCounterpartConfig: + pass + + yield create_global_default_decorator(GlobalCounterpartConfig) + del lazy_factory._pending_injections[GlobalCounterpartConfig.__name__] + + +def _abbreviation_first(global_default, name): + @global_default + @abbreviation(name) + @dataclass(frozen=True) + class WellFilterConfig: + well: str = "A01" + + return WellFilterConfig + + +def _abbreviation_last(global_default, name): + @abbreviation(name) + @global_default + @dataclass(frozen=True) + class WellFilterConfig: + well: str = "A01" + + return WellFilterConfig + + +DECORATOR_ORDERS = pytest.mark.parametrize( + "define", [_abbreviation_first, _abbreviation_last] +) + + +@DECORATOR_ORDERS +def test_class_inside_function_gets_abbreviation_on_base_and_lazy(define, global_default): + config = define(global_default, "wfc") + lazy = get_lazy_type_for_base(config) + + assert get_base_type_for_lazy(lazy) is config + assert get_group_abbreviation(config) == "wfc" + assert get_group_abbreviation(lazy) == "wfc" + + +@DECORATOR_ORDERS +def test_same_named_classes_in_one_module_keep_their_own_abbreviation( + define, global_default +): + first = define(global_default, "first") + second = define(global_default, "second") + + assert first.__name__ == second.__name__ + assert first.__module__ == second.__module__ + first_lazy = get_lazy_type_for_base(first) + second_lazy = get_lazy_type_for_base(second) + assert first_lazy is not second_lazy + assert [get_group_abbreviation(t) for t in (first, first_lazy)] == ["first"] * 2 + assert [get_group_abbreviation(t) for t in (second, second_lazy)] == ["second"] * 2 + + +@DECORATOR_ORDERS +def test_renamed_prefix_does_not_break_the_relation(define, global_default, monkeypatch): + monkeypatch.setattr(lazy_factory, "LAZY_CLASS_NAME_PREFIX", "Deferred") + config = define(global_default, "wfc") + lazy = get_lazy_type_for_base(config) + + assert lazy.__name__ == "DeferredWellFilterConfig" + assert get_group_abbreviation(lazy) == "wfc" + + monkeypatch.setattr(lazy_factory, "LAZY_CLASS_NAME_PREFIX", "Lazy") + assert LazyDataclassFactory.make_lazy_simple(config) is lazy + assert get_group_abbreviation(lazy) == "wfc" + + +def test_explicit_abbreviation_parameter_reaches_the_lazy_class(global_default): + @global_default(abbreviation="pp") + @dataclass(frozen=True) + class PathPlanningConfig: + suffix: str = "_out" + + assert get_group_abbreviation(PathPlanningConfig) == "pp" + assert get_group_abbreviation(get_lazy_type_for_base(PathPlanningConfig)) == "pp" + + +def test_two_different_abbreviations_for_one_class_raise(global_default): + with pytest.raises(ValueError, match="already declares abbreviation 'pp'"): + + @abbreviation("other") + @global_default(abbreviation="pp") + @dataclass(frozen=True) + class PathPlanningConfig: + suffix: str = "_out" + + +def test_subclass_without_declaration_reads_its_ancestor(global_default): + parent = _abbreviation_first(global_default, "wfc") + + @global_default + @dataclass(frozen=True) + class StepWellFilterConfig(parent): + pass + + @abbreviation("own") + @global_default + @dataclass(frozen=True) + class NamedWellFilterConfig(parent): + pass + + @global_default + @abbreviation("below") + @dataclass(frozen=True) + class RebuiltWellFilterConfig(parent): + pass + + assert get_group_abbreviation(get_lazy_type_for_base(RebuiltWellFilterConfig)) == "below" + assert get_group_abbreviation(get_lazy_type_for_base(StepWellFilterConfig)) == "wfc" + assert get_group_abbreviation(get_lazy_type_for_base(NamedWellFilterConfig)) == "own" + assert get_group_abbreviation(parent) == "wfc" + + +def test_registry_refuses_a_second_counterpart_in_either_direction(): + @dataclass(frozen=True) + class First: + value: int = 0 + + @dataclass(frozen=True) + class Second: + value: int = 0 + + @dataclass(frozen=True) + class Unregistered: + value: int = 0 + + lazy_first = LazyDataclassFactory.make_lazy_simple(First) + lazy_second = LazyDataclassFactory.make_lazy_simple(Second) + + with pytest.raises(ValueError, match="already registered"): + register_lazy_type_mapping(lazy_first, Second) + with pytest.raises(ValueError, match="already owns lazy type"): + register_lazy_type_mapping(Unregistered, First) + with pytest.raises(ValueError, match="already owns lazy type"): + LazyDataclassFactory.make_lazy_simple(First, "OtherLazyFirst") + assert get_lazy_type_for_base(First) is lazy_first + assert get_base_type_for_lazy(lazy_second) is Second + assert get_base_type_for_lazy(Unregistered) is None + + +def test_no_lazy_class_is_looked_up_by_constructed_name(): + import inspect + + source = inspect.getsource(lazy_factory) + assert "LAZY_CONFIG_PREFIX" not in source + assert "getattr(sys.modules" not in source From ba62f26d6fcc68021798046718693e6a37d6ef3b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 00:41:23 -0400 Subject: [PATCH 5/8] Resolve a field once: value with source scope and type, kept on the layer stack Attribute access and ObjectState both read resolve_with_provenance. The merged-config walk (resolve_field_inheritance) and the process-wide caches are deleted. Co-Authored-By: Claude Opus 5.5 --- src/objectstate/__init__.py | 10 +- src/objectstate/context_manager.py | 282 +++++++++-------- src/objectstate/dual_axis_resolver.py | 380 +++++++---------------- src/objectstate/lazy_factory.py | 126 ++------ src/objectstate/object_state_registry.py | 8 - tests/test_context_failures_surface.py | 2 +- tests/test_context_manager.py | 19 +- tests/test_one_field_resolution.py | 301 ++++++++++++++++++ 8 files changed, 586 insertions(+), 542 deletions(-) create mode 100644 tests/test_one_field_resolution.py diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index 44961bd..f2101c5 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -78,15 +78,14 @@ # Resolver from objectstate.dual_axis_resolver import ( - resolve_field_inheritance, - _has_concrete_field_override, + ResolvedField, + resolve_with_provenance, ) # Context from objectstate.context_manager import ( config_context, get_current_temp_global, - set_current_temp_global, clear_current_temp_global, merge_configs, extract_all_configs, @@ -168,12 +167,11 @@ 'is_global_config_type', 'is_global_config_instance', # Resolver - 'resolve_field_inheritance', - '_has_concrete_field_override', + 'ResolvedField', + 'resolve_with_provenance', # Context 'config_context', 'get_current_temp_global', - 'set_current_temp_global', 'clear_current_temp_global', 'merge_configs', 'extract_all_configs', diff --git a/src/objectstate/context_manager.py b/src/objectstate/context_manager.py index 5c57f19..98d9a97 100644 --- a/src/objectstate/context_manager.py +++ b/src/objectstate/context_manager.py @@ -22,7 +22,7 @@ import threading from collections.abc import Sequence from contextlib import ExitStack, contextmanager -from typing import Any, Dict, get_type_hints +from typing import Any, Dict, Optional, Tuple, get_type_hints from dataclasses import fields, is_dataclass from python_introspect import resolve_annotated, resolve_optional @@ -42,12 +42,60 @@ class NoActiveContextError(RuntimeError): # The stack is a tuple of types, ordered from outermost to innermost context context_type_stack = contextvars.ContextVar('context_type_stack', default=()) -# Context layer stack - tracks (scope_id, obj) tuples for provenance tracking -# Parallel to merged config - NOT flattened, preserves hierarchy for inheritance source lookup -# Used by get_field_provenance() to determine which scope provided a resolved value -from typing import Tuple, Optional -context_layer_stack: contextvars.ContextVar[Tuple[Tuple[str, Any], ...]] = contextvars.ContextVar( - 'context_layer_stack', default=() +class ContextLayer: + """One object entered with ``config_context``, with the scope it was entered under. + + ``scope_id`` is ``None`` for a layer entered without one. ``masks_outer_layers`` + is True for a layer entered with ``mask_with_none``: a ``None`` in it is final, + so no layer outside it supplies a value. + """ + + __slots__ = ("scope_id", "obj", "masks_outer_layers", "_configs") + + def __init__(self, scope_id: Optional[str], obj: Any, masks_outer_layers: bool = False): + self.scope_id = scope_id + self.obj = obj + self.masks_outer_layers = masks_outer_layers + self._configs: Optional[Dict[type, Any]] = None + + @property + def configs(self) -> Dict[type, Any]: + """The config instances this layer holds, by type. Extracted once per layer.""" + configs = self._configs + if configs is None: + configs = self._configs = extract_all_configs(self.obj) + return configs + + def __repr__(self) -> str: + return f"ContextLayer({self.scope_id!r}, {type(self.obj).__name__})" + + +class ContextLayerStack: + """The layers of one active context, outermost first. + + This is the value field resolution reads. Every ``config_context`` entry makes a + new stack, and ``resolved_fields`` holds the answers computed from that stack, so + the answers are dropped together with the stack they belong to. + """ + + __slots__ = ("layers", "resolved_fields", "__weakref__") + + def __init__(self, layers: Tuple[ContextLayer, ...] = ()): + self.layers = layers + self.resolved_fields: Dict[Tuple[type, str], Any] = {} + + def with_layer(self, layer: ContextLayer) -> "ContextLayerStack": + return ContextLayerStack(self.layers + (layer,)) + + def __repr__(self) -> str: + return f"ContextLayerStack({self.layers!r})" + + +NO_LAYERS = ContextLayerStack() + +# The layer stack of the active context. NO_LAYERS means no context is active. +context_layer_stack: contextvars.ContextVar[ContextLayerStack] = contextvars.ContextVar( + 'context_layer_stack', default=NO_LAYERS ) def _merge_nested_dataclass(base, override, mask_with_none: bool = False): @@ -132,9 +180,10 @@ def config_context(obj, mask_with_none: bool = False, use_live_global: bool = Tr loaded instance with static class defaults. use_live_global: If True (default), use LIVE global config (UI sees unsaved edits). If False, use SAVED global config (compiler sees saved values). - scope_id: Optional scope identifier for provenance tracking (e.g., "plate_123::step_0"). - When provided, the (scope_id, obj) tuple is pushed to context_layer_stack - to enable inheritance source lookup via get_field_provenance(). + scope_id: The scope this layer belongs to (e.g., "plate_123::step_0"). Field + resolution reports it as the source scope of every value the layer + supplies. A layer entered without one supplies values all the same and + is reported with source scope ``None``. Usage: # UI operations (default - uses LIVE) @@ -145,9 +194,9 @@ def config_context(obj, mask_with_none: bool = False, use_live_global: bool = Tr with config_context(orchestrator.pipeline_config, use_live_global=False): # Compiler uses saved global config only - # With provenance tracking + # With a scope id with config_context(step, scope_id="plate_123::step_0"): - # Layer tracked for inheritance source lookup + # Values this layer supplies are reported as coming from that scope """ # Get current context as base for nested contexts, or fall back to base global config current_context = get_current_temp_global() @@ -229,26 +278,27 @@ def config_context(obj, mask_with_none: bool = False, use_live_global: bool = Tr current_types = context_type_stack.get() new_types = current_types + (type(obj),) if obj is not None else current_types - # Track (scope_id, obj) in layer stack for provenance tracking - current_layers = context_layer_stack.get() - new_layers = current_layers + ((scope_id, obj),) if scope_id is not None else current_layers + # A context entered while none is active starts from the global config, so the + # global config is its outermost layer. + if current_context is None: + current_stack = ContextLayerStack((ContextLayer("", base_config),)) + else: + current_stack = context_layer_stack.get() + new_stack = ( + current_stack.with_layer(ContextLayer(scope_id, obj, mask_with_none)) + if obj is not None + else current_stack + ) merged_token = current_temp_global.set(merged_config) type_token = context_type_stack.set(new_types) - layer_token = context_layer_stack.set(new_layers) - # PERFORMANCE: Clear caches on context push (new merged config) - clear_extract_all_configs_cache() - from objectstate.dual_axis_resolver import clear_mro_resolution_cache - clear_mro_resolution_cache() + layer_token = context_layer_stack.set(new_stack) try: yield finally: current_temp_global.reset(merged_token) context_type_stack.reset(type_token) context_layer_stack.reset(layer_token) - # PERFORMANCE: Clear caches on context pop - clear_extract_all_configs_cache() - clear_mro_resolution_cache() def get_context_type_stack(): @@ -272,24 +322,19 @@ def get_context_type_stack(): return context_type_stack.get() -def get_context_layer_stack() -> Tuple[Tuple[str, Any], ...]: +def get_context_layer_stack() -> ContextLayerStack: """ - Get the current layer stack for provenance tracking. + Get the layer stack of the active context: the value field resolution reads. - Returns a tuple of (scope_id, obj) tuples, ordered from outermost to innermost. - Only includes layers where scope_id was explicitly provided to config_context(). - - Used by get_field_provenance() to determine which scope provided a resolved value. - - Returns: - Tuple of (scope_id, obj) tuples representing the context hierarchy. - Empty tuple if no layers with scope_ids are active. + ``.layers`` holds one ``ContextLayer`` per object entered with ``config_context``, + outermost first, below the global config the context started from. It is empty + when no context is active. Example: with config_context(plate, scope_id="plate_123"): with config_context(step, scope_id="plate_123::step_0"): - layers = get_context_layer_stack() - # layers == (("plate_123", plate), ("plate_123::step_0", step)) + scopes = [layer.scope_id for layer in get_context_layer_stack().layers] + # scopes == ["", "plate_123", "plate_123::step_0"] """ return context_layer_stack.get() @@ -304,11 +349,8 @@ def _normalize_type(t): PERFORMANCE: Results are cached with lru_cache since type->base_type mappings are immutable after class creation. """ - try: - from objectstate.lazy_factory import get_base_type_for_lazy - return get_base_type_for_lazy(t) or t - except ImportError: - return t + from objectstate.lazy_factory import get_base_type_for_lazy + return get_base_type_for_lazy(t) or t def _is_global_type(t): @@ -316,11 +358,8 @@ def _is_global_type(t): This function is defined here to avoid circular imports with lazy_factory. """ - try: - from objectstate.lazy_factory import is_global_config_type - return is_global_config_type(t) - except ImportError: - return False + from objectstate.lazy_factory import is_global_config_type + return is_global_config_type(t) # Hierarchy registry - built from active form managers @@ -543,8 +582,10 @@ def build_context_stack( Enter every context layer needed to resolve ``object_instance``; leave them on exit. Layers, outermost first: - 1. The global layer, under scope ``""``. When ``object_instance`` is itself a global - config, this is a fresh instance of its type holding the declared defaults. + 1. The global config, under scope ``""``. ``config_context`` puts it below the + first layer entered while no context is active. When ``object_instance`` is + itself a global config, a fresh instance of its type holding the declared + defaults is entered above it, also under scope ``""``, and masks it. 2. Each ancestor, under its own scope id. Global-config ancestors are left out because layer 1 already stands for them. 3. ``object_instance``, under ``current_scope_id``. @@ -560,23 +601,21 @@ def build_context_stack( """ from objectstate.lazy_factory import GlobalConfigBase + def enter(stack: ExitStack, obj: object, scope_id: str | None, mask: bool = False): + stack.enter_context( + config_context(obj, mask_with_none=mask, use_live_global=use_live, scope_id=scope_id) + ) + with ExitStack() as stack: if isinstance(object_instance, GlobalConfigBase): - declared_defaults = type(object_instance)() - stack.enter_context( - config_context(declared_defaults, mask_with_none=True, scope_id="") - ) - else: - global_layer = get_base_global_config(use_live=use_live) - if global_layer: - stack.enter_context(config_context(global_layer, scope_id="")) + enter(stack, type(object_instance)(), "", mask=True) for scope_id, ancestor_obj in ancestor_objects_with_scopes: if isinstance(ancestor_obj, GlobalConfigBase): continue - stack.enter_context(config_context(ancestor_obj, scope_id=scope_id)) + enter(stack, ancestor_obj, scope_id) - stack.enter_context(config_context(object_instance, scope_id=current_scope_id)) + enter(stack, object_instance, current_scope_id) yield @@ -603,34 +642,26 @@ def merge_configs(base, overrides: Dict[str, Any]): def get_base_global_config(use_live: bool = True): """ - Get the base global config (fallback when no context set). + Get the global config a context starts from when none is active. - This provides the global config that was set up with ensure_global_config_context(), - or a default if none was set. Used as the base for merging operations. + This is the config set with ensure_global_config_context(), or an instance of the + base config type holding its declared defaults when none was set. Raises when no + base config type is set or its defaults cannot be constructed. Args: use_live: If True (default), return LIVE config (UI sees unsaved edits). If False, return SAVED config (compiler sees saved values). - - Returns: - Current global config instance or default instance of base config type """ - try: - from objectstate.config import get_base_config_type - from objectstate.global_config import get_current_global_config + from objectstate.config import get_base_config_type + from objectstate.global_config import get_current_global_config - base_config_type = get_base_config_type() + base_config_type = get_base_config_type() - # Get the appropriate global config (live or saved) - current_global = get_current_global_config(base_config_type, use_live=use_live) - if current_global is not None: - return current_global + current_global = get_current_global_config(base_config_type, use_live=use_live) + if current_global is not None: + return current_global - # Fallback to default if none was set - return base_config_type() - except ImportError: - logger.warning("Could not get base config type") - return None + return base_config_type() def get_current_temp_global(): @@ -646,33 +677,15 @@ def get_current_temp_global(): return current_temp_global.get(None) -def set_current_temp_global(config): - """ - Set current context (for testing/debugging). - - This is primarily for testing purposes. Normal code should use - config_context() manager instead. - - Args: - config: Global config instance to set as current context - - Returns: - Token for resetting the context - """ - return current_temp_global.set(config) - - def clear_current_temp_global(): """ - Clear current context (for testing/debugging). - - This removes any active context, causing resolution to fall back - to default behavior. + Leave the active context without restoring an outer one. + + After this no context is active: lazy fields fall back to their declared + defaults until the next ``config_context`` is entered. """ - try: - current_temp_global.set(None) - except LookupError: - pass # No context was set + current_temp_global.set(None) + context_layer_stack.set(NO_LAYERS) # Utility functions for debugging and introspection @@ -714,17 +727,28 @@ def extract_all_configs_from_context() -> Dict[type, Any]: return extract_all_configs(current) -# PERFORMANCE: Cache extracted configs per context object id -# Cleared when context changes (config_context push/pop) -_extract_all_configs_cache: Dict[int, Dict[type, Any]] = {} +@functools.lru_cache(maxsize=None) +def _declared_config_fields(dataclass_type: type) -> Tuple[Tuple[str, type], ...]: + """The fields of a dataclass type that are declared as dataclass types, with those types. + + A fact about the class, so it is kept per class. + """ + annotations = get_type_hints(dataclass_type, include_extras=True) + declared = [] + for field_info in fields(dataclass_type): + field_type = annotations.get(field_info.name, field_info.type) + actual_type = resolve_optional(resolve_annotated(field_type)) + if is_dataclass(actual_type): + declared.append((field_info.name, actual_type)) + return tuple(declared) def extract_all_configs(context_obj) -> Dict[type, Any]: """ Extract all config instances from a context object using type-driven approach. - PERFORMANCE: Results are cached per context object id. Cache is cleared on - context changes via clear_extract_all_configs_cache(). + A context layer keeps its own result (``ContextLayer.configs``); this function + itself keeps nothing. This function leverages dataclass field type annotations to efficiently extract config instances, avoiding string matching and runtime attribute scanning. @@ -738,11 +762,6 @@ def extract_all_configs(context_obj) -> Dict[type, Any]: if context_obj is None: return {} - # PERFORMANCE: Check cache first - obj_id = id(context_obj) - if obj_id in _extract_all_configs_cache: - return _extract_all_configs_cache[obj_id] - configs: Dict[type, Any] = {} # Include the context object itself if it's a dataclass @@ -751,35 +770,27 @@ def extract_all_configs(context_obj) -> Dict[type, Any]: # Type-driven extraction: Use dataclass field annotations to find config fields if is_dataclass(type(context_obj)): - annotations = get_type_hints(type(context_obj), include_extras=True) - for field_info in fields(type(context_obj)): - field_type = annotations.get(field_info.name, field_info.type) - field_name = field_info.name - - actual_type = resolve_optional(resolve_annotated(field_type)) - - # Only process fields that are dataclass types (config objects) - if is_dataclass(actual_type): - field_value = getattr(context_obj, field_name) - if field_value is None: - continue - if not _is_compatible_config_type(field_value, actual_type): - raise TypeError( - f"{type(context_obj).__name__}.{field_name} declares " - f"{actual_type.__name__}, got {type(field_value).__name__}." - ) - _record_config_instance( - configs, - field_value, - source=f"{type(context_obj).__name__}.{field_name}", + for field_name, declared_type in _declared_config_fields(type(context_obj)): + # Read the stored value: a None field holds no config in this object, + # and resolving it here would ask the context that is being read. + field_value = object.__getattribute__(context_obj, field_name) + if field_value is None: + continue + if not _is_compatible_config_type(field_value, declared_type): + raise TypeError( + f"{type(context_obj).__name__}.{field_name} declares " + f"{declared_type.__name__}, got {type(field_value).__name__}." ) + _record_config_instance( + configs, + field_value, + source=f"{type(context_obj).__name__}.{field_name}", + ) # For non-dataclass objects (orchestrators, etc.), extract dataclass attributes else: _extract_from_object_attributes_typed(context_obj, configs) - # Cache result - _extract_all_configs_cache[obj_id] = configs if logger.isEnabledFor(logging.DEBUG): logger.debug( "Extracted %d configs: %s", @@ -789,11 +800,6 @@ def extract_all_configs(context_obj) -> Dict[type, Any]: return configs -def clear_extract_all_configs_cache() -> None: - """Clear the extract_all_configs cache. Call when context changes.""" - _extract_all_configs_cache.clear() - - def _record_config_instance( configs: Dict[type, Any], config_instance: Any, diff --git a/src/objectstate/dual_axis_resolver.py b/src/objectstate/dual_axis_resolver.py index 0bf4c61..bac41e1 100644 --- a/src/objectstate/dual_axis_resolver.py +++ b/src/objectstate/dual_axis_resolver.py @@ -1,62 +1,35 @@ """ Generic dual-axis resolver for lazy configuration inheritance. -This module provides the core inheritance resolution logic as a pure function, -supporting both context hierarchy (X-axis) and sibling inheritance (Y-axis). +``resolve_with_provenance`` is the one resolution of a field in the active context, +over both the context hierarchy (X-axis) and sibling inheritance (Y-axis). It returns +the value together with the scope and type that supplied it. The resolver is completely generic and has no application-specific dependencies. """ -import logging import functools -from typing import Any, Dict, Optional, Tuple -from dataclasses import MISSING, is_dataclass +import logging +from dataclasses import MISSING, fields, is_dataclass +from typing import Any, NamedTuple, Optional, Tuple logger = logging.getLogger(__name__) -# PERFORMANCE: Cache MRO resolution results -# Key: (obj_type, field_name, context_signature) -> resolved value -# context_signature = tuple of (config_type, id(config_instance)) for stable identity -_mro_resolution_cache: Dict[Tuple, Any] = {} -_CACHE_SENTINEL = object() # Distinguishes "cached None" from "not cached" - -def clear_mro_resolution_cache() -> None: - """Clear the MRO resolution cache. Call when context fundamentally changes.""" - _mro_resolution_cache.clear() +class ResolvedField(NamedTuple): + """What a field resolves to in one context, with the layer that supplied it. + ``source_scope`` is the scope id of the supplying layer and ``source_type`` the + config type it was read from, which is the container type or one of its bases. -def invalidate_mro_cache_for_field(changed_type: type, field_name: str) -> None: - """Invalidate cache entries for a specific field that could be affected by a type change. - - PERFORMANCE: Only clears cache entries where: - 1. The field_name matches - 2. The obj_type has changed_type in its MRO (could inherit from it) - - This is much more targeted than clear_mro_resolution_cache(). + When no layer holds a value, ``value`` is the declared default (or ``None``) and + the source names where the field lives in the context: the outermost layer holding + the nearest type in the MRO. Both are ``None`` when no layer holds such a type. """ - if not _mro_resolution_cache: - return - if not isinstance(changed_type, type): - return - from objectstate.lazy_factory import get_base_type_for_lazy - base_changed = get_base_type_for_lazy(changed_type) or changed_type - - keys_to_remove = [] - for key in _mro_resolution_cache: - obj_type, cached_field, _ = key - if cached_field != field_name: - continue - # Check if obj_type could inherit from changed_type - obj_base = get_base_type_for_lazy(obj_type) or obj_type - if not isinstance(obj_base, type): - continue - if base_changed in obj_base.__mro__: - keys_to_remove.append(key) - - for key in keys_to_remove: - del _mro_resolution_cache[key] + value: Any + source_scope: Optional[str] + source_type: Optional[type] @functools.lru_cache(maxsize=None) @@ -74,18 +47,6 @@ def _normalize_to_base(t: type) -> type: return get_base_type_for_lazy(t) or t -def _has_concrete_field_override(config_class, field_name: str) -> bool: - """ - Check if a class has a concrete field override (not None). - - Used by lazy_factory to find MRO concrete values. - """ - if hasattr(config_class, field_name): - class_attr_value = getattr(config_class, field_name) - return class_attr_value is not None - return False - - def _direct_owner_field_default(config_type: type, field_name: str) -> Tuple[bool, Any]: """Return the default owned by config_type for field_name. @@ -115,256 +76,135 @@ def _direct_owner_field_default(config_type: type, field_name: str) -> Tuple[boo return False, None -def resolve_field_inheritance( - obj, - field_name: str, - available_configs: Dict[type, Any] -) -> Any: - """ - MRO-based inheritance resolution. - - ALGORITHM: - For LazyDataclass types: - 1. Check if same-type config exists in context with concrete value - 2. Walk MRO to find parent class configs with concrete value - 3. Fall back to static class defaults if nothing found +def declared_default(container_type: type, field_name: str) -> Tuple[Any, Optional[type]]: + """The value a field has when no context layer supplies one. - For concrete classes with lazy resolution: - 1. SKIP same-type lookup (if you created ProcessingConfig(group_by=None), you want None) - 2. Walk MRO to find PARENT class configs with concrete value (sibling inheritance) - 3. Return None if nothing found (no static default fallback) - - Args: - obj: The object requesting field resolution - field_name: Name of the field to resolve - available_configs: Dict mapping config types to config instances - - Returns: - Resolved field value or None if not found - """ - obj_type = type(obj) - - # Check if this field needs resolution (instance value is None) - try: - instance_value = object.__getattribute__(obj, field_name) - needs_resolution = instance_value is None - except AttributeError: - needs_resolution = True - - # Step 1: Check if exact same type has concrete value in context - # Do same-type lookup if the field value is None (needs lazy resolution). - # This works for both LazyDataclass types AND concrete dataclasses with None fields. - obj_base = _normalize_to_base(obj_type) - - # PERFORMANCE: Check MRO resolution cache before doing the full walk. - # Cache key uses (obj_type, field_name, config_identity) where config_identity - # captures which config instances are active. Cache is cleared on context push/pop. - _cache_key = (obj_type, field_name, tuple((k, id(v)) for k, v in available_configs.items())) - _cached = _mro_resolution_cache.get(_cache_key, _CACHE_SENTINEL) - if _cached is not _CACHE_SENTINEL: - return _cached - - if needs_resolution: - for config_type, config_instance in available_configs.items(): - # Normalize both sides: LazyPartitionFilterConfig matches PartitionFilterConfig - instance_base = _normalize_to_base(type(config_instance)) - if instance_base == obj_base: - try: - field_value = object.__getattribute__(config_instance, field_name) - if field_value is not None: - _mro_resolution_cache[_cache_key] = field_value - return field_value - except AttributeError: - continue - - # Step 2: MRO-based inheritance - traverse MRO from most to least specific - # Skip the first entry (self type) since we already checked it above (for lazy) or want to skip it (for concrete) - # This finds PARENT class configs with concrete values (sibling inheritance) - - for mro_class in obj_type.__mro__[1:]: # Skip first (self type) - if not is_dataclass(mro_class): - continue + In order: the default the base type declares for the field itself, the first + concrete class attribute along the base type's MRO, then the default the field + had before it was rebuilt to inherit (kept in the field's metadata). - # Look for a config instance of this MRO class type in the available configs - # Normalize both sides: a LazyPartitionFilterConfig instance matches - # PartitionFilterConfig in the MRO - mro_base = _normalize_to_base(mro_class) - for config_instance in available_configs.values(): - instance_base = _normalize_to_base(type(config_instance)) - if instance_base == mro_base: - try: - value = object.__getattribute__(config_instance, field_name) - if value is not None: - _mro_resolution_cache[_cache_key] = value - return value - except AttributeError: - continue - - has_default, default_value = _direct_owner_field_default(obj_base, field_name) - if has_default: - _mro_resolution_cache[_cache_key] = default_value - return default_value - - # If MRO walk finds nothing and the field is not directly owned by this - # config class, return None so inherited fields continue to resolve through - # their owning MRO/default config. - _mro_resolution_cache[_cache_key] = None - return None - - -def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any, Optional[str], Optional[type]]: + Returns the value and, when the base type declares a default for the field + itself, that base type; otherwise ``None`` in its place. """ - Resolve a field value AND find its provenance source in ONE walk. + container_base = _normalize_to_base(container_type) + has_own_default, own_default = _direct_owner_field_default(container_base, field_name) + own_default_type = container_base if has_own_default else None + if own_default is not None: + return own_default, own_default_type - TWO-PHASE DUAL-AXIS RESOLUTION: + for mro_class in container_base.__mro__: + class_value = getattr(mro_class, field_name, None) + if class_value is not None: + return class_value, own_default_type - Phase 1 - Hierarchy (same-type only, outer to inner): - Walk scopes from global → pipeline → step, checking ONLY the same-type config. - If a concrete value is found, return it immediately. - This gives hierarchy precedence for directly-set values. + if is_dataclass(container_type): + for field_definition in fields(container_type): + if field_definition.name != field_name: + continue + metadata = field_definition.metadata + inherited = metadata.get("_inherited_default", MISSING) + if inherited is not MISSING: + return inherited, own_default_type + factory = metadata.get("_inherited_default_factory", MISSING) + if factory is not MISSING: + return factory(), own_default_type + return None, own_default_type - Phase 2 - MRO fallback (only if Phase 1 found nothing): - Walk scopes from inner to outer (step → pipeline → global), doing MRO walk. - This allows sibling inheritance when no concrete value exists in the hierarchy. - This ensures: - - GlobalPipelineConfig.partition_filter_config.partition_filter overrides the same field - at PipelineConfig level (hierarchy precedence for same-type) - - MRO inheritance only applies when NO concrete value exists in the hierarchy - for the specific config type being resolved +def resolve_with_provenance(container_type: type, field_name: str) -> ResolvedField: + """ + Resolve a field in the active context: its value and the layer that supplied it. - PERFORMANCE: Single walk instead of separate resolve + provenance calls. + This is the one resolution. Attribute access on a lazy config takes ``.value`` + from it; ``ObjectState`` takes the value and the source together. - IMPORTANT: Must be called within a config_context() that has scope_ids set up. - The layer stack is built by build_context_stack() with ancestor_objects_with_scopes. + The answer is kept on the active layer stack, so it lives exactly as long as the + context it was computed from. Args: container_type: The type containing the field (e.g., LazyPathPlanningConfig) - field_name: Name of the field to find provenance for (e.g., "partition_filter") + field_name: Name of the field to resolve (e.g., "partition_filter") - Returns: - (resolved_value, source_scope_id, source_type): The resolved value, scope that - provided it, and the TYPE that has the concrete value (may differ from container_type - due to MRO inheritance, e.g., PathPlanningConfig instead of PartitionFilterConfig). - If no concrete value found, returns (None, None, None). + Raises: + NoActiveContextError: no ``config_context`` is active. + """ + from objectstate.context_manager import NoActiveContextError, context_layer_stack + + stack = context_layer_stack.get() + if not stack.layers: + raise NoActiveContextError( + f"Cannot resolve {container_type.__name__}.{field_name}: " + "no config context is active." + ) + return resolve_in_stack(stack, container_type, field_name) + + +def resolve_in_stack(stack, container_type: type, field_name: str) -> ResolvedField: + """``resolve_with_provenance`` for a layer stack the caller already holds.""" + key = (container_type, field_name) + resolved = stack.resolved_fields.get(key) + if resolved is None: + resolved = stack.resolved_fields[key] = _resolve_in_layers( + stack.layers, container_type, field_name + ) + return resolved + + +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. + + 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. """ - from objectstate.context_manager import get_context_layer_stack, extract_all_configs + container_base = _normalize_to_base(container_type) - layers = get_context_layer_stack() - if not layers: - return None, None, None + visible_layers = [] + for layer in reversed(layers): + visible_layers.append(layer) + if layer.masks_outer_layers: + break - # Normalize the container type for comparison - container_base = _normalize_to_base(container_type) + # Where the field lives when every layer leaves it None: the outermost layer + # holding the first MRO type that any layer holds. + unset_scope: Optional[str] = None + unset_type: Optional[type] = None + unset_found = False - # Build MRO list for container_type (normalized) - includes self type first - mro_types = [] for mro_class in container_base.__mro__: if not is_dataclass(mro_class): continue - mro_base = _normalize_to_base(mro_class) - mro_types.append(mro_base) - - # TWO-PHASE RESOLUTION: - # Phase 1: Hierarchy walk (same-type only) - outer to inner - # Phase 2: MRO fallback (only if no concrete value in hierarchy) - # - # This ensures: - # - A concrete value at GlobalPipelineConfig.partition_filter_config.partition_filter overrides - # the same field at PipelineConfig level (hierarchy precedence) - # - MRO inheritance only applies when NO concrete value exists in the hierarchy - # for the specific config type being resolved - - # Track fallback provenance - where we find the field (even if None) - fallback_scope: Optional[str] = None - fallback_type: Optional[type] = None - - # Collect layer configs once for both phases - all_layer_configs: list[tuple[str, dict]] = [] - for scope_id, layer_obj in layers: - if layer_obj is None: - continue - all_layer_configs.append((scope_id, extract_all_configs(layer_obj))) - - # PHASE 1: Hierarchy walk - check ONLY same-type config at each scope (inner to outer) - # This gives hierarchy precedence: inner/child scopes override outer/parent scopes - # REVERSED ORDER: Walk from inner to outer so more specific scopes override general scopes - for scope_id, layer_configs in reversed(all_layer_configs): - - # extract_all_configs admits one instance per canonical owner type. - config_instance = layer_configs.get(container_base) - if config_instance is None: - continue - try: - value = object.__getattribute__(config_instance, field_name) - if value is not None: - # Found concrete value in hierarchy - return immediately - return value, scope_id, container_base - # Don't set fallback here - let Phase 2 walk MRO to find - # the highest type that defines this field - except AttributeError: - continue + mro_type = _normalize_to_base(mro_class) + outermost_scope: Optional[str] = None + held_by_a_layer = False - # PHASE 2: MRO fallback - no concrete value in hierarchy, try MRO inheritance - # - # IMPORTANT SEMANTICS: - # MRO order must dominate scope order. - # - # If a more-specific MRO class has a field set to None at an inner scope, that - # means "inherit". We must first check outer scopes for that SAME MRO class - # before falling back to later (less-specific) MRO classes at the inner scope. - # - # Therefore: for each MRO type (most→least specific), scan scopes (inner→outer) - # looking for the first non-None value. - - # Track where the "highest-precedence" field exists even if None, so callers - # can still navigate to a sensible provenance target when everything is None. - best_fallback_set = False - - for mro_type in mro_types: - last_scope_for_type: Optional[str] = None - saw_type_anywhere = False - - # Walk scopes from inner→outer for this MRO type - for scope_id, layer_configs in reversed(all_layer_configs): - config_instance = layer_configs.get(mro_type) + for layer in visible_layers: + config_instance = layer.configs.get(mro_type) if config_instance is None: continue - - saw_type_anywhere = True - last_scope_for_type = scope_id # update as we walk inner→outer (last = outermost) - + held_by_a_layer = True + outermost_scope = layer.scope_id try: value = object.__getattribute__(config_instance, field_name) except AttributeError: continue - - if value is not None: - # Found MRO-inherited value - return value, scope_id, mro_type - - # No non-None found for this MRO type across any scope. - # Record fallback for the FIRST MRO type that exists anywhere. - if (not best_fallback_set) and saw_type_anywhere: - best_fallback_set = True - fallback_scope = last_scope_for_type - fallback_type = mro_type + return ResolvedField(value, layer.scope_id, mro_type) - has_default, default_value = _direct_owner_field_default(container_base, field_name) - if has_default: - return default_value, fallback_scope, container_base + if held_by_a_layer and not unset_found: + unset_found = True + unset_scope = outermost_scope + unset_type = mro_type - # No non-None found - return None with fallback provenance (outermost/highest MRO type) - return None, fallback_scope, fallback_type + default_value, own_default_type = declared_default(container_type, field_name) + return ResolvedField(default_value, unset_scope, own_default_type or unset_type) def get_field_provenance(container_type: type, field_name: str) -> Tuple[Optional[str], Optional[type]]: """ Find which scope AND type provided the concrete value for a field. - CONVENIENCE WRAPPER: Calls resolve_with_provenance() and returns scope + type. - Use resolve_with_provenance() directly when you also need the value. + The scope and type of ``resolve_with_provenance()``, without the value. Args: container_type: The type containing the field (e.g., LazyPathPlanningConfig) @@ -373,7 +213,7 @@ def get_field_provenance(container_type: type, field_name: str) -> Tuple[Optiona Returns: (source_scope_id, source_type): The scope_id and type that provided the value. source_type may differ from container_type due to MRO inheritance. - Returns (None, None) if no layer has a concrete value. + See ``ResolvedField`` for what they name when no layer has a concrete value. """ _, source_scope, source_type = resolve_with_provenance(container_type, field_name) return source_scope, source_type diff --git a/src/objectstate/lazy_factory.py b/src/objectstate/lazy_factory.py index 1472892..dc9a69e 100644 --- a/src/objectstate/lazy_factory.py +++ b/src/objectstate/lazy_factory.py @@ -606,71 +606,21 @@ class LazyMethodBindings: @staticmethod def create_resolver() -> Callable[[Any, str], Any]: - """Create field resolver method using new pure function interface.""" - from objectstate.dual_axis_resolver import resolve_field_inheritance - from objectstate.context_manager import ( - NoActiveContextError, - extract_all_configs, - get_current_temp_global, - ) + """Create the method that resolves one field in the active context.""" + from objectstate.dual_axis_resolver import resolve_with_provenance def _resolve_field_value(self, field_name: str) -> Any: - current_context = get_current_temp_global() - if current_context is None: - raise NoActiveContextError( - f"Cannot resolve {type(self).__name__}.{field_name}: " - "no config context is active." - ) - available_configs = extract_all_configs(current_context) - return resolve_field_inheritance(self, field_name, available_configs) + return resolve_with_provenance(type(self), field_name).value return _resolve_field_value @staticmethod def create_getattribute() -> Callable[[Any, str], Any]: - """Create lazy __getattribute__ method using new context system.""" - from objectstate.dual_axis_resolver import resolve_field_inheritance, _has_concrete_field_override - from objectstate.context_manager import current_temp_global, extract_all_configs - - def _find_mro_concrete_value(base_class, name): - """Extract common MRO traversal pattern.""" - return next((getattr(cls, name) for cls in base_class.__mro__ - if _has_concrete_field_override(cls, name)), None) - - def _standalone_field_value(self, name): - """Resolve one inherited field without an active config context.""" - instance_type = object.__getattribute__(self, '__class__') - base_type = get_base_type_for_lazy(instance_type) or instance_type - mro_value = _find_mro_concrete_value(base_type, name) - if mro_value is not None: - return mro_value - - fields_by_name = instance_type.__dict__.get('_fields_by_name') - if fields_by_name is None: - field_tuple = fields(instance_type) - fields_by_name = {item.name: item for item in field_tuple} - instance_type._fields_by_name = fields_by_name - field_definition = fields_by_name.get(name) - if field_definition and '_inherited_default' in field_definition.metadata: - inherited = field_definition.metadata['_inherited_default'] - if inherited is not MISSING: - return inherited - factory = field_definition.metadata.get( - '_inherited_default_factory', MISSING - ) - if factory is not MISSING: - return factory() - return None + """Create lazy __getattribute__: a None field reads its value from the active context.""" + from objectstate.context_manager import context_layer_stack + from objectstate.dual_axis_resolver import declared_default, resolve_in_stack def __getattribute__(self: Any, name: str) -> Any: - """ - Three-stage resolution using new context system. - - Stage 1: Check instance value - Stage 2: Simple field path lookup in current scope's merged config - Stage 3: Inheritance resolution using same merged context - """ - # Stage 1: Get instance value value = object.__getattribute__(self, name) # Fast path: non-None values never need resolution if value is not None: @@ -689,57 +639,19 @@ def __getattribute__(self: Any, name: str) -> Any: if name not in _fns: return value - # Stage 2: Simple field path lookup in current scope's merged global - try: - current_context = current_temp_global.get() - if current_context is not None: - config_field_name = _single_declared_config_field( - type(current_context), - type(self), - required=False, - ) - if config_field_name is not None: - config_instance = getattr( - current_context, - config_field_name, - ) - if config_instance is not None: - resolved_value = object.__getattribute__( - config_instance, - name, - ) - if resolved_value is not None: - return resolved_value - except LookupError: - # No context available, continue to inheritance - pass - - # Stage 3: Inheritance resolution using same merged context - try: - current_context = current_temp_global.get() - if current_context is None: - return _standalone_field_value(self, name) - available_configs = extract_all_configs(current_context) - resolved_value = resolve_field_inheritance(self, name, available_configs) - - if resolved_value is not None: - return resolved_value - - # For nested dataclass fields, return lazy instance - # PERFORMANCE: O(1) dict lookup instead of O(n) linear scan - _fbm = self.__class__.__dict__.get('_fields_by_name') - if _fbm is None: - _ft = fields(self.__class__) - _fbm = {f.name: f for f in _ft} - self.__class__._fields_by_name = _fbm - field_obj = _fbm.get(name) - if field_obj and is_dataclass(field_obj.type): - return field_obj.type() - - return _standalone_field_value(self, name) - - except LookupError: - return _standalone_field_value(self, name) + stack = context_layer_stack.get() + if not stack.layers: + return declared_default(_cls, name)[0] + resolved_value = resolve_in_stack(stack, _cls, name).value + if resolved_value is not None: + return resolved_value + + # A nested dataclass field that nothing supplies reads as an empty instance. + field_obj = _cls.__dict__['_fields_by_name'].get(name) + if field_obj and is_dataclass(field_obj.type): + return field_obj.type() + return None + return __getattribute__ @staticmethod diff --git a/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index 3c765e0..8de7622 100644 --- a/src/objectstate/object_state_registry.py +++ b/src/objectstate/object_state_registry.py @@ -25,7 +25,6 @@ from objectstate.scope_key import ScopeKey from objectstate.global_config import GlobalContextValues from objectstate.context_manager import clear_current_temp_global -from objectstate.dual_axis_resolver import clear_mro_resolution_cache from objectstate.parameter_owner import ParameterOwner from objectstate.snapshot_model import Snapshot, StateSnapshot, Timeline from objectstate.time_travel_profile import TimeTravelProfiler @@ -766,12 +765,6 @@ def _invalidate_by_type_and_scope_now( ) -> None: """Immediate implementation for scope/type/field-aware invalidation.""" from objectstate.lazy_factory import get_base_type_for_lazy - from objectstate.dual_axis_resolver import invalidate_mro_cache_for_field - - # PERFORMANCE: Targeted cache invalidation - only clear entries for this field/type - if isinstance(changed_type, type): - invalidate_mro_cache_for_field(changed_type, field_name) - changed_scope = cls._normalize_scope_id(scope_id) # Normalize to base type for comparison (LazyX → X) @@ -1963,7 +1956,6 @@ def time_travel_to_snapshot(cls, snapshot_id: str) -> bool: for context in contexts.values(): context.apply() clear_current_temp_global() - clear_mro_resolution_cache() except Exception: try: for scope, restoration in reversed(list(prepared.items())): diff --git a/tests/test_context_failures_surface.py b/tests/test_context_failures_surface.py index b24cd44..dbfa21f 100644 --- a/tests/test_context_failures_surface.py +++ b/tests/test_context_failures_surface.py @@ -62,7 +62,7 @@ def test_build_context_stack_sets_nothing_until_entered_and_resets_on_exit(check with pending: assert get_current_temp_global().workers == 5 - assert [scope for scope, _ in context_layer_stack.get()] == ["", "plate", "step"] + assert [layer.scope_id for layer in context_layer_stack.get().layers] == ["", "plate", "step"] assert (get_current_temp_global(), context_layer_stack.get()) == before diff --git a/tests/test_context_manager.py b/tests/test_context_manager.py index 83cc92d..511c4b8 100644 --- a/tests/test_context_manager.py +++ b/tests/test_context_manager.py @@ -7,7 +7,6 @@ LazyDataclassFactory, config_context, get_current_temp_global, - set_current_temp_global, clear_current_temp_global, merge_configs, extract_all_configs, @@ -103,18 +102,14 @@ def test_config_context_cleanup(global_config): pass -def test_set_and_clear_current_temp_global(global_config): - """Test manually setting and clearing temp global.""" - set_current_temp_global(global_config) - assert get_current_temp_global() == global_config - - clear_current_temp_global() - try: - result = get_current_temp_global() - assert result is None - except LookupError: - pass +def test_clear_current_temp_global_leaves_no_active_context(global_config): + """After clearing inside a context, neither the merged config nor a layer is active.""" + from objectstate.context_manager import get_context_layer_stack + with config_context(global_config): + clear_current_temp_global() + assert get_current_temp_global() is None + assert get_context_layer_stack().layers == () def test_merge_configs(global_config): """Test merging overrides into base config. diff --git a/tests/test_one_field_resolution.py b/tests/test_one_field_resolution.py new file mode 100644 index 0000000..221becf --- /dev/null +++ b/tests/test_one_field_resolution.py @@ -0,0 +1,301 @@ +"""One resolution answers "what does this field resolve to", for reads and for display. + +Attribute access on a lazy config and ``ObjectState`` both take their value from +``resolve_with_provenance``. Its answers are held by the layer stack of the context +they were computed from, so no other context can drop or replace them. +""" + +import gc +import threading +import weakref +from dataclasses import dataclass, field + +import pytest + +from objectstate import ( + LazyDataclassFactory, + ObjectState, + build_context_stack, + config_context, + mark_global_config_type, + set_base_config_type, + set_global_config_for_editing, + set_live_global_config, +) +from objectstate.context_manager import get_base_global_config, get_context_layer_stack +from objectstate.dual_axis_resolver import resolve_with_provenance + + +@dataclass +class WellFilterConfig: + well_filter: int | None = None + mode: str | None = None + + +@dataclass +class PathPlanningConfig(WellFilterConfig): + suffix: str | None = None + + +@dataclass +class GlobalConfig: + well_filter_config: WellFilterConfig = field(default_factory=WellFilterConfig) + path_planning_config: PathPlanningConfig = field(default_factory=PathPlanningConfig) + + +mark_global_config_type(GlobalConfig) +LazyWellFilterConfig = LazyDataclassFactory.make_lazy_simple(WellFilterConfig) +LazyPathPlanningConfig = LazyDataclassFactory.make_lazy_simple(PathPlanningConfig) +LEAF_FIELDS = ("well_filter", "mode", "suffix") + + +@dataclass +class Pipeline: + well_filter_config: LazyWellFilterConfig = field(default_factory=LazyWellFilterConfig) + path_planning_config: LazyPathPlanningConfig = field(default_factory=LazyPathPlanningConfig) + + +@dataclass +class Step: + path_planning_config: LazyPathPlanningConfig = field(default_factory=LazyPathPlanningConfig) + + +def _use_global(saved: GlobalConfig, live: GlobalConfig | None = None) -> None: + set_base_config_type(GlobalConfig) + set_global_config_for_editing(GlobalConfig, saved) + if live is not None: + set_live_global_config(GlobalConfig, live) + + +def test_attribute_read_and_provenance_agree_when_a_layer_has_no_scope_id(): + """The reproducer of issue #29.""" + set_base_config_type(GlobalConfig) + outer = GlobalConfig(WellFilterConfig(well_filter=1)) + inner = GlobalConfig(WellFilterConfig(well_filter=2)) + + with config_context(outer, use_live_global=False, scope_id=""): + with config_context(inner): + read = LazyWellFilterConfig().well_filter + resolved = resolve_with_provenance(LazyWellFilterConfig, "well_filter") + + assert read == 2 + assert resolved == (2, None, WellFilterConfig) + + +def test_layers_entered_without_scope_ids_supply_values_with_their_type(): + _use_global(GlobalConfig(WellFilterConfig(well_filter=1, mode="include"))) + pipeline = Pipeline(path_planning_config=LazyPathPlanningConfig(suffix="_out")) + + with config_context(pipeline): + with config_context(Step()): + reads = {name: getattr(LazyPathPlanningConfig(), name) for name in LEAF_FIELDS} + resolved = { + name: resolve_with_provenance(LazyPathPlanningConfig, name) for name in LEAF_FIELDS + } + + assert reads == {"well_filter": 1, "mode": "include", "suffix": "_out"} + assert resolved == { + "well_filter": (1, "", WellFilterConfig), + "mode": ("include", "", WellFilterConfig), + "suffix": ("_out", None, PathPlanningConfig), + } + + +def _global_alone(): + _use_global(GlobalConfig(WellFilterConfig(well_filter=1))) + return Step(), [], "plate::step" + + +def _ancestor_supplies_same_type(): + _use_global(GlobalConfig(WellFilterConfig(well_filter=1))) + pipeline = Pipeline(path_planning_config=LazyPathPlanningConfig(well_filter=5, suffix="_p")) + return Step(), [("plate", pipeline)], "plate::step" + + +def _ancestor_supplies_base_type_only(): + _use_global(GlobalConfig(path_planning_config=PathPlanningConfig(mode="exclude"))) + pipeline = Pipeline(well_filter_config=LazyWellFilterConfig(well_filter=8, mode="include")) + return Step(), [("plate", pipeline)], "plate::step" + + +def _two_ancestors(): + _use_global(GlobalConfig(WellFilterConfig(mode="include"))) + plate = Pipeline(path_planning_config=LazyPathPlanningConfig(well_filter=5)) + group = Pipeline(path_planning_config=LazyPathPlanningConfig(suffix="_g")) + return Step(), [("plate", plate), ("plate::group", group)], "plate::group::step" + + +def _global_config_among_ancestors(): + saved = GlobalConfig(WellFilterConfig(well_filter=1)) + _use_global(saved) + pipeline = Pipeline(path_planning_config=LazyPathPlanningConfig(suffix="_p")) + return Step(), [("", saved), ("plate", pipeline)], "plate::step" + + +def _object_sets_its_own_value(): + _use_global(GlobalConfig(WellFilterConfig(well_filter=1))) + step = Step(path_planning_config=LazyPathPlanningConfig(well_filter=3)) + return step, [("plate", Pipeline())], "plate::step" + + +def _saved_global_differs_from_live(): + _use_global( + saved=GlobalConfig(WellFilterConfig(well_filter=1)), + live=GlobalConfig(WellFilterConfig(well_filter=9, mode="live-only")), + ) + return Step(), [("plate", Pipeline())], "plate::step" + + +def _nothing_supplies_a_value(): + _use_global(GlobalConfig()) + return Step(), [("plate", Pipeline())], "plate::step" + + +@pytest.mark.parametrize( + "arrangement", + [ + _global_alone, + _ancestor_supplies_same_type, + _ancestor_supplies_base_type_only, + _two_ancestors, + _global_config_among_ancestors, + _object_sets_its_own_value, + _saved_global_differs_from_live, + _nothing_supplies_a_value, + ], +) +def test_attribute_read_equals_what_object_state_reports(arrangement): + """For every stack ``build_context_stack`` builds, a read is the reported value.""" + step, ancestors, scope_id = arrangement() + + with build_context_stack(step, ancestors, scope_id, use_live=False): + reads = {name: getattr(step.path_planning_config, name) for name in LEAF_FIELDS} + sources = { + name: resolve_with_provenance(LazyPathPlanningConfig, name)[1:] + for name in LEAF_FIELDS + if object.__getattribute__(step.path_planning_config, name) is None + } + + reported, provenance = ObjectState.resolve_saved_object( + step, scope_id=scope_id, ancestor_objects_with_scopes=ancestors + ) + assert {name: getattr(reported.path_planning_config, name) for name in LEAF_FIELDS} == reads + assert { + name: provenance[f"path_planning_config.{name}"] for name in sources + } == sources + + +def test_attribute_read_equals_what_object_state_reports_for_a_global_config(): + """The global config is resolved against its declared defaults, which mask the saved one.""" + _use_global(GlobalConfig(WellFilterConfig(well_filter=1, mode="saved"))) + edited = GlobalConfig( + well_filter_config=WellFilterConfig(mode="edited"), + path_planning_config=PathPlanningConfig(suffix="_e"), + ) + + with build_context_stack(edited, current_scope_id="", use_live=False): + scopes = [layer.scope_id for layer in get_context_layer_stack().layers] + reads = {name: getattr(LazyPathPlanningConfig(), name) for name in LEAF_FIELDS} + resolved = { + name: resolve_with_provenance(LazyPathPlanningConfig, name).value + for name in LEAF_FIELDS + } + + assert scopes == ["", "", ""] + assert reads == resolved == {"well_filter": None, "mode": "edited", "suffix": "_e"} + + +def test_declared_default_is_read_and_reported_alike(): + @dataclass + class Display: + dimension_mode: str = "stack" + + @dataclass + class Streaming(Display): + port: int | None = None + + @dataclass + class StreamingGlobal: + workers: int = 1 + + lazy_streaming = LazyDataclassFactory.make_lazy_simple(Streaming) + set_base_config_type(StreamingGlobal) + + with config_context(StreamingGlobal(), scope_id=""): + read = lazy_streaming().dimension_mode + resolved = resolve_with_provenance(lazy_streaming, "dimension_mode") + + assert read == "stack" + assert resolved.value == "stack" + + +def test_answers_survive_another_thread_entering_and_leaving_a_context(): + """Issue #33: a context's answers are not dropped by a context in another thread.""" + saved_global = GlobalConfig(WellFilterConfig(well_filter=1)) + first_resolved = threading.Event() + other_thread_done = threading.Event() + answers = [] + errors = [] + + def resolve_twice(): + try: + _use_global(saved_global) # the global config is per thread + with config_context(Pipeline(), scope_id="plate"): + answers.append(resolve_with_provenance(LazyPathPlanningConfig, "well_filter")) + first_resolved.set() + assert other_thread_done.wait(timeout=10) + answers.append(resolve_with_provenance(LazyPathPlanningConfig, "well_filter")) + except BaseException as error: # noqa: BLE001 - reported by the main thread + errors.append(error) + first_resolved.set() + + def enter_and_leave(): + try: + assert first_resolved.wait(timeout=10) + _use_global(saved_global) # the global config is per thread + other = Pipeline(path_planning_config=LazyPathPlanningConfig(well_filter=7)) + with config_context(other, scope_id="other"): + answers.append(resolve_with_provenance(LazyPathPlanningConfig, "well_filter")) + except BaseException as error: # noqa: BLE001 - reported by the main thread + errors.append(error) + finally: + other_thread_done.set() + + threads = [threading.Thread(target=resolve_twice), threading.Thread(target=enter_and_leave)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=20) + + assert errors == [] + first, other, second = answers + assert first == (1, "", WellFilterConfig) + assert other == (7, "other", PathPlanningConfig) + # The very object computed before the other thread's context came and went. + assert second is first + + +def test_answers_are_dropped_with_the_context_they_were_computed_from(): + _use_global(GlobalConfig(WellFilterConfig(well_filter=1))) + + with config_context(Pipeline(), scope_id="plate"): + stack = get_context_layer_stack() + resolved = resolve_with_provenance(LazyPathPlanningConfig, "well_filter") + assert stack.resolved_fields[(LazyPathPlanningConfig, "well_filter")] is resolved + with config_context(Step(), scope_id="plate::step"): + assert get_context_layer_stack() is not stack + assert get_context_layer_stack().layers[:-1] == stack.layers + assert get_context_layer_stack() is stack + assert resolve_with_provenance(LazyPathPlanningConfig, "well_filter") is resolved + stack_alive = weakref.ref(stack) + + del stack + gc.collect() + assert stack_alive() is None + + +def test_base_global_config_is_never_silently_absent(monkeypatch): + """A global config that cannot be looked up raises; it is not replaced by ``None``.""" + monkeypatch.delattr("objectstate.global_config.get_current_global_config") + with pytest.raises(ImportError, match="get_current_global_config"): + get_base_global_config() From 37aedd37c444bc0faf6335de3c85eb7e5ce72ba8 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 00:52:59 -0400 Subject: [PATCH 6/8] Describe the one resolution and the layer stack in the docs Co-Authored-By: Claude Opus 5.5 --- docs/provenance.rst | 34 +++++++++++++++++-------- src/objectstate/context_manager.py | 3 ++- src/objectstate/docs/context_system.rst | 29 ++++++++------------- 3 files changed, 36 insertions(+), 30 deletions(-) diff --git a/docs/provenance.rst b/docs/provenance.rst index 193011a..edaf417 100644 --- a/docs/provenance.rst +++ b/docs/provenance.rst @@ -86,7 +86,8 @@ Get the source scope_id and type for an inherited field value: resolve_with_provenance() ~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Resolve a field value AND find its provenance source in ONE walk: +Resolve a field in the active context. This is the one resolution: attribute access on a +lazy config returns its ``value``, and ``ObjectState`` stores all three parts. .. code-block:: python @@ -109,11 +110,21 @@ Resolve a field value AND find its provenance source in ONE walk: **Returns:** +A ``ResolvedField`` named tuple: + * ``value``: The resolved field value (may be ``None``) -* ``source_scope``: The scope_id where the value was found (or ``None``) -* ``source_type``: The type that provided the value (or ``None``) +* ``source_scope``: The scope id of the layer that supplied the value. ``None`` for a layer + entered with ``config_context(obj)`` without a ``scope_id``. +* ``source_type``: The type the value was read from: the container type or one of its bases. + +When no layer holds a value, ``value`` is the declared default and the source names the +outermost layer holding the nearest type in the MRO (``None`` when no layer holds one). -**Performance:** Single walk instead of separate resolve + provenance calls. +Raises ``NoActiveContextError`` when no context is active. + +**Caching:** the answer is kept on the layer stack it was computed from +(``ContextLayerStack.resolved_fields``) and is dropped with that stack. Entering or leaving a +context, in this thread or another, never clears another context's answers. get_field_provenance() ~~~~~~~~~~~~~~~~~~~~~~~ @@ -142,13 +153,14 @@ The provenance system uses the context layer stack for tracking: from objectstate.context_manager import get_context_layer_stack - # Get the current layer stack for provenance tracking - layers = get_context_layer_stack() - # Returns: [(scope_id, obj), ...] tuples - - # Used by get_field_provenance() to determine which scope provided a resolved value + stack = get_context_layer_stack() + for layer in stack.layers: # outermost first + print(layer.scope_id, layer.obj) -The context layer stack tracks ``(scope_id, obj)`` tuples for provenance tracking, parallel to the merged config. It preserves hierarchy for inheritance source lookup. +The stack holds one ``ContextLayer`` for every object entered with ``config_context()``, with or +without a scope id, above the global config the context started from (scope ``""``). It is the +value ``resolve_with_provenance()`` reads. A layer entered with ``mask_with_none=True`` hides the +layers outside it. Usage Examples -------------- @@ -285,7 +297,7 @@ How Provenance Works The provenance system works through these mechanisms: -1. **Context Layer Stack**: The ``config_context()`` manager tracks ``(scope_id, obj)`` tuples as contexts are pushed. +1. **Context Layer Stack**: The ``config_context()`` manager adds a ``ContextLayer`` for every object entered. 2. **Dual-Axis Resolution with Provenance**: When resolving inherited fields, ``resolve_with_provenance()`` walks both the MRO and context hierarchy, returning the first concrete value found along with its source information. diff --git a/src/objectstate/context_manager.py b/src/objectstate/context_manager.py index 98d9a97..02e56a7 100644 --- a/src/objectstate/context_manager.py +++ b/src/objectstate/context_manager.py @@ -174,7 +174,8 @@ def config_context(obj, mask_with_none: bool = False, use_live_global: bool = Tr Args: obj: Object with config fields (pipeline_config, step, etc.) - mask_with_none: If True, None values override/mask base config values. + mask_with_none: If True, None values override/mask base config values, and + field resolution does not look at any layer outside this one. If False (default), None values are ignored (normal inheritance). Use True when editing GlobalPipelineConfig to mask thread-local loaded instance with static class defaults. diff --git a/src/objectstate/docs/context_system.rst b/src/objectstate/docs/context_system.rst index 482d298..6a47754 100644 --- a/src/objectstate/docs/context_system.rst +++ b/src/objectstate/docs/context_system.rst @@ -187,26 +187,18 @@ This hybrid approach uses thread-local for the global base and contextvars for d Resolution Integration --------------------- -The dual-axis resolver receives the merged context: +The dual-axis resolver reads the layer stack of the active context: .. code-block:: python - def resolve_field_inheritance(obj, field_name, available_configs): - """Resolve field through dual-axis algorithm. - - available_configs: Merged context from config_context() stack - """ - # Walk MRO - for mro_class in type(obj).__mro__: - # Check if this MRO class has a config instance in context - for config_type, config_instance in available_configs.items(): - if type(config_instance) == mro_class: - value = object.__getattribute__(config_instance, field_name) - if value is not None: - return value - return None - -The ``available_configs`` dict contains all configs from the context stack, flattened and ready for MRO traversal. + from objectstate.dual_axis_resolver import resolve_with_provenance + + 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. Usage Pattern ------------ @@ -272,5 +264,6 @@ Layers are entered outermost first: 3. **Current object** - under ``current_scope_id`` Every layer carries a scope id, so ``resolve_with_provenance()`` can name the scope -that supplied a value. Nothing is set until the ``with`` block is entered. A layer +that supplied a value. The global layer is the one ``config_context()`` puts below the +first layer entered while no context is active. Nothing is set until the ``with`` block is entered. A layer that cannot be built raises; the block never runs with a layer missing. From 73b9c1a0625d8a6738d6a7d67b022206f2896603 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 00:53:24 -0400 Subject: [PATCH 7/8] Fit added lines to the ruff limits Co-Authored-By: Claude Opus 5.5 --- src/objectstate/context_manager.py | 23 ++++++++++++++--------- src/objectstate/dual_axis_resolver.py | 20 +++++++++++--------- tests/test_context_failures_surface.py | 3 ++- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/objectstate/context_manager.py b/src/objectstate/context_manager.py index 02e56a7..4d1ee4b 100644 --- a/src/objectstate/context_manager.py +++ b/src/objectstate/context_manager.py @@ -22,7 +22,7 @@ import threading from collections.abc import Sequence from contextlib import ExitStack, contextmanager -from typing import Any, Dict, Optional, Tuple, get_type_hints +from typing import Any, Dict, get_type_hints from dataclasses import fields, is_dataclass from python_introspect import resolve_annotated, resolve_optional @@ -52,14 +52,14 @@ class ContextLayer: __slots__ = ("scope_id", "obj", "masks_outer_layers", "_configs") - def __init__(self, scope_id: Optional[str], obj: Any, masks_outer_layers: bool = False): + def __init__(self, scope_id: str | None, obj: Any, masks_outer_layers: bool = False): self.scope_id = scope_id self.obj = obj self.masks_outer_layers = masks_outer_layers - self._configs: Optional[Dict[type, Any]] = None + self._configs: dict[type, Any] | None = None @property - def configs(self) -> Dict[type, Any]: + def configs(self) -> dict[type, Any]: """The config instances this layer holds, by type. Extracted once per layer.""" configs = self._configs if configs is None: @@ -80,9 +80,9 @@ class ContextLayerStack: __slots__ = ("layers", "resolved_fields", "__weakref__") - def __init__(self, layers: Tuple[ContextLayer, ...] = ()): + def __init__(self, layers: tuple[ContextLayer, ...] = ()): self.layers = layers - self.resolved_fields: Dict[Tuple[type, str], Any] = {} + self.resolved_fields: dict[tuple[type, str], Any] = {} def with_layer(self, layer: ContextLayer) -> "ContextLayerStack": return ContextLayerStack(self.layers + (layer,)) @@ -163,7 +163,12 @@ def _merge_nested_dataclass(base, override, mask_with_none: bool = False): @contextmanager -def config_context(obj, mask_with_none: bool = False, use_live_global: bool = True, scope_id: Optional[str] = None): +def config_context( + obj, + mask_with_none: bool = False, + use_live_global: bool = True, + scope_id: str | None = None, +): """ Create new context scope with obj's matching fields merged into base config. @@ -728,8 +733,8 @@ def extract_all_configs_from_context() -> Dict[type, Any]: return extract_all_configs(current) -@functools.lru_cache(maxsize=None) -def _declared_config_fields(dataclass_type: type) -> Tuple[Tuple[str, type], ...]: +@functools.cache +def _declared_config_fields(dataclass_type: type) -> tuple[tuple[str, type], ...]: """The fields of a dataclass type that are declared as dataclass types, with those types. A fact about the class, so it is kept per class. diff --git a/src/objectstate/dual_axis_resolver.py b/src/objectstate/dual_axis_resolver.py index bac41e1..7e1fe04 100644 --- a/src/objectstate/dual_axis_resolver.py +++ b/src/objectstate/dual_axis_resolver.py @@ -11,7 +11,7 @@ import functools import logging from dataclasses import MISSING, fields, is_dataclass -from typing import Any, NamedTuple, Optional, Tuple +from typing import Any, NamedTuple logger = logging.getLogger(__name__) @@ -28,8 +28,8 @@ class ResolvedField(NamedTuple): """ value: Any - source_scope: Optional[str] - source_type: Optional[type] + source_scope: str | None + source_type: type | None @functools.lru_cache(maxsize=None) @@ -47,7 +47,7 @@ def _normalize_to_base(t: type) -> type: return get_base_type_for_lazy(t) or t -def _direct_owner_field_default(config_type: type, field_name: str) -> Tuple[bool, Any]: +def _direct_owner_field_default(config_type: type, field_name: str) -> tuple[bool, Any]: """Return the default owned by config_type for field_name. Lazy config resolution intentionally forces inherited dataclass fields to @@ -76,7 +76,7 @@ def _direct_owner_field_default(config_type: type, field_name: str) -> Tuple[boo return False, None -def declared_default(container_type: type, field_name: str) -> Tuple[Any, Optional[type]]: +def declared_default(container_type: type, field_name: str) -> tuple[Any, type | None]: """The value a field has when no context layer supplies one. In order: the default the base type declares for the field itself, the first @@ -167,15 +167,15 @@ def _resolve_in_layers(layers: tuple, container_type: type, field_name: str) -> # Where the field lives when every layer leaves it None: the outermost layer # holding the first MRO type that any layer holds. - unset_scope: Optional[str] = None - unset_type: Optional[type] = None + unset_scope: str | None = None + unset_type: type | None = None unset_found = False for mro_class in container_base.__mro__: if not is_dataclass(mro_class): continue mro_type = _normalize_to_base(mro_class) - outermost_scope: Optional[str] = None + outermost_scope: str | None = None held_by_a_layer = False for layer in visible_layers: @@ -200,7 +200,9 @@ def _resolve_in_layers(layers: tuple, container_type: type, field_name: str) -> return ResolvedField(default_value, unset_scope, own_default_type or unset_type) -def get_field_provenance(container_type: type, field_name: str) -> Tuple[Optional[str], Optional[type]]: +def get_field_provenance( + container_type: type, field_name: str +) -> tuple[str | None, type | None]: """ Find which scope AND type provided the concrete value for a field. diff --git a/tests/test_context_failures_surface.py b/tests/test_context_failures_surface.py index dbfa21f..f05f4c0 100644 --- a/tests/test_context_failures_surface.py +++ b/tests/test_context_failures_surface.py @@ -62,7 +62,8 @@ def test_build_context_stack_sets_nothing_until_entered_and_resets_on_exit(check with pending: assert get_current_temp_global().workers == 5 - assert [layer.scope_id for layer in context_layer_stack.get().layers] == ["", "plate", "step"] + scopes = [layer.scope_id for layer in context_layer_stack.get().layers] + assert scopes == ["", "plate", "step"] assert (get_current_temp_global(), context_layer_stack.get()) == before From abe763cbf58cbfbaa85649b1ef394a12778daca5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 02:10:27 -0400 Subject: [PATCH 8/8] Release 3.0.0: source for scope-less states, live resolver answers held by their objects - get_provenance reports (None, source_type) when a state without a scope id inherits a field from another config of its own object. It returned None, which reads as 'set on this field', while the resolved value was inherited. - LiveContextResolver keeps each answer with the config object and context objects it was computed from and finds it again by identity. No key is built from id(...). Answers of an earlier cache token are dropped. - Version 3.0.0: public names are removed by the merged changes. Co-Authored-By: Claude Opus 5.5 --- docs/provenance.rst | 5 + src/objectstate/__init__.py | 2 +- src/objectstate/live_context_resolver.py | 72 ++++++++----- src/objectstate/object_state.py | 26 ++++- tests/test_live_context_resolver_answers.py | 106 +++++++++++++++++++ tests/test_scopeless_state_resolution.py | 109 ++++++++++++++++++++ 6 files changed, 290 insertions(+), 30 deletions(-) create mode 100644 tests/test_live_context_resolver_answers.py create mode 100644 tests/test_scopeless_state_resolution.py diff --git a/docs/provenance.rst b/docs/provenance.rst index edaf417..cbad688 100644 --- a/docs/provenance.rst +++ b/docs/provenance.rst @@ -77,6 +77,11 @@ Get the source scope_id and type for an inherited field value: else: print("partition_filter is explicitly set on this object") +When another config of the same object supplies the value, the source scope is the +state's own ``scope_id``. For a state created without a scope id that is ``None``, so +``(None, source_type)`` means "this object, through ``source_type``". ``None`` alone +still means the value is set on the field itself. + **Returns:** * ``Tuple[str, type]`` if the value is inherited * ``None`` if the value is explicitly set on the current object diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index f2101c5..a99ec78 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -210,7 +210,7 @@ 'ObjectStateEditSession', ] -__version__ = "2.1.1" +__version__ = "3.0.0" __author__ = 'OpenHCS Team' __description__ = 'Generic configuration framework for lazy dataclass resolution' diff --git a/src/objectstate/live_context_resolver.py b/src/objectstate/live_context_resolver.py index b6e0e61..7224db2 100644 --- a/src/objectstate/live_context_resolver.py +++ b/src/objectstate/live_context_resolver.py @@ -11,22 +11,47 @@ - Caller is responsible for providing live context data """ -from typing import Any, Dict, Type, Optional, Tuple +import logging from dataclasses import is_dataclass +from typing import Any -from objectstate.lazy_factory import replace_raw from objectstate.context_manager import config_context -import logging +from objectstate.lazy_factory import replace_raw logger = logging.getLogger(__name__) +class _ResolvedAttr: + """One answer, held together with the objects it was computed from. + + Holding the objects keeps them alive for as long as the answer is kept, and + the answer is found again by comparing those objects with ``is``. + """ + + __slots__ = ("config_obj", "context_stack", "value") + + def __init__(self, config_obj: object, context_stack: tuple[object, ...], value: Any): + self.config_obj = config_obj + self.context_stack = context_stack + self.value = value + + def computed_from(self, config_obj: object, context_stack: list) -> bool: + return ( + self.config_obj is config_obj + and len(self.context_stack) == len(context_stack) + and all(kept is given for kept, given in zip(self.context_stack, context_stack)) + ) + + class LiveContextResolver: """ Pure service for resolving config attributes with live values. - Caches resolved config values to avoid expensive context stack building + resolution. - Token-based invalidation ensures cache coherency. + Keeps resolved values to avoid expensive context stack building + resolution. + The answers kept are those of one cache token. Each is stored with the config + object and the context objects it was computed from, so it is returned only + for those same objects. Answers of an earlier token are dropped when a new + token arrives. Completely generic - works with any dataclasses and any context hierarchy. UI layer is responsible for: @@ -36,15 +61,16 @@ class LiveContextResolver: """ def __init__(self): - self._resolved_value_cache: Dict[Tuple, Any] = {} + self._answers_token: int | None = None + self._answers_by_attr: dict[str, list[_ResolvedAttr]] = {} def resolve_config_attr( self, config_obj: object, attr_name: str, context_stack: list, - live_context: Dict[Type, Dict[str, Any]], - cache_token: int + live_context: dict[type, dict[str, Any]], + cache_token: int, ) -> Any: """ Resolve config attribute through context hierarchy with caching. @@ -61,29 +87,27 @@ def resolve_config_attr( Returns: Resolved attribute value """ - # Build cache key using object identities - context_ids = tuple(id(ctx) for ctx in context_stack) - cache_key = (id(config_obj), attr_name, context_ids, cache_token) + if cache_token != self._answers_token: + self._answers_by_attr = {} + self._answers_token = cache_token - # Check resolved value cache - if cache_key in self._resolved_value_cache: - return self._resolved_value_cache[cache_key] + answers = self._answers_by_attr.setdefault(attr_name, []) + for answer in answers: + if answer.computed_from(config_obj, context_stack): + return answer.value - # Cache miss - resolve resolved_value = self._resolve_uncached( config_obj, attr_name, context_stack, live_context ) - - # Store in cache - self._resolved_value_cache[cache_key] = resolved_value - + answers.append(_ResolvedAttr(config_obj, tuple(context_stack), resolved_value)) return resolved_value def invalidate(self) -> None: - """Invalidate all caches.""" - self._resolved_value_cache.clear() + """Drop every kept answer.""" + self._answers_token = None + self._answers_by_attr = {} - def reconstruct_live_values(self, live_values: Dict[str, Any]) -> Dict[str, Any]: + def reconstruct_live_values(self, live_values: dict[str, Any]) -> dict[str, Any]: """Return live values unchanged.""" return live_values if live_values else {} @@ -92,7 +116,7 @@ def _resolve_uncached( config_obj: object, attr_name: str, context_stack: list, - live_context: Dict[Type, Dict[str, Any]] + live_context: dict[type, dict[str, Any]] ) -> Any: """Resolve config attribute through context hierarchy (uncached).""" # Merge live values into each context object @@ -124,7 +148,7 @@ def resolve_in_context(contexts_remaining): return resolve_in_context(merged_contexts) - def _merge_live_values(self, base_obj: object, live_values: Optional[Dict[str, Any]]) -> object: + def _merge_live_values(self, base_obj: object, live_values: dict[str, Any] | None) -> object: """Merge live values into base object. CRITICAL: Passes None values through to dataclasses.replace(). When a field is reset diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index e87b207..56feae0 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -1279,7 +1279,7 @@ def _reconstruct_from_resolved( return result - def get_provenance(self, param_name: str) -> tuple[str, type] | None: + def get_provenance(self, param_name: str) -> tuple[str | None, type] | None: """Get the source scope_id and type for an inherited field value. For fields where the local value is None (inherited), returns the scope_id @@ -1297,17 +1297,33 @@ def get_provenance(self, param_name: str) -> tuple[str, type] | None: Returns: (source_scope_id, source_type): The scope and type that provided the value, - or None if the value is local (not inherited). + or None if the value is set on this field itself (not inherited). + + When another config of this same object provides the value, the scope + is this state's own ``scope_id``. For a state without a scope id that + is ``None``, so ``(None, source_type)`` means "this object, through + ``source_type``". """ self._ensure_live_resolved() result = self._live_provenance.get(param_name) if result is None: - return None # Field is local, not inherited + return None # Field is set on this object, not inherited scope_id, source_type = result - if scope_id is None or source_type is None: - return None # Field not found in hierarchy (shouldn't happen) + if source_type is None: + return None # No layer and no declared default supplied the field + if scope_id is None and not self._holds_config_type_without_scope(source_type): + return None # No layer held the field; the value is a declared default return (scope_id, source_type) + def _holds_config_type_without_scope(self, source_type: type) -> bool: + """True when this state has no scope id and its own object holds ``source_type``. + + A layer without a scope id supplies values under source scope ``None``. The + only such layer in this state's context stack is its own object, because the + global config and every ancestor are entered under a scope id. + """ + return self.scope_id is None and self.find_path_for_type(source_type) is not None + def find_path_for_type(self, container_type: type) -> str | None: """Find the path prefix for a container type in this ObjectState. diff --git a/tests/test_live_context_resolver_answers.py b/tests/test_live_context_resolver_answers.py new file mode 100644 index 0000000..6b74717 --- /dev/null +++ b/tests/test_live_context_resolver_answers.py @@ -0,0 +1,106 @@ +"""``LiveContextResolver`` keeps each answer with the objects it was computed from. + +No key is built from ``id(...)``: an address is reused once its object is collected, +so an answer keyed by address can be handed to a different object. +""" + +import inspect +from dataclasses import dataclass + +from objectstate import LiveContextResolver, live_context_resolver + + +@dataclass +class Settings: + name: str | None = None + + +def _counting(resolver: LiveContextResolver) -> list[tuple[object, str]]: + calls: list[tuple[object, str]] = [] + resolve = resolver._resolve_uncached + + def counted(config_obj, attr_name, context_stack, live_context): + calls.append((config_obj, attr_name)) + return resolve(config_obj, attr_name, context_stack, live_context) + + resolver._resolve_uncached = counted + return calls + + +def test_answer_is_reused_for_the_same_objects_and_token(): + resolver = LiveContextResolver() + calls = _counting(resolver) + settings = Settings("a") + + first = resolver.resolve_config_attr(settings, "name", [], {}, cache_token=1) + second = resolver.resolve_config_attr(settings, "name", [], {}, cache_token=1) + + assert (first, second) == ("a", "a") + assert len(calls) == 1 + + +def test_equal_but_distinct_object_gets_its_own_answer(): + resolver = LiveContextResolver() + calls = _counting(resolver) + one, other = Settings("a"), Settings("a") + assert one == other + + resolver.resolve_config_attr(one, "name", [], {}, cache_token=1) + resolver.resolve_config_attr(other, "name", [], {}, cache_token=1) + + assert [obj for obj, _ in calls] == [one, other] + assert calls[0][0] is one and calls[1][0] is other + + +def test_object_at_a_reused_address_does_not_get_the_collected_objects_answer(): + """The failure an ``id``-keyed cache has: a new object at an old address.""" + resolver = LiveContextResolver() + settings = Settings("first") + assert resolver.resolve_config_attr(settings, "name", [], {}, cache_token=1) == "first" + del settings + + for attempt in range(200): + replacement = Settings(f"replacement {attempt}") + resolved = resolver.resolve_config_attr(replacement, "name", [], {}, cache_token=1) + assert resolved == f"replacement {attempt}" + del replacement + + +def test_different_context_objects_get_their_own_answer(): + resolver = LiveContextResolver() + calls = _counting(resolver) + settings = Settings("a") + context_a, context_b = Settings("x"), Settings("x") + + resolver.resolve_config_attr(settings, "name", [context_a], {}, cache_token=1) + resolver.resolve_config_attr(settings, "name", [context_a], {}, cache_token=1) + resolver.resolve_config_attr(settings, "name", [context_b], {}, cache_token=1) + resolver.resolve_config_attr(settings, "name", [context_a, context_b], {}, cache_token=1) + + assert len(calls) == 3 + + +def test_new_token_drops_the_answers_of_the_old_one(): + resolver = LiveContextResolver() + settings = Settings("before") + assert resolver.resolve_config_attr(settings, "name", [], {}, cache_token=1) == "before" + + settings.name = "after" + assert resolver.resolve_config_attr(settings, "name", [], {}, cache_token=1) == "before" + assert resolver.resolve_config_attr(settings, "name", [], {}, cache_token=2) == "after" + assert sum(len(answers) for answers in resolver._answers_by_attr.values()) == 1 + + +def test_invalidate_drops_every_answer(): + resolver = LiveContextResolver() + settings = Settings("before") + resolver.resolve_config_attr(settings, "name", [], {}, cache_token=1) + + settings.name = "after" + resolver.invalidate() + + assert resolver.resolve_config_attr(settings, "name", [], {}, cache_token=1) == "after" + + +def test_no_key_is_built_from_an_address(): + assert "id(" not in inspect.getsource(live_context_resolver) diff --git a/tests/test_scopeless_state_resolution.py b/tests/test_scopeless_state_resolution.py new file mode 100644 index 0000000..32a3088 --- /dev/null +++ b/tests/test_scopeless_state_resolution.py @@ -0,0 +1,109 @@ +"""An ``ObjectState`` without a scope id resolves its fields like one with a scope id. + +Its own object is a layer of its context. A field left unset inherits from another +config of the same object before it inherits from the global config, exactly as an +attribute read on the object does, and the reported source is the object itself. +""" + +from dataclasses import dataclass, field + +import pytest + +from objectstate import ( + LazyDataclassFactory, + ObjectState, + config_context, + mark_global_config_type, + set_base_config_type, + set_global_config_for_editing, +) +from objectstate.inspection import FieldInspection + + +@dataclass +class WellFilterConfig: + well_filter: int | None = None + mode: str | None = None + + +@dataclass +class PathPlanningConfig(WellFilterConfig): + suffix: str | None = None + + +@dataclass +class GlobalConfig: + well_filter_config: WellFilterConfig = field(default_factory=WellFilterConfig) + path_planning_config: PathPlanningConfig = field(default_factory=PathPlanningConfig) + + +mark_global_config_type(GlobalConfig) +LazyWellFilterConfig = LazyDataclassFactory.make_lazy_simple(WellFilterConfig) +LazyPathPlanningConfig = LazyDataclassFactory.make_lazy_simple(PathPlanningConfig) + +OWN = "path_planning_config.well_filter" +SIBLING = "well_filter_config.well_filter" + + +@dataclass +class Pipeline: + well_filter_config: LazyWellFilterConfig = field(default_factory=LazyWellFilterConfig) + path_planning_config: LazyPathPlanningConfig = field(default_factory=LazyPathPlanningConfig) + + +@pytest.fixture(params=[None, "plate"], ids=["no scope id", "scope id"]) +def state(request) -> ObjectState: + set_base_config_type(GlobalConfig) + set_global_config_for_editing( + GlobalConfig, GlobalConfig(WellFilterConfig(well_filter=1, mode="global")) + ) + return ObjectState(Pipeline(), scope_id=request.param) + + +def test_unset_field_resolves_to_the_global_value_with_the_global_source(state): + assert state.parameters[OWN] is None + assert state.get_resolved_value(OWN) == 1 + assert state.get_provenance(OWN) == ("", WellFilterConfig) + + +def test_unset_field_inherits_from_a_sibling_config_of_the_same_object(state): + """The case the OpenHCS dropdown-flash test met with a state without a scope id.""" + state.update_parameter(SIBLING, 7) + + assert state.parameters[OWN] is None + assert state.get_resolved_value(OWN) == 7 + assert state.get_provenance(OWN) == (state.scope_id, WellFilterConfig) + assert state.get_resolved_value("path_planning_config.mode") == "global" + assert state.get_provenance("path_planning_config.mode") == ("", WellFilterConfig) + + +def test_state_reports_what_an_attribute_read_on_its_object_returns(state): + state.update_parameter(SIBLING, 7) + obj = state.to_object() + + with config_context(obj): + read = obj.path_planning_config.well_filter + + assert read == state.get_resolved_value(OWN) == 7 + + +def test_value_set_on_the_field_wins_and_has_no_inherited_source(state): + state.update_parameter(SIBLING, 7) + state.update_parameter(OWN, 9) + + assert state.parameters[OWN] == 9 + assert state.get_resolved_value(OWN) == 9 + assert state.get_provenance(OWN) is None + assert state.get_resolved_value(SIBLING) == 7 + + +def test_inspection_shows_the_inherited_value_with_its_source(state): + state.update_parameter(SIBLING, 7) + + inspection = FieldInspection.of(state, OWN) + + assert inspection.raw_value is None + assert inspection.resolved_value == 7 + assert inspection.provenance is not None + assert inspection.provenance.source_scope_id == state.scope_id + assert inspection.provenance.source_type is WellFilterConfig