Coverage for src / metaclass_registry / core.py: 80%

303 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-10 22:33 +0000

1""" 

2Generic metaclass infrastructure for automatic plugin registration. 

3 

4This module provides reusable metaclass infrastructure for Pattern A registry systems 

5(1:1 class-to-plugin mapping with automatic discovery). It eliminates code duplication 

6across MicroscopeHandlerMeta, StorageBackendMeta, and ContextProviderMeta. 

7 

8Pattern Selection Guide: 

9----------------------- 

10Use AutoRegisterMeta (Pattern A) when: 

11- You have a 1:1 mapping between classes and plugins 

12- Plugins should be automatically discovered and registered 

13- Registration happens at class definition time 

14- Simple metadata (just a key and maybe one secondary registry) 

15 

16Use Service Pattern (Pattern B) when: 

17- You have many-to-one mapping (multiple items per plugin) 

18- Complex metadata (FunctionMetadata with 8+ fields) 

19- Need aggregation across multiple sources 

20- Examples: Function registry, Format registry 

21 

22Use Functional Registry (Pattern C) when: 

23- Simple type-to-handler mappings 

24- No state needed 

25- Functional programming style preferred 

26- Examples: Widget creation registries 

27 

28Use Manual Registration (Pattern D) when: 

29- Complex initialization logic required 

30- Explicit control over registration timing needed 

31- Very few plugins (< 3) 

32- Examples: ZMQ servers, Pipeline steps 

33 

34Architecture: 

35------------ 

36AutoRegisterMeta uses a configuration-driven approach: 

371. RegistryConfig defines registration behavior 

382. AutoRegisterMeta applies the configuration during class creation 

393. Domain-specific metaclasses provide thin wrappers with their config 

40 

41This maintains domain-specific features while eliminating duplication. 

42""" 

43 

44import importlib 

45import logging 

46import threading 

47from abc import ABCMeta 

48from dataclasses import dataclass 

49from typing import Dict, Type, Optional, Callable, Any 

50 

51logger = logging.getLogger(__name__) 

52 

53# Lazy import to avoid circular dependency 

54_registry_cache_manager = None 

55 

56def _get_cache_manager(): 

57 """Lazy import of RegistryCacheManager to avoid circular imports.""" 

58 global _registry_cache_manager 

59 if _registry_cache_manager is None: 

60 from metaclass_registry.cache import ( 

61 RegistryCacheManager, 

62 CacheConfig, 

63 serialize_plugin_class, 

64 deserialize_plugin_class, 

65 get_package_file_mtimes 

66 ) 

67 _registry_cache_manager = { 

68 'RegistryCacheManager': RegistryCacheManager, 

69 'CacheConfig': CacheConfig, 

70 'serialize_plugin_class': serialize_plugin_class, 

71 'deserialize_plugin_class': deserialize_plugin_class, 

72 'get_package_file_mtimes': get_package_file_mtimes 

73 } 

74 return _registry_cache_manager 

75 

76 

77# Type aliases for clarity 

78RegistryDict = Dict[str, Type] 

79KeyExtractor = Callable[[str, Type], str] 

80 

81# Constants for key sources 

82PRIMARY_KEY = 'primary' 

83 

84 

85class SecondaryRegistryDict(dict): 

86 """ 

87 Dict for secondary registries that auto-triggers primary registry discovery. 

88 

89 When accessed, this dict triggers discovery of the primary registry, 

90 which populates both the primary and secondary registries. 

91 """ 

92 

93 def __init__(self, primary_registry: 'LazyDiscoveryDict'): 

94 super().__init__() 

95 self._primary_registry = primary_registry 

96 

97 def _ensure_discovered(self): 

98 """Trigger discovery of primary registry (which populates this secondary registry).""" 

99 if hasattr(self._primary_registry, '_discover'): 

100 self._primary_registry._discover() 

101 

102 def __getitem__(self, key): 

103 self._ensure_discovered() 

104 return super().__getitem__(key) 

105 

106 def __contains__(self, key): 

107 self._ensure_discovered() 

108 return super().__contains__(key) 

