Coverage for src/objectstate/context_manager.py: 69%

310 statements  

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

1""" 

2Generic contextvars-based context management system for lazy configuration. 

3 

4This module provides explicit context scoping using Python's contextvars to enable 

5hierarchical configuration resolution without explicit parameter passing. 

6 

7Key features: 

81. Explicit context scoping with config_context() manager 

92. Config extraction from functions, dataclasses, and objects 

103. Config merging for context hierarchy 

114. Clean separation between UI windows and contexts 

12 

13Key components: 

14- current_temp_global: ContextVar holding current merged global config 

15- config_context(): Context manager for creating context scopes 

16- merge_configs(): Merge overrides into base config 

17""" 

18 

19import contextvars 

20import functools 

21import logging 

22import threading 

23from collections.abc import Sequence 

24from contextlib import ExitStack, contextmanager 

25from typing import Any, Dict, get_type_hints 

26from dataclasses import fields, is_dataclass 

27from python_introspect import resolve_annotated, resolve_optional 

28 

29logger = logging.getLogger(__name__) 

30 

31 

32class NoActiveContextError(RuntimeError): 

33 """A field was resolved against the active context while no context was active.""" 

34 

35 

36# Core contextvar for current merged global config 

37# This holds the current context state that resolution functions can access 

38current_temp_global = contextvars.ContextVar('current_temp_global') 

39 

40# Context type stack - tracks the types of objects pushed via config_context() 

41# This enables generic hierarchy inference without hardcoding specific types 

42# The stack is a tuple of types, ordered from outermost to innermost context 

43context_type_stack = contextvars.ContextVar('context_type_stack', default=()) 

44 

45class ContextLayer: 

46 """One object entered with ``config_context``, with the scope it was entered under. 

47 

48 ``scope_id`` is ``None`` for a layer entered without one. ``masks_outer_layers`` 

49 is True for a layer entered with ``mask_with_none``: a ``None`` in it is final, 

50 so no layer outside it supplies a value. 

51 """ 

52 

53 __slots__ = ("scope_id", "obj", "masks_outer_layers", "_configs") 

54 

55 def __init__(self, scope_id: str | None, obj: Any, masks_outer_layers: bool = False): 

56 self.scope_id = scope_id 

57 self.obj = obj 

58 self.masks_outer_layers = masks_outer_layers 

59 self._configs: dict[type, Any] | None = None 

60 

61 @property 

62 def configs(self) -> dict[type, Any]: 

63 """The config instances this layer holds, by type. Extracted once per layer.""" 

64 configs = self._configs 

65 if configs is None: 

66 configs = self._configs = extract_all_configs(self.obj) 

67 return configs 

68 

69 def __repr__(self) -> str: 

70 return f"ContextLayer({self.scope_id!r}, {type(self.obj).__name__})" 

71 

72 

73class ContextLayerStack: 

74 """The layers of one active context, outermost first. 

75 

76 This is the value field resolution reads. Every ``config_context`` entry makes a 

77 new stack, and ``resolved_fields`` holds the answers computed from that stack, so 

78 the answers are dropped together with the stack they belong to. 

79 """ 

80 

81 __slots__ = ("layers", "resolved_fields", "__weakref__") 

82 

83 def __init__(self, layers: tuple[ContextLayer, ...] = ()): 

84 self.layers = layers 

85 self.resolved_fields: dict[tuple[type, str], Any] = {} 

86 

87 def with_layer(self, layer: ContextLayer) -> "ContextLayerStack": 

88 return ContextLayerStack(self.layers + (layer,)) 

89 

90 def __repr__(self) -> str: 

91 return f"ContextLayerStack({self.layers!r})" 

92 

93 

94NO_LAYERS = ContextLayerStack() 

95 

96# The layer stack of the active context. NO_LAYERS means no context is active. 

97context_layer_stack: contextvars.ContextVar[ContextLayerStack] = contextvars.ContextVar( 

98 'context_layer_stack', default=NO_LAYERS 

99) 

100 

101def _merge_nested_dataclass(base, override, mask_with_none: bool = False): 

102 """ 

103 Recursively merge nested dataclass fields. 

104 

105 For each field in override: 

106 - If value is None and mask_with_none=False: skip (don't override base) 

107 - If value is None and mask_with_none=True: override with None (mask base) 

108 - If value is dataclass: recursively merge with base's value 

109 - Otherwise: use override value 

110 

111 Args: 

112 base: Base dataclass instance 

113 override: Override dataclass instance 

114 mask_with_none: If True, None values override base values 

115 

116 Returns: 

117 Merged dataclass instance 

118 """ 

