Coverage for openhcs/config_framework/lazy_factory.py: 70.2%
440 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-01 18:33 +0000
1"""Generic lazy dataclass factory using flexible resolution."""
3# Standard library imports
4import dataclasses
5import inspect
6import logging
7import re
8import threading
9import sys
10import weakref
11from abc import ABCMeta
12from contextlib import contextmanager
13from dataclasses import dataclass, fields, is_dataclass, make_dataclass, MISSING, field
14from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union
16# OpenHCS imports
17from openhcs.config_framework.global_config import (
18 get_current_global_config,
19 set_current_global_config,
20)
21from openhcs.config_framework.placeholder import LazyDefaultPlaceholderService
22# Note: dual_axis_resolver_recursive and lazy_placeholder imports kept inline to avoid circular imports
25# Type registry for lazy dataclass to base class mapping
26_lazy_type_registry: Dict[Type, Type] = {}
28# Cache for lazy classes to prevent duplicate creation
29_lazy_class_cache: Dict[str, Type] = {}
32# ContextEventCoordinator removed - replaced with contextvars-based context system
37def register_lazy_type_mapping(lazy_type: Type, base_type: Type) -> None:
38 """Register mapping between lazy dataclass type and its base type."""
39 _lazy_type_registry[lazy_type] = base_type
42def get_base_type_for_lazy(lazy_type: Type) -> Optional[Type]:
43 """Get the base type for a lazy dataclass type."""
44 return _lazy_type_registry.get(lazy_type)
46# Optional imports (handled gracefully)
47try:
48 from PyQt6.QtWidgets import QApplication
49 HAS_PYQT = True
50except ImportError:
51 QApplication = None
52 HAS_PYQT = False
54logger = logging.getLogger(__name__)
57# Constants for lazy configuration system - simplified from class to module-level
58MATERIALIZATION_DEFAULTS_PATH = "materialization_defaults"
59RESOLVE_FIELD_VALUE_METHOD = "_resolve_field_value"
60GET_ATTRIBUTE_METHOD = "__getattribute__"
61TO_BASE_CONFIG_METHOD = "to_base_config"
62WITH_DEFAULTS_METHOD = "with_defaults"
63WITH_OVERRIDES_METHOD = "with_overrides"
64LAZY_FIELD_DEBUG_TEMPLATE = "LAZY FIELD CREATION: {field_name} - original={original_type}, has_default={has_default}, final={final_type}"
66LAZY_CLASS_NAME_PREFIX = "Lazy"
68# Legacy helper functions removed - new context system handles all resolution
71# Functional fallback strategies
72def _get_raw_field_value(obj: Any, field_name: str) -> Any:
73 """
74 Get raw field value bypassing lazy property getters to prevent infinite recursion.
76 Uses object.__getattribute__() to access stored values directly without triggering
77 lazy resolution, which would create circular dependencies in the resolution chain.
79 Args:
80 obj: Object to get field from
81 field_name: Name of field to access
83 Returns:
84 Raw field value or None if field doesn't exist
86 Raises:
87 AttributeError: If field doesn't exist (fail-loud behavior)
88 """
89 try:
90 return object.__getattribute__(obj, field_name)
91 except AttributeError:
92 return None
95@dataclass(frozen=True)
96class LazyMethodBindings:
97 """Declarative method bindings for lazy dataclasses."""
99 @staticmethod
100 def create_resolver() -> Callable[[Any, str], Any]:
101 """Create field resolver method using new pure function interface."""
102 from openhcs.config_framework.dual_axis_resolver import resolve_field_inheritance
103 from openhcs.config_framework.context_manager import current_temp_global, extract_all_configs
105 def _resolve_field_value(self, field_name: str) -> Any:
106 # Get current context from contextvars
107 try:
108 current_context = current_temp_global.get()
109 # Extract available configs from current context
110 available_configs = extract_all_configs(current_context)
112 # Use pure function for resolution
113 return resolve_field_inheritance(self, field_name, available_configs)
114 except LookupError:
115 # No context available - return None (fail-loud approach)
116 logger.debug(f"No context available for resolving {type(self).__name__}.{field_name}")
117 return None
119 return _resolve_field_value
121 @staticmethod
122 def create_getattribute() -> Callable[[Any, str], Any]:
123 """Create lazy __getattribute__ method using new context system."""
124 from openhcs.config_framework.dual_axis_resolver import resolve_field_inheritance, _has_concrete_field_override
125 from openhcs.config_framework.context_manager import current_temp_global, extract_all_configs
127 def _find_mro_concrete_value(base_class, name):
128 """Extract common MRO traversal pattern."""
129 return next((getattr(cls, name) for cls in base_class.__mro__
130 if _has_concrete_field_override(cls, name)), None)
132 def _try_global_context_value(self, base_class, name):
133 """Extract global context resolution logic using new pure function interface."""
134 if not hasattr(self, '_global_config_type'):
135 return None
137 # Get current context from contextvars
138 try:
139 current_context = current_temp_global.get()
140 # Extract available configs from current context
141 available_configs = extract_all_configs(current_context)
143 # Use pure function for resolution
144 resolved_value = resolve_field_inheritance(self, name, available_configs)
145 if resolved_value is not None:
146 return resolved_value
147 except LookupError:
148 # No context available - fall back to MRO
149 pass
151 # Fallback to MRO concrete value
152 return _find_mro_concrete_value(base_class, name)
154 def __getattribute__(self: Any, name: str) -> Any:
155 """
156 Three-stage resolution using new context system.
158 Stage 1: Check instance value
159 Stage 2: Simple field path lookup in current scope's merged config
160 Stage 3: Inheritance resolution using same merged context
161 """
162 # Stage 1: Get instance value
163 value = object.__getattribute__(self, name)
164 if value is not None or name not in {f.name for f in fields(self.__class__)}:
165 return value
167 # Stage 2: Simple field path lookup in current scope's merged global
168 try:
169 current_context = current_temp_global.get()
170 if current_context is not None: 170 ↛ 188line 170 didn't jump to line 188 because the condition on line 170 was always true
171 # Get the config type name for this lazy class
172 config_field_name = getattr(self, '_config_field_name', None)
173 if config_field_name: 173 ↛ 188line 173 didn't jump to line 188 because the condition on line 173 was always true
174 try:
175 config_instance = getattr(current_context, config_field_name)
176 if config_instance is not None: 176 ↛ 188line 176 didn't jump to line 188 because the condition on line 176 was always true
177 resolved_value = getattr(config_instance, name)
178 if resolved_value is not None:
179 return resolved_value
180 except AttributeError:
181 # Field doesn't exist in merged config, continue to inheritance
182 pass
183 except LookupError:
184 # No context available, continue to inheritance
185 pass
187 # Stage 3: Inheritance resolution using same merged context
188 try:
189 current_context = current_temp_global.get()
190 available_configs = extract_all_configs(current_context)
191 resolved_value = resolve_field_inheritance(self, name, available_configs)
193 if resolved_value is not None:
194 return resolved_value
196 # For nested dataclass fields, return lazy instance
197 field_obj = next((f for f in fields(self.__class__) if f.name == name), None)
198 if field_obj and is_dataclass(field_obj.type): 198 ↛ 199line 198 didn't jump to line 199 because the condition on line 198 was never true
199 return field_obj.type()
201 return None
203 except LookupError:
204 # No context available - fallback to MRO concrete values
205 return _find_mro_concrete_value(get_base_type_for_lazy(self.__class__), name)
206 return __getattribute__
208 @staticmethod
209 def create_to_base_config(base_class: Type) -> Callable[[Any], Any]:
210 """Create base config converter method."""
211 def to_base_config(self):
212 # CRITICAL FIX: Use object.__getattribute__ to preserve raw None values
213 # getattr() triggers lazy resolution, converting None to static defaults
214 # None values must be preserved for dual-axis inheritance to work correctly
215 #
216 # Context: to_base_config() is called DURING config_context() setup (line 124 in context_manager.py)
217 # If we use getattr() here, it triggers resolution BEFORE the context is fully set up,
218 # causing resolution to use the wrong/stale context and losing the GlobalPipelineConfig base.
219 # We must extract raw None values here, let config_context() merge them into the hierarchy,
220 # and THEN resolution happens later with the properly built context.
221 field_values = {f.name: object.__getattribute__(self, f.name) for f in fields(self)}
222 return base_class(**field_values)
223 return to_base_config
225 @staticmethod
226 def create_class_methods() -> Dict[str, Any]:
227 """Create class-level utility methods."""
228 return {
229 WITH_DEFAULTS_METHOD: classmethod(lambda cls: cls()),
230 WITH_OVERRIDES_METHOD: classmethod(lambda cls, **kwargs: cls(**kwargs))
231 }
234class LazyDataclassFactory:
235 """Generic factory for creating lazy dataclasses with flexible resolution."""
241 @staticmethod
242 def _introspect_dataclass_fields(base_class: Type, debug_template: str, global_config_type: Type = None, parent_field_path: str = None, parent_instance_provider: Optional[Callable[[], Any]] = None) -> List[Tuple[str, Type, None]]:
243 """
244 Introspect dataclass fields for lazy loading.
246 Converts nested dataclass fields to lazy equivalents and makes fields Optional
247 if they lack defaults. Complex logic handles type unwrapping and lazy nesting.
248 """
249 base_fields = fields(base_class)
250 lazy_field_definitions = []
252 for field in base_fields:
253 # Check if field already has Optional type
254 origin = getattr(field.type, '__origin__', None)
255 is_already_optional = (origin is Union and
256 type(None) in getattr(field.type, '__args__', ()))
258 # Check if field has default value or factory
259 has_default = (field.default is not MISSING or
260 field.default_factory is not MISSING)
262 # Check if field type is a dataclass that should be made lazy
263 field_type = field.type
264 if is_dataclass(field.type):
265 # SIMPLIFIED: Create lazy version using simple factory
266 lazy_nested_type = LazyDataclassFactory.make_lazy_simple(
267 base_class=field.type,
268 lazy_class_name=f"Lazy{field.type.__name__}"
269 )
270 field_type = lazy_nested_type
271 logger.debug(f"Created lazy class for {field.name}: {field.type} -> {lazy_nested_type}")
273 # Complex type logic: make Optional if no default, preserve existing Optional types
274 if is_already_optional or not has_default:
275 final_field_type = Union[field_type, type(None)] if not is_already_optional else field_type
276 else:
277 final_field_type = field_type
279 # CRITICAL FIX: Create default factory for Optional dataclass fields
280 # This eliminates the need for field introspection and ensures UI always has instances to render
281 default_value = None
282 if (is_already_optional or not has_default) and is_dataclass(field.type): 282 ↛ 286line 282 didn't jump to line 286 because the condition on line 282 was never true
283 # For Optional dataclass fields, create default factory that creates lazy instances
284 # This ensures the UI always has nested lazy instances to render recursively
285 # CRITICAL: field_type is already the lazy type, so use it directly
286 default_value = dataclasses.field(default_factory=field_type)
288 lazy_field_definitions.append((field.name, final_field_type, default_value))
290 # Debug logging with provided template (reduced to DEBUG level to reduce log pollution)
291 logger.debug(debug_template.format(
292 field_name=field.name,
293 original_type=field.type,
294 has_default=has_default,
295 final_type=final_field_type
296 ))
298 return lazy_field_definitions
300 @staticmethod
301 def _create_lazy_dataclass_unified(
302 base_class: Type,
303 instance_provider: Callable[[], Any],
304 lazy_class_name: str,
305 debug_template: str,
306 use_recursive_resolution: bool = False,
307 fallback_chain: Optional[List[Callable[[str], Any]]] = None,
308 global_config_type: Type = None,
309 parent_field_path: str = None,
310 parent_instance_provider: Optional[Callable[[], Any]] = None
311 ) -> Type:
312 """
313 Create lazy dataclass with declarative configuration.
315 Core factory method that creates lazy dataclass with introspected fields,
316 binds resolution methods, and registers type mappings. Complex orchestration
317 of field analysis, method binding, and class creation.
318 """
319 if not is_dataclass(base_class): 319 ↛ 320line 319 didn't jump to line 320 because the condition on line 319 was never true
320 raise ValueError(f"{base_class} must be a dataclass")
322 # Check cache first to prevent duplicate creation
323 cache_key = f"{base_class.__name__}_{lazy_class_name}_{id(instance_provider)}"
324 if cache_key in _lazy_class_cache: 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true
325 return _lazy_class_cache[cache_key]
327 # ResolutionConfig system removed - dual-axis resolver handles all resolution
329 # Create lazy dataclass with introspected fields
330 # CRITICAL FIX: Avoid inheriting from classes with custom metaclasses to prevent descriptor conflicts
331 # Exception: InheritAsNoneMeta is safe to inherit from as it only modifies field defaults
332 # Exception: Classes with _inherit_as_none marker are safe even with ABCMeta (processed by @global_pipeline_config)
333 base_metaclass = type(base_class)
334 has_inherit_as_none_marker = hasattr(base_class, '_inherit_as_none') and base_class._inherit_as_none
335 has_unsafe_metaclass = (
336 (hasattr(base_class, '__metaclass__') or base_metaclass != type) and
337 base_metaclass != InheritAsNoneMeta and
338 not has_inherit_as_none_marker
339 )
341 if has_unsafe_metaclass: 341 ↛ 343line 341 didn't jump to line 343 because the condition on line 341 was never true
342 # Base class has unsafe custom metaclass - don't inherit, just copy interface
343 print(f"🔧 LAZY FACTORY: {base_class.__name__} has custom metaclass {base_metaclass.__name__}, avoiding inheritance")
344 lazy_class = make_dataclass(
345 lazy_class_name,
346 LazyDataclassFactory._introspect_dataclass_fields(
347 base_class, debug_template, global_config_type, parent_field_path, parent_instance_provider
348 ),
349 bases=(), # No inheritance to avoid metaclass conflicts
350 frozen=True
351 )
352 else:
353 # Safe to inherit from regular dataclass
354 lazy_class = make_dataclass(
355 lazy_class_name,
356 LazyDataclassFactory._introspect_dataclass_fields(
357 base_class, debug_template, global_config_type, parent_field_path, parent_instance_provider
358 ),
359 bases=(base_class,),
360 frozen=True
361 )
363 # Add constructor parameter tracking to detect user-set fields
364 original_init = lazy_class.__init__
365 def __init_with_tracking__(self, **kwargs):
366 # Track which fields were explicitly passed to constructor
367 object.__setattr__(self, '_explicitly_set_fields', set(kwargs.keys()))
368 # Store the global config type for inheritance resolution
369 object.__setattr__(self, '_global_config_type', global_config_type)
370 # Store the config field name for simple field path lookup
371 import re
372 def _camel_to_snake_local(name: str) -> str:
373 s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
374 return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
375 config_field_name = _camel_to_snake_local(base_class.__name__)
376 object.__setattr__(self, '_config_field_name', config_field_name)
377 original_init(self, **kwargs)
379 lazy_class.__init__ = __init_with_tracking__
381 # Bind methods declaratively - inline single-use method
382 method_bindings = {
383 RESOLVE_FIELD_VALUE_METHOD: LazyMethodBindings.create_resolver(),
384 GET_ATTRIBUTE_METHOD: LazyMethodBindings.create_getattribute(),
385 TO_BASE_CONFIG_METHOD: LazyMethodBindings.create_to_base_config(base_class),
386 **LazyMethodBindings.create_class_methods()
387 }
388 for method_name, method_impl in method_bindings.items():
389 setattr(lazy_class, method_name, method_impl)
391 # Automatically register the lazy dataclass with the type registry
392 register_lazy_type_mapping(lazy_class, base_class)
394 # Cache the created class to prevent duplicates
395 _lazy_class_cache[cache_key] = lazy_class
397 return lazy_class
403 @staticmethod
404 def make_lazy_simple(
405 base_class: Type,
406 lazy_class_name: str = None
407 ) -> Type:
408 """
409 Create lazy dataclass using new contextvars system.
411 SIMPLIFIED: No complex hierarchy providers or field path detection needed.
412 Uses new contextvars system for all resolution.
414 Args:
415 base_class: Base dataclass to make lazy
416 lazy_class_name: Optional name for the lazy class
418 Returns:
419 Generated lazy dataclass with contextvars-based resolution
420 """
421 # Generate class name if not provided
422 lazy_class_name = lazy_class_name or f"Lazy{base_class.__name__}"
424 # Simple provider that uses new contextvars system
425 def simple_provider():
426 """Simple provider using new contextvars system."""
427 return base_class() # Lazy __getattribute__ handles resolution
429 return LazyDataclassFactory._create_lazy_dataclass_unified(
430 base_class=base_class,
431 instance_provider=simple_provider,
432 lazy_class_name=lazy_class_name,
433 debug_template=f"Simple contextvars resolution for {base_class.__name__}",
434 use_recursive_resolution=False,
435 fallback_chain=[],
436 global_config_type=None,
437 parent_field_path=None,
438 parent_instance_provider=None
439 )
441 # All legacy methods removed - use make_lazy_simple() for all use cases
444# Generic utility functions for clean thread-local storage management
445def ensure_global_config_context(global_config_type: Type, global_config_instance: Any) -> None:
446 """Ensure proper thread-local storage setup for any global config type."""
447 from openhcs.config_framework.global_config import set_global_config_for_editing
448 set_global_config_for_editing(global_config_type, global_config_instance)
451# Context provider registry and metaclass for automatic registration
452CONTEXT_PROVIDERS = {}
454from abc import ABCMeta
456class ContextProviderMeta(ABCMeta):
457 """Metaclass for automatic registration of context provider classes."""
459 def __new__(cls, name, bases, attrs):
460 new_class = super().__new__(cls, name, bases, attrs)
462 # Only register concrete classes that have a context_type attribute
463 context_type = getattr(new_class, '_context_type', None)
464 if context_type and not getattr(new_class, '__abstractmethods__', None):
465 CONTEXT_PROVIDERS[context_type] = new_class
466 logger.debug(f"Auto-registered context provider: {context_type} -> {name}")
468 return new_class
471class ContextProvider(metaclass=ContextProviderMeta):
472 """Base class for objects that can provide context for lazy resolution."""
473 _context_type: Optional[str] = None # Override in subclasses
476def _detect_context_type(obj: Any) -> Optional[str]:
477 """
478 Detect what type of context object this is using registered providers.
480 Returns the context type name or None if not a recognized context type.
481 """
482 # Check for functions first (simple callable check)
483 if callable(obj) and hasattr(obj, '__name__'):
484 return "function"
486 # Check if object is an instance of any registered context provider
487 for context_type, provider_class in CONTEXT_PROVIDERS.items():
488 if isinstance(obj, provider_class):
489 return context_type
491 return None
494# ContextInjector removed - replaced with contextvars-based context system
499def resolve_lazy_configurations_for_serialization(data: Any) -> Any:
500 """
501 Recursively resolve lazy dataclass instances to concrete values for serialization.
503 CRITICAL: This function must be called WITHIN a config_context() block!
504 The context provides the hierarchy for lazy resolution.
506 How it works:
507 1. For lazy dataclasses: Access fields with getattr() to trigger resolution
508 2. The lazy __getattribute__ uses the active config_context() to resolve None values
509 3. Convert resolved values to base config for pickling
511 Example (from README.md):
512 with config_context(orchestrator.pipeline_config):
513 # Lazy resolution happens here via context
514 resolved_steps = resolve_lazy_configurations_for_serialization(steps)
515 """
516 # Check if this is a lazy dataclass
517 base_type = get_base_type_for_lazy(type(data))
518 if base_type is not None:
519 # This is a lazy dataclass - resolve fields using getattr() within the active context
520 # getattr() triggers lazy __getattribute__ which uses config_context() for resolution
521 resolved_fields = {}
522 for f in fields(data):
523 # CRITICAL: Use getattr() to trigger lazy resolution via context
524 # The active config_context() provides the hierarchy for resolution
525 resolved_value = getattr(data, f.name)
526 resolved_fields[f.name] = resolved_value
528 # Create base config instance with resolved values
529 resolved_data = base_type(**resolved_fields)
530 else:
531 # Not a lazy dataclass
532 resolved_data = data
534 # CRITICAL FIX: Handle step objects (non-dataclass objects with dataclass attributes)
535 step_context_type = _detect_context_type(resolved_data)
536 if step_context_type:
537 # This is a context object - inject it for its dataclass attributes
538 import inspect
539 frame = inspect.currentframe()
540 context_var_name = f"__{step_context_type}_context__"
541 frame.f_locals[context_var_name] = resolved_data
542 logger.debug(f"Injected {context_var_name} = {type(resolved_data).__name__}")
544 try:
545 # Process step attributes recursively
546 resolved_attrs = {}
547 for attr_name in dir(resolved_data):
548 if attr_name.startswith('_'):
549 continue
550 try:
551 attr_value = getattr(resolved_data, attr_name)
552 if not callable(attr_value): # Skip methods
553 logger.debug(f"Resolving {type(resolved_data).__name__}.{attr_name} = {type(attr_value).__name__}")
554 resolved_attrs[attr_name] = resolve_lazy_configurations_for_serialization(attr_value)
555 except (AttributeError, Exception):
556 continue
558 # Handle function objects specially - they can't be recreated with __new__
559 if step_context_type == "function":
560 # For functions, just process attributes for resolution but return original function
561 # The resolved config values will be stored in func plan by compiler
562 return resolved_data
564 # Create new step object with resolved attributes
565 # CRITICAL FIX: Copy all original attributes using __dict__ to preserve everything
566 new_step = type(resolved_data).__new__(type(resolved_data))
568 # Copy all attributes from the original object's __dict__
569 if hasattr(resolved_data, '__dict__'): 569 ↛ 573line 569 didn't jump to line 573 because the condition on line 569 was always true
570 new_step.__dict__.update(resolved_data.__dict__)
572 # Update with resolved config attributes (these override the originals)
573 for attr_name, attr_value in resolved_attrs.items():
574 setattr(new_step, attr_name, attr_value)
575 return new_step
576 finally:
577 if context_var_name in frame.f_locals: 577 ↛ 579line 577 didn't jump to line 579 because the condition on line 577 was always true
578 del frame.f_locals[context_var_name]
579 del frame
581 # Recursively process nested structures based on type
582 elif is_dataclass(resolved_data) and not isinstance(resolved_data, type):
583 # Process dataclass fields recursively - inline field processing pattern
584 # CRITICAL FIX: Inject parent object as context for sibling config inheritance
585 context_type = _detect_context_type(resolved_data) or "dataclass" # Default to "dataclass" for generic dataclasses
586 import inspect
587 frame = inspect.currentframe()
588 context_var_name = f"__{context_type}_context__"
589 frame.f_locals[context_var_name] = resolved_data
590 logger.debug(f"Injected {context_var_name} = {type(resolved_data).__name__}")
592 # Add debug to see which fields are being resolved
593 logger.debug(f"Resolving fields for {type(resolved_data).__name__}: {[f.name for f in fields(resolved_data)]}")
595 try:
596 resolved_fields = {}
597 for f in fields(resolved_data):
598 field_value = getattr(resolved_data, f.name)
599 logger.debug(f"Resolving {type(resolved_data).__name__}.{f.name} = {type(field_value).__name__}")
600 resolved_fields[f.name] = resolve_lazy_configurations_for_serialization(field_value)
601 return type(resolved_data)(**resolved_fields)
602 finally:
603 if context_var_name in frame.f_locals: 603 ↛ 605line 603 didn't jump to line 605 because the condition on line 603 was always true
604 del frame.f_locals[context_var_name]
605 del frame
607 elif isinstance(resolved_data, dict):
608 # Process dictionary values recursively
609 return {
610 key: resolve_lazy_configurations_for_serialization(value)
611 for key, value in resolved_data.items()
612 }
614 elif isinstance(resolved_data, (list, tuple)):
615 # Process sequence elements recursively
616 resolved_items = [resolve_lazy_configurations_for_serialization(item) for item in resolved_data]
617 return type(resolved_data)(resolved_items)
619 else:
620 # Primitive type or unknown structure - return as-is
621 return resolved_data
624# Generic dataclass editing with configurable value preservation
625T = TypeVar('T')
628def create_dataclass_for_editing(dataclass_type: Type[T], source_config: Any, preserve_values: bool = False, context_provider: Optional[Callable[[Any], None]] = None) -> T:
629 """Create dataclass for editing with configurable value preservation."""
630 if not is_dataclass(dataclass_type):
631 raise ValueError(f"{dataclass_type} must be a dataclass")
633 # Set up context if provider is given (e.g., thread-local storage)
634 if context_provider:
635 context_provider(source_config)
637 # Mathematical simplification: Convert verbose loop to unified comprehension
638 from openhcs.config_framework.placeholder import LazyDefaultPlaceholderService
639 field_values = {
640 f.name: (getattr(source_config, f.name) if preserve_values
641 else f.type() if is_dataclass(f.type) and LazyDefaultPlaceholderService.has_lazy_resolution(f.type)
642 else None)
643 for f in fields(dataclass_type)
644 }
646 return dataclass_type(**field_values)
652def rebuild_lazy_config_with_new_global_reference(
653 existing_lazy_config: Any,
654 new_global_config: Any,
655 global_config_type: Optional[Type] = None
656) -> Any:
657 """
658 Rebuild lazy config to reference new global config while preserving field states.
660 This function preserves the exact field state of the existing lazy config:
661 - Fields that are None (using lazy resolution) remain None
662 - Fields that have been explicitly set retain their concrete values
663 - Nested dataclass fields are recursively rebuilt to reference new global config
664 - The underlying global config reference is updated for None field resolution
666 Args:
667 existing_lazy_config: Current lazy config instance
668 new_global_config: New global config to reference for lazy resolution
669 global_config_type: Type of the global config (defaults to type of new_global_config)
671 Returns:
672 New lazy config instance with preserved field states and updated global reference
673 """
674 if existing_lazy_config is None:
675 return None
677 # Determine global config type
678 if global_config_type is None:
679 global_config_type = type(new_global_config)
681 # Set new global config in thread-local storage
682 ensure_global_config_context(global_config_type, new_global_config)
684 # Extract current field values without triggering lazy resolution - inline field processing pattern
685 def process_field_value(field_obj):
686 raw_value = object.__getattribute__(existing_lazy_config, field_obj.name)
688 if raw_value is not None and hasattr(raw_value, '__dataclass_fields__'):
689 try:
690 # Check if this is a concrete dataclass that should be converted to lazy
691 is_lazy = LazyDefaultPlaceholderService.has_lazy_resolution(type(raw_value))
693 if not is_lazy:
694 lazy_type = LazyDefaultPlaceholderService._get_lazy_type_for_base(type(raw_value))
696 if lazy_type:
697 # Convert concrete dataclass to lazy version while preserving ONLY non-default field values
698 # This allows fields that match class defaults to inherit from context
699 concrete_field_values = {}
700 for f in fields(raw_value):
701 field_value = object.__getattribute__(raw_value, f.name)
703 # Get the class default for this field
704 class_default = getattr(type(raw_value), f.name, None)
706 # Only preserve values that differ from class defaults
707 # This allows default values to be inherited from context
708 if field_value != class_default:
709 concrete_field_values[f.name] = field_value
711 logger.debug(f"Converting concrete {type(raw_value).__name__} to lazy version {lazy_type.__name__} for placeholder resolution")
712 return lazy_type(**concrete_field_values)
714 # If already lazy or no lazy version available, rebuild recursively
715 nested_result = rebuild_lazy_config_with_new_global_reference(raw_value, new_global_config, global_config_type)
716 return nested_result
717 except Exception as e:
718 logger.debug(f"Failed to rebuild nested config {field_obj.name}: {e}")
719 return raw_value
720 return raw_value
722 current_field_values = {f.name: process_field_value(f) for f in fields(existing_lazy_config)}
724 return type(existing_lazy_config)(**current_field_values)
727# Declarative Global Config Field Injection System
728# Moved inline imports to top-level
730# Naming configuration
731GLOBAL_CONFIG_PREFIX = "Global"
732LAZY_CONFIG_PREFIX = "Lazy"
734# Registry to accumulate all decorations before injection
735_pending_injections = {}
739class InheritAsNoneMeta(ABCMeta):
740 """
741 Metaclass that applies inherit_as_none modifications during class creation.
743 This runs BEFORE @dataclass and modifies the class definition to add
744 field overrides with None defaults for inheritance.
745 """
747 def __new__(mcs, name, bases, namespace, **kwargs):
748 # Create the class first
749 cls = super().__new__(mcs, name, bases, namespace)
751 # Check if this class should have inherit_as_none applied
752 if hasattr(cls, '_inherit_as_none') and cls._inherit_as_none:
753 # Add multiprocessing safety marker
754 cls._multiprocessing_safe = True
755 # Get explicitly defined fields (in this class's namespace)
756 explicitly_defined_fields = set()
757 if '__annotations__' in namespace:
758 for field_name in namespace['__annotations__']:
759 if field_name in namespace:
760 explicitly_defined_fields.add(field_name)
762 # Process parent classes to find fields that need None overrides
763 processed_fields = set()
764 for base in bases:
765 if hasattr(base, '__annotations__'):
766 for field_name, field_type in base.__annotations__.items():
767 if field_name in processed_fields:
768 continue
770 # Check if parent has concrete default
771 parent_has_concrete_default = False
772 if hasattr(base, field_name):
773 parent_value = getattr(base, field_name)
774 parent_has_concrete_default = parent_value is not None
776 # Add None override if needed
777 if (field_name not in explicitly_defined_fields and parent_has_concrete_default):
778 # Set the class attribute to None
779 setattr(cls, field_name, None)
781 # Ensure annotation exists
782 if not hasattr(cls, '__annotations__'):
783 cls.__annotations__ = {}
784 cls.__annotations__[field_name] = field_type
786 processed_fields.add(field_name)
787 else:
788 processed_fields.add(field_name)
790 return cls
792 def __reduce__(cls):
793 """Make classes with this metaclass pickle-safe for multiprocessing."""
794 # Filter out problematic descriptors that cause conflicts during pickle/unpickle
795 safe_dict = {}
796 for key, value in cls.__dict__.items():
797 # Skip descriptors that cause conflicts
798 if hasattr(value, '__get__') and hasattr(value, '__set__'):
799 continue # Skip data descriptors
800 if hasattr(value, '__dict__') and hasattr(value, '__class__'):
801 # Skip complex objects that might have descriptor conflicts
802 if 'descriptor' in str(type(value)).lower():
803 continue
804 # Include safe attributes
805 safe_dict[key] = value
807 # Return reconstruction using the base type (not the metaclass)
808 return (type, (cls.__name__, cls.__bases__, safe_dict))
811def create_global_default_decorator(target_config_class: Type):
812 """
813 Create a decorator factory for a specific global config class.
815 The decorator accumulates all decorations, then injects all fields at once
816 when the module finishes loading. Also creates lazy versions of all decorated configs.
817 """
818 target_class_name = target_config_class.__name__
819 if target_class_name not in _pending_injections: 819 ↛ 825line 819 didn't jump to line 825 because the condition on line 819 was always true
820 _pending_injections[target_class_name] = {
821 'target_class': target_config_class,
822 'configs_to_inject': []
823 }
825 def global_default_decorator(cls=None, *, optional: bool = False, inherit_as_none: bool = True, ui_hidden: bool = False):
826 """
827 Decorator that can be used with or without parameters.
829 Args:
830 cls: The class being decorated (when used without parentheses)
831 optional: Whether to wrap the field type with Optional (default: False)
832 inherit_as_none: Whether to set all inherited fields to None by default (default: True)
833 ui_hidden: Whether to hide from UI (apply decorator but don't inject into global config) (default: False)
834 """
835 def decorator(actual_cls):
836 # Apply inherit_as_none by modifying class BEFORE @dataclass (multiprocessing-safe)
837 if inherit_as_none: 837 ↛ 883line 837 didn't jump to line 883 because the condition on line 837 was always true
838 # Mark the class for inherit_as_none processing
839 actual_cls._inherit_as_none = True
841 # Apply inherit_as_none logic by directly modifying the class definition
842 # This must happen BEFORE @dataclass processes the class
843 explicitly_defined_fields = set()
844 if hasattr(actual_cls, '__annotations__'): 844 ↛ 854line 844 didn't jump to line 854 because the condition on line 844 was always true
845 for field_name in actual_cls.__annotations__:
846 # Check if field has a concrete default value in THIS class definition (not inherited)
847 if field_name in actual_cls.__dict__: # Only fields defined in THIS class 847 ↛ 845line 847 didn't jump to line 845 because the condition on line 847 was always true
848 field_value = actual_cls.__dict__[field_name]
849 # Only consider it explicitly defined if it has a concrete value (not None)
850 if field_value is not None:
851 explicitly_defined_fields.add(field_name)
853 # Process parent classes to find fields that need None overrides
854 processed_fields = set()
855 fields_set_to_none = set() # Track which fields were actually set to None
856 for base in actual_cls.__bases__:
857 if hasattr(base, '__annotations__'):
858 for field_name, field_type in base.__annotations__.items():
859 if field_name in processed_fields:
860 continue
862 # Set inherited fields to None (except explicitly defined ones)
863 if field_name not in explicitly_defined_fields:
864 # CRITICAL: Force the field to be seen as locally defined by @dataclass
865 # We need to ensure @dataclass processes this as a local field, not inherited
867 # 1. Set the class attribute to None
868 setattr(actual_cls, field_name, None)
869 fields_set_to_none.add(field_name)
871 # 2. Ensure annotation exists in THIS class
872 if not hasattr(actual_cls, '__annotations__'): 872 ↛ 873line 872 didn't jump to line 873 because the condition on line 872 was never true
873 actual_cls.__annotations__ = {}
874 actual_cls.__annotations__[field_name] = field_type
876 processed_fields.add(field_name)
878 # Note: We modify class attributes here, but we also need to fix the dataclass
879 # field definitions after @dataclass runs, since @dataclass processes the MRO
880 # and may use parent class field definitions instead of our modified attributes.
882 # Generate field and class names
883 field_name = _camel_to_snake(actual_cls.__name__)
884 lazy_class_name = f"{LAZY_CONFIG_PREFIX}{actual_cls.__name__}"
886 # Add to pending injections for field injection (unless ui_hidden)
887 if not ui_hidden:
888 _pending_injections[target_class_name]['configs_to_inject'].append({
889 'config_class': actual_cls,
890 'field_name': field_name,
891 'lazy_class_name': lazy_class_name,
892 'optional': optional, # Store the optional flag
893 'inherit_as_none': inherit_as_none # Store the inherit_as_none flag
894 })
896 # Immediately create lazy version of this config (not dependent on injection)
899 lazy_class = LazyDataclassFactory.make_lazy_simple(
900 base_class=actual_cls,
901 lazy_class_name=lazy_class_name
902 )
904 # Export lazy class to config module immediately
905 config_module = sys.modules[actual_cls.__module__]
906 setattr(config_module, lazy_class_name, lazy_class)
908 # CRITICAL: Post-process dataclass fields after @dataclass has run
909 # This fixes the constructor behavior for inherited fields that should be None
910 if inherit_as_none and hasattr(actual_cls, '__dataclass_fields__'): 910 ↛ 913line 910 didn't jump to line 913 because the condition on line 910 was always true
911 _fix_dataclass_field_defaults_post_processing(actual_cls, fields_set_to_none)
913 return actual_cls
915 # Handle both @decorator and @decorator() usage
916 if cls is None:
917 # Called with parentheses: @decorator(optional=True)
918 return decorator
919 else:
920 # Called without parentheses: @decorator
921 return decorator(cls)
923 return global_default_decorator
926def _fix_dataclass_field_defaults_post_processing(cls: Type, fields_set_to_none: set) -> None:
927 """
928 Fix dataclass field defaults after @dataclass has processed the class.
930 This is necessary because @dataclass processes the MRO and may use parent class
931 field definitions instead of our modified class attributes. We need to ensure
932 that fields we set to None actually use None as the default in the constructor.
933 """
934 import dataclasses
936 # Store the original __init__ method
937 original_init = cls.__init__
939 def custom_init(self, **kwargs):
940 """Custom __init__ that ensures inherited fields use None defaults."""
941 # For fields that should be None, set them to None if not explicitly provided
942 for field_name in fields_set_to_none:
943 if field_name not in kwargs:
944 kwargs[field_name] = None
946 # Call the original __init__ with modified kwargs
947 original_init(self, **kwargs)
949 # Replace the __init__ method
950 cls.__init__ = custom_init
952 # Also update the field defaults for consistency
953 for field_name in fields_set_to_none:
954 if field_name in cls.__dataclass_fields__: 954 ↛ 953line 954 didn't jump to line 953 because the condition on line 954 was always true
955 # Get the field object
956 field_obj = cls.__dataclass_fields__[field_name]
958 # Update the field default to None (overriding any parent class default)
959 field_obj.default = None
960 field_obj.default_factory = dataclasses.MISSING
962 # Also ensure the class attribute is None (should already be set, but double-check)
963 setattr(cls, field_name, None)
967def _inject_all_pending_fields():
968 """Inject all accumulated fields at once."""
969 for target_name, injection_data in _pending_injections.items():
970 target_class = injection_data['target_class']
971 configs = injection_data['configs_to_inject']
973 if configs: # Only inject if there are configs to inject 973 ↛ 969line 973 didn't jump to line 969 because the condition on line 973 was always true
974 _inject_multiple_fields_into_dataclass(target_class, configs)
976def _camel_to_snake(name: str) -> str:
977 """Convert CamelCase to snake_case for field names."""
978 s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
979 return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
981def _inject_multiple_fields_into_dataclass(target_class: Type, configs: List[Dict]) -> None:
982 """Mathematical simplification: Batch field injection with direct dataclass recreation."""
983 # Imports moved to top-level
985 # Direct field reconstruction - guaranteed by dataclass contract
986 existing_fields = [
987 (f.name, f.type, field(default_factory=f.default_factory) if f.default_factory != MISSING
988 else f.default if f.default != MISSING else f.type)
989 for f in fields(target_class)
990 ]
992 # Mathematical simplification: Unified field construction with algebraic common factors
993 def create_field_definition(config):
994 """Create field definition with optional and inherit_as_none support."""
995 field_type = config['config_class']
996 is_optional = config.get('optional', False)
998 # Algebraic simplification: factor out common default_value logic
999 if is_optional: 999 ↛ 1000line 999 didn't jump to line 1000 because the condition on line 999 was never true
1000 field_type = Union[field_type, type(None)]
1001 default_value = None
1002 else:
1003 # Both inherit_as_none and regular cases use same default factory
1004 default_value = field(default_factory=field_type)
1006 return (config['field_name'], field_type, default_value)
1008 all_fields = existing_fields + [create_field_definition(config) for config in configs]
1010 # Direct dataclass recreation - fail-loud
1011 new_class = make_dataclass(
1012 target_class.__name__,
1013 all_fields,
1014 bases=target_class.__bases__,
1015 frozen=target_class.__dataclass_params__.frozen
1016 )
1018 # Sibling inheritance is now handled by the dual-axis resolver system
1020 # Direct module replacement
1021 module = sys.modules[target_class.__module__]
1022 setattr(module, target_class.__name__, new_class)
1023 globals()[target_class.__name__] = new_class
1025 # Mathematical simplification: Extract common module assignment pattern
1026 def _register_lazy_class(lazy_class, class_name, module_name):
1027 """Register lazy class in both module and global namespace."""
1028 setattr(sys.modules[module_name], class_name, lazy_class)
1029 globals()[class_name] = lazy_class
1031 # Create lazy classes and recreate PipelineConfig inline
1032 for config in configs:
1033 lazy_class = LazyDataclassFactory.make_lazy_simple(
1034 base_class=config['config_class'],
1035 lazy_class_name=config['lazy_class_name']
1036 )
1037 _register_lazy_class(lazy_class, config['lazy_class_name'], config['config_class'].__module__)
1039 # Create lazy version of the updated global config itself with proper naming
1040 # Global configs must start with GLOBAL_CONFIG_PREFIX - fail-loud if not
1041 if not target_class.__name__.startswith(GLOBAL_CONFIG_PREFIX): 1041 ↛ 1042line 1041 didn't jump to line 1042 because the condition on line 1041 was never true
1042 raise ValueError(f"Target class '{target_class.__name__}' must start with '{GLOBAL_CONFIG_PREFIX}' prefix")
1044 # Remove global prefix (GlobalPipelineConfig → PipelineConfig)
1045 lazy_global_class_name = target_class.__name__[len(GLOBAL_CONFIG_PREFIX):]
1047 lazy_global_class = LazyDataclassFactory.make_lazy_simple(
1048 base_class=new_class,
1049 lazy_class_name=lazy_global_class_name
1050 )
1052 # Use extracted helper for consistent registration
1053 _register_lazy_class(lazy_global_class, lazy_global_class_name, target_class.__module__)
1059def auto_create_decorator(global_config_class):
1060 """
1061 Decorator that automatically creates:
1062 1. A field injection decorator for other configs to use
1063 2. A lazy version of the global config itself
1065 Global config classes must start with "Global" prefix.
1066 """
1067 # Validate naming convention
1068 if not global_config_class.__name__.startswith(GLOBAL_CONFIG_PREFIX): 1068 ↛ 1069line 1068 didn't jump to line 1069 because the condition on line 1068 was never true
1069 raise ValueError(f"Global config class '{global_config_class.__name__}' must start with '{GLOBAL_CONFIG_PREFIX}' prefix")
1071 decorator_name = _camel_to_snake(global_config_class.__name__)
1072 decorator = create_global_default_decorator(global_config_class)
1074 # Export decorator to module globals
1075 module = sys.modules[global_config_class.__module__]
1076 setattr(module, decorator_name, decorator)
1078 # Lazy global config will be created after field injection
1080 return global_config_class