109 

110 def __iter__(self): 

111 self._ensure_discovered() 

112 return super().__iter__() 

113 

114 def __len__(self): 

115 self._ensure_discovered() 

116 return super().__len__() 

117 

118 def keys(self): 

119 self._ensure_discovered() 

120 return super().keys() 

121 

122 def values(self): 

123 self._ensure_discovered() 

124 return super().values() 

125 

126 def items(self): 

127 self._ensure_discovered() 

128 return super().items() 

129 

130 def get(self, key, default=None): 

131 self._ensure_discovered() 

132 return super().get(key, default) 

133 

134 

135class LazyDiscoveryDict(dict): 

136 """ 

137 Dict that auto-discovers plugins on first access with optional caching. 

138 

139 Supports caching discovered plugins to speed up subsequent application starts. 

140 Cache is validated against package version and file modification times. 

141 

142 Thread-safe: Uses locking to ensure discovery happens only once 

143 even when accessed from multiple threads simultaneously. 

144 """ 

145 

146 def __init__(self, enable_cache: bool = True): 

147 """ 

148 Initialize lazy discovery dict. 

149 

150 Args: 

151 enable_cache: If True, use caching to speed up discovery 

152 """ 

153 super().__init__() 

154 self._base_class = None 

155 self._config = None 

156 self._discovered = False 

157 self._enable_cache = enable_cache 

158 self._cache_manager = None 

159 self._discovery_lock = threading.RLock() # Reentrant lock for same-thread re-entry 

160 

161 def _set_config(self, base_class: Type, config: 'RegistryConfig') -> None: 

162 self._base_class = base_class 

163 self._config = config 

164 

165 # Initialize cache manager if caching is enabled 

166 if self._enable_cache and config.discovery_package: 

167 try: 

168 cache_utils = _get_cache_manager() 

169 

170 # Get version getter (try to get version from discovery package) 

171 def get_version(): 

172 try: 

173 # Try to get version from the root package 

174 root_package = config.discovery_package.split('.')[0] 

175 mod = __import__(root_package) 

176 return getattr(mod, '__version__', 'unknown') 

177 except: 

178 return "unknown" 

179 

180 self._cache_manager = cache_utils['RegistryCacheManager']( 

181 cache_name=f"{config.registry_name.replace(' ', '_')}_registry", 

182 version_getter=get_version, 

183 serializer=cache_utils['serialize_plugin_class'], 

184 deserializer=cache_utils['deserialize_plugin_class'], 

185 config=cache_utils['CacheConfig']( 

186 max_age_days=7, 

187 check_mtimes=True # Validate file modifications 

188 ) 

189 ) 

190 except Exception as e: 

191 logger.debug(f"Failed to initialize cache manager: {e}") 

192 self._cache_manager = None 

193 

194 def _discover(self) -> None: 

195 """ 

196 Run discovery once, using cache if available. 

197 

198 Thread-safe: Discovery happens inside the lock to ensure atomicity. 

199 The lock is held for the entire duration to prevent other threads 

200 from reading a partially-populated registry. 

201 

202 CRITICAL: No fast path check outside the lock! All threads must acquire 

203 the lock to ensure they don't read a partially-populated registry. 

204 """ 

205 # No config = nothing to discover 

206 if not self._config or not self._config.discovery_package: 

207 return 

208 

209 # ALWAYS acquire lock - no fast path to avoid race condition 

210 # RLock allows same thread to re-acquire during module imports 

211 with self._discovery_lock: 

212 # Check if already discovered (inside lock) 

213 if self._discovered: 

214 return 

215 

216 # Mark as discovered to prevent infinite re-entry from same thread 

217 # (module imports during discovery might access registry) 

218 self._discovered = True 

219 

220 # Try to load from cache first 

221 if self._cache_manager: 

222 try: 

223 cached_plugins = self._cache_manager.load_cache() 

224 if cached_plugins is not None: 

225 # Reconstruct registry from cache 

226 self.update(cached_plugins) 

227 logger.info( 

228 f"✅ Loaded {len(self)} {self._config.registry_name}s from cache" 

229 ) 

