Coverage for src/hieraconf/lazy_factory.py: 37%

454 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-02 21:44 +0000

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

2 

3# Standard library imports 

4import dataclasses 

5import logging 

6import re 

7import sys 

8from abc import ABCMeta 

9from dataclasses import dataclass, fields, is_dataclass, make_dataclass, MISSING, field 

10from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union 

11 

12# OpenHCS imports 

13from hieraconf.placeholder import LazyDefaultPlaceholderService 

14# Optional: metaclass_registry for context provider registration 

15try: 

16 from metaclass_registry import AutoRegisterMeta, RegistryConfig 

17except ImportError: 

18 # Provide minimal fallback implementations 

19 class AutoRegisterMeta(type): 

20 """Fallback metaclass when metaclass_registry is not available.""" 

21 def __new__(mcs, name, bases, attrs, registry_config=None): 

22 return super().__new__(mcs, name, bases, attrs) 

23 

24 class RegistryConfig: 

25 """Fallback registry config when metaclass_registry is not available.""" 

26 def __init__(self, **kwargs): 

27 pass 

28 

29# Note: dual_axis_resolver_recursive and lazy_placeholder imports kept inline to avoid circular imports 

30 

31 

32# Type registry for lazy dataclass to base class mapping 

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

34 

35# Cache for lazy classes to prevent duplicate creation 

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

37 

38 

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

40 

41 

42 

43 

44def register_lazy_type_mapping(lazy_type: Type, base_type: Type) -> None: 

45 """Register mapping between lazy dataclass type and its base type.""" 

46 _lazy_type_registry[lazy_type] = base_type 

47 

48 

49def get_base_type_for_lazy(lazy_type: Type) -> Optional[Type]: 

50 """Get the base type for a lazy dataclass type.""" 

51 return _lazy_type_registry.get(lazy_type) 

52 

53# Optional imports (handled gracefully) 

54try: 

55 from PyQt6.QtWidgets import QApplication 

56 HAS_PYQT = True 

57except ImportError: 

58 QApplication = None 

59 HAS_PYQT = False 

60 

61logger = logging.getLogger(__name__) 

62 

63 

64# Constants for lazy configuration system - simplified from class to module-level 

65MATERIALIZATION_DEFAULTS_PATH = "materialization_defaults" 

66RESOLVE_FIELD_VALUE_METHOD = "_resolve_field_value" 

67GET_ATTRIBUTE_METHOD = "__getattribute__" 

68TO_BASE_CONFIG_METHOD = "to_base_config" 

69WITH_DEFAULTS_METHOD = "with_defaults" 

70WITH_OVERRIDES_METHOD = "with_overrides" 

71LAZY_FIELD_DEBUG_TEMPLATE = "LAZY FIELD CREATION: {field_name} - original={original_type}, has_default={has_default}, final={final_type}" 

72 

73LAZY_CLASS_NAME_PREFIX = "Lazy" 

74 

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

76 

77 

78# Functional fallback strategies 

79def _get_raw_field_value(obj: Any, field_name: str) -> Any: 

80 """ 

81 Get raw field value bypassing lazy property getters to prevent infinite recursion. 

82 

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

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

85 

86 Args: 

87 obj: Object to get field from 

88 field_name: Name of field to access 

89 

90 Returns: 

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

92 

93 Raises: 

94 AttributeError: If field doesn't exist (fail-loud behavior) 

95 """ 

96 try: 

97 return object.__getattribute__(obj, field_name) 

98 except AttributeError: 

99 return None 

100 

101 

102@dataclass(frozen=True) 

103class LazyMethodBindings: 

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

105 

106 @staticmethod 

107 def create_resolver() -> Callable[[Any, str], Any]: 

108 """Create field resolver method using new pure function interface.""" 

109 from hieraconf.dual_axis_resolver import resolve_field_inheritance 

110 from hieraconf.context_manager import current_temp_global, extract_all_configs 

111 

112 def _resolve_field_value(self, field_name: str) -> Any: 

113 # Get current context from contextvars 

114 try: 

115 current_context = current_temp_global.get() 

116 # Extract available configs from current context 

117 available_configs = extract_all_configs(current_context) 

118 

119 # Use pure function for resolution 

120 return resolve_field_inheritance(self, field_name, available_configs) 

121 except LookupError: 

122 # No context available - return None (fail-loud approach) 

123 logger.debug(f"No context available for resolving {type(self).__name__}.{field_name}") 

124 return None 

125 

126 return _resolve_field_value 

127 

128 @staticmethod 

129 def create_getattribute() -> Callable[[Any, str], Any]: 

130 """Create lazy __getattribute__ method using new context system.""" 

131 from hieraconf.dual_axis_resolver import resolve_field_inheritance, _has_concrete_field_override 

132 from hieraconf.context_manager import current_temp_global, extract_all_configs 

133 

134 def _find_mro_concrete_value(base_class, name): 

135 """Extract common MRO traversal pattern.""" 

136 return next((getattr(cls, name) for cls in base_class.__mro__ 

137 if _has_concrete_field_override(cls, name)), None) 

138 

139 def _try_global_context_value(self, base_class, name): 

140 """Extract global context resolution logic using new pure function interface.""" 

141 if not hasattr(self, '_global_config_type'): 

142 return None 

143 

144 # Get current context from contextvars 

145 try: 

146 current_context = current_temp_global.get() 

147 # Extract available configs from current context 

148 available_configs = extract_all_configs(current_context) 

149 

150 # Use pure function for resolution 

151 resolved_value = resolve_field_inheritance(self, name, available_configs) 

152 if resolved_value is not None: 

153 return resolved_value 

154 except LookupError: 

155 # No context available - fall back to MRO 

156 pass 

157 

158 # Fallback to MRO concrete value 

159 return _find_mro_concrete_value(base_class, name) 

