Coverage for src/metaclass_registry/discovery.py: 83%

100 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-02 00:58 +0000

1""" 

2Generic registry class discovery utility. 

3 

4Consolidates duplicated registry discovery patterns across: 

5- Library registries (processing backends) 

6- Format registries (experimental analysis) 

7- Microscope handler registries 

8- Storage backend registries 

9 

10This module eliminates ~70 lines of duplicated pkgutil + importlib boilerplate 

11by providing a single, well-tested discovery function. 

12""" 

13 

14import importlib 

15import inspect 

16import logging 

17import pkgutil 

18import threading 

19from collections.abc import Callable, Iterable 

20from types import ModuleType 

21 

22logger = logging.getLogger(__name__) 

23_ROOT_LOGGING_IMPORT_LOCK = threading.RLock() 

24 

25 

26def import_module_preserving_root_logging( 

27 module_name: str, 

28 package: str | None = None, 

29) -> ModuleType: 

30 """Import a module without transferring root-logger ownership to it. 

31 

32 Plugin and callable discovery imports code owned by third parties. Some 

33 packages configure the process root logger at import time, which can replace 

34 application handlers or write diagnostics into machine-readable stdout. 

35 Discovery owns importing those modules, but the hosting application retains 

36 ownership of root logging. 

37 

38 A temporary handler prevents ordinary ``logging.basicConfig`` calls from 

39 installing handlers. The prior root handler set, level, disabled state, and 

40 global logging threshold are restored even when the import fails. Named 

41 package loggers remain under the imported package's control. 

42 """ 

43 

44 with _ROOT_LOGGING_IMPORT_LOCK: 

45 root_logger = logging.getLogger() 

46 previous_handlers = tuple(root_logger.handlers) 

47 previous_level = root_logger.level 

48 previous_disabled = root_logger.disabled 

49 previous_disable_threshold = root_logger.manager.disable 

50 guard_handler = logging.NullHandler() 

51 root_logger.handlers = [guard_handler] 

52 try: 

53 return importlib.import_module(module_name, package) 

54 finally: 

55 temporary_handlers = tuple(root_logger.handlers) 

56 root_logger.handlers = list(previous_handlers) 

57 root_logger.setLevel(previous_level) 

58 root_logger.disabled = previous_disabled 

59 logging.disable(previous_disable_threshold) 

60 previous_handler_ids = {id(handler) for handler in previous_handlers} 

61 for handler in temporary_handlers: 

62 if id(handler) not in previous_handler_ids: 

63 handler.close() 

64 

65 

66def discover_registry_classes( 

67 package_path: Iterable[str], 

68 package_prefix: str, 

69 base_class: type, 

70 exclude_modules: set[str] | None = None, 

71 validation_func: Callable[[type], bool] | None = None, 

72 skip_packages: bool = True, 

73 module_filter: Callable[[str], bool] | None = None, 

74) -> list[type]: 

75 """ 

76 Generic registry class discovery using pkgutil + importlib pattern. 

77 

78 Scans a package for classes that inherit from a base class and automatically 

79 discovers them for registration. This eliminates duplicated discovery code 

80 across different registry systems. 

81 

82 Args: 

83 package_path: Package __path__ attribute to scan (e.g., openhcs.io.__path__) 

84 Accepts any iterable of strings (List, Tuple, _NamespacePath, etc.) 

85 package_prefix: Module prefix for importlib (e.g., "openhcs.io.") 

86 base_class: Base class to filter for (e.g., StorageBackend) 

87 exclude_modules: Set of module name substrings to skip (e.g., {'base', 'registry'}) 

88 validation_func: Optional function to validate discovered classes 

89 Should return True to include, False to exclude 

90 skip_packages: If True, skip package directories (default: True) 

91 module_filter: Declaration-owned eligibility before import. Selected 

92 import failures propagate instead of incomplete absence. 

93 

94 Returns: 

95 List of discovered registry classes 

96 

97 Example: 

98 >>> from openhcs.io.base import StorageBackend 

99 >>> import openhcs.io 

100 >>> backends = discover_registry_classes( 

101 ... package_path=openhcs.io.__path__, 

102 ... package_prefix="openhcs.io.", 

103 ... base_class=StorageBackend, 

104 ... exclude_modules={'base', 'backend_registry'} 

105 ... ) 

106 >>> print([b.__name__ for b in backends]) 

107 ['DiskStorageBackend', 'MemoryStorageBackend', 'ZarrStorageBackend'] 

108 """ 

109 registry_classes = [] 

110 exclude_modules = exclude_modules or set() 

111 

112 logger.debug( 

113 f"Discovering registry classes: base={base_class.__name__}, " 

114 f"prefix={package_prefix}, exclude={exclude_modules}" 

115 ) 

116 

117 for importer, module_name, ispkg in pkgutil.iter_modules(package_path, package_prefix): 

118 # Skip packages if requested 

119 if ispkg and skip_packages: 

120 continue 

121 

122 # Skip excluded modules 

123 if any(excluded in module_name for excluded in exclude_modules): 

124 logger.debug(f"Skipping excluded module: {module_name}") 

125 continue 

126 

127 if module_filter is not None and not module_filter(module_name): 