230 return 

231 except Exception as e: 

232 logger.debug(f"Cache load failed for {self._config.registry_name}: {e}") 

233 

234 # Cache miss or disabled - perform full discovery 

235 try: 

236 pkg = importlib.import_module(self._config.discovery_package) 

237 

238 if self._config.discovery_function: 

239 self._config.discovery_function( 

240 pkg.__path__, 

241 f"{self._config.discovery_package}.", 

242 self._base_class 

243 ) 

244 else: 

245 from metaclass_registry.discovery import ( 

246 discover_registry_classes, 

247 discover_registry_classes_recursive 

248 ) 

249 func = ( 

250 discover_registry_classes_recursive 

251 if self._config.discovery_recursive 

252 else discover_registry_classes 

253 ) 

254 func(pkg.__path__, f"{self._config.discovery_package}.", self._base_class) 

255 

256 logger.debug(f"Discovered {len(self)} {self._config.registry_name}s") 

257 

258 # Save to cache if enabled 

259 if self._cache_manager: 

260 try: 

261 cache_utils = _get_cache_manager() 

262 file_mtimes = cache_utils['get_package_file_mtimes']( 

263 self._config.discovery_package 

264 ) 

265 self._cache_manager.save_cache(dict(self), file_mtimes) 

266 except Exception as e: 

267 logger.debug(f"Failed to save cache for {self._config.registry_name}: {e}") 

268 

269 except Exception as e: 

270 logger.warning(f"Discovery failed: {e}") 

271 # Lock released here - registry is now fully populated and safe to read 

272 

273 def __getitem__(self, k): 

274 with self._discovery_lock: 

275 self._discover() 

276 return super().__getitem__(k) 

277 

278 def __contains__(self, k): 

279 with self._discovery_lock: 

280 self._discover() 

281 return super().__contains__(k) 

282 

283 def __iter__(self): 

284 with self._discovery_lock: 

285 self._discover() 

286 return super().__iter__() 

287 

288 def __len__(self): 

289 with self._discovery_lock: 

290 self._discover() 

291 return super().__len__() 

292 

293 def keys(self): 

294 with self._discovery_lock: 

295 self._discover() 

296 return super().keys() 

297 

298 def values(self): 

299 with self._discovery_lock: 

300 self._discover() 

301 return super().values() 

302 

303 def items(self): 

304 with self._discovery_lock: 

305 self._discover() 

306 return super().items() 

307 

308 def get(self, k, default=None): 

309 with self._discovery_lock: 

310 self._discover() 

311 return super().get(k, default) 

312 

313 

314@dataclass(frozen=True) 

315class SecondaryRegistry: 

316 """Configuration for a secondary registry (e.g., metadata handlers).""" 

317 registry_dict: RegistryDict 

318 key_source: str # 'primary' or attribute name 

319 attr_name: str # Attribute to check on the class 

320 

321 

322@dataclass(frozen=True) 

323class RegistryConfig: 

