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

1"""Generic lazy dataclass factory using flexible resolution.""" 

2 

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 

15 

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 

23 

24 

25# Type registry for lazy dataclass to base class mapping 

26_lazy_type_registry: Dict[Type, Type] = {} 

27 

28# Cache for lazy classes to prevent duplicate creation 

29_lazy_class_cache: Dict[str, Type] = {} 

30 

31 

32# ContextEventCoordinator removed - replaced with contextvars-based context system 

33 

34 

35 

36 

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 

40 

41 

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) 

45 

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 

53 

54logger = logging.getLogger(__name__) 

55 

56 

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}" 

65 

66LAZY_CLASS_NAME_PREFIX = "Lazy" 

67 

68# Legacy helper functions removed - new context system handles all resolution 

69 

70 

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. 

75 

76 Uses object.__getattribute__() to access stored values directly without triggering 

77 lazy resolution, which would create circular dependencies in the resolution chain. 

78 

79 Args: 

80 obj: Object to get field from 

81 field_name: Name of field to access 

82 

83 Returns: 

84 Raw field value or None if field doesn't exist 

85 

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 

93 

94 

95@dataclass(frozen=True) 

96class LazyMethodBindings: 

97 """Declarative method bindings for lazy dataclasses.""" 

98 

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 

104 

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) 

111 

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 

118 

119 return _resolve_field_value 

120 

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 

126 

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) 

131 

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 

136 

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) 

142 

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 

150 

151 # Fallback to MRO concrete value 

152 return _find_mro_concrete_value(base_class, name) 

153 

154 def __getattribute__(self: Any, name: str) -> Any: 

155 """ 

156 Three-stage resolution using new context system. 

157 

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 

166 

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 

186 

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) 

192 

193 if resolved_value is not None: 

194 return resolved_value 

195 

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() 

200 

201 return None 

202 

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__ 

207 

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 

224 

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 } 

232 

233 

234class LazyDataclassFactory: 

235 """Generic factory for creating lazy dataclasses with flexible resolution.""" 

236 

237 

238 

239 

240 

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. 

245 

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 = [] 

251 

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__', ())) 

257 

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) 

261 

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}") 

272 

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 

278 

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) 

287 

288 lazy_field_definitions.append((field.name, final_field_type, default_value)) 

289 

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 )) 

297 

298 return lazy_field_definitions 

299 

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. 

314 

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") 

321 

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] 

326 

327 # ResolutionConfig system removed - dual-axis resolver handles all resolution 

328 

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 ) 

340 

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 ) 

362 

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) 

378 

379 lazy_class.__init__ = __init_with_tracking__ 

380 

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) 

390 

391 # Automatically register the lazy dataclass with the type registry 

392 register_lazy_type_mapping(lazy_class, base_class) 

393 

394 # Cache the created class to prevent duplicates 

395 _lazy_class_cache[cache_key] = lazy_class 

396 

397 return lazy_class 

398 

399 

400 

401 

402 

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. 

410 

411 SIMPLIFIED: No complex hierarchy providers or field path detection needed. 

412 Uses new contextvars system for all resolution. 

413 

414 Args: 

415 base_class: Base dataclass to make lazy 

416 lazy_class_name: Optional name for the lazy class 

417 

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__}" 

423 

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 

428 

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 ) 

440 

441 # All legacy methods removed - use make_lazy_simple() for all use cases 

442 

443 

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) 

449 

450 

451# Context provider registry and metaclass for automatic registration 

452CONTEXT_PROVIDERS = {} 

453 

454from abc import ABCMeta 

455 

456class ContextProviderMeta(ABCMeta): 

457 """Metaclass for automatic registration of context provider classes.""" 

458 

459 def __new__(cls, name, bases, attrs): 

460 new_class = super().__new__(cls, name, bases, attrs) 

461 

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}") 

467 

468 return new_class 

469 

470 

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 

474 

475 

476def _detect_context_type(obj: Any) -> Optional[str]: 

477 """ 

478 Detect what type of context object this is using registered providers. 

479 

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" 

485 

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 

490 

491 return None 

492 

493 

494# ContextInjector removed - replaced with contextvars-based context system 

495 

496 

497 

498 

499def resolve_lazy_configurations_for_serialization(data: Any) -> Any: 