160 

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

162 """ 

163 Three-stage resolution using new context system. 

164 

165 Stage 1: Check instance value 

166 Stage 2: Simple field path lookup in current scope's merged config 

167 Stage 3: Inheritance resolution using same merged context 

168 """ 

169 # Stage 1: Get instance value 

170 value = object.__getattribute__(self, name) 

171 if value is not None or name not in {f.name for f in fields(self.__class__)}: 

172 return value 

173 

174 # Stage 2: Simple field path lookup in current scope's merged global 

175 try: 

176 current_context = current_temp_global.get() 

177 if current_context is not None: 

178 # Get the config type name for this lazy class 

179 config_field_name = getattr(self, '_config_field_name', None) 

180 if config_field_name: 

181 try: 

182 config_instance = getattr(current_context, config_field_name) 

183 if config_instance is not None: 

184 resolved_value = getattr(config_instance, name) 

185 if resolved_value is not None: 

186 return resolved_value 

187 except AttributeError: 

188 # Field doesn't exist in merged config, continue to inheritance 

189 pass 

190 except LookupError: 

191 # No context available, continue to inheritance 

192 pass 

193 

194 # Stage 3: Inheritance resolution using same merged context 

195 try: 

196 current_context = current_temp_global.get() 

197 available_configs = extract_all_configs(current_context) 

198 resolved_value = resolve_field_inheritance(self, name, available_configs) 

199 

200 if resolved_value is not None: 

201 return resolved_value 

202 

203 # For nested dataclass fields, return lazy instance 

204 field_obj = next((f for f in fields(self.__class__) if f.name == name), None) 

205 if field_obj and is_dataclass(field_obj.type): 

206 return field_obj.type() 

207 

208 return None 

209 

210 except LookupError: 

211 # No context available - fallback to MRO concrete values 

212 return _find_mro_concrete_value(get_base_type_for_lazy(self.__class__), name) 

213 return __getattribute__ 

214 

215 @staticmethod 

216 def create_to_base_config(base_class: Type) -> Callable[[Any], Any]: 

217 """Create base config converter method.""" 

218 def to_base_config(self): 

219 # CRITICAL FIX: Use object.__getattribute__ to preserve raw None values 

220 # getattr() triggers lazy resolution, converting None to static defaults 

221 # None values must be preserved for dual-axis inheritance to work correctly 

222 # 

223 # Context: to_base_config() is called DURING config_context() setup (line 124 in context_manager.py) 

224 # If we use getattr() here, it triggers resolution BEFORE the context is fully set up, 

225 # causing resolution to use the wrong/stale context and losing the GlobalPipelineConfig base. 

226 # We must extract raw None values here, let config_context() merge them into the hierarchy, 

227 # and THEN resolution happens later with the properly built context. 

228 field_values = {f.name: object.__getattribute__(self, f.name) for f in fields(self)} 

229 return base_class(**field_values) 

230 return to_base_config 

231 

232 @staticmethod 

233 def create_class_methods() -> Dict[str, Any]: 

234 """Create class-level utility methods.""" 

235 return { 

236 WITH_DEFAULTS_METHOD: classmethod(lambda cls: cls()), 

237 WITH_OVERRIDES_METHOD: classmethod(lambda cls, **kwargs: cls(**kwargs)) 

238 } 

239 

240 

241class LazyDataclassFactory: 

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

243 

244 

245 

246 

247 

248 @staticmethod 

249 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]]: 

250 """ 

251 Introspect dataclass fields for lazy loading. 

252 

253 Converts nested dataclass fields to lazy equivalents and makes fields Optional 

254 if they lack defaults. Complex logic handles type unwrapping and lazy nesting. 

255 """ 

256 base_fields = fields(base_class) 

257 lazy_field_definitions = [] 

258 

259 for field in base_fields: 

260 # Check if field already has Optional type 

261 origin = getattr(field.type, '__origin__', None) 

262 is_already_optional = (origin is Union and 

263 type(None) in getattr(field.type, '__args__', ())) 

264 

265 # Check if field has default value or factory 

266 has_default = (field.default is not MISSING or 

267 field.default_factory is not MISSING) 

268 

269 # Check if field type is a dataclass that should be made lazy 

270 field_type = field.type 

271 if is_dataclass(field.type): 

272 # SIMPLIFIED: Create lazy version using simple factory 

273 lazy_nested_type = LazyDataclassFactory.make_lazy_simple( 

274 base_class=field.type, 

275 lazy_class_name=f"Lazy{field.type.__name__}" 

276 ) 

277 field_type = lazy_nested_type 

278 logger.debug(f"Created lazy class for {field.name}: {field.type} -> {lazy_nested_type}") 

279 

280 # Complex type logic: make Optional if no default, preserve existing Optional types 

281 if is_already_optional or not has_default: 

282 final_field_type = Union[field_type, type(None)] if not is_already_optional else field_type 

283 else: 

284 final_field_type = field_type 

285 

286 # CRITICAL FIX: Create default factory for Optional dataclass fields 

287 # This eliminates the need for field introspection and ensures UI always has instances to render 

288 # CRITICAL: Always preserve metadata from original field (e.g., ui_hidden flag) 

289 if (is_already_optional or not has_default) and is_dataclass(field.type): 

290 # For Optional dataclass fields, create default factory that creates lazy instances 

291 # This ensures the UI always has nested lazy instances to render recursively 

292 # CRITICAL: field_type is already the lazy type, so use it directly 

293 field_def = (field.name, final_field_type, dataclasses.field(default_factory=field_type, metadata=field.metadata)) 

294 elif field.metadata: 

295 # For fields with metadata but no dataclass default factory, create a Field object to preserve metadata 

296 # We need to replicate the original field's default behavior 

297 if field.default is not MISSING: 

298 field_def = (field.name, final_field_type, dataclasses.field(default=field.default, metadata=field.metadata)) 