119 if not is_dataclass(base) or not is_dataclass(override): 

120 return override 

121 

122 from objectstate.lazy_factory import get_base_type_for_lazy, replace_raw 

123 

124 base_type = get_base_type_for_lazy(type(base)) or type(base) 

125 override_type = get_base_type_for_lazy(type(override)) or type(override) 

126 # Lazy overlays of the same owner inherit on the base instance. An authored 

127 # nominal change instead owns the result, including subtype-only fields. 

128 result_owner = base if base_type is override_type else override 

129 base_fields = {field_info.name for field_info in fields(base)} 

130 merge_values = {} 

131 for field_info in fields(override): 

132 field_name = field_info.name 

133 override_value = object.__getattribute__(override, field_name) 

134 if field_name not in base_fields: 

135 # This field belongs only to the authored nominal owner. 

136 continue 

137 base_value = object.__getattribute__(base, field_name) 

138 

139 if override_value is None: 

140 if mask_with_none: 

141 # None overrides base value (masking mode) 

142 merge_values[field_name] = None 

143 else: 

144 # None means inherit, including when the result owner changes. 

145 if result_owner is override: 

146 merge_values[field_name] = base_value 

147 elif is_dataclass(override_value): 

148 # Recursively merge nested dataclass 

149 if base_value is not None and is_dataclass(base_value): 

150 merge_values[field_name] = _merge_nested_dataclass(base_value, override_value, mask_with_none) 

151 else: 

152 merge_values[field_name] = override_value 

153 else: 

154 # Concrete value - use override 

155 merge_values[field_name] = override_value 

156 

157 # Merge on the nominal owner using replace_raw to preserve None values 

158 # (dataclasses.replace triggers lazy resolution, baking in resolved values) 

159 if merge_values: 

160 return replace_raw(result_owner, **merge_values) 

161 else: 

162 return result_owner 

163 

164 

165@contextmanager 

166def config_context( 

167 obj, 

168 mask_with_none: bool = False, 

169 use_live_global: bool = True, 

170 scope_id: str | None = None, 

171): 

172 """ 

173 Create new context scope with obj's matching fields merged into base config. 

174 

175 This is the universal context manager for all config context needs. It works by: 

176 1. Finding fields that exist on both obj and the base config type 

177 2. Using matching field values to create a temporary merged config 

178 3. Setting that as the current context 

179 

180 Args: 

181 obj: Object with config fields (pipeline_config, step, etc.) 

182 mask_with_none: If True, None values override/mask base config values, and 

183 field resolution does not look at any layer outside this one. 

184 If False (default), None values are ignored (normal inheritance). 

185 Use True when editing GlobalPipelineConfig to mask the 

186 loaded instance with static class defaults. 

187 use_live_global: If True (default), use LIVE global config (UI sees unsaved edits). 

188 If False, use SAVED global config (compiler sees saved values). 

189 scope_id: The scope this layer belongs to (e.g., "plate_123::step_0"). Field 

190 resolution reports it as the source scope of every value the layer 

191 supplies. A layer entered without one supplies values all the same and 

192 is reported with source scope ``None``. 

193 

194 Usage: 

195 # UI operations (default - uses LIVE) 

196 with config_context(orchestrator.pipeline_config): 

197 # UI sees unsaved global config edits 

198 

199 # Compilation (explicit - uses SAVED) 

200 with config_context(orchestrator.pipeline_config, use_live_global=False): 

201 # Compiler uses saved global config only 

202 

203 # With a scope id 

204 with config_context(step, scope_id="plate_123::step_0"): 

205 # Values this layer supplies are reported as coming from that scope 

206 """ 

207 # Get current context as base for nested contexts, or fall back to base global config 

208 current_context = get_current_temp_global() 

209 base_config = current_context if current_context is not None else get_base_global_config(use_live=use_live_global) 

210 

211 # Find matching fields between obj and base config type 

212 overrides = {} 

213 if obj is not None: 

214 from objectstate.config import get_base_config_type 

215 

216 base_config_type = get_base_config_type() 

217 

218 for field_info in fields(base_config_type): 

219 field_name = field_info.name 

220 expected_type = field_info.type 

221 

222 # Check if obj has this field 