128 continue 

129 

130 try: 

131 # Import the module 

132 module = import_module_preserving_root_logging(module_name) 

133 

134 # Find all classes in the module 

135 for name, obj in inspect.getmembers(module, inspect.isclass): 

136 # Filter for subclasses of base_class 

137 if not issubclass(obj, base_class): 

138 continue 

139 

140 # Exclude the base class itself 

141 if obj is base_class: 

142 continue 

143 

144 # Only include classes defined in this module (not imported) 

145 if obj.__module__ != module_name: 

146 continue 

147 

148 # Apply optional validation function 

149 if validation_func and not validation_func(obj): 

150 logger.debug(f"Validation failed for {obj.__name__}") 

151 continue 

152 

153 logger.debug(f"Discovered registry class: {obj.__name__} from {module_name}") 

154 registry_classes.append(obj) 

155 

156 except ImportError as e: 

157 if module_filter is not None: 

158 raise 

159 # Skip modules that can't be imported (e.g., missing optional dependencies) 

160 logger.debug(f"Could not import module {module_name}: {e}") 

161 continue 

162 except Exception as e: 

163 if module_filter is not None: 

164 raise 

165 # Log unexpected errors but continue discovery 

166 logger.warning(f"Failed to load registry module {module_name}: {e}") 

167 continue 

168 

169 logger.info( 

170 f"Discovered {len(registry_classes)} registry classes for {base_class.__name__}: " 

171 f"{[cls.__name__ for cls in registry_classes]}" 

172 ) 

173 

174 return registry_classes 

175 

176 

177def discover_registry_classes_recursive( 

178 package_path: Iterable[str], 

179 package_prefix: str, 

180 base_class: type, 

181 exclude_modules: set[str] | None = None, 

182 validation_func: Callable[[type], bool] | None = None, 

183) -> list[type]: 

184 """ 

185 Recursive version of discover_registry_classes that walks entire package tree. 

186 

187 Uses pkgutil.walk_packages instead of iter_modules to recursively scan 

188 all subpackages. Useful for deeply nested registry structures. 

189 

190 Args: 

191 package_path: Package __path__ attribute to scan 

192 Accepts any iterable of strings (List, Tuple, _NamespacePath, etc.) 

193 package_prefix: Module prefix for importlib 

194 base_class: Base class to filter for 

195 exclude_modules: Set of module name substrings to skip 

196 validation_func: Optional function to validate discovered classes 

197 

198 Returns: 

199 List of discovered registry classes 

200 

201 Example: 

202 >>> from openhcs.processing.backends.lib_registry.unified_registry import ( 

203 ... LibraryRegistryBase, 

204 ... ) 

205 >>> import openhcs.processing.backends.experimental_analysis 

206 >>> registries = discover_registry_classes_recursive( 

207 ... package_path=openhcs.processing.backends.experimental_analysis.__path__, 

208 ... package_prefix="openhcs.processing.backends.experimental_analysis.", 

209 ... base_class=MicroscopeFormatRegistryBase, 

210 ... exclude_modules={'base'} 

211 ... ) 

212 """ 

213 registry_classes = [] 

214 exclude_modules = exclude_modules or set() 

215 

216 logger.debug( 

217 f"Discovering registry classes (recursive): base={base_class.__name__}, " 

218 f"prefix={package_prefix}, exclude={exclude_modules}" 

219 ) 

220 

221 # Walk through all modules in the package tree 

222 for importer, modname, ispkg in pkgutil.walk_packages(package_path, prefix=package_prefix): 

223 # Skip packages (only process modules) 

224 if ispkg: 

225 continue 

226 

227 # Skip excluded modules 

228 if any(excluded in modname for excluded in exclude_modules): 

229 logger.debug(f"Skipping excluded module: {modname}") 

230 continue 

231 

232 try: 

233 # Import the module 

234 module = import_module_preserving_root_logging(modname) 

235 

236 # Find all classes in the module 

237 for attr_name in dir(module): 

238 attr = getattr(module, attr_name) 

239 

240 # Check if it's a class 

241 if not isinstance(attr, type): 

242 continue 

243 

244 # Check if it's a subclass of base_class 

245 if not issubclass(attr, base_class): 

246 continue 

247 

248 # Exclude the base class itself 

249 if attr is base_class: 

250 continue 

251 

252 # Apply optional validation function 

253 if validation_func and not validation_func(attr): 

254 logger.debug(f"Validation failed for {attr.__name__}") 

255 continue 

256 

257 logger.debug(f"Discovered registry class: {attr.__name__} from {modname}") 

258 registry_classes.append(attr) 

259 

260 except ImportError as e: 

261 # Skip modules that can't be imported 

262 logger.debug(f"Could not import module {modname}: {e}") 

263 continue 

264 except Exception as e: 

265 # Log unexpected errors but continue discovery 

266 logger.warning(f"Failed to load registry module {modname}: {e}") 

267 continue 

268 

269 logger.info( 

270 "Discovered %d registry classes (recursive) for %s: %s", 

271 len(registry_classes), 

272 base_class.__name__, 

273 [cls.__name__ for cls in registry_classes], 

274 ) 

275 

276 return registry_classes