324 """ 

325 Configuration for automatic class registration behavior. 

326 

327 This dataclass encapsulates all the configuration needed for metaclass 

328 registration, making the pattern explicit and easy to understand. 

329 

330 Attributes: 

331 registry_dict: Dictionary to register classes into (e.g., MICROSCOPE_HANDLERS) 

332 key_attribute: Name of class attribute containing the registration key 

333 (e.g., '_microscope_type', '_backend_type', '_context_type') 

334 key_extractor: Optional function to derive key from class name if key_attribute 

335 is not set. Signature: (class_name: str, cls: Type) -> str 

336 skip_if_no_key: If True, skip registration when key_attribute is None. 

337 If False, require either key_attribute or key_extractor. 

338 secondary_registries: Optional list of secondary registry configurations 

339 log_registration: If True, log debug message when class is registered 

340 registry_name: Human-readable name for logging (e.g., 'microscope handler') 

341 discovery_package: Optional package name to auto-discover (e.g., 'openhcs.microscopes') 

342 discovery_recursive: If True, use recursive discovery (default: False) 

343 

344 Examples: 

345 # Microscope handlers with name-based key extraction and secondary registry 

346 RegistryConfig( 

347 registry_dict=MICROSCOPE_HANDLERS, 

348 key_attribute='_microscope_type', 

349 key_extractor=extract_key_from_handler_suffix, 

350 skip_if_no_key=False, 

351 secondary_registries=[ 

352 SecondaryRegistry( 

353 registry_dict=METADATA_HANDLERS, 

354 key_source=PRIMARY_KEY, 

355 attr_name='_metadata_handler_class' 

356 ) 

357 ], 

358 log_registration=True, 

359 registry_name='microscope handler' 

360 ) 

361 

362 # Storage backends with explicit key and skip-if-none behavior 

363 RegistryConfig( 

364 registry_dict=STORAGE_BACKENDS, 

365 key_attribute='_backend_type', 

366 skip_if_no_key=True, 

367 registry_name='storage backend' 

368 ) 

369 

370 # Context providers with simple explicit key 

371 RegistryConfig( 

372 registry_dict=CONTEXT_PROVIDERS, 

373 key_attribute='_context_type', 

374 skip_if_no_key=True, 

375 registry_name='context provider' 

376 ) 

377 """ 

378 registry_dict: RegistryDict 

379 key_attribute: str 

380 key_extractor: Optional[KeyExtractor] = None 

381 skip_if_no_key: bool = False 

382 secondary_registries: Optional[list[SecondaryRegistry]] = None 

383 log_registration: bool = True 

384 registry_name: str = "plugin" 

385 discovery_package: Optional[str] = None # Auto-inferred from base class module if None 

386 discovery_recursive: bool = False 

387 discovery_function: Optional[Callable] = None # Custom discovery function 

388 

389 

390class AutoRegisterMeta(ABCMeta): 

391 """ 

392 Generic metaclass for automatic plugin registration (Pattern A). 

393  

394 This metaclass automatically registers concrete classes in a global registry 

395 when they are defined, eliminating the need for manual registration calls. 

396  

397 Features: 

398 - Skips abstract classes (checks __abstractmethods__) 

399 - Supports explicit keys via class attributes 

400 - Supports derived keys via key extraction functions 

401 - Supports secondary registries (e.g., metadata handlers) 

402 - Configurable skip-if-no-key behavior 

403 - Debug logging for registration events 

404  

405 Usage: 

406 # Create domain-specific metaclass 

407 class MicroscopeHandlerMeta(AutoRegisterMeta): 

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

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

410 registry_config=_MICROSCOPE_REGISTRY_CONFIG) 

411  

412 # Use in class definition 

413 class ImageXpressHandler(MicroscopeHandler, metaclass=MicroscopeHandlerMeta): 

414 _microscope_type = 'imagexpress' # Optional if key_extractor is provided 

415 _metadata_handler_class = ImageXpressMetadata # Optional secondary registration 

416  

417 Design Principles: 

418 - Explicit configuration over magic behavior 

419 - Preserve all domain-specific features 

420 - Zero breaking changes to existing code 

421 - Easy to understand and debug 

422 """ 

423 

424 def __new__(mcs, name: str, bases: tuple, attrs: dict, 

425 registry_config: Optional[RegistryConfig] = None): 

426 """ 

427 Create a new class and register it if appropriate. 

428 

429 Args: 

430 name: Name of the class being created 

431 bases: Base classes 

432 attrs: Class attributes dictionary 

433 registry_config: Configuration for registration behavior. 

434 If None, auto-configures from class attributes or skips registration. 

435 

436 Returns: 

437 The newly created class 

438 """ 

439 # Create the class using ABCMeta 

440 new_class = super().__new__(mcs, name, bases, attrs) 

441 

442 # Auto-configure registry if not provided but class has __registry__ attributes 

443 if registry_config is None: 

444 registry_config = mcs._auto_configure_registry(new_class, attrs) 

445 if registry_config is None: 

446 return new_class # No config and no auto-config possible 

447 

448 # Set up lazy discovery if registry dict supports it (only once for base class) 