299 elif field.default_factory is not MISSING: 

300 field_def = (field.name, final_field_type, dataclasses.field(default_factory=field.default_factory, metadata=field.metadata)) 

301 else: 

302 # Field has metadata but no default - use MISSING to indicate required field 

303 field_def = (field.name, final_field_type, dataclasses.field(default=MISSING, metadata=field.metadata)) 

304 else: 

305 # No metadata, no special handling needed 

306 field_def = (field.name, final_field_type, None) 

307 

308 lazy_field_definitions.append(field_def) 

309 

310 # Debug logging with provided template (reduced to DEBUG level to reduce log pollution) 

311 logger.debug(debug_template.format( 

312 field_name=field.name, 

313 original_type=field.type, 

314 has_default=has_default, 

315 final_type=final_field_type 

316 )) 

317 

318 return lazy_field_definitions 

319 

320 @staticmethod 

321 def _create_lazy_dataclass_unified( 

322 base_class: Type, 

323 instance_provider: Callable[[], Any], 

324 lazy_class_name: str, 

325 debug_template: str, 

326 use_recursive_resolution: bool = False, 

327 fallback_chain: Optional[List[Callable[[str], Any]]] = None, 

328 global_config_type: Type = None, 

329 parent_field_path: str = None, 

330 parent_instance_provider: Optional[Callable[[], Any]] = None 

331 ) -> Type: 

332 """ 

333 Create lazy dataclass with declarative configuration. 

334 

335 Core factory method that creates lazy dataclass with introspected fields, 

336 binds resolution methods, and registers type mappings. Complex orchestration 

337 of field analysis, method binding, and class creation. 

338 """ 

339 if not is_dataclass(base_class): 

340 raise ValueError(f"{base_class} must be a dataclass") 

341 

342 # Check cache first to prevent duplicate creation 

343 cache_key = f"{base_class.__name__}_{lazy_class_name}_{id(instance_provider)}" 

344 if cache_key in _lazy_class_cache: 

345 return _lazy_class_cache[cache_key] 

346 

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

348 

349 # Create lazy dataclass with introspected fields 

350 # CRITICAL FIX: Avoid inheriting from classes with custom metaclasses to prevent descriptor conflicts 

351 # Exception: InheritAsNoneMeta is safe to inherit from as it only modifies field defaults 

352 # Exception: Classes with _inherit_as_none marker are safe even with ABCMeta (processed by @global_pipeline_config) 

353 base_metaclass = type(base_class) 

354 has_inherit_as_none_marker = hasattr(base_class, '_inherit_as_none') and base_class._inherit_as_none 

355 has_unsafe_metaclass = ( 

356 (hasattr(base_class, '__metaclass__') or base_metaclass != type) and 

357 base_metaclass != InheritAsNoneMeta and 

358 not has_inherit_as_none_marker 

359 ) 

360 

361 # Determine if base class is frozen to avoid frozen/non-frozen conflicts 

362 base_is_frozen = base_class.__dataclass_params__.frozen if hasattr(base_class, '__dataclass_params__') else False 

363 

364 if has_unsafe_metaclass: 

365 # Base class has unsafe custom metaclass - don't inherit, just copy interface 

366 print(f"🔧 LAZY FACTORY: {base_class.__name__} has custom metaclass {base_metaclass.__name__}, avoiding inheritance") 

367 lazy_class = make_dataclass( 

368 lazy_class_name, 

369 LazyDataclassFactory._introspect_dataclass_fields( 

370 base_class, debug_template, global_config_type, parent_field_path, parent_instance_provider 

371 ), 

372 bases=(), # No inheritance to avoid metaclass conflicts 

373 frozen=base_is_frozen # Match base class frozen state 

374 ) 

375 else: 

376 # Safe to inherit from regular dataclass 

377 lazy_class = make_dataclass( 

378 lazy_class_name, 

379 LazyDataclassFactory._introspect_dataclass_fields( 

380 base_class, debug_template, global_config_type, parent_field_path, parent_instance_provider 

381 ), 

382 bases=(base_class,), 

383 frozen=base_is_frozen # Match base class frozen state 

384 ) 

385 

386 # Add constructor parameter tracking to detect user-set fields 

387 original_init = lazy_class.__init__ 

388 def __init_with_tracking__(self, **kwargs): 

389 # Track which fields were explicitly passed to constructor 

390 object.__setattr__(self, '_explicitly_set_fields', set(kwargs.keys())) 

391 # Store the global config type for inheritance resolution 

392 object.__setattr__(self, '_global_config_type', global_config_type) 

393 # Store the config field name for simple field path lookup 

394 import re 

395 def _camel_to_snake_local(name: str) -> str: 

396 s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) 

397 return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() 

398 config_field_name = _camel_to_snake_local(base_class.__name__) 

399 object.__setattr__(self, '_config_field_name', config_field_name) 

400 original_init(self, **kwargs) 

401 

402 lazy_class.__init__ = __init_with_tracking__ 

403 

404 # Bind methods declaratively - inline single-use method 

405 method_bindings = { 

406 RESOLVE_FIELD_VALUE_METHOD: LazyMethodBindings.create_resolver(), 

407 GET_ATTRIBUTE_METHOD: LazyMethodBindings.create_getattribute(), 

408 TO_BASE_CONFIG_METHOD: LazyMethodBindings.create_to_base_config(base_class), 

409 **LazyMethodBindings.create_class_methods() 

410 } 

411 for method_name, method_impl in method_bindings.items(): 

412 setattr(lazy_class, method_name, method_impl) 

413 

414 # CRITICAL: Preserve original module for proper imports in generated code 

415 # make_dataclass() sets __module__ to the caller's module (lazy_factory.py) 

416 # We need to set it to the base class's original module for correct import paths 

