Coverage for src/objectstate/lazy_factory.py: 81%

539 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-17 07:22 +0000

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

2 

3# Standard library imports 

4import copy 

5import dataclasses 

6import inspect 

7import logging 

8import re 

9import sys 

10from abc import ABC 

11from contextlib import contextmanager 

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

13from functools import lru_cache 

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

15 

16from python_introspect import ( 

17 make_optional, 

18 register_type_resolver, 

19 resolve_annotated, 

20 resolve_optional, 

21) 

22 

23from objectstate.ui_visibility import mark_ui_hidden_config 

24 

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

26 

27 

28# Type registry for lazy dataclass to base class mapping 

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

30 

31# Reverse registry for base class to lazy dataclass mapping (for O(1) lookup) 

32_base_to_lazy_registry: Dict[Type, Type] = {} 

33 

34 

35def _resolved_dataclass_annotations(dataclass_type: type) -> Dict[str, object]: 

36 """Resolve declarations even while a class decorator precedes module binding.""" 

37 

38 return get_type_hints( 

39 dataclass_type, 

40 localns={dataclass_type.__name__: dataclass_type}, 

41 include_extras=True, 

42 ) 

43 

44 

45def _recreated_dataclass_namespace( 

46 declared_type: type, 

47 recreated_name: str, 

48) -> dict[str, object]: 

49 """Return declaration-owned metadata for a recreated dataclass. 

50 

51 The module and qualified name are in the namespace so the class has them 

52 while it is being created: a metaclass that registers classes then sees the 

53 declaration the class was rebuilt from, not ``abc`` or this module. 

54 """ 

55 

56 namespace: dict[str, object] = { 

57 "__doc__": declared_type.__doc__, 

58 "__module__": declared_type.__module__, 

59 } 

60 if recreated_name == declared_type.__name__: 

61 namespace["__qualname__"] = declared_type.__qualname__ 

62 return namespace 

63 

64 

65def _restore_recreated_dataclass_metadata( 

66 recreated_type: type, 

67 declared_type: type, 

68) -> type: 

69 """Restore runtime metadata that class creation may replace or remove.""" 

70 

71 recreated_type.__module__ = declared_type.__module__ 

72 recreated_type.__doc__ = declared_type.__doc__ 

73 if recreated_type.__name__ == declared_type.__name__: 

74 recreated_type.__qualname__ = declared_type.__qualname__ 

75 

76 declared_first_line = declared_type.__dict__.get("__firstlineno__", MISSING) 

77 if declared_first_line is not MISSING: 

78 try: 

79 setattr(recreated_type, "__firstlineno__", declared_first_line) 

80 except (AttributeError, TypeError): 

81 pass 

82 return recreated_type 

83 

84 

85# ============================================================================= 

86# UNIFIED NONE-FORCING: Single path for both base and lazy classes 

87# Replaces the old 3-stage approach (pre-process setattr, post-process Field patch) 

88# ============================================================================= 

89 

90def get_inherited_field_names(cls: Type) -> set: 

91 """ 

92 Get names of fields inherited from parent dataclasses (not defined in cls itself). 

93 

94 A field is "inherited" if it exists in a parent's __dataclass_fields__ but 

95 is NOT in this class's own __annotations__ (i.e., not redefined here). 

96 """ 

97 # Get all field names from parent dataclasses 

98 parent_fields = set() 

99 for base in cls.__mro__[1:]: # Skip cls itself 

100 if dataclasses.is_dataclass(base): 

101 parent_fields.update(base.__dataclass_fields__.keys()) 

102 

103 # cls's OWN annotations (not inherited). Python 3.14 (PEP 649) no longer 

104 # stores them in cls.__dict__['__annotations__']. 

105 own_defined = set(inspect.get_annotations(cls, eval_str=False)) 

106 

107 return parent_fields - own_defined 

108 

109 

110def _inherited_default_metadata(field_definition) -> dict: 

111 """Preserve one concrete dataclass field's standalone fallback values.""" 

112 

113 metadata = dict(field_definition.metadata) 

114 metadata.setdefault( 

115 "_inherited_default", 

116 ( 

117 field_definition.default 

118 if field_definition.default is not MISSING 

119 else MISSING 

120 ), 

121 ) 

122 metadata.setdefault( 

123 "_inherited_default_factory", 

124 field_definition.default_factory, 

125 ) 

126 return metadata 

127 

128 

129def rebuild_with_none_defaults( 

130 cls: Type, 

131 field_names_to_none: Optional[set] = None, 

132 new_name: Optional[str] = None 

133) -> Type: 

134 """ 

135 Rebuild a dataclass via make_dataclass with None defaults for specified fields. 

136 

137 This is the UNIFIED approach for both base classes (inherit_as_none) and lazy classes. 

138 Instead of patching Field objects after @dataclass, we rebuild with correct defaults. 

139 

140 Args: 

141 cls: The dataclass to rebuild 

142 field_names_to_none: Fields that should have default=None. 

143 If None, ALL fields get default=None (for lazy classes). 

144 new_name: Optional new class name (for lazy classes) 

145 

146 Returns: 

147 A new class with the same fields but modified defaults 

148 """ 

149 if not dataclasses.is_dataclass(cls): 

150 raise ValueError(f"{cls} is not a dataclass") 

151 

152 if field_names_to_none is None: 

153 # All fields get None (for lazy classes) 

154 field_names_to_none = {f.name for f in fields(cls)} 

155 

156 annotations = _resolved_dataclass_annotations(cls) 

157 

158 # Build field definitions 

159 field_defs = [] 

160 for f in fields(cls): 

161 declared_type = annotations.get(f.name, f.type) 

162 if f.name in field_names_to_none: 

163 # Force None default, but preserve original default in metadata for fallback 

164 # This allows standalone usage to fall back to parent's static default 

165 field_defs.append( 

166 ( 

167 f.name, 

168 make_optional(declared_type), 

169 field( 

170 default=None, 

171 metadata=_inherited_default_metadata(f), 

172 ), 

173 ) 

174 ) 

175 else: 

176 # Preserve original field (copy to avoid sharing) 

177 field_defs.append((f.name, declared_type, copy.copy(f))) 

178 

179 # Collect non-dunder attributes to preserve (methods, class vars, etc.) 

180 recreated_name = new_name or cls.__name__ 

181 namespace = _recreated_dataclass_namespace(cls, recreated_name) 

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

183 if key.startswith('__') and key.endswith('__'): 

184 # Skip most dunders (make_dataclass will generate them) 

185 # BUT preserve lifecycle/registration declarations. 

186 if key not in {"__post_init__", "__registry_key__"}: 

187 continue 

188 if key == '__dataclass_fields__': 

189 continue # Will be regenerated 

190 namespace[key] = value 

191 

192 # Keep original bases for isinstance() to work 

193 bases = cls.__bases__ 

194 

195 # Check if any base is a frozen dataclass - if so, new class must also be frozen 

196 is_frozen = any( 

197 dataclasses.is_dataclass(b) and b.__dataclass_fields__ and 

198 getattr(b, '__dataclass_params__', None) and b.__dataclass_params__.frozen 

199 for b in cls.__mro__[1:] 

200 ) 

201 

202 # Get the original metaclass to preserve it 

203 orig_metaclass = type(cls) 

204 

205 # Create new class 