500 """ 

501 Recursively resolve lazy dataclass instances to concrete values for serialization. 

502 

503 CRITICAL: This function must be called WITHIN a config_context() block! 

504 The context provides the hierarchy for lazy resolution. 

505 

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 

510 

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 

527 

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 

533 

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__}") 

543 

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 

557 

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 

563 

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)) 

567 

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__) 

571 

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 

580 

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__}") 

591 

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)]}") 

594 

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 

606 

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 } 

613 

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) 

618 

619 else: 

620 # Primitive type or unknown structure - return as-is 

621 return resolved_data 

622 

623 

624# Generic dataclass editing with configurable value preservation 

625T = TypeVar('T') 

626 

627 

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") 

632 

633 # Set up context if provider is given (e.g., thread-local storage) 

634 if context_provider: 

635 context_provider(source_config) 

636 

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 } 

645 

646 return dataclass_type(**field_values) 

647 

648 

649 

650 

651 

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. 

659 

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 

665 

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) 

670 

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 

676 

677 # Determine global config type 

678 if global_config_type is None: 

679 global_config_type = type(new_global_config) 

680 

681 # Set new global config in thread-local storage 

682 ensure_global_config_context(global_config_type, new_global_config) 

683 

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) 

687 

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)) 

692 

693 if not is_lazy: 

694 lazy_type = LazyDefaultPlaceholderService._get_lazy_type_for_base(type(raw_value)) 

695 

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) 

702 

703 # Get the class default for this field 

704 class_default = getattr(type(raw_value), f.name, None) 

705 

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 

710 

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) 

713 

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 

721 

722 current_field_values = {f.name: process_field_value(f) for f in fields(existing_lazy_config)} 

723 

724 return type(existing_lazy_config)(**current_field_values) 

725 

726 

727# Declarative Global Config Field Injection System 

728# Moved inline imports to top-level 

729 

730# Naming configuration 

731GLOBAL_CONFIG_PREFIX = "Global" 

732LAZY_CONFIG_PREFIX = "Lazy" 

733 

734# Registry to accumulate all decorations before injection 

735_pending_injections = {} 

736 

737 

738 

739class InheritAsNoneMeta(ABCMeta): 

740 """ 

741 Metaclass that applies inherit_as_none modifications during class creation. 

742 

743 This runs BEFORE @dataclass and modifies the class definition to add 

744 field overrides with None defaults for inheritance. 

745 """ 

746 

747 def __new__(mcs, name, bases, namespace, **kwargs): 

748 # Create the class first 

749 cls = super().__new__(mcs, name, bases, namespace) 

750 

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) 

761 

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 

769 

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 

775 

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) 

780 

781 # Ensure annotation exists 

782 if not hasattr(cls, '__annotations__'): 

783 cls.__annotations__ = {} 

784 cls.__annotations__[field_name] = field_type 

785 

786 processed_fields.add(field_name) 

787 else: 

788 processed_fields.add(field_name) 

789 

790 return cls 

791 

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 

806 

807 # Return reconstruction using the base type (not the metaclass) 

808 return (type, (cls.__name__, cls.__bases__, safe_dict)) 

809 

810 

811def create_global_default_decorator(target_config_class: Type): 

812 """ 

813 Create a decorator factory for a specific global config class. 

814 

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 } 

824 

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. 

828 

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 

840 

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) 

852 

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 

861 

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 

866 

867 # 1. Set the class attribute to None 

868 setattr(actual_cls, field_name, None) 

869 fields_set_to_none.add(field_name) 

870 

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 

875 

876 processed_fields.add(field_name) 

877 

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. 

881 

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__}" 

885 

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 }) 

895 

896 # Immediately create lazy version of this config (not dependent on injection) 

897 

898 

899 lazy_class = LazyDataclassFactory.make_lazy_simple( 

900 base_class=actual_cls, 

901 lazy_class_name=lazy_class_name 

902 ) 

903 

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) 

907 

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) 

912 

913 return actual_cls 

914 

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) 

922 

923 return global_default_decorator 

924 

925 

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. 

929 

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 

935 

936 # Store the original __init__ method 

937 original_init = cls.__init__ 

938 

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 

945 

946 # Call the original __init__ with modified kwargs 

947 original_init(self, **kwargs) 

948 

949 # Replace the __init__ method 

950 cls.__init__ = custom_init 

951 

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] 

957 

958 # Update the field default to None (overriding any parent class default) 

959 field_obj.default = None 

960 field_obj.default_factory = dataclasses.MISSING 

961 

962 # Also ensure the class attribute is None (should already be set, but double-check) 

963 setattr(cls, field_name, None) 

964 

965 

966 

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'] 

972 

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) 

975 

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() 

980 

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 

984 

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 ] 

991 

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) 

997 

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) 

1005 

1006 return (config['field_name'], field_type, default_value) 

1007 

1008 all_fields = existing_fields + [create_field_definition(config) for config in configs] 

1009 

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 ) 

1017 

1018 # Sibling inheritance is now handled by the dual-axis resolver system 

1019 

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 

1024 

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 

1030 

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__) 

1038 

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") 

1043 

1044 # Remove global prefix (GlobalPipelineConfig → PipelineConfig) 

1045 lazy_global_class_name = target_class.__name__[len(GLOBAL_CONFIG_PREFIX):] 

1046 

1047 lazy_global_class = LazyDataclassFactory.make_lazy_simple( 

1048 base_class=new_class, 

1049 lazy_class_name=lazy_global_class_name 

1050 ) 

1051 

1052 # Use extracted helper for consistent registration 

1053 _register_lazy_class(lazy_global_class, lazy_global_class_name, target_class.__module__) 

1054 

1055 

1056 

1057 

1058 

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 

1064 

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") 

1070 

1071 decorator_name = _camel_to_snake(global_config_class.__name__) 

1072 decorator = create_global_default_decorator(global_config_class) 

1073 

1074 # Export decorator to module globals 

1075 module = sys.modules[global_config_class.__module__] 

1076 setattr(module, decorator_name, decorator) 

1077 

1078 # Lazy global config will be created after field injection 

1079 

1080 return global_config_class 

1081 

1082 

1083 

1084 

1085 

1086 

1087