417 lazy_class.__module__ = base_class.__module__ 

418 

419 # Automatically register the lazy dataclass with the type registry 

420 register_lazy_type_mapping(lazy_class, base_class) 

421 

422 # Cache the created class to prevent duplicates 

423 _lazy_class_cache[cache_key] = lazy_class 

424 

425 return lazy_class 

426 

427 

428 

429 

430 

431 @staticmethod 

432 def make_lazy_simple( 

433 base_class: Type, 

434 lazy_class_name: str = None 

435 ) -> Type: 

436 """ 

437 Create lazy dataclass using new contextvars system. 

438 

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

440 Uses new contextvars system for all resolution. 

441 

442 Args: 

443 base_class: Base dataclass to make lazy 

444 lazy_class_name: Optional name for the lazy class 

445 

446 Returns: 

447 Generated lazy dataclass with contextvars-based resolution 

448 """ 

449 # Generate class name if not provided 

450 lazy_class_name = lazy_class_name or f"Lazy{base_class.__name__}" 

451 

452 # Simple provider that uses new contextvars system 

453 def simple_provider(): 

454 """Simple provider using new contextvars system.""" 

455 return base_class() # Lazy __getattribute__ handles resolution 

456 

457 return LazyDataclassFactory._create_lazy_dataclass_unified( 

458 base_class=base_class, 

459 instance_provider=simple_provider, 

460 lazy_class_name=lazy_class_name, 

461 debug_template=f"Simple contextvars resolution for {base_class.__name__}", 

462 use_recursive_resolution=False, 

463 fallback_chain=[], 

464 global_config_type=None, 

465 parent_field_path=None, 

466 parent_instance_provider=None 

467 ) 

468 

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

470 

471 

472# Generic utility functions for clean thread-local storage management 

473def ensure_global_config_context(global_config_type: Type, global_config_instance: Any) -> None: 

474 """Ensure proper thread-local storage setup for any global config type.""" 

475 from hieraconf.global_config import set_global_config_for_editing 

476 set_global_config_for_editing(global_config_type, global_config_instance) 

477 

478 

479# Context provider registry and metaclass for automatic registration 

480CONTEXT_PROVIDERS = {} 

481 

482 

483# Configuration for context provider registration 

484_CONTEXT_PROVIDER_REGISTRY_CONFIG = RegistryConfig( 

485 registry_dict=CONTEXT_PROVIDERS, 

486 key_attribute='_context_type', 

487 key_extractor=None, # Requires explicit _context_type 

488 skip_if_no_key=True, # Skip if no _context_type set 

489 secondary_registries=None, 

490 log_registration=True, 

491 registry_name='context provider' 

492) 

493 

494 

495class ContextProviderMeta(AutoRegisterMeta): 

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

497 

498 def __new__(mcs, name, bases, attrs): 

499 return super().__new__(mcs, name, bases, attrs, 

500 registry_config=_CONTEXT_PROVIDER_REGISTRY_CONFIG) 

501 

502 

503class ContextProvider(metaclass=ContextProviderMeta): 

504 """Base class for objects that can provide context for lazy resolution.""" 

505 _context_type: Optional[str] = None # Override in subclasses 

506 

507 

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

509 """ 

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

511 

512 Returns the context type name or None if not a recognized context type. 

513 """ 

514 # Check for functions first (simple callable check) 

515 if callable(obj) and hasattr(obj, '__name__'): 

516 return "function" 

517 

518 # Check if object is an instance of any registered context provider 

519 for context_type, provider_class in CONTEXT_PROVIDERS.items(): 

520 if isinstance(obj, provider_class): 

521 return context_type 

522 

523 return None 

524 

525 

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

527 

528 

529 

530 

531def resolve_hieraconfurations_for_serialization(data: Any) -> Any: 

532 """ 

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

534 

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

536 The context provides the hierarchy for lazy resolution. 

537 

538 How it works: 

539 1. For lazy dataclasses: Access fields with getattr() to trigger resolution 

540 2. The lazy __getattribute__ uses the active config_context() to resolve None values 

541 3. Convert resolved values to base config for pickling 

542 

543 Example (from README.md): 

544 with config_context(orchestrator.pipeline_config): 

545 # Lazy resolution happens here via context 

546 resolved_steps = resolve_hieraconfurations_for_serialization(steps) 

547 """ 

548 # Check if this is a lazy dataclass 

549 base_type = get_base_type_for_lazy(type(data)) 

550 if base_type is not None: 

551 # This is a lazy dataclass - resolve fields using getattr() within the active context 

552 # getattr() triggers lazy __getattribute__ which uses config_context() for resolution 

553 resolved_fields = {} 

554 for f in fields(data): 

555 # CRITICAL: Use getattr() to trigger lazy resolution via context 

556 # The active config_context() provides the hierarchy for resolution 

557 resolved_value = getattr(data, f.name) 

558 resolved_fields[f.name] = resolved_value 

559 

560 # Create base config instance with resolved values 

561 resolved_data = base_type(**resolved_fields) 

562 else: 

563 # Not a lazy dataclass 

564 resolved_data = data 

565 

566 # CRITICAL FIX: Handle step objects (non-dataclass objects with dataclass attributes) 

567 step_context_type = _detect_context_type(resolved_data) 

568 if step_context_type: 

569 # This is a context object - inject it for its dataclass attributes 

570 import inspect 

571 frame = inspect.currentframe() 

572 context_var_name = f"__{step_context_type}_context__" 

573 frame.f_locals[context_var_name] = resolved_data 

574 logger.debug(f"Injected {context_var_name} = {type(resolved_data).__name__}") 

575 

576 try: 

577 # Process step attributes recursively 

578 resolved_attrs = {} 

579 for attr_name in dir(resolved_data): 

580 if attr_name.startswith('_'): 

581 continue 

582 try: 