223 try: 

224 # Use object.__getattribute__ to avoid triggering lazy resolution 

225 if hasattr(obj, field_name): 

226 value = object.__getattribute__(obj, field_name) 

227 # CRITICAL: When mask_with_none=True, None values override base config 

228 # This allows static defaults to mask loaded instance values 

229 if value is not None or mask_with_none: 

230 # When masking with None, always include the value (even if None) 

231 if mask_with_none: 

232 # For nested dataclasses, merge with mask_with_none=True 

233 if is_dataclass(value): 

234 base_value = getattr(base_config, field_name, None) 

235 if base_value is not None and is_dataclass(base_value): 

236 merged_nested = _merge_nested_dataclass(base_value, value, mask_with_none=True) 

237 overrides[field_name] = merged_nested 

238 else: 

239 overrides[field_name] = value 

240 else: 

241 overrides[field_name] = value 

242 # Normal mode: only include non-None values 

243 elif value is not None: 

244 # Check if value is compatible (handles lazy-to-base type mapping) 

245 if _is_compatible_config_type(value, expected_type): 

246 # CRITICAL FIX: Do NOT call to_base_config() here! 

247 # to_base_config() passes None values to base class constructor, 

248 # but frozen dataclasses with non-Optional defaults substitute the 

249 # default value for None. This breaks cross-hierarchy inheritance. 

250 # Example: LazyPartitionFilterConfig(partition_filter_mode=None) 

251 # becomes PartitionFilterConfig(partition_filter_mode=INCLUDE) 

252 # instead of keeping None. 

253 # 

254 # Instead, pass lazy dataclass directly to _merge_nested_dataclass, 

255 # which uses object.__getattribute__ to get raw values and correctly 

256 # skips None overrides. 

257 

258 # Recursively merge nested dataclass fields 

259 if is_dataclass(value): 

260 base_value = getattr(base_config, field_name, None) 

261 if base_value is not None and is_dataclass(base_value): 

262 merged_nested = _merge_nested_dataclass(base_value, value, mask_with_none=False) 

263 overrides[field_name] = merged_nested 

264 else: 

265 # No base value to merge with - convert if needed 

266 if hasattr(value, 'to_base_config'): 

267 value = value.to_base_config() 

268 overrides[field_name] = value 

269 else: 

270 # Non-dataclass field, use override as-is 

271 overrides[field_name] = value 

272 except AttributeError: 

273 continue 

274 

275 # Create merged config if we have overrides 

276 # Use replace_raw to preserve None values (dataclasses.replace triggers lazy resolution) 

277 if overrides: 

278 from objectstate.lazy_factory import replace_raw 

279 merged_config = replace_raw(base_config, **overrides) 

280 else: 

281 merged_config = base_config 

282 

283 # Track the type in the context type stack 

284 current_types = context_type_stack.get() 

285 new_types = current_types + (type(obj),) if obj is not None else current_types 

286 

287 # A context entered while none is active starts from the global config, so the 

288 # global config is its outermost layer. 

289 if current_context is None: 

290 current_stack = ContextLayerStack((ContextLayer("", base_config),)) 

291 else: 

292 current_stack = context_layer_stack.get() 

293 new_stack = ( 

294 current_stack.with_layer(ContextLayer(scope_id, obj, mask_with_none)) 

295 if obj is not None 

296 else current_stack 

297 ) 

298 

299 merged_token = current_temp_global.set(merged_config) 

300 type_token = context_type_stack.set(new_types) 

301 layer_token = context_layer_stack.set(new_stack) 

302 try: 

303 yield 

304 finally: 

305 current_temp_global.reset(merged_token) 

306 context_type_stack.reset(type_token) 

307 context_layer_stack.reset(layer_token) 

308 

309 

310def get_context_type_stack(): 

311 """ 

312 Get the current stack of context types (outermost to innermost). 

313 

314 This enables generic hierarchy inference without hardcoding specific types. 

315 The stack represents the order in which config_context() calls were nested. 

316 

317 Returns: 

318 Tuple of types representing the context hierarchy. 

319 Empty tuple if no context is active. 

320 

321 Example: 

322 with config_context(global_config): # stack = (GlobalPipelineConfig,) 

323 with config_context(pipeline_config): # stack = (GlobalPipelineConfig, PipelineConfig) 

324 with config_context(step): # stack = (GlobalPipelineConfig, PipelineConfig, Step) 

325 types = get_context_type_stack() 

326 # types == (GlobalPipelineConfig, PipelineConfig, Step) 

327 """ 