206 new_cls = make_dataclass( 

207 recreated_name, 

208 fields=field_defs, 

209 bases=bases, 

210 namespace=namespace, 

211 frozen=is_frozen, 

212 ) 

213 

214 # make_dataclass takes no metaclass parameter: it derives one from the 

215 # bases. When the declared class names a metaclass its bases do not carry, 

216 # create the class once more with that metaclass. @dataclass is NOT 

217 # re-applied, since make_dataclass already did that. 

218 if type(new_cls) is not orig_metaclass: 

219 new_cls = orig_metaclass( 

220 new_cls.__name__, 

221 new_cls.__bases__, 

222 {**new_cls.__dict__, **_recreated_dataclass_namespace(cls, recreated_name)}, 

223 ) 

224 

225 return _restore_recreated_dataclass_metadata(new_cls, cls) 

226 

227 

228def replace_raw(instance, **changes): 

229 """ 

230 Replace dataclass fields while preserving raw None values. 

231 

232 Unlike dataclasses.replace(), this function uses object.__getattribute__ 

233 to get field values, preventing lazy resolution from being triggered. 

234 This is critical for lazy dataclasses where None means "inherit from parent" 

235 and must not be resolved during copy operations. 

236 

237 Args: 

238 instance: The dataclass instance to copy 

239 **changes: Field values to override 

240 

241 Returns: 

242 A new instance with raw values preserved (not resolved) 

243 """ 

244 if not is_dataclass(instance): 

245 raise TypeError(f"replace_raw() should be called on dataclass instances, got {type(instance)}") 

246 

247 # Get all field values using object.__getattribute__ to avoid lazy resolution 

248 field_values = {} 

249 for f in fields(instance): 

250 if f.name in changes: 

251 field_values[f.name] = changes[f.name] 

252 else: 

253 # Use object.__getattribute__ to get raw value (bypass lazy __getattribute__) 

254 field_values[f.name] = object.__getattribute__(instance, f.name) 

255 

256 # Create new instance with raw values 

257 return type(instance)(**field_values) 

258 

259 

260 

261def retain_only_authored_paths(value, authored_field_paths) -> Any: 

262 """Copy a lazy dataclass, marking only ``authored_field_paths`` as explicitly set. 

263 

264 An ObjectState rebuild constructs every nested lazy dataclass from all its 

265 flattened fields, so every constructor keyword reads as explicit. Clean 

266 source generation needs the authored paths ObjectState already tracks 

267 (its signature-diff fields); this applies them to the lazy metadata without 

268 changing any field value. 

269 """ 

270 

271 from objectstate.field_access import DottedFieldPath 

272 

273 paths = tuple(DottedFieldPath(path) for path in authored_field_paths) 

274 return _copy_with_only_authored_paths(value, DottedFieldPath(""), paths) 

275 

276 

277def _copy_with_only_authored_paths(value, prefix, authored_field_paths): 

278 from objectstate.field_access import DataclassFieldAccess 

279 

280 field_values = {} 

281 for field_definition in fields(value): 

282 field_value = DataclassFieldAccess.raw_value(value, field_definition.name) 

283 if isinstance(field_value, LazyDataclass): 

284 field_value = _copy_with_only_authored_paths( 

285 field_value, 

286 prefix.child(field_definition.name), 

287 authored_field_paths, 

288 ) 

289 field_values[field_definition.name] = field_value 

290 

291 projected = type(value)(**field_values) 

292 object.__setattr__( 

293 projected, 

294 "_explicitly_set_fields", 

295 { 

296 field_definition.name 

297 for field_definition in fields(projected) 

298 if prefix.child(field_definition.name).contains_any(authored_field_paths) 

299 }, 

300 ) 

301 return projected 

302 

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

304 

305 

306 

307 

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

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

310 if not isinstance(lazy_type, type) or not isinstance(base_type, type): 

311 raise TypeError("Lazy type mappings require two types.") 

312 existing_base = _lazy_type_registry.get(lazy_type) 

313 if existing_base is not None and existing_base is not base_type: 

314 raise ValueError( 

315 f"{lazy_type.__name__} is already registered for " 

316 f"{existing_base.__name__}, not {base_type.__name__}." 

317 ) 

318 existing_lazy = _base_to_lazy_registry.get(base_type) 

319 if existing_lazy is not None and existing_lazy is not lazy_type: 

320 raise ValueError( 

321 f"{base_type.__name__} already owns lazy type " 

322 f"{existing_lazy.__name__}, not {lazy_type.__name__}." 

323 ) 

324 _lazy_type_registry[lazy_type] = base_type 

325 _base_to_lazy_registry[base_type] = lazy_type 

326 

327 

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

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

330 return _lazy_type_registry.get(lazy_type) 

331 

332 

333register_type_resolver(get_base_type_for_lazy) 

334 

335 

336def is_lazy_dataclass(obj_or_type) -> bool: 

337 """ 

338 Check if an object or type is a lazy dataclass. 

339 

340 ANTI-DUCK-TYPING: Uses isinstance() check against LazyDataclass base class 

341 instead of hasattr() attribute sniffing. 

342 

343 Works with both instances and types, and naturally handles Optional types 

344 without unwrapping. 

345 

346 Args: 

347 obj_or_type: Either a dataclass instance or a dataclass type 

348 

349 Returns: 

350 True if the object/type is a lazy dataclass 

351 

352 Examples: 

353 >>> is_lazy_dataclass(PipelineConfig) # True (type check) 

354 >>> is_lazy_dataclass(GlobalPipelineConfig) # False 

355 >>> is_lazy_dataclass(pipeline_config_instance) # True (instance check) 

356 >>> is_lazy_dataclass(LazyPathPlanningConfig) # True 

357 >>> is_lazy_dataclass(PathPlanningConfig) # False 

358 

359 # Works with Optional without unwrapping! 

360 >>> config: Optional[PipelineConfig] = PipelineConfig() 

361 >>> is_lazy_dataclass(config) # True - checks the instance, not the type annotation 

362 """ 

363 if isinstance(obj_or_type, type): 

364 # Type check: is it a subclass of LazyDataclass? 

365 return issubclass(obj_or_type, LazyDataclass) 

366 else: 

367 # Instance check: is it an instance of LazyDataclass? 

368 return isinstance(obj_or_type, LazyDataclass) 

369 

370logger = logging.getLogger(__name__) 

371 

372 

373# ============================================================================= 

374# GENERIC SCOPE RULE: Virtual base class for global configs using __instancecheck__ 

375# This allows isinstance() checks without actual inheritance, so lazy versions don't inherit it 

376# ============================================================================= 

377 

378 

379_GLOBAL_CONFIG_TYPES: set[type] = set() 

380 

381 

382def mark_global_config_type(config_type: Type) -> Type: 

383 """Register config_type as an ObjectState global-config authority.""" 

384 if not isinstance(config_type, type) or not is_dataclass(config_type): 

385 raise TypeError("Global config authorities must be dataclass types.") 

386 _GLOBAL_CONFIG_TYPES.add(config_type) 

387 return config_type 

388 

389 

390class GlobalConfigMeta(type): 

391 """ 

392 Metaclass that makes isinstance(obj, GlobalConfigBase) use the global config registry. 

393 

394 This enables type-safe isinstance checks without inheritance: 

395 if isinstance(config, GlobalConfigBase): # Returns True for GlobalPipelineConfig 

396 # Returns False for PipelineConfig (lazy version) 

397 """ 