583 attr_value = getattr(resolved_data, attr_name) 

584 if not callable(attr_value): # Skip methods 

585 logger.debug(f"Resolving {type(resolved_data).__name__}.{attr_name} = {type(attr_value).__name__}") 

586 resolved_attrs[attr_name] = resolve_hieraconfurations_for_serialization(attr_value) 

587 except (AttributeError, Exception): 

588 continue 

589 

590 # Handle function objects specially - they can't be recreated with __new__ 

591 if step_context_type == "function": 

592 # For functions, just process attributes for resolution but return original function 

593 # The resolved config values will be stored in func plan by compiler 

594 return resolved_data 

595 

596 # Create new step object with resolved attributes 

597 # CRITICAL FIX: Copy all original attributes using __dict__ to preserve everything 

598 new_step = type(resolved_data).__new__(type(resolved_data)) 

599 

600 # Copy all attributes from the original object's __dict__ 

601 if hasattr(resolved_data, '__dict__'): 

602 new_step.__dict__.update(resolved_data.__dict__) 

603 

604 # Update with resolved config attributes (these override the originals) 

605 for attr_name, attr_value in resolved_attrs.items(): 

606 setattr(new_step, attr_name, attr_value) 

607 return new_step 

608 finally: 

609 if context_var_name in frame.f_locals: 

610 del frame.f_locals[context_var_name] 

611 del frame 

612 

613 # Recursively process nested structures based on type 

614 elif is_dataclass(resolved_data) and not isinstance(resolved_data, type): 

615 # Process dataclass fields recursively - inline field processing pattern 

616 # CRITICAL FIX: Inject parent object as context for sibling config inheritance 

617 context_type = _detect_context_type(resolved_data) or "dataclass" # Default to "dataclass" for generic dataclasses 

618 import inspect 

619 frame = inspect.currentframe() 

620 context_var_name = f"__{context_type}_context__" 

621 frame.f_locals[context_var_name] = resolved_data 

622 logger.debug(f"Injected {context_var_name} = {type(resolved_data).__name__}") 

623 

624 # Add debug to see which fields are being resolved 

625 logger.debug(f"Resolving fields for {type(resolved_data).__name__}: {[f.name for f in fields(resolved_data)]}") 

626 

627 try: 

628 resolved_fields = {} 

629 for f in fields(resolved_data): 

630 field_value = getattr(resolved_data, f.name) 

631 logger.debug(f"Resolving {type(resolved_data).__name__}.{f.name} = {type(field_value).__name__}") 

632 resolved_fields[f.name] = resolve_hieraconfurations_for_serialization(field_value) 

633 return type(resolved_data)(**resolved_fields) 

634 finally: 

635 if context_var_name in frame.f_locals: 

636 del frame.f_locals[context_var_name] 

637 del frame 

638 

639 elif isinstance(resolved_data, dict): 

640 # Process dictionary values recursively 

641 return { 

642 key: resolve_hieraconfurations_for_serialization(value) 

643 for key, value in resolved_data.items() 

644 } 

645 

646 elif isinstance(resolved_data, (list, tuple)): 

647 # Process sequence elements recursively 

648 resolved_items = [resolve_hieraconfurations_for_serialization(item) for item in resolved_data] 

649 return type(resolved_data)(resolved_items) 

650 

651 else: 

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

653 return resolved_data 

654 

655 

656# Generic dataclass editing with configurable value preservation 

657T = TypeVar('T') 

658 

659 

660def create_dataclass_for_editing(dataclass_type: Type[T], source_config: Any, preserve_values: bool = False, context_provider: Optional[Callable[[Any], None]] = None) -> T: 

661 """Create dataclass for editing with configurable value preservation.""" 

662 if not is_dataclass(dataclass_type): 

663 raise ValueError(f"{dataclass_type} must be a dataclass") 

664 

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

666 if context_provider: 

667 context_provider(source_config) 

668 

669 # Mathematical simplification: Convert verbose loop to unified comprehension 

670 from hieraconf.placeholder import LazyDefaultPlaceholderService 

671 field_values = { 

672 f.name: (getattr(source_config, f.name) if preserve_values 

673 else f.type() if is_dataclass(f.type) and LazyDefaultPlaceholderService.has_lazy_resolution(f.type) 

674 else None) 

675 for f in fields(dataclass_type) 

676 } 

677 

678 return dataclass_type(**field_values) 

679 

680 

681 

682 

683 

684def rebuild_hieraconf_with_new_global_reference( 

685 existing_hieraconf: Any, 

686 new_global_config: Any, 

687 global_config_type: Optional[Type] = None 

688) -> Any: 

689 """ 

690 Rebuild lazy config to reference new global config while preserving field states. 

691 

692 This function preserves the exact field state of the existing lazy config: 

693 - Fields that are None (using lazy resolution) remain None 

694 - Fields that have been explicitly set retain their concrete values 

695 - Nested dataclass fields are recursively rebuilt to reference new global config 

696 - The underlying global config reference is updated for None field resolution 

697 

698 Args: 

699 existing_hieraconf: Current lazy config instance 

700 new_global_config: New global config to reference for lazy resolution 

701 global_config_type: Type of the global config (defaults to type of new_global_config) 

702 

703 Returns: 

704 New lazy config instance with preserved field states and updated global reference 

705 """ 

706 if existing_hieraconf is None: 

707 return None 

708 

709 # Determine global config type 

710 if global_config_type is None: 

711 global_config_type = type(new_global_config) 

712 

713 # Set new global config in thread-local storage 

714 ensure_global_config_context(global_config_type, new_global_config) 

715 

716 # Extract current field values without triggering lazy resolution - inline field processing pattern 

717 def process_field_value(field_obj): 

718 raw_value = object.__getattribute__(existing_hieraconf, field_obj.name) 

719 