328 return context_type_stack.get() 

329 

330 

331def get_context_layer_stack() -> ContextLayerStack: 

332 """ 

333 Get the layer stack of the active context: the value field resolution reads. 

334 

335 ``.layers`` holds one ``ContextLayer`` per object entered with ``config_context``, 

336 outermost first, below the global config the context started from. It is empty 

337 when no context is active. 

338 

339 Example: 

340 with config_context(plate, scope_id="plate_123"): 

341 with config_context(step, scope_id="plate_123::step_0"): 

342 scopes = [layer.scope_id for layer in get_context_layer_stack().layers] 

343 # scopes == ["", "plate_123", "plate_123::step_0"] 

344 """ 

345 return context_layer_stack.get() 

346 

347 

348@functools.lru_cache(maxsize=None) 

349def _normalize_type(t): 

350 """Normalize a type by getting its base type if it's a lazy variant. 

351 

352 This function is defined here to avoid circular imports with lazy_factory. 

353 The actual get_base_type_for_lazy is imported lazily when needed. 

354 

355 PERFORMANCE: Results are cached with lru_cache since type->base_type 

356 mappings are immutable after class creation. 

357 """ 

358 from objectstate.lazy_factory import get_base_type_for_lazy 

359 return get_base_type_for_lazy(t) or t 

360 

361 

362def _is_global_type(t): 

363 """Check if a type is a global config type. 

364 

365 This function is defined here to avoid circular imports with lazy_factory. 

366 """ 

367 from objectstate.lazy_factory import is_global_config_type 

368 return is_global_config_type(t) 

369 

370 

371# Hierarchy registry - built from active form managers 

372# Maps: child_type -> parent_type (normalized base types) 

373# This is populated by ParameterFormManager when it registers 

374_known_hierarchy: dict = {} 

375 

376 

377def _normalize_type_for_hierarchy(t): 

378 """Normalize a type for hierarchy registry, but preserve lazy-global distinction. 

379 

380 Normalization maps lazy types to their base types (e.g., LazyPathPlanningConfig → PathPlanningConfig). 

381 However, PipelineConfig → GlobalPipelineConfig is a special case: they represent DISTINCT 

382 hierarchy levels (pipeline overrides vs global defaults), so we must NOT collapse them. 

383 

384 Rule: If normalization would produce a global type from a non-global type, keep the original. 

385 """ 

386 normalized = _normalize_type(t) 

387 # Don't collapse lazy-to-global - they're distinct hierarchy levels 

388 if _is_global_type(normalized) and not _is_global_type(t): 

389 return t 

390 return normalized 

391 

392 

393def register_hierarchy_relationship(context_obj_type, object_instance_type): 

394 """Register that context_obj_type is the parent of object_instance_type in the hierarchy. 

395 

396 Called by ParameterFormManager when a root manager registers. 

397 This builds up the known hierarchy from actual usage patterns. 

398 

399 Args: 

400 context_obj_type: The parent/context type (e.g., PipelineConfig for Step editor) 

401 object_instance_type: The child type being edited (e.g., Step) 

402 

403 Note: 

404 Types are normalized to base types (e.g., LazyPathPlanningConfig → PathPlanningConfig), 

405 but lazy-to-global normalization is prevented to preserve distinct hierarchy levels 

406 (PipelineConfig stays as PipelineConfig, not collapsed to GlobalPipelineConfig). 

407 """ 

408 if context_obj_type is None or object_instance_type is None: 

409 return 

410 

411 parent_base = _normalize_type_for_hierarchy(context_obj_type) 

412 child_base = _normalize_type_for_hierarchy(object_instance_type) 

413 

414 if parent_base != child_base: 

415 _known_hierarchy[child_base] = parent_base 

416 logger.debug(f"Registered hierarchy: {parent_base.__name__} -> {child_base.__name__}") 

417 

418 

419def unregister_hierarchy_relationship(object_instance_type): 

420 """Remove a type from the hierarchy registry when its editor closes. 

421 

422 Args: 

423 object_instance_type: The type to remove from the registry 

424 """ 

425 child_base = _normalize_type_for_hierarchy(object_instance_type) 

426 if child_base in _known_hierarchy: 

427 del _known_hierarchy[child_base] 

428 logger.debug(f"Unregistered hierarchy for: {child_base.__name__}") 

