diff --git a/docs/provenance.rst b/docs/provenance.rst index 193011a..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 @@ -86,7 +91,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 +115,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 +158,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 +302,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/__init__.py b/src/objectstate/__init__.py index 44961bd..a99ec78 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', @@ -212,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/context_manager.py b/src/objectstate/context_manager.py index cf023f8..4d1ee4b 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,19 @@ import functools import logging import threading -from contextlib import contextmanager +from collections.abc import Sequence +from contextlib import ExitStack, contextmanager from typing import Any, Dict, 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') @@ -37,12 +42,60 @@ # 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: 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: dict[type, Any] | None = 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): @@ -110,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. @@ -121,15 +179,17 @@ 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. 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) @@ -140,9 +200,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() @@ -215,41 +275,36 @@ 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() 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(): @@ -273,24 +328,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. - - 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(). + Get the layer stack of the active context: the value field resolution reads. - 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() @@ -305,11 +355,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): @@ -317,11 +364,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 @@ -533,380 +577,97 @@ 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 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``. - 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) + 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) + ) - obj_type_name = obj_type.__name__ + with ExitStack() as stack: + if isinstance(object_instance, GlobalConfigBase): + enter(stack, type(object_instance)(), "", mask=True) - # 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] - 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 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 - - + enter(stack, ancestor_obj, scope_id) - - -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 + enter(stack, object_instance, 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): """ - 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(): @@ -922,33 +683,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 @@ -990,17 +733,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.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. + """ + 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. @@ -1014,11 +768,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 @@ -1027,35 +776,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", @@ -1065,11 +806,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/docs/context_system.rst b/src/objectstate/docs/context_system.rst index 68e2935..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 ------------ @@ -249,66 +241,29 @@ 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. 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. diff --git a/src/objectstate/dual_axis_resolver.py b/src/objectstate/dual_axis_resolver.py index 69cec7d..7e1fe04 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 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: str | None + source_type: type | None @functools.lru_cache(maxsize=None) @@ -74,19 +47,7 @@ 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]: +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 @@ -115,260 +76,137 @@ 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, type | None]: + """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) + 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). - 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 + Returns the value and, when the base type declares a default for the field + itself, that base type; otherwise ``None`` in its place. """ - 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 - - # 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]]: - """ - 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: str | None = None + unset_type: type | None = 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 - try: - layer_configs = extract_all_configs(layer_obj) - all_layer_configs.append((scope_id, layer_configs)) - except Exception: - continue - - # 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: str | None = 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]]: +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. - 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) @@ -377,7 +215,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 e45675e..1539e06 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 current_temp_global, extract_all_configs + """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: - # 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 + 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 @@ -890,10 +802,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 +851,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 +866,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 +966,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 +976,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 +1261,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 +1273,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 +1306,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 +1315,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 +1369,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 +1430,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 +1446,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 +1459,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 +1494,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 +1507,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 +1622,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/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 0a0402e..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. @@ -1783,13 +1799,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 +2129,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/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index 3c765e0..a041bd1 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 ( @@ -25,7 +26,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 @@ -136,8 +136,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 +158,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 +198,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 +215,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 +329,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 +348,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 +359,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 +536,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 +769,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, @@ -766,12 +800,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) @@ -877,13 +905,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 +947,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 +968,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 +978,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 +998,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 +1056,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 +1149,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 +1916,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), @@ -1963,7 +1993,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_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_context_failures_surface.py b/tests/test_context_failures_surface.py new file mode 100644 index 0000000..f05f4c0 --- /dev/null +++ b/tests/test_context_failures_surface.py @@ -0,0 +1,130 @@ +"""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 + 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 + + +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") 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_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 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_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_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() 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 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) ]