720 if raw_value is not None and hasattr(raw_value, '__dataclass_fields__'): 

721 try: 

722 # Check if this is a concrete dataclass that should be converted to lazy 

723 is_lazy = LazyDefaultPlaceholderService.has_lazy_resolution(type(raw_value)) 

724 

725 if not is_lazy: 

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

727 

728 if lazy_type: 

729 # Convert concrete dataclass to lazy version while preserving ONLY non-default field values 

730 # This allows fields that match class defaults to inherit from context 

731 concrete_field_values = {} 

732 for f in fields(raw_value): 

733 field_value = object.__getattribute__(raw_value, f.name) 

734 

735 # Get the class default for this field 

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

737 

738 # Only preserve values that differ from class defaults 

739 # This allows default values to be inherited from context 

740 if field_value != class_default: 

741 concrete_field_values[f.name] = field_value 

742 

743 logger.debug(f"Converting concrete {type(raw_value).__name__} to lazy version {lazy_type.__name__} for placeholder resolution") 

744 return lazy_type(**concrete_field_values) 

745 

746 # If already lazy or no lazy version available, rebuild recursively 

747 nested_result = rebuild_hieraconf_with_new_global_reference(raw_value, new_global_config, global_config_type) 

748 return nested_result 

749 except Exception as e: 

750 logger.debug(f"Failed to rebuild nested config {field_obj.name}: {e}") 

751 return raw_value 

752 return raw_value 

753 

754 current_field_values = {f.name: process_field_value(f) for f in fields(existing_hieraconf)} 

755 

756 return type(existing_hieraconf)(**current_field_values) 

757 

758 

759# Declarative Global Config Field Injection System 

760# Moved inline imports to top-level 

761 

762# Naming configuration 

763GLOBAL_CONFIG_PREFIX = "Global" 

764LAZY_CONFIG_PREFIX = "Lazy" 

765 

766# Registry to accumulate all decorations before injection 

767_pending_injections = {} 

768 

769 

770 

771class InheritAsNoneMeta(ABCMeta): 

772 """ 

773 Metaclass that applies inherit_as_none modifications during class creation. 

774 

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

776 field overrides with None defaults for inheritance. 

777 """ 

778 

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

780 # Create the class first 

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

782 

783 # Check if this class should have inherit_as_none applied 

784 if hasattr(cls, '_inherit_as_none') and cls._inherit_as_none: 

785 # Add multiprocessing safety marker 

786 cls._multiprocessing_safe = True 

787 # Get explicitly defined fields (in this class's namespace) 

788 explicitly_defined_fields = set() 

789 if '__annotations__' in namespace: 

790 for field_name in namespace['__annotations__']: 

791 if field_name in namespace: 

792 explicitly_defined_fields.add(field_name) 

793 

794 # Process parent classes to find fields that need None overrides 

795 processed_fields = set() 

796 for base in bases: 

797 if hasattr(base, '__annotations__'): 

798 for field_name, field_type in base.__annotations__.items(): 

799 if field_name in processed_fields: 

800 continue 

801 

802 # Check if parent has concrete default 

803 parent_has_concrete_default = False 

804 if hasattr(base, field_name): 

805 parent_value = getattr(base, field_name) 

806 parent_has_concrete_default = parent_value is not None 

807 

808 # Add None override if needed 

809 if (field_name not in explicitly_defined_fields and parent_has_concrete_default): 

810 # Set the class attribute to None 

811 setattr(cls, field_name, None) 

812 

813 # Ensure annotation exists 

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

815 cls.__annotations__ = {} 

816 cls.__annotations__[field_name] = field_type 

817 

818 processed_fields.add(field_name) 

819 else: 

820 processed_fields.add(field_name) 

821 

822 return cls 

823 

824 def __reduce__(cls): 

825 """Make classes with this metaclass pickle-safe for multiprocessing.""" 

826 # Filter out problematic descriptors that cause conflicts during pickle/unpickle 

827 safe_dict = {} 

828 for key, value in cls.__dict__.items(): 

829 # Skip descriptors that cause conflicts 

830 if hasattr(value, '__get__') and hasattr(value, '__set__'): 

831 continue # Skip data descriptors 

832 if hasattr(value, '__dict__') and hasattr(value, '__class__'): 

833 # Skip complex objects that might have descriptor conflicts 

834 if 'descriptor' in str(type(value)).lower(): 

835 continue 

836 # Include safe attributes 

837 safe_dict[key] = value 

838 

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

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

841 

842 

843def create_global_default_decorator(target_config_class: Type): 

844 """ 

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

846 

847 The decorator accumulates all decorations, then injects all fields at once 

848 when the module finishes loading. Also creates lazy versions of all decorated configs. 

849 """ 

850 target_class_name = target_config_class.__name__ 

851 if target_class_name not in _pending_injections: 

852 _pending_injections[target_class_name] = { 

853 'target_class': target_config_class, 

854 'configs_to_inject': [] 

855 } 

856 

857 def global_default_decorator(cls=None, *, optional: bool = False, inherit_as_none: bool = True, ui_hidden: bool = False): 

858 """ 

859 Decorator that can be used with or without parameters. 

860 

861 Args: 

862 cls: The class being decorated (when used without parentheses) 

863 optional: Whether to wrap the field type with Optional (default: False) 

864 inherit_as_none: Whether to set all inherited fields to None by default (default: True) 

865 ui_hidden: Whether to hide from UI (apply decorator but don't inject into global config) (default: False) 

866 """ 

867 def decorator(actual_cls): 

868 # Apply inherit_as_none by modifying class BEFORE @dataclass (multiprocessing-safe) 

869 if inherit_as_none: 

870 # Mark the class for inherit_as_none processing 

871 actual_cls._inherit_as_none = True 

872 

873 # Apply inherit_as_none logic by directly modifying the class definition 