429 

430 

431def get_ancestors_from_hierarchy(target_type): 

432 """Get all ancestor types for target_type by walking up the known hierarchy. 

433 

434 Returns ancestors in order from outermost to innermost (excluding target_type itself). 

435 

436 Args: 

437 target_type: The type to find ancestors for 

438 

439 Returns: 

440 List of ancestor types in hierarchy order (grandparent, parent, ...) 

441 """ 

442 target_base = _normalize_type_for_hierarchy(target_type) 

443 ancestors = [] 

444 

445 # Walk up the hierarchy 

446 current = target_base 

447 visited = set() 

448 while current in _known_hierarchy: 

449 if current in visited: 

450 # Cycle detected - stop 

451 break 

452 visited.add(current) 

453 parent = _known_hierarchy[current] 

454 ancestors.append(parent) 

455 current = parent 

456 

457 # Reverse so outermost is first 

458 ancestors.reverse() 

459 return ancestors 

460 

461 

462def get_normalized_stack(): 

463 """ 

464 Get the context type stack with normalized (base) types, excluding global configs. 

465 

466 Returns: 

467 List of base types in hierarchy order (outermost to innermost), 

468 with global config types filtered out. 

469 """ 

470 canonical_stack = get_context_type_stack() 

471 normalized = [] 

472 for t in canonical_stack: 

473 base_t = _normalize_type(t) 

474 if not _is_global_type(base_t): 

475 normalized.append(base_t) 

476 return normalized 

477 

478 

479def get_types_before_in_stack(target_type): 

480 """ 

481 Get all non-global types that come before target_type in the hierarchy. 

482 

483 First tries the active context_type_stack (for resolution during config_context), 

484 then falls back to the known hierarchy registry (for cross-window updates). 

485 

486 Args: 

487 target_type: The type to find ancestors for (will be normalized) 

488 

489 Returns: 

490 List of base types that come before target_type in the hierarchy. 

491 Empty list if no ancestors found. 

492 """ 

493 # First try active context stack 

494 normalized_stack = get_normalized_stack() 

495 if normalized_stack: 

496 target_base = _normalize_type(target_type) 

497 

498 # Find target's position 

499 target_index = -1 

500 for i, base_t in enumerate(normalized_stack): 

501 if base_t == target_base: 

502 target_index = i 

503 break 

504 

505 if target_index > 0: 

506 return normalized_stack[:target_index] 

507 

508 # Fall back to known hierarchy registry 

509 return get_ancestors_from_hierarchy(target_type) 

510 

511 

512def is_ancestor_in_context(ancestor_type, descendant_type): 

513 """ 

514 Check if ancestor_type comes before descendant_type in the context hierarchy. 

515 

516 This determines whether changes to ancestor_type should affect descendant_type. 

517 

518 Args: 

519 ancestor_type: The potential ancestor type 

520 descendant_type: The potential descendant type 

521 

522 Returns: 

523 True if ancestor_type is an ancestor of descendant_type, 

524 False otherwise. 

525 """ 

526 from objectstate.lazy_factory import get_base_type_for_lazy 

527 

528 # Check 1: Is ancestor_type the lazy base of descendant_type? 

529 # This handles GlobalPipelineConfig → PipelineConfig relationship 

530 # PipelineConfig is a lazy version of GlobalPipelineConfig 

531 descendant_base = get_base_type_for_lazy(descendant_type) 

532 if descendant_base is not None and descendant_base == ancestor_type: 

533 return True 

534 

535 # Check 2: Normalize for comparison (handles nested lazy configs like LazyPathPlanningConfig) 

536 ancestor_base = _normalize_type(ancestor_type) 

537 descendant_normalized = _normalize_type(descendant_type) 

538 

539 # Check 3: Active context stack (uses normalized types) 

540 normalized_stack = get_normalized_stack() 

541 if normalized_stack: 

542 ancestor_index = -1 

543 descendant_index = -1 

544 for i, base_t in enumerate(normalized_stack): 

545 if base_t == ancestor_base: 

546 ancestor_index = i 

547 if base_t == descendant_normalized: 

548 descendant_index = i 

549 

550 if ancestor_index >= 0 and descendant_index >= 0: 

551 return ancestor_index < descendant_index 

552 

553 # Check 4: Known hierarchy registry (uses actual types, not normalized) 

554 ancestors = get_ancestors_from_hierarchy(descendant_type) 