398 def __instancecheck__(cls, instance): 

399 return is_global_config_type(type(instance)) 

400 

401 

402class GlobalConfigBase(metaclass=GlobalConfigMeta): 

403 """ 

404 Virtual base class for all global config types. 

405 

406 Uses custom metaclass to check the global config registry instead of actual inheritance. 

407 This prevents lazy versions (PipelineConfig) from being considered global configs. 

408 

409 Usage: 

410 if isinstance(config, GlobalConfigBase): # Generic, works for any global config 

411 

412 Instead of: 

413 if isinstance(config, GlobalPipelineConfig): # Hardcoded, breaks extensibility 

414 """ 

415 pass 

416 

417 

418class LazyResolutionDataclass(ABC): 

419 """Nominal root for dataclasses whose ``None`` fields resolve by context.""" 

420 

421 

422class LazyDataclass(LazyResolutionDataclass): 

423 """ 

424 Base class for all lazy dataclasses created by LazyDataclassFactory. 

425 

426 This enables isinstance() checks without duck typing or unwrapping: 

427 isinstance(config, LazyDataclass) # Works! 

428 isinstance(optional_config, LazyDataclass) # Works even for Optional! 

429 

430 All lazy dataclasses inherit from this, regardless of naming convention: 

431 - PipelineConfig (lazy version of GlobalPipelineConfig) 

432 - LazyPathPlanningConfig 

433 - LazyPartitionFilterConfig 

434 - etc. 

435 

436 ANTI-DUCK-TYPING: Use isinstance(obj, LazyDataclass) instead of hasattr() checks. 

437 """ 

438 pass 

439 

440 

441def has_lazy_resolution(obj_or_type: object) -> bool: 

442 """Return whether a value or declared type owns lazy field resolution.""" 

443 

444 candidate = resolve_optional(obj_or_type) 

445 if isinstance(candidate, type): 

446 return issubclass(candidate, LazyResolutionDataclass) 

447 return isinstance(candidate, LazyResolutionDataclass) 

448 

449 

450def is_global_config_type(config_type: Type) -> bool: 

451 """ 

452 Check if a config type is a global config (marked by @auto_create_decorator). 

453 

454 GENERIC SCOPE RULE: Use this instead of hardcoding class name checks like: 

455 if config_class == GlobalPipelineConfig: 

456 

457 Instead use: 

458 if is_global_config_type(config_class): 

459 

460 Args: 

461 config_type: The config class to check 

462 

463 Returns: 

464 True if the type is marked as a global config, False otherwise 

465 """ 

466 return config_type in _GLOBAL_CONFIG_TYPES 

467 

468 

469def is_global_config_instance(config_instance: Any) -> bool: 

470 """ 

471 Check if a config instance is an instance of a global config class. 

472 

473 GENERIC SCOPE RULE: Use this instead of hardcoding isinstance checks like: 

474 if isinstance(config, GlobalPipelineConfig): 

475 

476 Instead use: 

477 if is_global_config_instance(config): 

478 

479 Or use the virtual base class: 

480 if isinstance(config, GlobalConfigBase): 

481 

482 Args: 

483 config_instance: The config instance to check 

484 

485 Returns: 

486 True if the instance is of a global config type, False otherwise 

487 """ 

488 return is_global_config_type(type(config_instance)) 

489 

490 

491def get_lazy_type_for_base(base_type: Type) -> Optional[Type]: 

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

493 return _base_to_lazy_registry.get(base_type) 

494 

495 

496def _normalized_config_type(annotation: object) -> Optional[type]: 

497 """Return the nominal config owner declared by one field annotation.""" 

498 

499 candidate = resolve_optional(resolve_annotated(annotation)) 

500 if not isinstance(candidate, type): 

501 return None 

502 return get_base_type_for_lazy(candidate) or candidate 

503 

504 

505@lru_cache(maxsize=None) 

506def _declared_config_field_names( 

507 owner_type: type, 

508 config_type: type, 

509) -> Tuple[str, ...]: 

510 """Find fields whose resolved declaration owns ``config_type``.""" 

511 

512 if not is_dataclass(owner_type): 

513 raise TypeError(f"{owner_type!r} is not a dataclass type.") 

514 config_base = get_base_type_for_lazy(config_type) or config_type 

515 annotations = _resolved_dataclass_annotations(owner_type) 

516 return tuple( 

517 field_definition.name 

518 for field_definition in fields(owner_type) 

519 if _normalized_config_type( 

520 annotations.get(field_definition.name, field_definition.type) 

521 ) 

522 is config_base 

523 ) 

524 

525 

526def _single_declared_config_field( 

527 owner_type: type, 

528 config_type: type, 

529 *, 

530 required: bool, 

531) -> Optional[str]: 

532 """Select one annotation-owned config field and reject ambiguity.""" 

533 

534 field_names = _declared_config_field_names(owner_type, config_type) 

535 if len(field_names) == 1: 

536 return field_names[0] 

537 if not field_names and not required: 

538 return None 

539 config_base = get_base_type_for_lazy(config_type) or config_type 

540 if not field_names: 

541 raise TypeError( 

542 f"{owner_type.__name__} has no field declared as " 

543 f"{config_base.__name__}." 

544 ) 

545 raise TypeError( 

546 f"{owner_type.__name__} declares multiple {config_base.__name__} " 

547 f"fields: {', '.join(field_names)}." 

548 ) 

549 

550 

551# ============================================================================= 

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

553MATERIALIZATION_DEFAULTS_PATH = "materialization_defaults" 

554RESOLVE_FIELD_VALUE_METHOD = "_resolve_field_value" 

555GET_ATTRIBUTE_METHOD = "__getattribute__" 

556TO_BASE_CONFIG_METHOD = "to_base_config" 

557FROM_CONFIG_METHOD = "from_config" 

558WITH_DEFAULTS_METHOD = "with_defaults" 

559WITH_OVERRIDES_METHOD = "with_overrides" 

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

561 

562LAZY_CLASS_NAME_PREFIX = "Lazy" 

563 

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

565 

566 

567# Functional fallback strategies 

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

569 """ 

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

571 

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

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

574 

575 Args: 

576 obj: Object to get field from 

577 field_name: Name of field to access 

578 

579 Returns: 

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

581 

582 Raises: 

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

584 """ 

585 try: 

586 return object.__getattribute__(obj, field_name) 

587 except AttributeError: 

588 return None 

589 

590 

591def bind_lazy_resolution_to_class(cls: Type) -> None: 

592 """ 

593 Add lazy __getattribute__ to an existing class. 

594 

595 This enables concrete classes (like PartitionFilterConfig stored in 

596 GlobalPipelineConfig) to resolve None values via MRO without 

597 changing their static defaults. 

598 

599 Args: 

600 cls: The class to add lazy resolution to 

601 """ 

602 if has_lazy_resolution(cls): 

603 return 

604 

605 lazy_getattribute = LazyMethodBindings.create_getattribute() 

606 cls.__getattribute__ = lazy_getattribute 

607 LazyResolutionDataclass.register(cls) 

608 

609 

610@dataclass(frozen=True) 

611class LazyMethodBindings: 

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

613 

614 @staticmethod 

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

616 """Create the method that resolves one field in the active context.""" 