874 # This must happen BEFORE @dataclass processes the class 

875 explicitly_defined_fields = set() 

876 if hasattr(actual_cls, '__annotations__'): 

877 for field_name in actual_cls.__annotations__: 

878 # Check if field has a concrete default value in THIS class definition (not inherited) 

879 if field_name in actual_cls.__dict__: # Only fields defined in THIS class 

880 field_value = actual_cls.__dict__[field_name] 

881 # Only consider it explicitly defined if it has a concrete value (not None) 

882 if field_value is not None: 

883 explicitly_defined_fields.add(field_name) 

884 

885 # Process parent classes to find fields that need None overrides 

886 processed_fields = set() 

887 fields_set_to_none = set() # Track which fields were actually set to None 

888 for base in actual_cls.__bases__: 

889 if hasattr(base, '__annotations__'): 

890 for field_name, field_type in base.__annotations__.items(): 

891 if field_name in processed_fields: 

892 continue 

893 

894 # Set inherited fields to None (except explicitly defined ones) 

895 if field_name not in explicitly_defined_fields: 

896 # CRITICAL: Force the field to be seen as locally defined by @dataclass 

897 # We need to ensure @dataclass processes this as a local field, not inherited 

898 

899 # 1. Set the class attribute to None 

900 setattr(actual_cls, field_name, None) 

901 fields_set_to_none.add(field_name) 

902 

903 # 2. Ensure annotation exists in THIS class 

904 if not hasattr(actual_cls, '__annotations__'): 

905 actual_cls.__annotations__ = {} 

906 actual_cls.__annotations__[field_name] = field_type 

907 

908 processed_fields.add(field_name) 

909 

910 # Note: We modify class attributes here, but we also need to fix the dataclass 

911 # field definitions after @dataclass runs, since @dataclass processes the MRO 

912 # and may use parent class field definitions instead of our modified attributes. 

913 

914 # Generate field and class names 

915 field_name = _camel_to_snake(actual_cls.__name__) 

916 lazy_class_name = f"{LAZY_CONFIG_PREFIX}{actual_cls.__name__}" 

917 

918 # Mark class with ui_hidden metadata for UI layer to check 

919 # This allows the config to remain in the context (for lazy resolution) 

920 # while being hidden from UI rendering 

921 if ui_hidden: 

922 actual_cls._ui_hidden = True 

923 

924 # Check if class is abstract (has unimplemented abstract methods) 

925 # Abstract classes should NEVER be injected into GlobalPipelineConfig 

926 # because they can't be instantiated 

927 # NOTE: We need to check if the class ITSELF is abstract, not just if it inherits from ABC 

928 # Concrete subclasses of abstract classes should still be injected 

929 # We check for __abstractmethods__ attribute which exists even before @dataclass runs 

930 # (it's set by ABCMeta when the class is created) 

931 is_abstract = hasattr(actual_cls, '__abstractmethods__') and len(actual_cls.__abstractmethods__) > 0 

932 

933 # Add to pending injections for field injection 

934 # Skip injection for abstract classes (they can't be instantiated) 

935 # For concrete classes: inject even if ui_hidden (needed for lazy resolution context) 

936 if not is_abstract: 

937 _pending_injections[target_class_name]['configs_to_inject'].append({ 

938 'config_class': actual_cls, 

939 'field_name': field_name, 

940 'lazy_class_name': lazy_class_name, 

941 'optional': optional, # Store the optional flag 

942 'inherit_as_none': inherit_as_none, # Store the inherit_as_none flag 

943 'ui_hidden': ui_hidden # Store the ui_hidden flag for field metadata 

944 }) 

945 

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

947 

948 

949 lazy_class = LazyDataclassFactory.make_lazy_simple( 

950 base_class=actual_cls, 

951 lazy_class_name=lazy_class_name 

952 ) 

953 

954 # Export lazy class to config module immediately 

955 config_module = sys.modules[actual_cls.__module__] 

956 setattr(config_module, lazy_class_name, lazy_class) 

957 

958 # Also mark lazy class with ui_hidden metadata 

959 if ui_hidden: 

960 lazy_class._ui_hidden = True 

961 

962 # CRITICAL: Post-process dataclass fields after @dataclass has run 

963 # This fixes the constructor behavior for inherited fields that should be None 

964 if inherit_as_none and hasattr(actual_cls, '__dataclass_fields__'): 

965 _fix_dataclass_field_defaults_post_processing(actual_cls, fields_set_to_none) 

966 

967 return actual_cls 

968 

969 # Handle both @decorator and @decorator() usage 

970 if cls is None: 

971 # Called with parentheses: @decorator(optional=True) 

972 return decorator 

973 else: 

974 # Called without parentheses: @decorator 

975 return decorator(cls) 

976 

977 return global_default_decorator 

978 

979 

980def _fix_dataclass_field_defaults_post_processing(cls: Type, fields_set_to_none: set) -> None: 

981 """ 

982 Fix dataclass field defaults after @dataclass has processed the class. 

983 

984 This is necessary because @dataclass processes the MRO and may use parent class 

985 field definitions instead of our modified class attributes. We need to ensure 

986 that fields we set to None actually use None as the default in the constructor. 

987 """ 

988 import dataclasses 

989 

990 # Store the original __init__ method 

991 original_init = cls.__init__ 

992 

993 def custom_init(self, **kwargs): 

994 """Custom __init__ that ensures inherited fields use None defaults.""" 

995 # For fields that should be None, set them to None if not explicitly provided 

996 for field_name in fields_set_to_none: 

997 if field_name not in kwargs: 

998 kwargs[field_name] = None 

999 

1000 # Call the original __init__ with modified kwargs 

1001 original_init(self, **kwargs) 

1002 

1003 # Replace the __init__ method 

1004 cls.__init__ = custom_init 

1005 

1006 # Also update the field defaults for consistency 