555 # Check if ancestor_type OR its normalized form is in the ancestor list 

556 return ancestor_type in ancestors or ancestor_base in [_normalize_type(a) for a in ancestors] 

557 

558 

559def is_same_type_in_context(type_a, type_b): 

560 """ 

561 Check if two types are the same when normalized. 

562 

563 Handles lazy vs base type equivalence. 

564 

565 Args: 

566 type_a: First type to compare 

567 type_b: Second type to compare 

568 

569 Returns: 

570 True if both types normalize to the same base type. 

571 """ 

572 return _normalize_type(type_a) == _normalize_type(type_b) 

573 

574 

575# ============================================================================ 

576# Context Stack Building (for UI placeholder resolution) 

577# ============================================================================ 

578 

579 

580@contextmanager 

581def build_context_stack( 

582 object_instance: object, 

583 ancestor_objects_with_scopes: Sequence[tuple[str, object]] = (), 

584 current_scope_id: str | None = None, 

585 use_live: bool = True, 

586): 

587 """ 

588 Enter the complete layer stack that resolves ``object_instance``; leave it on exit. 

589 

590 The stack is built from the arguments and the global config alone. A context the 

591 caller has entered is set aside for the duration of the block and is active again 

592 after it: none of its layers is below, between or above the layers entered here. 

593 This is how an ``ObjectState`` resolves, so a state's resolved values depend on 

594 its object, the states registered above it and the global config, and never on 

595 where the read was made. A lazy object that no state owns is resolved by plain 

596 attribute access against whatever ``config_context`` is entered. 

597 

598 Layers, outermost first: 

599 1. The global config, under scope ``""``. When ``object_instance`` is itself a 

600 global config, a fresh instance of its type holding the declared defaults is 

601 entered above it, also under scope ``""``, and masks it. 

602 2. Each ancestor, under its own scope id. Global-config ancestors are left out 

603 because layer 1 already stands for them. 

604 3. ``object_instance``, under ``current_scope_id``. 

605 

606 Nothing is set until the ``with`` block is entered. Any layer that cannot be 

607 built raises, and the layers entered before it are left again. 

608 

609 Args: 

610 object_instance: The object whose fields are being resolved. 

611 ancestor_objects_with_scopes: ``(scope_id, object)`` pairs, least specific first. 

612 current_scope_id: Scope id of ``object_instance``. 

613 use_live: Read the live global config (True) or the saved one (False). 

614 """ 

615 from objectstate.lazy_factory import GlobalConfigBase 

616 

617 def enter(stack: ExitStack, obj: object, scope_id: str | None, mask: bool = False): 

618 stack.enter_context( 

619 config_context(obj, mask_with_none=mask, use_live_global=use_live, scope_id=scope_id) 

620 ) 

621 

622 with ExitStack() as stack: 

623 # With no context active, the first layer entered starts from the global config. 

624 for variable, no_context in ( 

625 (current_temp_global, None), 

626 (context_type_stack, ()), 

627 (context_layer_stack, NO_LAYERS), 

628 ): 

629 stack.callback(variable.reset, variable.set(no_context)) 

630 

631 if isinstance(object_instance, GlobalConfigBase): 

632 enter(stack, type(object_instance)(), "", mask=True) 

633 

634 for scope_id, ancestor_obj in ancestor_objects_with_scopes: 

635 if isinstance(ancestor_obj, GlobalConfigBase): 

636 continue 

637 enter(stack, ancestor_obj, scope_id) 

638 

639 enter(stack, object_instance, current_scope_id) 

640 yield 

641 

642 

643def merge_configs(base, overrides: Dict[str, Any]): 

644 """ 

645 Merge overrides into base config, creating new immutable instance. 

646 

647 A ``None`` override is kept: it means "inherit this field from the parent context". 

648 Raises whatever the config's constructor raises when the overrides are not accepted. 

649 

650 Args: 

651 base: Base config instance (base config type) 

652 overrides: Dict of field_name -> value to override 

653 

654 Returns: 

655 New config instance with overrides applied 

656 """ 

657 if not base or not overrides: 

658 return base 

659 

660 from objectstate.lazy_factory import replace_raw 

661 return replace_raw(base, **overrides) 

662 

663 

664def get_base_global_config(use_live: bool = True): 