449 if isinstance(registry_config.registry_dict, LazyDiscoveryDict) and not registry_config.registry_dict._config: 

450 from dataclasses import replace 

451 

452 # Auto-infer discovery_package from base class module if not specified 

453 config = registry_config 

454 if config.discovery_package is None: 

455 # Extract package from base class module (e.g., 'openhcs.microscopes.microscope_base' → 'openhcs.microscopes') 

456 module_parts = new_class.__module__.rsplit('.', 1) 

457 inferred_package = module_parts[0] if len(module_parts) > 1 else new_class.__module__ 

458 # Create new config with inferred package 

459 config = replace(config, discovery_package=inferred_package) 

460 logger.debug(f"Auto-inferred discovery_package='{inferred_package}' from {new_class.__module__}") 

461 

462 # Auto-infer discovery_recursive based on package structure 

463 # Check if package has subdirectories with __init__.py (indicating nested structure) 

464 if config.discovery_package: 

465 try: 

466 pkg = importlib.import_module(config.discovery_package) 

467 if hasattr(pkg, '__path__'): 

468 import os 

469 has_subpackages = False 

470 for path in pkg.__path__: 

471 if os.path.isdir(path): 

472 # Check if any subdirectories contain __init__.py 

473 for entry in os.listdir(path): 

474 subdir = os.path.join(path, entry) 

475 if os.path.isdir(subdir) and os.path.exists(os.path.join(subdir, '__init__.py')): 

476 has_subpackages = True 

477 break 

478 if has_subpackages: 

479 break 

480 

481 # Only override if discovery_recursive is still at default (False) 

482 # This allows explicit overrides to take precedence 

483 if has_subpackages and not config.discovery_recursive: 

484 config = replace(config, discovery_recursive=True) 

485 logger.debug(f"Auto-inferred discovery_recursive=True for '{config.discovery_package}' (has subpackages)") 

486 elif not has_subpackages and config.discovery_recursive: 

487 logger.debug(f"Keeping explicit discovery_recursive=True for '{config.discovery_package}' (no subpackages detected)") 

488 except Exception as e: 

489 logger.debug(f"Failed to auto-infer discovery_recursive: {e}") 

490 

491 # Auto-wrap secondary registries with SecondaryRegistryDict 

492 if config.secondary_registries: 

493 import sys 

494 wrapped_secondaries = [] 

495 module = sys.modules.get(new_class.__module__) 

496 

497 for sec_reg in config.secondary_registries: 

498 # Check if secondary registry needs wrapping 

499 if isinstance(sec_reg.registry_dict, dict) and not isinstance(sec_reg.registry_dict, SecondaryRegistryDict): 

500 # Create a new SecondaryRegistryDict wrapping the primary registry 

501 wrapped_dict = SecondaryRegistryDict(registry_config.registry_dict) 

502 # Copy any existing entries from the old dict 

503 wrapped_dict.update(sec_reg.registry_dict) 

504 

505 # Find and update the module global variable 

506 if module: 

507 for var_name, var_value in vars(module).items(): 

508 if var_value is sec_reg.registry_dict: 

509 setattr(module, var_name, wrapped_dict) 

510 logger.debug(f"Auto-wrapped secondary registry '{var_name}' in {new_class.__module__}") 

511 break 

512 

513 # Create new SecondaryRegistry with wrapped dict 

514 wrapped_sec_reg = SecondaryRegistry( 

515 registry_dict=wrapped_dict, 

516 key_source=sec_reg.key_source, 

517 attr_name=sec_reg.attr_name 

518 ) 

519 wrapped_secondaries.append(wrapped_sec_reg) 

520 else: 

521 wrapped_secondaries.append(sec_reg) 

522 

523 # Rebuild config with wrapped secondary registries 

524 config = replace(config, secondary_registries=wrapped_secondaries) 

525 

526 registry_config.registry_dict._set_config(new_class, config) 

527 

528 # Only register concrete classes (not abstract base classes) 

529 if not bases or getattr(new_class, '__abstractmethods__', None): 

530 return new_class 

531 

532 # Get or derive the registration key 

