diff --git a/src/objectstate/context_manager.py b/src/objectstate/context_manager.py index cf023f8..5c57f19 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') @@ -215,16 +220,10 @@ def config_context(obj, mask_with_none: bool = False, use_live_global: bool = Tr # Create merged config if we have overrides # Use replace_raw to preserve None values (dataclasses.replace triggers lazy resolution) if overrides: - try: - from objectstate.lazy_factory import replace_raw - merged_config = replace_raw(base_config, **overrides) - logger.debug(f"Creating config context with {len(overrides)} field overrides from {type(obj).__name__}") - except Exception as e: - logger.warning(f"Failed to merge config overrides from {type(obj).__name__}: {e}") - merged_config = base_config + from objectstate.lazy_factory import replace_raw + merged_config = replace_raw(base_config, **overrides) else: merged_config = base_config - logger.debug(f"Creating config context with no overrides from {type(obj).__name__}") # Track the type in the context type stack current_types = context_type_stack.get() @@ -533,348 +532,73 @@ def is_same_type_in_context(type_a, type_b): # ============================================================================ -def _inject_context_layer( - stack, - t: type | None, - values: dict | None, - stored: object | None, -) -> None: - """ - Inject a context layer into the stack. - - Handles dataclass instantiation with SimpleNamespace fallback. - For types that can't be reconstructed from editable values alone, - injects stored object + SimpleNamespace so type tracking works and live values win. - - Args: - stack: ExitStack to inject into - t: Type to instantiate (None = use SimpleNamespace) - values: Dict of values to use (None = use stored only) - stored: Stored object to fall back to (None = no fallback) - """ - from types import SimpleNamespace - - if values is None: - if stored is not None: - stack.enter_context(config_context(stored)) - return - - # Have values - try to instantiate as dataclass, fall back to SimpleNamespace - if t is not None and is_dataclass(t): - try: - stack.enter_context(config_context(t(**values))) - return - except Exception: - # Dataclass ctor failed (e.g., missing required args not in form) - # Inject stored (for type tracking) + SimpleNamespace (for values) - if stored is not None: - stack.enter_context(config_context(stored)) - stack.enter_context(config_context(SimpleNamespace(**values))) - return - - # Non-dataclass or no type - use SimpleNamespace - stack.enter_context(config_context(SimpleNamespace(**values))) - - +@contextmanager def build_context_stack( object_instance: object, - ancestor_objects: list[object] | None = None, - ancestor_objects_with_scopes: list[tuple[str, object]] | None = None, + ancestor_objects_with_scopes: Sequence[tuple[str, object]] = (), current_scope_id: str | None = None, use_live: bool = True, ): """ - Build a complete context stack for placeholder resolution. + Enter every context layer needed to resolve ``object_instance``; leave them on exit. - SINGLE SOURCE OF TRUTH: Uses ancestor_objects from ObjectStateRegistry as the - sole mechanism for parent hierarchy. No separate context_obj parameter. + Layers, outermost first: + 1. The global layer, under scope ``""``. When ``object_instance`` is itself a global + config, this is a fresh instance of its type holding the declared defaults. + 2. Each ancestor, under its own scope id. Global-config ancestors are left out + because layer 1 already stands for them. + 3. ``object_instance``, under ``current_scope_id``. - Layer order (innermost to outermost when entered): - 1. Global context layer (from ancestors or thread-local) - 2. Ancestor objects (from ObjectStateRegistry.get_ancestor_objects()) - 3. Current object_instance + Nothing is set until the ``with`` block is entered. Any layer that cannot be + built raises, and the layers entered before it are left again. Args: - object_instance: The object being edited (type used to infer global editing mode) - ancestor_objects: List of ancestor objects from least→most specific (from ObjectStateRegistry). - This is the SINGLE SOURCE OF TRUTH for parent hierarchy. - DEPRECATED: Use ancestor_objects_with_scopes for provenance tracking. - ancestor_objects_with_scopes: List of (scope_id, object) tuples from least→most specific. - Enables provenance tracking via context_layer_stack. - current_scope_id: Scope ID for the current object_instance. Used for provenance tracking. - - Returns: - ExitStack with all context layers entered. Caller must manage the stack lifecycle. + object_instance: The object whose fields are being resolved. + ancestor_objects_with_scopes: ``(scope_id, object)`` pairs, least specific first. + current_scope_id: Scope id of ``object_instance``. + use_live: Read the live global config (True) or the saved one (False). """ - from contextlib import ExitStack from objectstate.lazy_factory import GlobalConfigBase - stack = ExitStack() - obj_type = type(object_instance) - - # Infer global editing mode from object_instance type - is_global_config_editing = isinstance(object_instance, GlobalConfigBase) - - obj_type_name = obj_type.__name__ - - # PERFORMANCE: Only build ancestor list for logging when DEBUG is enabled - if logger.isEnabledFor(logging.DEBUG): - if ancestor_objects_with_scopes: - ancestor_types = [type(o).__name__ for _, o in ancestor_objects_with_scopes] - elif ancestor_objects: - ancestor_types = [type(o).__name__ for o in ancestor_objects] + with ExitStack() as stack: + if isinstance(object_instance, GlobalConfigBase): + declared_defaults = type(object_instance)() + stack.enter_context( + config_context(declared_defaults, mask_with_none=True, scope_id="") + ) else: - ancestor_types = [] - logger.debug(f"🔧 build_context_stack: obj={obj_type_name}, ancestors={ancestor_types}") - - # 1. Global context layer (least specific) - # ALWAYS use LIVE thread-local for global config - it's the SINGLE SOURCE OF TRUTH - # Don't use ancestor_objects for GlobalConfigBase since that comes from to_object() - # which can be stale relative to the LIVE thread-local we just updated. - if is_global_config_editing: - try: - global_layer = obj_type() # Fresh instance with static defaults - stack.enter_context(config_context(global_layer, mask_with_none=True, scope_id="")) - except Exception: - pass # Couldn't create global layer - else: - # Use LIVE or SAVED thread-local based on use_live parameter - global_layer = get_base_global_config(use_live=use_live) - if global_layer: - stack.enter_context(config_context(global_layer, scope_id="")) - - # 2. Ancestor objects (intermediate layers, excluding GlobalConfigBase already handled) - if ancestor_objects_with_scopes: - # New format with scope_ids for provenance tracking + global_layer = get_base_global_config(use_live=use_live) + if global_layer: + stack.enter_context(config_context(global_layer, scope_id="")) + for scope_id, ancestor_obj in ancestor_objects_with_scopes: if isinstance(ancestor_obj, GlobalConfigBase): continue stack.enter_context(config_context(ancestor_obj, scope_id=scope_id)) - elif ancestor_objects: - # Legacy format without scope_ids - for ancestor_obj in ancestor_objects: - if isinstance(ancestor_obj, GlobalConfigBase): - continue - stack.enter_context(config_context(ancestor_obj)) - - # 3. Current object overlay (most specific) - stack.enter_context(config_context(object_instance, scope_id=current_scope_id)) - - return stack - - -def _get_global_context_layer( - live_context: dict | None, - is_global_config_editing: bool, - global_config_type: type | None, -) -> object | None: - """ - Get the global context layer for the stack. - - Priority: - 1. If editing global config, use static defaults (mask_with_none will mask thread-local) - 2. If live_context has a global config, use that (from another open editor) - 3. Fall back to thread-local global config - - PERFORMANCE OPTIMIZATION: Within a dispatch cycle, the GLOBAL layer is cached - since it's the same for all sibling refreshes. - - Args: - live_context: Dict mapping types to their live values - is_global_config_editing: True if editing a global config - global_config_type: The global config type - - Returns: - Global config instance to use, or None if not available - """ - layer = None - - # 1) Global config editing → fresh instance (mask thread-local when entering) - if is_global_config_editing and global_config_type is not None: - try: - layer = global_config_type() - except Exception: - layer = None - - # 2) Live global from other window - if layer is None: - layer = _find_live_global(live_context) - - # 3) Fallback to thread-local base - if layer is None: - layer = get_base_global_config() - - return layer - - -def _find_live_global(live_context: dict | None) -> object | None: - """Find and instantiate a global config from live_context if present.""" - if not live_context: - return None - - from objectstate.lazy_factory import is_global_config_type - - for config_type, config_values in live_context.items(): - if is_global_config_type(config_type): - try: - return config_type(**config_values) - except Exception: - continue - return None - - - - -def _find_live_values_for_type(target_type: type, live_context: dict) -> dict | None: - """ - Find live values for a target type in live_context. - - Handles type normalization (lazy vs base types) AND inheritance. - For sibling inheritance, a StepPartitionFilterConfig's values should be - usable when resolving PartitionFilterConfig's placeholders. - - IMPORTANT: Prefers subclass matches over exact matches. - This ensures StepPartitionFilterConfig values (with concrete value) are used - for PartitionFilterConfig resolution, not PartitionFilterConfig values (with None). - - Args: - target_type: The type to find values for - live_context: Dict mapping types to their live values - - Returns: - Dict of field values, or None if not found - """ - target_base = _normalize_type(target_type) - logger.debug(f"_find_live_values_for_type: target={target_type.__name__} -> base={target_base.__name__}") - logger.debug(f"_find_live_values_for_type: live_context has {len(live_context)} types") - - # Pass 0: exact type match without normalization (prefer most specific) - for config_type, config_values in live_context.items(): - if config_type == target_type: - logger.debug(f"_find_live_values_for_type: ✅ exact type match for {config_type.__name__}") - return config_values - - # First pass: look for subclass match (more specific wins) after normalization - # e.g., StepPartitionFilterConfig values for PartitionFilterConfig resolution - for config_type, config_values in live_context.items(): - config_base = _normalize_type(config_type) - try: - if config_base != target_base and issubclass(config_base, target_base): - logger.debug(f"_find_live_values_for_type: ✅ using {config_base.__name__} values for {target_base.__name__} (subclass)") - return config_values - except TypeError: - pass # Not a class - - # Second pass: exact type match (after normalization) - for config_type, config_values in live_context.items(): - config_base = _normalize_type(config_type) - if config_base == target_base: - logger.debug(f"_find_live_values_for_type: ✅ exact match for {target_base.__name__}") - return config_values - - logger.debug(f"_find_live_values_for_type: ❌ no match for {target_base.__name__}") - return None - - -# Removed: extract_config_overrides - no longer needed with field matching approach - - -def extract_from_dataclass_fields(obj) -> Dict[str, Any]: - """ - Get non-None fields as config overrides. - - This extracts concrete values from dataclass instances, ignoring None values - which represent fields that should inherit from context. - - Args: - obj: Dataclass instance to extract field values from - - Returns: - Dict of field_name -> value for non-None fields - """ - if not is_dataclass(obj): - return {} - - overrides = {} - - for field in fields(obj): - value = getattr(obj, field.name) - if value is not None: - overrides[field.name] = value - - logger.debug(f"Extracted {len(overrides)} overrides from dataclass {type(obj).__name__}") - return overrides - - -def extract_from_object_attributes(obj) -> Dict[str, Any]: - """ - Extract config attributes from step/pipeline objects. - - This handles orchestrators, steps, and other objects that have *_config attributes. - It flattens the config hierarchy into a single dict of field overrides. - - Args: - obj: Object to extract config attributes from - - Returns: - Dict of field_name -> value for all non-None config fields - """ - overrides = {} - - try: - for attr_name in dir(obj): - if attr_name.endswith('_config'): - attr_value = getattr(obj, attr_name) - if attr_value is not None and is_dataclass(attr_value): - # Extract all non-None fields from this config - config_overrides = extract_from_dataclass_fields(attr_value) - overrides.update(config_overrides) - - logger.debug(f"Extracted {len(overrides)} overrides from object {type(obj).__name__}") - - except Exception as e: - logger.debug(f"Error extracting from object {obj}: {e}") - - return overrides + stack.enter_context(config_context(object_instance, scope_id=current_scope_id)) + yield def merge_configs(base, overrides: Dict[str, Any]): """ Merge overrides into base config, creating new immutable instance. - - This creates a new config instance with override values merged in, - preserving immutability of the original base config. - + + A ``None`` override is kept: it means "inherit this field from the parent context". + Raises whatever the config's constructor raises when the overrides are not accepted. + Args: base: Base config instance (base config type) overrides: Dict of field_name -> value to override - + Returns: New config instance with overrides applied """ if not base or not overrides: return base - try: - # CRITICAL: Do NOT filter out None values! - # None can be a semantic value such as "inherit from parent context". - # When an override dict contains None, it means "reset this field to None" - # which should override the base value with None for lazy resolution. - - if not overrides: - return base - - # Use replace_raw to preserve None values (dataclasses.replace triggers lazy resolution) - from objectstate.lazy_factory import replace_raw - merged = replace_raw(base, **overrides) - - logger.debug(f"Merged {len(overrides)} overrides into {type(base).__name__}") - return merged - - except Exception as e: - logger.warning(f"Failed to merge configs: {e}") - return base + from objectstate.lazy_factory import replace_raw + return replace_raw(base, **overrides) def get_base_global_config(use_live: bool = True): diff --git a/src/objectstate/docs/context_system.rst b/src/objectstate/docs/context_system.rst index 68e2935..482d298 100644 --- a/src/objectstate/docs/context_system.rst +++ b/src/objectstate/docs/context_system.rst @@ -249,66 +249,28 @@ No manual cleanup needed - Python's context manager protocol handles it. Framework-Agnostic Context Stack Building ----------------------------------------- -For UI placeholder resolution, the ``build_context_stack()`` function provides a -framework-agnostic way to build complete context stacks: +``build_context_stack()`` is a context manager that enters every layer needed to +resolve one object, and leaves them again when the block ends: .. code-block:: python - from objectstate import build_context_stack + from objectstate import ObjectStateRegistry, build_context_stack - # Build context stack for placeholder resolution - stack = build_context_stack( - context_obj=parent_config, # Parent context - overlay=current_parameters, # Current form values - dataclass_type=config_type, # Type being edited - live_context=live_context_dict, # Live values from other forms - root_form_values=root_values, # Root form's values (for sibling inheritance) - root_form_type=root_type, # Root form's dataclass type - ) + ancestors = ObjectStateRegistry.get_ancestor_objects_with_scopes(scope_id) - with stack: - # Context layers are active + with build_context_stack( + object_instance=current_object, + ancestor_objects_with_scopes=ancestors, # (scope_id, object), least specific first + current_scope_id=scope_id, + ): placeholder = resolve_placeholder(field_name) -The stack builds layers in order: - -1. **Global context layer** - Thread-local global config or live editor values -2. **Intermediate layers** - Ancestors from ``get_types_before_in_stack()`` -3. **Parent context** - The ``context_obj`` parameter -4. **Root form layer** - For sibling inheritance (see below) -5. **Overlay** - Current form values - -Sibling Inheritance via Root Form -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -When nested configs need to inherit from siblings, the root form's values enable this: - -.. code-block:: python - - # Root form contains both sibling configs - root_values = { - 'base_config': LazyBaseConfig(shared_value=123), - 'child_config': LazyChildConfig(shared_value=None), # Inherits from base - # Additional sibling configs may follow. - } - - # When resolving child_config.shared_value: - # 1. stack includes root_values - # 2. LazyChildConfig.shared_value resolution walks MRO - # 3. Finds BaseConfig (superclass) in context - # 4. Uses base_config.shared_value = 123 - -For non-dataclass roots, the function wraps values in ``SimpleNamespace`` to maintain a unified code path: - -.. code-block:: python - - if root_form_type and is_dataclass(root_form_type): - root_instance = root_form_type(**root_form_values) - else: - # Non-dataclass root - wrap in SimpleNamespace - from types import SimpleNamespace - root_instance = SimpleNamespace(**root_form_values) +Layers are entered outermost first: - stack.enter_context(config_context(root_instance)) +1. **Global layer** - the global config, under scope ``""`` +2. **Ancestors** - each under its own scope id +3. **Current object** - under ``current_scope_id`` -This enables sibling inheritance for any root type. +Every layer carries a scope id, so ``resolve_with_provenance()`` can name the scope +that supplied a value. Nothing is set until the ``with`` block is entered. A layer +that cannot be built raises; the block never runs with a layer missing. diff --git a/src/objectstate/dual_axis_resolver.py b/src/objectstate/dual_axis_resolver.py index 69cec7d..0bf4c61 100644 --- a/src/objectstate/dual_axis_resolver.py +++ b/src/objectstate/dual_axis_resolver.py @@ -284,11 +284,7 @@ def resolve_with_provenance(container_type: type, field_name: str) -> Tuple[Any, for scope_id, layer_obj in layers: if layer_obj is None: continue - try: - layer_configs = extract_all_configs(layer_obj) - all_layer_configs.append((scope_id, layer_configs)) - except Exception: - continue + all_layer_configs.append((scope_id, extract_all_configs(layer_obj))) # PHASE 1: Hierarchy walk - check ONLY same-type config at each scope (inner to outer) # This gives hierarchy precedence: inner/child scopes override outer/parent scopes diff --git a/src/objectstate/lazy_factory.py b/src/objectstate/lazy_factory.py index e45675e..1472892 100644 --- a/src/objectstate/lazy_factory.py +++ b/src/objectstate/lazy_factory.py @@ -608,21 +608,21 @@ class LazyMethodBindings: def create_resolver() -> Callable[[Any, str], Any]: """Create field resolver method using new pure function interface.""" from objectstate.dual_axis_resolver import resolve_field_inheritance - from objectstate.context_manager import current_temp_global, extract_all_configs + from objectstate.context_manager import ( + NoActiveContextError, + extract_all_configs, + get_current_temp_global, + ) def _resolve_field_value(self, field_name: str) -> Any: - # Get current context from contextvars - try: - current_context = current_temp_global.get() - # Extract available configs from current context - available_configs = extract_all_configs(current_context) - - # Use pure function for resolution - return resolve_field_inheritance(self, field_name, available_configs) - except LookupError: - # No context available - return None (fail-loud approach) - logger.debug(f"No context available for resolving {type(self).__name__}.{field_name}") - return None + current_context = get_current_temp_global() + if current_context is None: + raise NoActiveContextError( + f"Cannot resolve {type(self).__name__}.{field_name}: " + "no config context is active." + ) + available_configs = extract_all_configs(current_context) + return resolve_field_inheritance(self, field_name, available_configs) return _resolve_field_value diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 0a0402e..e87b207 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -1783,13 +1783,11 @@ def _recompute_invalid_fields(self) -> set[str]: # without triggering delegate sync that would invalidate our cache mid-computation. current_obj = self.to_object(update_delegate=False, sync_delegate=False) - stack = build_context_stack( + with build_context_stack( object_instance=current_obj, ancestor_objects_with_scopes=ancestor_objects_with_scopes, current_scope_id=self.scope_id, - ) - - with stack: + ): # For each inherited field, resolve using dual-axis resolution with provenance for dotted_path in inherited_fields: container_type = self._path_to_type.get(dotted_path) @@ -2115,19 +2113,17 @@ def _resolve_parameter_snapshot( from objectstate.dual_axis_resolver import resolve_with_provenance from objectstate.lazy_factory import has_lazy_resolution - stack = build_context_stack( - object_instance=current_obj, - ancestor_objects_with_scopes=ancestor_objects_with_scopes, - current_scope_id=scope_id, - use_live=not use_saved, - ) - snapshot: dict[str, Any] = {} provenance: dict[str, tuple[str | None, type | None]] = {} # UNIFIED: Resolve ALL fields in single context stack # For each path, check if it has a lazy dataclass container type - with stack: + with build_context_stack( + object_instance=current_obj, + ancestor_objects_with_scopes=ancestor_objects_with_scopes, + current_scope_id=scope_id, + use_live=not use_saved, + ): for dotted_path in parameters.keys(): raw_value = values.get(dotted_path) container_type = structure.owner_paths.get(dotted_path) diff --git a/tests/test_context_failures_surface.py b/tests/test_context_failures_surface.py new file mode 100644 index 0000000..b24cd44 --- /dev/null +++ b/tests/test_context_failures_surface.py @@ -0,0 +1,129 @@ +"""A context that cannot be built must fail; it must never run with a layer missing.""" + +from dataclasses import dataclass, field + +import pytest + +from objectstate import ( + LazyDataclassFactory, + build_context_stack, + config_context, + mark_global_config_type, + merge_configs, + set_base_config_type, + set_global_config_for_editing, +) +from objectstate.context_manager import ( + NoActiveContextError, + context_layer_stack, + get_current_temp_global, +) +from objectstate.dual_axis_resolver import resolve_with_provenance + + +@dataclass +class Policy: + value: int | None = 1 + + +@dataclass +class CheckedGlobal: + """Global config whose constructor rejects a negative worker count.""" + + workers: int = 1 + policy: Policy = field(default_factory=Policy) + + def __post_init__(self): + if self.workers < 0: + raise ValueError("workers must not be negative") + + +@dataclass +class Pipeline: + workers: int = 1 + + +@pytest.fixture +def checked_global(): + set_base_config_type(CheckedGlobal) + saved = CheckedGlobal() + set_global_config_for_editing(CheckedGlobal, saved) + return saved + + +def test_build_context_stack_sets_nothing_until_entered_and_resets_on_exit(checked_global): + pipeline = Pipeline(workers=5) + before = (get_current_temp_global(), context_layer_stack.get()) + + pending = build_context_stack( + Policy(), ancestor_objects_with_scopes=[("plate", pipeline)], current_scope_id="step" + ) + assert (get_current_temp_global(), context_layer_stack.get()) == before + + with pending: + assert get_current_temp_global().workers == 5 + assert [scope for scope, _ in context_layer_stack.get()] == ["", "plate", "step"] + + assert (get_current_temp_global(), context_layer_stack.get()) == before + + +def test_build_context_stack_resets_when_the_block_raises(checked_global): + before = (get_current_temp_global(), context_layer_stack.get()) + with pytest.raises(KeyError): + with build_context_stack(Pipeline(workers=5), current_scope_id="step"): + raise KeyError("block failed") + assert (get_current_temp_global(), context_layer_stack.get()) == before + + +def test_build_context_stack_rejects_ancestors_without_scopes(checked_global): + with pytest.raises(TypeError): + build_context_stack(Pipeline(), ancestor_objects=[Pipeline()]) + + +def test_layer_that_cannot_be_merged_raises(checked_global): + with pytest.raises(ValueError, match="workers must not be negative"): + with config_context(Pipeline(workers=-1)): + pytest.fail("the block ran without the layer's overrides") + + +def test_merge_configs_that_cannot_be_applied_raises(checked_global): + with pytest.raises(ValueError, match="workers must not be negative"): + merge_configs(checked_global, {"workers": -1}) + + +def test_global_type_that_cannot_be_constructed_raises(): + @dataclass + class RequiredGlobal: + workers: int + + mark_global_config_type(RequiredGlobal) + set_base_config_type(RequiredGlobal) + set_global_config_for_editing(RequiredGlobal, RequiredGlobal(workers=2)) + + with pytest.raises(TypeError, match="workers"): + with build_context_stack(RequiredGlobal(workers=3), current_scope_id=""): + pytest.fail("the block ran without the global layer") + + +def test_layer_whose_configs_cannot_be_extracted_raises(checked_global): + @dataclass + class Unrelated: + value: int = 9 + + @dataclass + class Step: + step_policy: Policy + + lazy_policy = LazyDataclassFactory.make_lazy_simple(Policy) + mistyped_step = Step(step_policy=Unrelated()) + + with config_context(mistyped_step, scope_id="plate::step"): + with pytest.raises(TypeError, match="Step.step_policy declares Policy"): + resolve_with_provenance(lazy_policy, "value") + + +def test_resolver_method_without_active_context_raises(): + lazy_policy = LazyDataclassFactory.make_lazy_simple(Policy) + assert get_current_temp_global() is None + with pytest.raises(NoActiveContextError): + lazy_policy(value=None)._resolve_field_value("value")