665 """ 

666 Get the global config a context starts from when none is active. 

667 

668 This is the global config of the base config type (see ``objectstate.global_config``), 

669 or an instance of that type holding its declared defaults when there is none. 

670 Raises when no base config type is set or its defaults cannot be constructed. 

671 

672 Args: 

673 use_live: If True (default), return LIVE config (UI sees unsaved edits). 

674 If False, return SAVED config (compiler sees saved values). 

675 """ 

676 from objectstate.config import get_base_config_type 

677 from objectstate.global_config import get_live_global_config, get_saved_global_config 

678 

679 base_config_type = get_base_config_type() 

680 

681 read = get_live_global_config if use_live else get_saved_global_config 

682 current_global = read(base_config_type) 

683 if current_global is not None: 

684 return current_global 

685 

686 return base_config_type() 

687 

688 

689def get_current_temp_global(): 

690 """ 

691 Get current context or None. 

692  

693 This is the primary interface for resolution functions to access 

694 the current context. Returns None if no context is active. 

695  

696 Returns: 

697 Current merged global config or None 

698 """ 

699 return current_temp_global.get(None) 

700 

701 

702# Utility functions for debugging and introspection 

703 

704def get_context_info() -> Dict[str, Any]: 

705 """ 

706 Get information about current context for debugging. 

707  

708 Returns: 

709 Dict with context information including type, field count, etc. 

710 """ 

711 current = get_current_temp_global() 

712 if current is None: 

713 return {"active": False} 

714 

715 return { 

716 "active": True, 

717 "type": type(current).__name__, 

718 "field_count": len(fields(current)) if is_dataclass(current) else 0, 

719 "non_none_fields": sum(1 for f in fields(current) 

720 if getattr(current, f.name) is not None) if is_dataclass(current) else 0 

721 } 

722 

723 

724def extract_all_configs_from_context() -> Dict[type, Any]: 

725 """ 

726 Extract all *_config attributes from current context. 

727 

728 This is used by the resolution system to get all available configs 

729 for cross-dataclass inheritance resolution. 

730 

731 Returns: 

732 Dict of config type -> config instance. 

733 """ 

734 current = get_current_temp_global() 

735 if current is None: 

736 return {} 

737 

738 return extract_all_configs(current) 

739 

740 

741@functools.cache 

742def _declared_config_fields(dataclass_type: type) -> tuple[tuple[str, type], ...]: 

743 """The fields of a dataclass type that are declared as dataclass types, with those types. 

744 

745 A fact about the class, so it is kept per class. 

746 """ 

747 annotations = get_type_hints(dataclass_type, include_extras=True) 

748 declared = [] 

749 for field_info in fields(dataclass_type): 

750 field_type = annotations.get(field_info.name, field_info.type) 

751 actual_type = resolve_optional(resolve_annotated(field_type)) 

752 if is_dataclass(actual_type): 

753 declared.append((field_info.name, actual_type)) 

754 return tuple(declared) 

755 

756 

757def extract_all_configs(context_obj) -> Dict[type, Any]: 

758 """ 

759 Extract all config instances from a context object using type-driven approach. 

760 

761 A context layer keeps its own result (``ContextLayer.configs``); this function 

762 itself keeps nothing. 

763 

764 This function leverages dataclass field type annotations to efficiently extract 

765 config instances, avoiding string matching and runtime attribute scanning. 

766 

767 Args: 

768 context_obj: Object to extract configs from (orchestrator, merged config, etc.) 

769 

770 Returns: 

771 Dict mapping nominal config types to config instances. 

772 """ 

773 if context_obj is None: 

774 return {} 

775 

776 configs: Dict[type, Any] = {} 

777 

778 # Include the context object itself if it's a dataclass 

779 if is_dataclass(context_obj): 

780 _record_config_instance(configs, context_obj, source="context object") 

781 

782 # Type-driven extraction: Use dataclass field annotations to find config fields 

783 if is_dataclass(type(context_obj)): 

784 for field_name, declared_type in _declared_config_fields(type(context_obj)): 

785 # Read the stored value: a None field holds no config in this object, 

786 # and resolving it here would ask the context that is being read. 

787 field_value = object.__getattribute__(context_obj, field_name) 

788 if field_value is None: 

789 continue 

790 if not _is_compatible_config_type(field_value, declared_type): 

791 raise TypeError( 

792 f"{type(context_obj).__name__}.{field_name} declares " 

793 f"{declared_type.__name__}, got {type(field_value).__name__}." 

794 ) 