1007 for field_name in fields_set_to_none: 

1008 if field_name in cls.__dataclass_fields__: 

1009 # Get the field object 

1010 field_obj = cls.__dataclass_fields__[field_name] 

1011 

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

1013 field_obj.default = None 

1014 field_obj.default_factory = dataclasses.MISSING 

1015 

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

1017 setattr(cls, field_name, None) 

1018 

1019 

1020 

1021def _inject_all_pending_fields(): 

1022 """Inject all accumulated fields at once.""" 

1023 for target_name, injection_data in _pending_injections.items(): 

1024 target_class = injection_data['target_class'] 

1025 configs = injection_data['configs_to_inject'] 

1026 

1027 if configs: # Only inject if there are configs to inject 

1028 _inject_multiple_fields_into_dataclass(target_class, configs) 

1029 

1030def _camel_to_snake(name: str) -> str: 

1031 """Convert CamelCase to snake_case for field names.""" 

1032 s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) 

1033 return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower() 

1034 

1035def _inject_multiple_fields_into_dataclass(target_class: Type, configs: List[Dict]) -> None: 

1036 """Mathematical simplification: Batch field injection with direct dataclass recreation.""" 

1037 # Imports moved to top-level 

1038 

1039 # Direct field reconstruction - guaranteed by dataclass contract 

1040 existing_fields = [ 

1041 (f.name, f.type, field(default_factory=f.default_factory) if f.default_factory != MISSING 

1042 else f.default if f.default != MISSING else f.type) 

1043 for f in fields(target_class) 

1044 ] 

1045 

1046 # Mathematical simplification: Unified field construction with algebraic common factors 

1047 def create_field_definition(config): 

1048 """Create field definition with optional and inherit_as_none support.""" 

1049 field_type = config['config_class'] 

1050 is_optional = config.get('optional', False) 

1051 is_ui_hidden = config.get('ui_hidden', False) 

1052 

1053 # Algebraic simplification: factor out common default_value logic 

1054 if is_optional: 

1055 field_type = Union[field_type, type(None)] 

1056 default_value = None 

1057 else: 

1058 # Both inherit_as_none and regular cases use same default factory 

1059 # Add ui_hidden metadata to the field so UI layer can check it 

1060 default_value = field(default_factory=field_type, metadata={'ui_hidden': is_ui_hidden}) 

1061 

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

1063 

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

1065 

1066 # Direct dataclass recreation - fail-loud 

1067 new_class = make_dataclass( 

1068 target_class.__name__, 

1069 all_fields, 

1070 bases=target_class.__bases__, 

1071 frozen=target_class.__dataclass_params__.frozen 

1072 ) 

1073 

1074 # CRITICAL: Preserve original module for proper imports in generated code 

1075 # make_dataclass() sets __module__ to the caller's module (lazy_factory.py) 

1076 # We need to set it to the target class's original module for correct import paths 

1077 new_class.__module__ = target_class.__module__ 

1078 

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

1080 

1081 # Direct module replacement 

1082 module = sys.modules[target_class.__module__] 

1083 setattr(module, target_class.__name__, new_class) 

1084 globals()[target_class.__name__] = new_class 

1085 

1086 # Mathematical simplification: Extract common module assignment pattern 

1087 def _register_lazy_class(lazy_class, class_name, module_name): 

1088 """Register lazy class in both module and global namespace.""" 

1089 setattr(sys.modules[module_name], class_name, lazy_class) 

1090 globals()[class_name] = lazy_class 

1091 

1092 # Create lazy classes and recreate PipelineConfig inline 

1093 for config in configs: 

1094 lazy_class = LazyDataclassFactory.make_lazy_simple( 

1095 base_class=config['config_class'], 

1096 lazy_class_name=config['lazy_class_name'] 

1097 ) 

1098 _register_lazy_class(lazy_class, config['lazy_class_name'], config['config_class'].__module__) 

1099 

1100 # Create lazy version of the updated global config itself with proper naming 

1101 # Global configs must start with GLOBAL_CONFIG_PREFIX - fail-loud if not 

1102 if not target_class.__name__.startswith(GLOBAL_CONFIG_PREFIX): 

1103 raise ValueError(f"Target class '{target_class.__name__}' must start with '{GLOBAL_CONFIG_PREFIX}' prefix") 

1104 

1105 # Remove global prefix (GlobalPipelineConfig → PipelineConfig) 

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

1107 

1108 lazy_global_class = LazyDataclassFactory.make_lazy_simple( 

1109 base_class=new_class, 

1110 lazy_class_name=lazy_global_class_name 

1111 ) 

1112 

1113 # Use extracted helper for consistent registration 

1114 _register_lazy_class(lazy_global_class, lazy_global_class_name, target_class.__module__) 

1115 

1116 

1117 

1118 

1119 

1120def auto_create_decorator(global_config_class): 

1121 """ 

1122 Decorator that automatically creates: 

1123 1. A field injection decorator for other configs to use 

1124 2. A lazy version of the global config itself 

1125 

1126 Global config classes must start with "Global" prefix. 

1127 """ 

1128 # Validate naming convention 

1129 if not global_config_class.__name__.startswith(GLOBAL_CONFIG_PREFIX): 

1130 raise ValueError(f"Global config class '{global_config_class.__name__}' must start with '{GLOBAL_CONFIG_PREFIX}' prefix") 

1131 

1132 decorator_name = _camel_to_snake(global_config_class.__name__) 

1133 decorator = create_global_default_decorator(global_config_class) 

1134 

1135 # Export decorator to module globals 

1136 module = sys.modules[global_config_class.__module__] 

1137 setattr(module, decorator_name, decorator) 

1138 

1139 # Lazy global config will be created after field injection 

1140 

1141 return global_config_class 

1142 

1143 

1144 

1145 

1146 

1147 

1148