617 from objectstate.dual_axis_resolver import resolve_with_provenance 

618 

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

620 return resolve_with_provenance(type(self), field_name).value 

621 

622 return _resolve_field_value 

623 

624 @staticmethod 

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

626 """Create lazy __getattribute__: a None field reads its value from the active context.""" 

627 from objectstate.context_manager import context_layer_stack 

628 from objectstate.dual_axis_resolver import declared_default, resolve_in_stack 

629 

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

631 value = object.__getattribute__(self, name) 

632 # Fast path: non-None values never need resolution 

633 if value is not None: 

634 return value 

635 # PERFORMANCE: Use pre-computed frozenset (O(1) lookup) instead of 

636 # rebuilding {f.name for f in fields(self.__class__)} (O(n)) on every access. 

637 # Use object.__getattribute__ for __class__ to avoid recursion. 

638 # Lazily populate if not yet set (e.g. class created outside _create_lazy_dataclass_unified). 

639 _cls = object.__getattribute__(self, '__class__') 

640 _fns = _cls.__dict__.get('_field_names_set') 

641 if _fns is None: 

642 _ft = fields(_cls) 

643 _fns = frozenset(f.name for f in _ft) 

644 _cls._field_names_set = _fns 

645 _cls._fields_by_name = {f.name: f for f in _ft} 

646 if name not in _fns: 

647 return value 

648 

649 stack = context_layer_stack.get() 

650 if not stack.layers: 

651 return declared_default(_cls, name)[0] 

652 resolved_value = resolve_in_stack(stack, _cls, name).value 

653 if resolved_value is not None: 

654 return resolved_value 

655 

656 # A nested dataclass field that nothing supplies reads as an empty instance. 

657 field_obj = _cls.__dict__['_fields_by_name'].get(name) 

658 if field_obj and is_dataclass(field_obj.type): 

659 return field_obj.type() 

660 return None 

661 

662 return __getattribute__ 

663 

664 @staticmethod 

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

666 """Create base config converter method.""" 

667 def to_base_config(self): 

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

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

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

671 # 

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

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

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

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

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

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

678 return base_class(**field_values) 

679 return to_base_config 

680 

681 @staticmethod 

682 def create_from_config(base_class: Type) -> classmethod: 

683 """Create one generic concrete projection and composition constructor.""" 

684 

685 def from_config(cls, *configs, inherited=None): 

686 if len(configs) == 1 and isinstance(configs[0], base_class): 

687 config = configs[0] 

688 if isinstance(config, LazyDataclass): 

689 raise TypeError( 

690 f"{cls.__name__}.from_config requires concrete " 

691 f"{base_class.__name__}, got {type(config).__name__}." 

692 ) 

693 if inherited is not None and ( 

694 isinstance(inherited, LazyDataclass) 

695 or not isinstance(inherited, base_class) 

696 ): 

697 raise TypeError( 

698 f"{cls.__name__}.from_config inherited value must be " 

699 f"{base_class.__name__}, got {type(inherited).__name__}." 

700 ) 

701 

702 values = {} 

703 for field_definition in fields(base_class): 

704 if not field_definition.init: 

705 continue 

706 value = object.__getattribute__(config, field_definition.name) 

707 if inherited is not None and value == object.__getattribute__( 

708 inherited, 

709 field_definition.name, 

710 ): 

711 continue 

712 values[field_definition.name] = value 

713 return cls(**values) 

714 

715 if inherited is not None: 

716 raise TypeError( 

717 f"{cls.__name__}.from_config accepts inherited only when " 

718 f"projecting one {base_class.__name__}." 

719 ) 

720 values = {} 

721 for config in configs: 

722 if isinstance(config, LazyDataclass): 

723 raise TypeError( 

724 f"{cls.__name__}.from_config requires concrete dataclass " 

725 f"values, got {type(config).__name__}." 

726 ) 

727 lazy_type = get_lazy_type_for_base(type(config)) 

728 if lazy_type is None: 

729 raise TypeError( 

730 f"{cls.__name__}.from_config has no registered lazy type " 

731 f"for {type(config).__name__}." 

732 ) 

733 field_name = _single_declared_config_field( 

734 cls, 

735 type(config), 

736 required=True, 

737 ) 

738 if field_name in values: 

739 raise ValueError( 

740 f"{cls.__name__}.from_config received duplicate config " 

741 f"values for {field_name!r}." 

742 ) 

743 values[field_name] = lazy_type.from_config(config) 

744 return cls(**values) 

745 

746 return classmethod(from_config) 

747 

748 @staticmethod 

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

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

751 return { 

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

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

754 } 

755 

756 

757class LazyDataclassFactory: 

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

759 

760 @staticmethod 

761 def registered_type_pairs() -> tuple[tuple[type, type], ...]: 

762 """Project current lazy/public declarations without constructing values.""" 

763 return tuple(_lazy_type_registry.items()) 

764 

765 

766 

767 

768 

769 @staticmethod 

770 def _introspect_dataclass_fields( 

771 base_class: Type, 

772 debug_template: str, 

773 ) -> List[Tuple[str, Type, None]]: 

774 """ 

775 Introspect dataclass fields for lazy loading. 

776 

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

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

779 """ 

780 base_fields = fields(base_class) 

781 annotations = _resolved_dataclass_annotations(base_class) 

782 lazy_field_definitions = [] 

783 

784 for field_definition in base_fields: 

785 # Check if field has default value or factory 

786 has_default = ( 

787 field_definition.default is not MISSING 

788 or field_definition.default_factory is not MISSING 

789 ) 

790 

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

792 field_type = annotations.get( 

793 field_definition.name, 

794 field_definition.type, 

795 ) 

796 lazy_nested_type = None # Track if we created a lazy nested type 

797 nested_type = resolve_annotated(field_type) 

798 nested_is_registered_lazy = ( 

799 isinstance(nested_type, type) 

800 and ( 

801 get_base_type_for_lazy(nested_type) is not None 

802 or get_lazy_type_for_base(nested_type) is not None 

803 or has_lazy_resolution(nested_type) 

804 ) 

805 ) 

806 if is_dataclass(nested_type) and nested_is_registered_lazy: 

807 if get_base_type_for_lazy(nested_type) is not None: 

808 lazy_nested_type = nested_type 

809 else: 

810 lazy_nested_type = get_lazy_type_for_base(nested_type) 

811 if lazy_nested_type is None: 

812 lazy_nested_type = LazyDataclassFactory.make_lazy_simple(nested_type) 

813 field_type = lazy_nested_type 

814 logger.debug(f"Created lazy class for {field_definition.name}: {nested_type} -> {lazy_nested_type}") 

815 

816 # CRITICAL FIX: For lazy configs, nested dataclass fields should use default_factory 

817 # to provide lazy instances (e.g., LazyPathPlanningConfig), not None. 

818 # This allows getattr(pipeline_config, 'path_planning_config') to return an instance. 

819 # Non-dataclass fields still default to None for placeholder inheritance. 

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

821 if lazy_nested_type is not None: 

822 final_field_type = field_type 

823 # Nested dataclass field: use default_factory so accessing returns an instance 

824 # This matches AbstractStep pattern: napari_streaming_config = LazyNapariStreamingConfig() 

825 field_def = ( 

826 field_definition.name, 

827 final_field_type, 

828 dataclasses.field( 

829 default_factory=lazy_nested_type, 

830 metadata=field_definition.metadata, 

831 ), 

832 ) 