533 key = mcs._get_registration_key(name, new_class, registry_config) 

534 

535 # Handle missing key 

536 if key is None: 

537 return mcs._handle_missing_key(name, registry_config, new_class) 

538 

539 # Register in primary registry 

540 mcs._register_class(new_class, key, registry_config) 

541 

542 # Handle secondary registrations 

543 if registry_config.secondary_registries: 

544 mcs._register_secondary(new_class, key, registry_config.secondary_registries) 

545 

546 # Log registration if enabled 

547 if registry_config.log_registration: 

548 logger.debug(f"Auto-registered {name} as '{key}' {registry_config.registry_name}") 

549 

550 return new_class 

551 

552 @staticmethod 

553 def _get_registration_key(name: str, cls: Type, config: RegistryConfig) -> Optional[str]: 

554 """Get the registration key for a class (explicit or derived).""" 

555 # Try explicit key first 

556 key = getattr(cls, config.key_attribute, None) 

557 if key is not None: 

558 return key 

559 

560 # Try key extractor if provided 

561 if config.key_extractor is not None: 

562 return config.key_extractor(name, cls) 

563 

564 return None 

565 

566 @staticmethod 

567 def _handle_missing_key(name: str, config: RegistryConfig, new_class: Type) -> Type: 

568 """Handle case where no registration key is available.""" 

569 if config.skip_if_no_key: 

570 if config.log_registration: 

571 logger.debug(f"Skipping registration for {name} - no {config.key_attribute}") 

572 return new_class # Return the class, just don't register it 

573 else: 

574 raise ValueError( 

575 f"Class {name} must have {config.key_attribute} attribute " 

576 f"or provide a key_extractor in registry config" 

577 ) 

578 

579 @classmethod 

580 def _auto_configure_registry(mcs, new_class: Type, attrs: dict) -> Optional[RegistryConfig]: 

581 """ 

582 Auto-configure registry from metaclass OR base class attributes. 

583 

584 Priority: 

585 1. Metaclass attributes (__registry_dict__, __registry_key__ on metaclass) 

586 2. Base class attributes (__registry_key__ on class, auto-create __registry__) 

587 3. Parent class __registry__ (inherit from parent) 

588 

589 Returns: 

590 RegistryConfig if auto-configuration successful, None otherwise 

591 """ 

592 # Check if the metaclass has __registry_dict__ attribute (old style) 

593 registry_dict = getattr(mcs, '__registry_dict__', None) 

594 

595 # If no metaclass registry, check if base class wants auto-creation or inheritance 

596 if registry_dict is None: 

597 # First check if any parent class has __registry__ (inherit from parent) 

598 # This takes priority over creating a new registry 

599 for base in new_class.__mro__[1:]: # Skip self 

600 if hasattr(base, '__registry__'): 

601 registry_dict = base.__registry__ 

602 key_attribute = getattr(base, '__registry_key__', None) 

603 key_extractor = getattr(base, '__key_extractor__', None) 

604 skip_if_no_key = getattr(base, '__skip_if_no_key__', True) 

605 secondary_registries = getattr(base, '__secondary_registries__', None) 

606 registry_name = getattr(base, '__registry_name__', None) 

607 break 

608 else: 

609 # No parent registry found - check if class explicitly defines __registry_key__ 

610 # (only create new registry if __registry_key__ is in the class body, not inherited) 

611 key_attribute = attrs.get('__registry_key__') 

612 if key_attribute is not None: 

613 # Check if class already provides its own __registry__ dict 

614 # (allows opting out of LazyDiscoveryDict) 

615 if '__registry__' in attrs: 

616 registry_dict = attrs['__registry__'] 

617 else: 

618 # Auto-create registry dict and store on the class 

619 registry_dict = LazyDiscoveryDict() 

620 new_class.__registry__ = registry_dict 

621 

622 # Get other optional attributes from class 

623 key_extractor = attrs.get('__key_extractor__') 

624 skip_if_no_key = attrs.get('__skip_if_no_key__', True) 

625 secondary_registries = attrs.get('__secondary_registries__') 