795 _record_config_instance( 

796 configs, 

797 field_value, 

798 source=f"{type(context_obj).__name__}.{field_name}", 

799 ) 

800 

801 # For non-dataclass objects (orchestrators, etc.), extract dataclass attributes 

802 else: 

803 _extract_from_object_attributes_typed(context_obj, configs) 

804 

805 if logger.isEnabledFor(logging.DEBUG): 

806 logger.debug( 

807 "Extracted %d configs: %s", 

808 len(configs), 

809 [config_type.__name__ for config_type in configs], 

810 ) 

811 return configs 

812 

813 

814def _record_config_instance( 

815 configs: Dict[type, Any], 

816 config_instance: Any, 

817 *, 

818 source: str, 

819) -> None: 

820 """Record one nominal config identity and reject ambiguous duplicates.""" 

821 

822 from objectstate.lazy_factory import get_base_type_for_lazy 

823 

824 instance_type = type(config_instance) 

825 base_type = get_base_type_for_lazy(instance_type) or instance_type 

826 existing = configs.get(base_type) 

827 if existing is not None and existing is not config_instance: 

828 raise ValueError( 

829 f"{source} introduces a second {base_type.__name__} instance into " 

830 "one configuration context." 

831 ) 

832 configs[base_type] = config_instance 

833 

834 

835def _extract_from_object_attributes_typed(obj, configs: Dict[type, Any]) -> None: 

836 """ 

837 Type-safe extraction from object attributes for non-dataclass objects. 

838 

839 This is used for orchestrators and other objects that aren't dataclasses 

840 but have config attributes. Uses type checking instead of string matching. 

841 """ 

842 for attr_name in dir(obj): 

843 if attr_name.startswith('_'): 

844 continue 

845 

846 try: 

847 attr_value = getattr(obj, attr_name) 

848 except (AttributeError, TypeError): 

849 continue 

850 if attr_value is not None and is_dataclass(attr_value): 

851 _record_config_instance( 

852 configs, 

853 attr_value, 

854 source=f"{type(obj).__name__}.{attr_name}", 

855 ) 

856 logger.debug( 

857 "Extracted config %s from attribute %s", 

858 type(attr_value).__name__, 

859 attr_name, 

860 ) 

861 

862 

863def _is_compatible_config_type(value, expected_type) -> bool: 

864 """ 

865 Check if value is compatible with expected_type, handling lazy-to-base type mapping. 

866 

867 This handles cases where: 

868 - value is LazyStepMaterializationConfig, expected_type is StepMaterializationConfig 

869 - value is a subclass of the expected type 

870 - value is exactly the expected type 

871 """ 

872 from objectstate.lazy_factory import get_base_type_for_lazy 

873 

874 expected_type = resolve_optional(resolve_annotated(expected_type)) 

875 if not isinstance(expected_type, type): 

876 return False 

877 value_type = type(value) 

878 value_base = get_base_type_for_lazy(value_type) or value_type 

879 expected_base = get_base_type_for_lazy(expected_type) or expected_type 

880 return issubclass(value_base, expected_base) 

881 

882 

883def spawn_thread_with_context( 

884 target, 

885 daemon: bool = True, 

886 name: str = None, 

887 *args, 

888 **kwargs, 

889): 

890 """Spawn a thread with current context propagated. 

891 

892 A new thread starts with no ``config_context`` entered. This starts one with the 

893 layers the caller has entered (``contextvars.copy_context()``), so plain lazy 

894 objects read in it what they read in the caller. The global config and the 

895 registry are one per process and need no copying. 

896 

897 Args: 

898 target: The function to run in thread 

899 daemon: Whether thread should be a daemon thread (default: True) 

900 name: Optional thread name 

901 *args: Positional arguments to pass to target 

902 **kwargs: Keyword arguments to pass to target 

903 

904 Returns: 

905 The spawned Thread object 

906 

907 Example: 

908 >>> spawn_thread_with_context(my_function, arg1, kwarg=value) 

909 >>> # Equivalent to: 

910 >>> ctx = contextvars.copy_context() 

911 >>> threading.Thread(target=ctx.run, args=(my_function, arg1), kwargs={'kwarg': value}).start() 

912 """ 

913 ctx = contextvars.copy_context() 

914 thread = threading.Thread( 

915 target=lambda: ctx.run(target, *args, **kwargs), 

916 daemon=daemon, 

917 name=name, 

918 ) 

919 thread.start() 

920 return thread