From ba62f26d6fcc68021798046718693e6a37d6ef3b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Mon, 12 Oct 2026 00:41:23 -0400 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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