833 else: 

834 final_field_type = make_optional(field_type) 

835 # CRITICAL FIX: For lazy configs, ALL non-dataclass fields should default to None 

836 # This enables proper inheritance from parent configs and placeholder styling 

837 field_def = ( 

838 field_definition.name, 

839 final_field_type, 

840 dataclasses.field( 

841 default=None, 

842 metadata=_inherited_default_metadata(field_definition), 

843 ), 

844 ) 

845 

846 lazy_field_definitions.append(field_def) 

847 

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

849 logger.debug(debug_template.format( 

850 field_name=field_definition.name, 

851 original_type=field_definition.type, 

852 has_default=has_default, 

853 final_type=final_field_type 

854 )) 

855 

856 return lazy_field_definitions 

857 

858 @staticmethod 

859 def _create_lazy_dataclass_unified( 

860 base_class: Type, 

861 lazy_class_name: str | None, 

862 debug_template: str, 

863 ) -> Type: 

864 """ 

865 Create lazy dataclass with declarative configuration. 

866 

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

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

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

870 """ 

871 if not is_dataclass(base_class): 

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

873 

874 registered_type = get_lazy_type_for_base(base_class) 

875 if registered_type is not None: 

876 if lazy_class_name not in (None, registered_type.__name__): 

877 raise ValueError( 

878 f"{base_class.__name__} already owns lazy type " 

879 f"{registered_type.__name__}; cannot also create " 

880 f"{lazy_class_name}." 

881 ) 

882 return registered_type 

883 if lazy_class_name is None: 

884 lazy_class_name = f"{LAZY_CLASS_NAME_PREFIX}{base_class.__name__}" 

885 

886 # Create lazy dataclass with introspected fields 

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

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

889 # Classes processed by the global-config decorator own lazy resolution 

890 # nominally and are safe even when they use a custom metaclass. 

891 base_metaclass = type(base_class) 

892 has_unsafe_metaclass = ( 

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

894 not has_lazy_resolution(base_class) 

895 ) 

896 

897 # Determine inheritance: always include LazyDataclass, optionally include base_class 

898 if has_unsafe_metaclass: 

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

900 logger.debug( 

901 "Lazy factory: %s has custom metaclass %s, avoiding inheritance", 

902 base_class.__name__, 

903 base_metaclass.__name__, 

904 ) 

905 bases = (LazyDataclass,) # Only inherit from LazyDataclass 

906 else: 

907 # Safe to inherit from regular dataclass 

908 bases = (base_class, LazyDataclass) # Inherit from both 

909 

910 # Check if base_class is frozen - must match to avoid inheritance error 

911 # "cannot inherit frozen dataclass from a non-frozen one" and vice versa 

912 base_is_frozen = ( 

913 is_dataclass(base_class) and 

914 hasattr(base_class, '__dataclass_params__') and 

915 base_class.__dataclass_params__.frozen 

916 ) 

917 

918 # Single make_dataclass call - no duplication 

919 lazy_class = make_dataclass( 

920 lazy_class_name, 

921 LazyDataclassFactory._introspect_dataclass_fields( 

922 base_class, 

923 debug_template, 

924 ), 

925 bases=bases, 

926 namespace=_recreated_dataclass_namespace( 

927 base_class, 

928 lazy_class_name, 

929 ), 

930 frozen=base_is_frozen # Match base class frozen status 

931 ) 

932 

933 # PERFORMANCE: Cache field names set and field-by-name dict per class. 

934 # These are derived from dataclasses.fields() which is immutable after class creation. 

935 # Avoids O(n) set creation on every __getattribute__ call and O(n) linear scan 

936 # on every field lookup. 

937 from dataclasses import fields as _dc_fields 

938 _field_tuple = _dc_fields(lazy_class) 

939 lazy_class._field_names_set = frozenset(f.name for f in _field_tuple) 

940 lazy_class._fields_by_name = {f.name: f for f in _field_tuple} 

941 

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

943 original_init = lazy_class.__init__ 

944 def __init_with_tracking__(self, **kwargs): 

945 # Track which fields were explicitly passed to constructor 

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

947 original_init(self, **kwargs) 

948 

949 lazy_class.__init__ = __init_with_tracking__ 

950 

951 # Bind methods declaratively - inline single-use method 

952 method_bindings = { 

953 RESOLVE_FIELD_VALUE_METHOD: LazyMethodBindings.create_resolver(), 

954 GET_ATTRIBUTE_METHOD: LazyMethodBindings.create_getattribute(), 

955 TO_BASE_CONFIG_METHOD: LazyMethodBindings.create_to_base_config(base_class), 

956 FROM_CONFIG_METHOD: LazyMethodBindings.create_from_config(base_class), 

957 **LazyMethodBindings.create_class_methods() 

958 } 

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

960 setattr(lazy_class, method_name, method_impl) 

961 

962 _restore_recreated_dataclass_metadata(lazy_class, base_class) 

963 

964 # Automatically register the lazy dataclass with the type registry 

965 register_lazy_type_mapping(lazy_class, base_class) 

966 

967 return lazy_class 

968 

969 

970 

971 

972 

973 @staticmethod 

974 def make_lazy_simple( 

975 base_class: Type, 

976 lazy_class_name: str | None = None 

977 ) -> Type: 

978 """ 

979 Create lazy dataclass using new contextvars system. 

980 

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

982 Uses new contextvars system for all resolution. 

983 

984 Args: 

985 base_class: Base dataclass to make lazy 

986 lazy_class_name: Name for the lazy class. When omitted the factory 

987 names it, or returns the lazy class the base already owns. 

988 

989 Returns: 

990 Generated lazy dataclass with contextvars-based resolution 

991 """ 

992 return LazyDataclassFactory._create_lazy_dataclass_unified( 

993 base_class=base_class, 

994 lazy_class_name=lazy_class_name, 

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

996 ) 

997 

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

999 

1000 

1001# ============================================================================= 

1002# Constructor Patching for Code Execution 

1003# ============================================================================= 

1004 

1005@contextmanager 

1006def patch_lazy_constructors(types: Optional[List[Type]] = None): 

1007 """ 

1008 Context manager that patches lazy dataclass constructors to preserve None vs concrete distinction. 

1009 

1010 This is critical for code editors that use exec() to create dataclass instances. 

1011 Without patching, lazy dataclasses would resolve None values to concrete defaults 

1012 during construction, making it impossible to distinguish between explicitly set 

1013 values and inherited values. 

1014 

1015 The patched constructor sets fields provided in kwargs and otherwise uses the 

1016 dataclass defaults/default_factory (or None if none exist). This preserves the 

1017 None vs concrete distinction while still instantiating nested lazy configs. 

1018 

1019 Args: 

1020 types: Optional list of lazy types to patch. If None, uses every type 

1021 in the authoritative lazy-to-base registry. 

1022 

1023 Usage: 

1024 # Patch during code execution 

1025 with patch_lazy_constructors(): 

1026 exec(code_string, namespace) 

1027 # Lazy dataclasses created during exec() will preserve None values 

1028 

1029 Example: 

1030 # Without patching: 

1031 LazyZarrConfig(compression='gzip') # All unspecified fields resolve to defaults 

1032 

1033 # With patching: 

1034 with patch_lazy_constructors(): 

1035 LazyZarrConfig(compression='gzip') # Only compression is set, rest are None 

1036 """ 