626 registry_name = attrs.get('__registry_name__') 

627 else: 

628 return None # No registry configuration found 

629 else: 

630 # Old style: get from metaclass 

631 key_attribute = getattr(mcs, '__registry_key__', '_registry_key') 

632 key_extractor = getattr(mcs, '__key_extractor__', None) 

633 skip_if_no_key = getattr(mcs, '__skip_if_no_key__', True) 

634 secondary_registries = getattr(mcs, '__secondary_registries__', None) 

635 registry_name = getattr(mcs, '__registry_name__', None) 

636 

637 # Auto-derive registry name if not provided 

638 if registry_name is None: 

639 # Derive from class name: "StorageBackend" → "storage backend" 

640 clean_name = new_class.__name__ 

641 for suffix in ['Base', 'Meta', 'Handler', 'Registry']: 

642 if clean_name.endswith(suffix): 

643 clean_name = clean_name[:-len(suffix)] 

644 break 

645 # Convert CamelCase to space-separated lowercase 

646 import re 

647 registry_name = re.sub(r'([A-Z])', r' \1', clean_name).strip().lower() 

648 

649 logger.debug(f"Auto-configured registry for {new_class.__name__}: " 

650 f"key_attribute={key_attribute}, registry_name={registry_name}") 

651 

652 return RegistryConfig( 

653 registry_dict=registry_dict, 

654 key_attribute=key_attribute, 

655 key_extractor=key_extractor, 

656 skip_if_no_key=skip_if_no_key, 

657 secondary_registries=secondary_registries, 

658 registry_name=registry_name 

659 ) 

660 

661 @staticmethod 

662 def _register_class(cls: Type, key: str, config: RegistryConfig) -> None: 

663 """Register class in primary registry.""" 

664 config.registry_dict[key] = cls 

665 setattr(cls, config.key_attribute, key) 

666 

667 @staticmethod 

668 def _register_secondary( 

669 cls: Type, 

670 primary_key: str, 

671 secondary_registries: list[SecondaryRegistry] 

672 ) -> None: 

673 """Handle secondary registry registrations.""" 

674 for sec_reg in secondary_registries: 

675 value = getattr(cls, sec_reg.attr_name, None) 

676 if value is None: 

677 continue 

678 

679 # Determine the key for secondary registration 

680 if sec_reg.key_source == PRIMARY_KEY: 

681 secondary_key = primary_key 

682 else: 

683 secondary_key = getattr(cls, sec_reg.key_source, None) 

684 if secondary_key is None: 

685 logger.warning( 

686 f"Cannot register {sec_reg.attr_name} for {cls.__name__} - " 

687 f"no {sec_reg.key_source} attribute" 

688 ) 

689 continue 

690 

691 # Register in secondary registry 

692 sec_reg.registry_dict[secondary_key] = value 

693 logger.debug(f"Auto-registered {sec_reg.attr_name} from {cls.__name__} as '{secondary_key}'") 

694 

695 

696# Helper functions for common key extraction patterns 

697 

698def make_suffix_extractor(suffix: str) -> KeyExtractor: 

699 """ 

700 Create a key extractor that removes a suffix from class names. 

701 

702 Args: 

703 suffix: The suffix to remove (e.g., 'Handler', 'Backend') 

704 

705 Returns: 

706 A key extractor function 

707 

708 Examples: 

709 extract_handler = make_suffix_extractor('Handler') 

710 extract_handler('ImageXpressHandler', cls) -> 'imagexpress' 

711 

712 extract_backend = make_suffix_extractor('Backend') 

713 extract_backend('DiskStorageBackend', cls) -> 'diskstorage' 

714 """ 

715 suffix_len = len(suffix) 

716 

717 def extractor(name: str, cls: Type) -> str: 

718 if name.endswith(suffix): 

719 return name[:-suffix_len].lower() 

720 return name.lower() 

721 

722 return extractor 

723 

724 

725# Pre-built extractors for common patterns 

726extract_key_from_handler_suffix = make_suffix_extractor('Handler') 

727extract_key_from_backend_suffix = make_suffix_extractor('Backend') 

728