1037 lazy_types = ( 

1038 list(_lazy_type_registry) 

1039 if types is None 

1040 else list(types) 

1041 ) 

1042 

1043 if not lazy_types: 

1044 # No types to patch - just yield 

1045 yield 

1046 return 

1047 

1048 # Store original constructors 

1049 original_constructors: Dict[Type, callable] = {} 

1050 

1051 # Patch all lazy types 

1052 for lazy_type in lazy_types: 

1053 # Store original constructor 

1054 original_constructors[lazy_type] = lazy_type.__init__ 

1055 

1056 # Create patched constructor that uses raw values 

1057 def create_patched_init(dataclass_type, original_init): 

1058 def patched_init(self, **kwargs): 

1059 # Materialize the raw constructor payload without reading fields 

1060 # through lazy ``__getattribute__``. The nominal constructor must 

1061 # still run: it owns dataclass ``__post_init__`` validation and any 

1062 # other declaration-level invariants. 

1063 raw_values = {} 

1064 for field_definition in dataclasses.fields(dataclass_type): 

1065 if field_definition.name in kwargs: 

1066 value = kwargs[field_definition.name] 

1067 elif field_definition.default_factory is not dataclasses.MISSING: # type: ignore 

1068 value = field_definition.default_factory() 

1069 elif field_definition.default is not dataclasses.MISSING: 

1070 value = field_definition.default 

1071 else: 

1072 value = None 

1073 

1074 raw_values[field_definition.name] = value 

1075 

1076 original_init(self, **raw_values) 

1077 

1078 # The generated lazy constructor tracks every received keyword. 

1079 # Restore the authored subset because the remaining raw values were 

1080 # supplied only to preserve inheritance during reconstruction. 

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

1082 

1083 return patched_init 

1084 

1085 # Apply the patch 

1086 lazy_type.__init__ = create_patched_init( 

1087 lazy_type, 

1088 original_constructors[lazy_type], 

1089 ) 

1090 

1091 try: 

1092 yield 

1093 finally: 

1094 # Restore original constructors 

1095 for lazy_type, original_init in original_constructors.items(): 

1096 lazy_type.__init__ = original_init 

1097 

1098 

1099# ContextProvider infrastructure removed - was dead code feeding broken frame.f_locals manipulation 

1100 

1101 

1102 

1103 

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

1105 """ 

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

1107 

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

1109 The context provides the hierarchy for lazy resolution. 

1110 

1111 How it works: 

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

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

1114 3. Convert resolved values to base config for pickling 

1115 

1116 Example (from README.md): 

1117 with config_context(orchestrator.pipeline_config): 

1118 # Lazy resolution happens here via context 

1119 resolved_steps = resolve_lazy_configurations_for_serialization(steps) 

1120 """ 

1121 # Check if this is a lazy dataclass 

1122 base_type = get_base_type_for_lazy(type(data)) 

1123 if base_type is not None: 

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

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

1126 resolved_fields = {} 

1127 for f in fields(data): 

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

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

1130 resolved_value = getattr(data, f.name) 

1131 resolved_fields[f.name] = resolved_value 

1132 

1133 # Create base config instance with resolved values 

1134 resolved_data = base_type(**resolved_fields) 

1135 else: 

1136 # Not a lazy dataclass 

1137 resolved_data = data 

1138 

1139 # Recursively process nested structures based on type 

1140 if is_dataclass(resolved_data) and not isinstance(resolved_data, type): 

1141 # Process dataclass fields recursively 

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

1143 resolved_fields = {} 

1144 for f in fields(resolved_data): 

1145 field_value = getattr(resolved_data, f.name) 

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

1147 resolved_fields[f.name] = resolve_lazy_configurations_for_serialization(field_value) 

1148 cls = type(resolved_data) 

1149 

1150 # Optional hook: allow dataclasses with custom constructors to control rebuild. 

1151 rebuild = getattr(cls, "__objectstate_rebuild__", None) 

1152 if callable(rebuild): 

1153 return rebuild(**resolved_fields) 

1154 

1155 # Default: try normal construction, then fall back to field-wise construction. 

1156 try: 

1157 return cls(**resolved_fields) 

1158 except TypeError: 

1159 obj = cls.__new__(cls) 

1160 for k, v in resolved_fields.items(): 

1161 object.__setattr__(obj, k, v) 

1162 return obj 

1163 

1164 elif isinstance(resolved_data, dict): 

1165 # Process dictionary values recursively 

1166 return { 

1167 key: resolve_lazy_configurations_for_serialization(value) 

1168 for key, value in resolved_data.items() 

1169 } 

1170 

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

1172 # Process sequence elements recursively 

1173 resolved_items = [resolve_lazy_configurations_for_serialization(item) for item in resolved_data] 

1174 return type(resolved_data)(resolved_items) 

1175 

1176 else: 

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

1178 return resolved_data 

1179 

1180 

1181# Generic dataclass editing with configurable value preservation 

1182T = TypeVar('T') 

1183 

1184 

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

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

1187 if not is_dataclass(dataclass_type): 

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

1189 

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

1191 if context_provider: 

1192 context_provider(source_config) 

1193 

1194 field_values = { 

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

1196 else f.type() if is_dataclass(f.type) and has_lazy_resolution(f.type) 

1197 else None) 

1198 for f in fields(dataclass_type) 

1199 } 

1200 

1201 return dataclass_type(**field_values) 

1202 

1203 

1204 

1205 

1206 

1207# Declarative Global Config Field Injection System 

1208# Moved inline imports to top-level 

1209 

1210# Naming configuration 

1211GLOBAL_CONFIG_PREFIX = "Global" 

1212 

1213@dataclass(frozen=True) 

1214class ConfigInjection: 

1215 """An injected config owns its annotation, default and dataclass field tuple.""" 

1216 

1217 config_class: Type 

1218 field_name: str 

1219 nullable: bool = False 

1220 ui_hidden: bool = False 

1221 

1222 def field_tuple(self) -> Tuple[str, object, dataclasses.Field]: 

1223 metadata = {'ui_hidden': self.ui_hidden} 

1224 if self.nullable: 

1225 return self.field_name, Optional[self.config_class], field(default=None, metadata=metadata) 

1226 return self.field_name, self.config_class, field(default_factory=self.config_class, metadata=metadata) 

1227 

1228 

1229_pending_injections: Dict[Type, List[ConfigInjection]] = {} 

1230 

1231# Preview label registry: Type -> label string 

1232# Used by UI to auto-discover which configs should appear in list item previews 

1233PREVIEW_LABEL_REGISTRY: Dict[Type, str] = {} 

1234 

1235# Field abbreviations registry: Type -> {field_name: abbreviation} 

1236# Used by UI to display compact field names in list item previews 

1237FIELD_ABBREVIATIONS_REGISTRY: Dict[Type, Dict[str, str]] = {} 

1238 

1239# Class attribute holding a config class's own group abbreviation. The class 

1240# is the only owner; get_group_abbreviation reads it on demand. 

1241GROUP_ABBREVIATION_ATTRIBUTE = "_abbreviation" 

1242 

1243# Always viewable fields registry: Type -> List[field_name] 

1244# Used by UI to auto-discover which fields should always be shown in list item previews 

1245# regardless of the specific widget's PREVIEW_FIELD_CONFIGS. 

1246# This allows config types to declare their own preview fields declaratively. 

1247ALWAYS_VIEWABLE_FIELDS_REGISTRY: Dict[Type, List[str]] = {} 

1248 

1249 

1250class abbreviation: 

1251 """ 

1252 Decorator/marker for abbreviations in config classes. 

1253 

1254 Can be used as: 

1255 1. Class decorator: @abbreviation('wfc') to set class abbreviation 

1256 2. In Annotated types: Annotated[Type, abbreviation('pf')] for field abbreviation 

1257 

1258 Examples: 

1259 @abbreviation('wfc') 

1260 @global_pipeline_config 

1261 @dataclass 

1262 class PartitionFilterConfig: 

1263 partition_filter: Annotated[Optional[int], abbreviation('pf')] = None 

1264 partition_filter_mode: Annotated[ 

1265 type[PartitionFilterMode], abbreviation('pfm') 

1266 ] = IncludePartitions 

1267 

1268 Args: 

1269 name: The abbreviation string 

1270 

1271 Returns: 

1272 When used as class decorator: the class, declaring its group abbreviation 

1273 When used in Annotated: a marker object storing the abbreviation 

1274 """ 

1275 

1276 def __init__(self, name: str): 

1277 self.name = name 

1278 

1279 def __call__(self, cls: Type) -> Type: 

1280 """Class decorator usage: @abbreviation('wfc')""" 

1281 declare_group_abbreviation(cls, self.name) 

1282 return cls 

1283 

1284 def __repr__(self) -> str: 

1285 return f"abbreviation({self.name!r})" 

1286 

1287 

1288def _extract_abbreviations_from_annotations(cls: Type) -> Dict[str, str]: 

1289 """ 

1290 Extract field abbreviations from Annotated type hints. 

1291  

1292 Scans class annotations for fields using Annotated[..., abbreviation('...')] 

1293 and returns a dict mapping field names to abbreviations. 

1294  

1295 Walks the MRO from most-specific to least-specific, so child class 

1296 annotations override parent class annotations (provenance wins). 

1297  

1298 Args: 

1299 cls: The class to scan 

1300  

1301 Returns: 

1302 Dict mapping field names to abbreviation strings 

1303 """ 

1304 from typing import get_origin, get_args 

1305 

1306 field_abbreviations = {} 

1307 

1308 # Walk MRO from most-specific to least-specific 

1309 # This ensures child class annotations override parent class annotations 

1310 for klass in cls.__mro__: 

1311 if klass is object: 

1312 continue 

1313 

1314 # Get this class's own annotations (not inherited) 

1315 annotations = inspect.get_annotations(klass, eval_str=False) 

1316 

1317 for field_name, field_type in annotations.items(): 

1318 # Check if this is an Annotated type 

1319 origin = get_origin(field_type) 

1320 if origin is not None: 

1321 # Get the args - first is the actual type, rest are metadata 

1322 args = get_args(field_type) 

1323 if len(args) > 1: 

1324 # Check metadata for abbreviation markers 

1325 for metadata in args[1:]: 

1326 if isinstance(metadata, abbreviation): 

1327 # Child class definitions override parent class 

1328 # because we walk MRO most-specific first 

1329 field_abbreviations[field_name] = metadata.name 

1330 break 

1331 

1332 return field_abbreviations 

1333 

1334 

1335def declare_group_abbreviation(config_type: type, name: str) -> None: 

1336 """Declare a config class's own group abbreviation. 

1337 

1338 A lazy class has no abbreviation of its own: the declaration lands on its 

1339 base class. A second, different declaration for one class raises. 

1340 """ 

1341 declaring_type = get_base_type_for_lazy(config_type) or config_type 

1342 declared = vars(declaring_type).get(GROUP_ABBREVIATION_ATTRIBUTE) 

1343 if declared is not None and declared != name: 

1344 raise ValueError( 

1345 f"{declaring_type.__qualname__} already declares abbreviation " 

1346 f"{declared!r}; cannot also declare {name!r}." 

1347 ) 

1348 setattr(declaring_type, GROUP_ABBREVIATION_ATTRIBUTE, name) 

1349 

1350 

1351def get_group_abbreviation(config_type: str | type) -> str: 

1352 """Read a config class's group abbreviation from its declaration. 

1353 

1354 A lazy class answers with its base class's declaration. A class with no 

1355 declaration of its own answers with its nearest declaring ancestor, then 

1356 with the prefix of its class name. A type name string answers with its 

1357 prefix. 

1358 """ 

1359 if isinstance(config_type, str): 

1360 return config_type.split('_')[0] if config_type else "root" 

1361 

1362 declaring_type = get_base_type_for_lazy(config_type) or config_type 

1363 for ancestor in declaring_type.__mro__: 

1364 declared = vars(ancestor).get(GROUP_ABBREVIATION_ATTRIBUTE) 

1365 if declared is not None: 

1366 return declared 

1367 return config_type.__name__.split('_')[0] 

1368 

1369 

1370def create_global_default_decorator(target_config_class: Type): 

1371 """ 

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

1373 

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

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

1376 """ 

1377 _pending_injections.setdefault(target_config_class, []) 

1378 

1379 def global_default_decorator(cls=None, *, optional: bool = False, inherit_as_none: bool = True, ui_hidden: bool = False, preview_label: Optional[str] = None, abbreviation: Optional[str] = None, field_abbreviations: Optional[Dict[str, str]] = None, always_viewable_fields: Optional[List[str]] = None): 

1380 """ 

1381 Decorator that can be used with or without parameters. 

1382 

1383 Args: 

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

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

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

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

1388 preview_label: Short label for list item previews (e.g., "NAP", "FIJI", "MAT"). If set, 

1389 config will appear in preview when enabled. Registered in PREVIEW_LABEL_REGISTRY. 

1390 abbreviation: Short abbreviation for config class name used in grouped previews (e.g., "pp" for PathPlanningConfig). 

1391 Declared on the class; see declare_group_abbreviation. 

1392 field_abbreviations: Dict mapping field names to abbreviations for compact display. 

1393 E.g., {'partition_filter': 'pf', 'num_workers': 'W'}. Registered in 

1394 FIELD_ABBREVIATIONS_REGISTRY. 

1395 always_viewable_fields: List of field names that should always be shown in list item previews 

1396 for this config type. E.g., ['enabled', 'persistent']. Registered in ALWAYS_VIEWABLE_FIELDS_REGISTRY. 

1397 """ 

1398 def decorator(actual_cls): 

1399 # UNIFIED NONE-FORCING: Single make_dataclass rebuild instead of old 3-stage approach 

1400 if inherit_as_none: 

1401 # Rebuild class with None defaults for inherited fields 

1402 # This replaces the old pre-process setattr + post-process Field patching 

1403 inherited_fields = get_inherited_field_names(actual_cls) 

1404 if inherited_fields: 

1405 actual_cls = rebuild_with_none_defaults(actual_cls, inherited_fields) 

1406 

1407 field_name = _camel_to_snake(actual_cls.__name__) 

1408 

1409 # Mark class with typed ui_hidden metadata for UI layer to check 

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

1411 # while being hidden from UI rendering 

1412 if ui_hidden: 

1413 mark_ui_hidden_config(actual_cls) 

1414 

1415 # Register preview label for UI list item previews 

1416 # Allows ABC to auto-discover which configs should appear in preview 

1417 if preview_label is not None: 

1418 PREVIEW_LABEL_REGISTRY[actual_cls] = preview_label 

1419 

1420 # The class owns its group abbreviation; @abbreviation writes the 

1421 # same declaration, so the two decorators may come in either order. 

1422 if abbreviation is not None: 

1423 declare_group_abbreviation(actual_cls, abbreviation) 

1424 

1425 # Register field abbreviations for compact preview display 

1426 # Priority: explicit parameter > @abbreviation in Annotated types 

1427 detected_field_abbrs = _extract_abbreviations_from_annotations(actual_cls) 

1428 final_field_abbrs = {**detected_field_abbrs, **(field_abbreviations or {})} 

1429 logger.debug(f"🔍 Field abbrs detected={detected_field_abbrs}, explicit={field_abbreviations}, final={final_field_abbrs}") 

1430 if final_field_abbrs: 

1431 FIELD_ABBREVIATIONS_REGISTRY[actual_cls] = final_field_abbrs 

1432 

1433 # Register always viewable fields for this config type 

1434 # These fields will always be shown in list item previews regardless of widget config 

1435 if always_viewable_fields: 

1436 ALWAYS_VIEWABLE_FIELDS_REGISTRY[actual_cls] = always_viewable_fields 

1437 logger.debug(f"🔍 Registered always_viewable_fields for {actual_cls.__name__}: {always_viewable_fields}") 

1438 

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

1440 # Abstract classes should NEVER be injected into GlobalPipelineConfig 

1441 # because they can't be instantiated 

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

1443 # Concrete subclasses of abstract classes should still be injected 

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

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

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

1447 

1448 # Add to pending injections for field injection 

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

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

1451 if not is_abstract: 

1452 _pending_injections[target_config_class].append( 

1453 ConfigInjection(actual_cls, field_name, nullable=optional, ui_hidden=ui_hidden)) 

1454 

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

1456 

1457 

1458 # Declare contextual resolution on the concrete type before deriving 

1459 # its lazy form. The nominal relationship, not a marker attribute, 

1460 # is the capability contract used by the factory and its consumers. 

1461 bind_lazy_resolution_to_class(actual_cls) 

1462 

1463 lazy_class = LazyDataclassFactory.make_lazy_simple(actual_cls) 

1464 

1465 # Export lazy class to config module immediately 

1466 config_module = sys.modules[actual_cls.__module__] 

1467 setattr(config_module, lazy_class.__name__, lazy_class) 

1468 

1469 # Copy metadata to lazy class for UI compatibility 

1470 if ui_hidden: 

1471 mark_ui_hidden_config(lazy_class) 

1472 if preview_label is not None: 

1473 PREVIEW_LABEL_REGISTRY[lazy_class] = preview_label 

1474 # Copy field abbreviations to lazy class (from explicit param or detected from Annotated) 

1475 field_abbr_to_copy = field_abbreviations or detected_field_abbrs 

1476 if field_abbr_to_copy: 

1477 FIELD_ABBREVIATIONS_REGISTRY[lazy_class] = field_abbr_to_copy 

1478 

1479 # Copy always_viewable_fields to lazy class 

1480 if always_viewable_fields: 

1481 ALWAYS_VIEWABLE_FIELDS_REGISTRY[lazy_class] = always_viewable_fields 

1482 

1483 # Note: No Stage 3 post-processing needed! 

1484 # - Base class: rebuilt via rebuild_with_none_defaults() above 

1485 # - Lazy class: _introspect_dataclass_fields() already sets None defaults 

1486 

1487 return actual_cls 

1488 

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

1490 if cls is None: 

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

1492 return decorator 

1493 else: 

1494 # Called without parentheses: @decorator 

1495 return decorator(cls) 

1496 

1497 return global_default_decorator 

1498 

1499 

1500def _inject_all_pending_fields(): 

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

1502 for target_class, configs in _pending_injections.items(): 

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

1504 _inject_multiple_fields_into_dataclass(target_class, configs) 

1505 

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

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

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

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

1510 

1511def _inject_multiple_fields_into_dataclass(target_class: Type, configs: List[ConfigInjection]) -> None: 

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

1513 # Imports moved to top-level 

1514 

1515 annotations = _resolved_dataclass_annotations(target_class) 

1516 existing_fields = [ 

1517 (f.name, annotations.get(f.name, f.type), copy.copy(f)) 

1518 for f in fields(target_class) 

1519 ] 

1520 

1521 all_fields = existing_fields + [config.field_tuple() for config in configs] 

1522 

1523 # Direct dataclass recreation - fail-loud 

1524 new_class = make_dataclass( 

1525 target_class.__name__, 

1526 all_fields, 

1527 bases=target_class.__bases__, 

1528 namespace=_recreated_dataclass_namespace( 

1529 target_class, 

1530 target_class.__name__, 

1531 ), 

1532 frozen=target_class.__dataclass_params__.frozen 

1533 ) 

1534 

1535 _restore_recreated_dataclass_metadata(new_class, target_class) 

1536 

1537 # Preserve global-config registration across dataclass recreation. 

1538 # Registration is set by @auto_create_decorator but lost when make_dataclass creates a new class. 

1539 if is_global_config_type(target_class): 

1540 mark_global_config_type(new_class) 

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

1542 

1543 # Direct module replacement 

1544 module = sys.modules[target_class.__module__] 

1545 setattr(module, target_class.__name__, new_class) 

1546 globals()[target_class.__name__] = new_class 

1547 

1548 # Mathematical simplification: Extract common module assignment pattern 

1549 def _register_lazy_class(lazy_class, class_name, module_name): 

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

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

1552 globals()[class_name] = lazy_class 

1553 

1554 # Create lazy classes and recreate PipelineConfig inline 

1555 for config in configs: 

1556 lazy_class = LazyDataclassFactory.make_lazy_simple(config.config_class) 

1557 _register_lazy_class(lazy_class, lazy_class.__name__, config.config_class.__module__) 

1558 

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

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

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

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

1563 

1564 # Remove global prefix (GlobalPipelineConfig → PipelineConfig) 

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

1566 

1567 lazy_global_class = LazyDataclassFactory.make_lazy_simple( 

1568 base_class=new_class, 

1569 lazy_class_name=lazy_global_class_name 

1570 ) 

1571 

1572 # Use extracted helper for consistent registration 

1573 _register_lazy_class(lazy_global_class, lazy_global_class_name, target_class.__module__) 

1574 

1575 

1576 

1577 

1578 

1579def auto_create_decorator(global_config_class): 

1580 """ 

1581 Decorator that automatically creates: 

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

1583 2. A lazy version of the global config itself 

1584 

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

1586 """ 

1587 # Validate naming convention 

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

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

1590 

1591 # Mark this class as a global config for isinstance checks via GlobalConfigBase. 

1592 mark_global_config_type(global_config_class) 

1593 

1594 decorator_name = _camel_to_snake(global_config_class.__name__) 

1595 decorator = create_global_default_decorator(global_config_class) 

1596 

1597 # Export decorator to module globals 

1598 module = sys.modules[global_config_class.__module__] 

1599 setattr(module, decorator_name, decorator) 

1600 

1601 # Lazy global config will be created after field injection 

1602 

1603 return global_config_class