Coverage for openhcs/microscopes/microscope_base.py: 65.0%

223 statements  

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

1""" 

2Microscope base implementations for openhcs. 

3 

4This module provides the base implementations for microscope-specific functionality, 

5including filename parsing and metadata handling. 

6""" 

7 

8import logging 

9import os 

10from abc import ABC, abstractmethod 

11from pathlib import Path 

12from typing import Any, Dict, List, Optional, Tuple, Union, Type 

13 

14# Import constants 

15from openhcs.constants.constants import Backend 

16# Import generic metaclass infrastructure 

17from openhcs.core.auto_register_meta import ( 

18 AutoRegisterMeta, 

19 SecondaryRegistry, 

20 extract_key_from_handler_suffix, 

21 PRIMARY_KEY 

22) 

23# PatternDiscoveryEngine imported locally to avoid circular imports 

24from openhcs.io.filemanager import FileManager 

25# Import interfaces from the base interfaces module 

26from openhcs.microscopes.microscope_interfaces import (FilenameParser, 

27 MetadataHandler) 

28 

29logger = logging.getLogger(__name__) 

30 

31# Dictionary to store registered metadata handlers for auto-detection 

32# This will be auto-wrapped with SecondaryRegistryDict by the metaclass 

33METADATA_HANDLERS = {} 

34 

35 

36def register_metadata_handler(handler_class, metadata_handler_class): 

37 """ 

38 Register a metadata handler for a microscope handler class. 

39 

40 This function is called when _metadata_handler_class is set after class definition. 

41 """ 

42 microscope_type = getattr(handler_class, '_microscope_type', None) 

43 if microscope_type: 43 ↛ 47line 43 didn't jump to line 47 because the condition on line 43 was always true

44 METADATA_HANDLERS[microscope_type] = metadata_handler_class 

45 logger.debug(f"Registered metadata handler {metadata_handler_class.__name__} for '{microscope_type}'") 

46 else: 

47 logger.warning(f"Could not register metadata handler for {handler_class.__name__} - no microscope type found") 

48 

49 

50 

51 

52class MicroscopeHandler(ABC, metaclass=AutoRegisterMeta): 

53 """ 

54 Composed class for handling microscope-specific functionality. 

55 

56 Registry auto-created and stored as MicroscopeHandler.__registry__. 

57 Subclasses auto-register by setting _microscope_type class attribute. 

58 Secondary registry METADATA_HANDLERS populated via _metadata_handler_class. 

59 """ 

60 __registry_key__ = '_microscope_type' 

61 __key_extractor__ = extract_key_from_handler_suffix 

62 __skip_if_no_key__ = False 

63 __secondary_registries__ = [ 

64 SecondaryRegistry( 

65 registry_dict=METADATA_HANDLERS, 

66 key_source=PRIMARY_KEY, 

67 attr_name='_metadata_handler_class' 

68 ) 

69 ] 

70 

71 DEFAULT_MICROSCOPE = 'auto' 

72 _handlers_cache = None 

73 

74 # Optional class attribute for explicit metadata handler registration 

75 _metadata_handler_class: Optional[Type[MetadataHandler]] = None 

76 

77 def __init__(self, parser: Optional[FilenameParser], 

78 metadata_handler: MetadataHandler): 

79 """ 

80 Initialize the microscope handler. 

81 

82 Args: 

83 parser: Parser for microscopy filenames. Can be None for virtual backends 

84 that don't need workspace preparation or pattern discovery. 

85 metadata_handler: Handler for microscope metadata. 

86 """ 

87 self.parser = parser 

88 self.metadata_handler = metadata_handler 

89 self.plate_folder: Optional[Path] = None # Store workspace path if needed by methods 

90 

91 # Pattern discovery engine will be created on demand with the provided filemanager 

92 

93 @property 

94 @abstractmethod 

95 def root_dir(self) -> str: 

96 """ 

97 Root directory where virtual workspace preparation starts. 

98 

99 This defines: 

100 1. The starting point for virtual workspace operations (flattening, remapping) 

101 2. The subdirectory key used when saving virtual workspace metadata 

102 

103 Examples: 

104 - ImageXpress: "." (plate root - TimePoint/ZStep folders are flattened from plate root) 

105 - OperaPhenix: "Images" (field remapping applied to Images/ subdirectory) 

106 - OpenHCS: Determined from metadata (e.g., "zarr", "images") 

107 """ 

108 pass 

109 

110 @property 

111 @abstractmethod 

112 def microscope_type(self) -> str: 

113 """Microscope type identifier (for interface enforcement only).""" 

114 pass 

115 

116 @property 

117 @abstractmethod 

118 def metadata_handler_class(self) -> Type[MetadataHandler]: 

119 """Metadata handler class (for interface enforcement only).""" 

120 pass 

121 

122 @property 

123 @abstractmethod 

124 def compatible_backends(self) -> List[Backend]: 

125 """ 

126 List of storage backends this microscope handler is compatible with, in priority order. 

127 

128 Must be explicitly declared by each handler implementation. 

129 The first backend in the list is the preferred/highest priority backend. 

130 The compiler will use the first backend for initial step materialization. 

131 

132 Common patterns: 

133 - [Backend.DISK] - Basic handlers (ImageXpress, Opera Phenix) 

134 - [Backend.ZARR, Backend.DISK] - Advanced handlers (OpenHCS: zarr preferred, disk fallback) 

135 - [Backend.OMERO_LOCAL] - Virtual backends (OMERO: single required backend) 

136 

137 Returns: 

138 List of Backend enum values this handler can work with, in priority order 

139 """ 

140 pass 

141 

142 def get_required_backend(self) -> Optional['MaterializationBackend']: 

143 """ 

144 Get the required materialization backend if this microscope has only one compatible backend. 

145 

146 For microscopes with a single compatible backend (e.g., OMERO with OMERO_LOCAL), 

147 this returns the required backend for auto-correction. For microscopes with multiple 

148 compatible backends, returns None (user must choose explicitly). 

149 

150 Returns: 

151 MaterializationBackend if microscope requires a specific backend, None otherwise 

152 """ 

153 from openhcs.core.config import MaterializationBackend 

154 

155 if len(self.compatible_backends) == 1: 

156 backend_value = self.compatible_backends[0].value 

157 # Convert Backend enum value to MaterializationBackend enum 

158 try: 

159 return MaterializationBackend(backend_value) 

160 except ValueError: 

161 # Backend not in MaterializationBackend (e.g., MEMORY, VIRTUAL_WORKSPACE) 

162 return None 

163 return None 

164 

165 def get_available_backends(self, plate_path: Union[str, Path]) -> List[Backend]: 

166 """ 

167 Get available storage backends for this specific plate. 

168 

169 Default implementation returns all compatible backends. 

170 Override this method only if you need to check actual disk state 

171 (like OpenHCS which reads from metadata). 

172 

173 Args: 

174 plate_path: Path to the plate folder 

175 

176 Returns: 

177 List of Backend enums that are available for this plate. 

178 """ 

179 return self.compatible_backends 

180 

181 def get_primary_backend(self, plate_path: Union[str, Path], filemanager: 'FileManager') -> str: 

182 """ 

183 Get the primary backend name for this plate. 

184 

185 Checks FileManager registry first for registered backends (like virtual_workspace), 

186 then falls back to compatible backends. 

187 

188 Override this method only if you need custom backend selection logic 

189 (like OpenHCS which reads from metadata). 

190 

191 Args: 

192 plate_path: Path to the plate folder (or subdirectory) 

193 filemanager: FileManager instance to check for registered backends 

194 

195 Returns: 

196 Backend name string (e.g., 'disk', 'zarr', 'virtual_workspace') 

197 """ 

198 # Check if virtual workspace backend is registered in FileManager 

199 # This takes priority over compatible backends 

200 if Backend.VIRTUAL_WORKSPACE.value in filemanager.registry: 200 ↛ 205line 200 didn't jump to line 205 because the condition on line 200 was always true

201 logger.info(f"✅ Using backend '{Backend.VIRTUAL_WORKSPACE.value}' from FileManager registry") 

202 return Backend.VIRTUAL_WORKSPACE.value 

203 

204 # Fall back to compatible backends 

205 available_backends = self.get_available_backends(plate_path) 

206 if not available_backends: 

207 raise RuntimeError(f"No available backends for {self.microscope_type} microscope at {plate_path}") 

208 logger.info(f"⚠️ Using backend '{available_backends[0].value}' from compatible backends (virtual workspace not registered)") 

209 return available_backends[0].value 

210 

211 def _register_virtual_workspace_backend(self, plate_path: Union[str, Path], filemanager: FileManager) -> None: 

212 """ 

213 Register virtual workspace backend if not already registered. 

214 

215 Centralized registration logic to avoid duplication across handlers. 

216 

217 Args: 

218 plate_path: Path to plate directory 

219 filemanager: FileManager instance 

220 """ 

221 from openhcs.io.virtual_workspace import VirtualWorkspaceBackend 

222 from openhcs.constants.constants import Backend 

223 

224 if Backend.VIRTUAL_WORKSPACE.value not in filemanager.registry: 

225 backend = VirtualWorkspaceBackend(plate_root=Path(plate_path)) 

226 filemanager.registry[Backend.VIRTUAL_WORKSPACE.value] = backend 

227 logger.info(f"Registered virtual workspace backend for {plate_path}") 

228 

229 def initialize_workspace(self, plate_path: Path, filemanager: FileManager) -> Path: 

230 """ 

231 Initialize plate by creating virtual mapping in metadata. 

232 

233 No physical workspace directory is created - mapping is purely metadata-based. 

234 All paths are relative to plate_path. 

235 

236 Args: 

237 plate_path: Path to plate directory 

238 filemanager: FileManager instance 

239 

240 Returns: 

241 Path to image directory (determined from plate structure) 

242 """ 

243 plate_path = Path(plate_path) 

244 

245 # Set plate_folder for this handler 

246 self.plate_folder = plate_path 

247 

248 # Call microscope-specific virtual mapping builder 

249 # This builds the plate-relative mapping dict and saves to metadata 

250 self._build_virtual_mapping(plate_path, filemanager) 

251 

252 # Register virtual workspace backend 

253 self._register_virtual_workspace_backend(plate_path, filemanager) 

254 

255 # Return image directory using post_workspace 

256 # skip_preparation=True because _build_virtual_mapping() already ran 

257 return self.post_workspace(plate_path, filemanager, skip_preparation=True) 

258 

259 def post_workspace(self, plate_path: Union[str, Path], filemanager: FileManager, skip_preparation: bool = False) -> Path: 

260 """ 

261 Apply post-workspace processing using virtual mapping. 

262 

263 All operations use plate_path - no workspace directory concept. 

264 

265 Args: 

266 plate_path: Path to plate directory 

267 filemanager: FileManager instance 

268 skip_preparation: Skip microscope-specific preparation (default: False) 

269 

270 Returns: 

271 Path to image directory 

272 """ 

273 # Ensure plate_path is a Path object 

274 if isinstance(plate_path, str): 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true

275 plate_path = Path(plate_path) 

276 

277 # NO existence check - virtual workspaces are metadata-only 

278 

279 # Apply microscope-specific preparation logic 

280 # _build_virtual_mapping() returns the correct image directory for each microscope: 

281 # - ImageXpress: plate_path (images are at plate root with TimePoint_X/ prefix in mapping) 

282 # - OperaPhenix: plate_path/Images (images are in Images/ subdirectory) 

283 if skip_preparation: 283 ↛ 311line 283 didn't jump to line 311 because the condition on line 283 was always true

284 logger.info("📁 SKIPPING PREPARATION: Virtual mapping already built") 

285 # When skipping, we need to determine image_dir from metadata 

286 # Read metadata to get the subdirectory key 

287 from openhcs.microscopes.openhcs import OpenHCSMetadataHandler 

288 from openhcs.io.exceptions import MetadataNotFoundError 

289 from openhcs.io.metadata_writer import resolve_subdirectory_path 

290 

291 openhcs_metadata_handler = OpenHCSMetadataHandler(filemanager) 

292 metadata = openhcs_metadata_handler._load_metadata_dict(plate_path) 

293 subdirs = metadata.get("subdirectories", {}) 

294 

295 # Find the subdirectory with workspace_mapping (should be "." or "Images") 

296 subdir_with_mapping = next( 

297 (name for name, data in subdirs.items() if "workspace_mapping" in data), 

298 None 

299 ) 

300 

301 # Fail if no workspace_mapping found 

302 if subdir_with_mapping is None: 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true

303 raise MetadataNotFoundError( 

304 f"skip_preparation=True but no workspace_mapping found in metadata for {plate_path}. " 

305 "Virtual workspace must be prepared before skipping." 

306 ) 

307 

308 # Convert subdirectory name to path (handles "." -> plate_path) 

309 image_dir = resolve_subdirectory_path(subdir_with_mapping, plate_path) 

310 else: 

311 logger.info("🔄 APPLYING PREPARATION: Building virtual mapping") 

312 # _build_virtual_mapping() returns the image directory 

313 image_dir = self._build_virtual_mapping(plate_path, filemanager) 

314 

315 # Determine backend - check if virtual workspace backend is registered 

316 if Backend.VIRTUAL_WORKSPACE.value in filemanager.registry: 316 ↛ 319line 316 didn't jump to line 319 because the condition on line 316 was always true

317 backend_type = Backend.VIRTUAL_WORKSPACE.value 

318 else: 

319 backend_type = Backend.DISK.value 

320 

321 # Ensure parser is provided 

322 parser = self.parser 

323 

324 # Get all image files in the directory 

325 image_files = filemanager.list_image_files(image_dir, backend_type) 

326 

327 # Map original filenames to reconstructed filenames 

328 rename_map = {} 

329 

330 for file_path in image_files: 

331 # FileManager should return strings, but handle Path objects too 

332 if isinstance(file_path, str): 332 ↛ 334line 332 didn't jump to line 334 because the condition on line 332 was always true

333 original_name = os.path.basename(file_path) 

334 elif isinstance(file_path, Path): 

335 original_name = file_path.name 

336 else: 

337 # Skip any unexpected types 

338 logger.warning("Unexpected file path type: %s", type(file_path).__name__) 

339 continue 

340 

341 # Parse the filename components 

342 metadata = parser.parse_filename(original_name) 

343 if not metadata: 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true

344 logger.warning("Could not parse filename: %s", original_name) 

345 continue 

346 

347 # Validate required components 

348 if metadata['site'] is None: 348 ↛ 349line 348 didn't jump to line 349 because the condition on line 348 was never true

349 logger.warning("Missing 'site' component in filename: %s", original_name) 

350 continue 

351 

352 if metadata['channel'] is None: 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true

353 logger.warning("Missing 'channel' component in filename: %s", original_name) 

354 continue 

355 

356 # z_index is optional - default to 1 if not present 

357 site = metadata['site'] 

358 channel = metadata['channel'] 

359 z_index = metadata['z_index'] if metadata['z_index'] is not None else 1 

360 

361 # Log the components for debugging 

362 logger.debug( 

363 "Parsed components for %s: site=%s, channel=%s, z_index=%s", 

364 original_name, site, channel, z_index 

365 ) 

366 

367 # Reconstruct the filename with proper padding 

368 metadata['site'] = site 

369 metadata['channel'] = channel 

370 metadata['z_index'] = z_index 

371 new_name = parser.construct_filename(**metadata) 

372 

373 # Add to rename map if different 

374 if original_name != new_name: 374 ↛ 375line 374 didn't jump to line 375 because the condition on line 374 was never true

375 rename_map[original_name] = new_name 

376 

377 # Perform the renaming 

378 for original_name, new_name in rename_map.items(): 378 ↛ 380line 378 didn't jump to line 380 because the loop on line 378 never started

379 # Create paths for the source and destination 

380 if isinstance(image_dir, str): 

381 original_path = os.path.join(image_dir, original_name) 

382 new_path = os.path.join(image_dir, new_name) 

383 else: # Path object 

384 original_path = image_dir / original_name 

385 new_path = image_dir / new_name 

386 

387 try: 

388 # Ensure the parent directory exists 

389 # Clause 245: Workspace operations are disk-only by design 

390 # This call is structurally hardcoded to use the "disk" backend 

391 parent_dir = os.path.dirname(new_path) if isinstance(new_path, str) else new_path.parent 

392 filemanager.ensure_directory(parent_dir, Backend.DISK.value) 

393 

394 # Rename the file using move operation 

395 # Clause 245: Workspace operations are disk-only by design 

396 # This call is structurally hardcoded to use the "disk" backend 

397 # Use replace_symlinks=True to allow overwriting existing symlinks 

398 filemanager.move(original_path, new_path, Backend.DISK.value, replace_symlinks=True) 

399 logger.debug("Renamed %s to %s", original_path, new_path) 

400 except (OSError, FileNotFoundError) as e: 

401 logger.error("Filesystem error renaming %s to %s: %s", original_path, new_path, e) 

402 except TypeError as e: 

403 logger.error("Type error renaming %s to %s: %s", original_path, new_path, e) 

404 except Exception as e: 

405 logger.error("Unexpected error renaming %s to %s: %s", original_path, new_path, e) 

406 

407 return image_dir 

408 

409 def _build_virtual_mapping(self, plate_path: Path, filemanager: FileManager) -> Path: 

410 """ 

411 Build microscope-specific virtual workspace mapping. 

412 

413 This method creates a plate-relative mapping dict and saves it to metadata. 

414 All paths in the mapping are relative to plate_path. 

415 

416 Override in subclasses that need virtual workspace mapping (e.g., ImageXpress, Opera Phenix). 

417 Handlers that override initialize_workspace() completely (e.g., OMERO, OpenHCS) don't need 

418 to implement this method. 

419 

420 Args: 

421 plate_path: Path to plate directory 

422 filemanager: FileManager instance for file operations 

423 

424 Returns: 

425 Path: Suggested directory for further processing 

426 

427 Raises: 

428 NotImplementedError: If called on a handler that doesn't support virtual workspace mapping 

429 """ 

430 raise NotImplementedError( 

431 f"{self.__class__.__name__} does not implement _build_virtual_mapping(). " 

432 f"This method is only needed for handlers that use the base class initialize_workspace(). " 

433 f"Handlers that override initialize_workspace() completely (like OMERO, OpenHCS) don't need this." 

434 ) 

435 

436 

437 # Delegate methods to parser 

438 def parse_filename(self, filename: str) -> Optional[Dict[str, Any]]: 

439 """Delegate to parser.""" 

440 return self.parser.parse_filename(filename) 

441 

442 def construct_filename(self, extension: str = '.tif', **component_values) -> str: 

443 """ 

444 Delegate to parser using pure generic interface. 

445 """ 

446 return self.parser.construct_filename(extension=extension, **component_values) 

447 

448 def auto_detect_patterns(self, folder_path: Union[str, Path], filemanager: FileManager, backend: str, 

449 extensions=None, group_by=None, variable_components=None, **kwargs): 

450 """ 

451 Delegate to pattern engine. 

452 

453 Args: 

454 folder_path: Path to the folder (string or Path object) 

455 filemanager: FileManager instance for file operations 

456 backend: Backend to use for file operations (required) 

457 extensions: Optional list of file extensions to include 

458 group_by: GroupBy enum to group patterns by (e.g., GroupBy.CHANNEL, GroupBy.Z_INDEX) 

459 variable_components: List of components to make variable (e.g., ['site', 'z_index']) 

460 **kwargs: Dynamic filter parameters (e.g., well_filter, site_filter, channel_filter) 

461 

462 Returns: 

463 Dict[str, Any]: Dictionary mapping axis values to patterns 

464 """ 

465 # Ensure folder_path is a valid path 

466 if isinstance(folder_path, str): 466 ↛ 468line 466 didn't jump to line 468 because the condition on line 466 was always true

467 folder_path = Path(folder_path) 

468 elif not isinstance(folder_path, Path): 

469 raise TypeError(f"Expected string or Path object, got {type(folder_path).__name__}") 

470 

471 # Ensure the path exists using FileManager abstraction 

472 if not filemanager.exists(str(folder_path), backend): 472 ↛ 473line 472 didn't jump to line 473 because the condition on line 472 was never true

473 raise ValueError(f"Folder path does not exist: {folder_path}") 

474 

475 # Set default GroupBy if none provided 

476 if group_by is None: 

477 from openhcs.constants.constants import GroupBy 

478 group_by = GroupBy.CHANNEL 

479 

480 # Create pattern engine on demand with the provided filemanager 

481 from openhcs.formats.pattern.pattern_discovery import PatternDiscoveryEngine 

482 pattern_engine = PatternDiscoveryEngine(self.parser, filemanager) 

483 

484 # Get patterns from the pattern engine 

485 patterns_by_well = pattern_engine.auto_detect_patterns( 

486 folder_path, 

487 extensions=extensions, 

488 group_by=group_by, 

489 variable_components=variable_components, 

490 backend=backend, 

491 **kwargs # Pass through dynamic filter parameters 

492 ) 

493 

494 # 🔒 Clause 74 — Runtime Behavior Variation 

495 # Ensure we always return a dictionary, not a generator 

496 if not isinstance(patterns_by_well, dict): 496 ↛ 498line 496 didn't jump to line 498 because the condition on line 496 was never true

497 # Convert to dictionary if it's not already one 

498 return dict(patterns_by_well) 

499 

500 return patterns_by_well 

501 

502 def path_list_from_pattern(self, directory: Union[str, Path], pattern, filemanager: FileManager, backend: str, variable_components: Optional[List[str]] = None): 

503 """ 

504 Delegate to pattern engine. 

505 

506 Args: 

507 directory: Directory to search (string or Path object) 

508 pattern: Pattern to match (str for literal filenames) 

509 filemanager: FileManager instance for file operations 

510 backend: Backend to use for file operations (required) 

511 variable_components: List of components that can vary (will be ignored during matching) 

512 

513 Returns: 

514 List of matching filenames 

515 

516 Raises: 

517 TypeError: If a string with braces is passed (pattern paths are no longer supported) 

518 ValueError: If directory does not exist 

519 """ 

520 # Ensure directory is a valid path using FileManager abstraction 

521 if isinstance(directory, str): 521 ↛ 525line 521 didn't jump to line 525 because the condition on line 521 was always true

522 directory_path = Path(directory) 

523 if not filemanager.exists(str(directory_path), backend): 523 ↛ 524line 523 didn't jump to line 524 because the condition on line 523 was never true

524 raise ValueError(f"Directory does not exist: {directory}") 

525 elif isinstance(directory, Path): 

526 directory_path = directory 

527 if not filemanager.exists(str(directory_path), backend): 

528 raise ValueError(f"Directory does not exist: {directory}") 

529 else: 

530 raise TypeError(f"Expected string or Path object, got {type(directory).__name__}") 

531 

532 # Allow string patterns with braces - they are used for template matching 

533 # The pattern engine will handle template expansion to find matching files 

534 

535 # Create pattern engine on demand with the provided filemanager 

536 from openhcs.formats.pattern.pattern_discovery import PatternDiscoveryEngine 

537 pattern_engine = PatternDiscoveryEngine(self.parser, filemanager) 

538 

539 # Delegate to the pattern engine 

540 return pattern_engine.path_list_from_pattern(directory_path, pattern, backend=backend, variable_components=variable_components) 

541 

542 # Delegate metadata handling methods to metadata_handler with context 

543 

544 def find_metadata_file(self, plate_path: Union[str, Path]) -> Optional[Path]: 

545 """Delegate to metadata handler.""" 

546 return self.metadata_handler.find_metadata_file(plate_path) 

547 

548 def get_grid_dimensions(self, plate_path: Union[str, Path]) -> Tuple[int, int]: 

549 """Delegate to metadata handler.""" 

550 return self.metadata_handler.get_grid_dimensions(plate_path) 

551 

552 def get_pixel_size(self, plate_path: Union[str, Path]) -> float: 

553 """Delegate to metadata handler.""" 

554 return self.metadata_handler.get_pixel_size(plate_path) 

555 

556 

557# Import handler classes at module level with explicit mapping 

558# No aliases or legacy compatibility layers (Clause 77) 

559 

560# Factory function 

561def create_microscope_handler(microscope_type: str = 'auto', 

562 plate_folder: Optional[Union[str, Path]] = None, 

563 filemanager: Optional[FileManager] = None, 

564 pattern_format: Optional[str] = None, 

565 allowed_auto_types: Optional[List[str]] = None) -> MicroscopeHandler: 

566 """ 

567 Factory function to create a microscope handler. 

568 

569 This function enforces explicit dependency injection by requiring a FileManager 

570 instance to be provided. This ensures that all components requiring file operations 

571 receive their dependencies explicitly, eliminating runtime fallbacks and enforcing 

572 declarative configuration. 

573 

574 Args: 

575 microscope_type: 'auto', 'imagexpress', 'opera_phenix', 'openhcs'. 

576 plate_folder: Required for 'auto' detection. 

577 filemanager: FileManager instance. Must be provided. 

578 pattern_format: Name of the pattern format to use. 

579 allowed_auto_types: For 'auto' mode, limit detection to these types. 

580 'openhcs' is always included and tried first. 

581 

582 Returns: 

583 An initialized MicroscopeHandler instance. 

584 

585 Raises: 

586 ValueError: If filemanager is None or if microscope_type cannot be determined. 

587 """ 

588 if filemanager is None: 588 ↛ 589line 588 didn't jump to line 589 because the condition on line 588 was never true

589 raise ValueError( 

590 "FileManager must be provided to create_microscope_handler. " 

591 "Default fallback has been removed." 

592 ) 

593 

594 logger.info("Using provided FileManager for microscope handler.") 

595 

596 # Auto-detect microscope type if needed 

597 if microscope_type == 'auto': 597 ↛ 606line 597 didn't jump to line 606 because the condition on line 597 was always true

598 if not plate_folder: 598 ↛ 599line 598 didn't jump to line 599 because the condition on line 598 was never true

599 raise ValueError("plate_folder is required for auto-detection") 

600 

601 plate_folder = Path(plate_folder) if isinstance(plate_folder, str) else plate_folder 

602 microscope_type = _auto_detect_microscope_type(plate_folder, filemanager, allowed_types=allowed_auto_types) 

603 logger.info("Auto-detected microscope type: %s", microscope_type) 

604 

605 # Handlers auto-discovered on first access to MICROSCOPE_HANDLERS 

606 from openhcs.microscopes.handler_registry_service import get_all_handler_types 

607 

608 # Get the appropriate handler class from the registry 

609 # No dynamic imports or fallbacks (Clause 77: Rot Intolerance) 

610 handler_class = MICROSCOPE_HANDLERS.get(microscope_type.lower()) 

611 if not handler_class: 611 ↛ 612line 611 didn't jump to line 612 because the condition on line 611 was never true

612 available_types = get_all_handler_types() 

613 raise ValueError( 

614 f"Unsupported microscope type: {microscope_type}. " 

615 f"Available types: {available_types}" 

616 ) 

617 

618 # Create and configure the handler 

619 logger.info(f"Creating {handler_class.__name__}") 

620 

621 # Create the handler with the parser and metadata handler 

622 # The filemanager will be passed to methods that need it 

623 handler = handler_class(filemanager, pattern_format=pattern_format) 

624 

625 # If the handler is OpenHCSMicroscopeHandler, set its plate_folder attribute. 

626 # This is crucial for its dynamic parser loading mechanism. 

627 # Use string comparison to avoid circular import 

628 if handler.__class__.__name__ == 'OpenHCSMicroscopeHandler': 

629 if plate_folder: 629 ↛ 635line 629 didn't jump to line 635 because the condition on line 629 was always true

630 handler.plate_folder = Path(plate_folder) if isinstance(plate_folder, str) else plate_folder 

631 logger.info(f"Set plate_folder for OpenHCSMicroscopeHandler: {handler.plate_folder}") 

632 else: 

633 # This case should ideally not happen if auto-detection or explicit type setting 

634 # implies a plate_folder is known. 

635 logger.warning("OpenHCSMicroscopeHandler created without an initial plate_folder. " 

636 "Parser will load upon first relevant method call with a path e.g. post_workspace.") 

637 

638 return handler 

639 

640 

641def validate_backend_compatibility(handler: MicroscopeHandler, backend: Backend) -> bool: 

642 """ 

643 Validate that a microscope handler supports a given storage backend. 

644 

645 Args: 

646 handler: MicroscopeHandler instance to check 

647 backend: Backend to validate compatibility with 

648 

649 Returns: 

650 bool: True if the handler supports the backend, False otherwise 

651 

652 Example: 

653 >>> handler = ImageXpressHandler(filemanager) 

654 >>> validate_backend_compatibility(handler, Backend.ZARR) 

655 False 

656 >>> validate_backend_compatibility(handler, Backend.DISK) 

657 True 

658 """ 

659 return backend in handler.supported_backends 

660 

661 

662def _try_metadata_detection(handler_class, filemanager: FileManager, plate_folder: Path) -> Optional[Path]: 

663 """ 

664 Try metadata detection with a handler, normalizing return types. 

665 

666 Args: 

667 handler_class: MetadataHandler class to try 

668 filemanager: FileManager instance 

669 plate_folder: Path to plate directory 

670 

671 Returns: 

672 Path if metadata found, None if metadata not found 

673 

674 Raises: 

675 Any exception from the handler (fail-loud behavior) 

676 """ 

677 handler = handler_class(filemanager) 

678 result = handler.find_metadata_file(plate_folder) 

679 

680 # Normalize return type: convert any truthy result to Path, falsy to None 

681 return Path(result) if result else None 

682 

683 

684def _auto_detect_microscope_type(plate_folder: Path, filemanager: FileManager, 

685 allowed_types: Optional[List[str]] = None) -> str: 

686 """ 

687 Auto-detect microscope type using registry iteration. 

688 

689 Args: 

690 plate_folder: Path to plate directory 

691 filemanager: FileManager instance 

692 allowed_types: Optional list of microscope types to try. 

693 If None, tries all registered types. 

694 'openhcs' is always included and tried first. 

695 

696 Returns: 

697 Detected microscope type string 

698 

699 Raises: 

700 ValueError: If microscope type cannot be determined 

701 MetadataNotFoundError: If metadata files are missing 

702 Any other exception from metadata handlers (fail-loud) 

703 """ 

704 # METADATA_HANDLERS is a SecondaryRegistryDict that auto-triggers discovery 

705 from openhcs.io.exceptions import MetadataNotFoundError 

706 

707 # Build detection order: openhcsdata first, then filtered/ordered list 

708 detection_order = ['openhcsdata'] # Always first, always included (correct registration name) 

709 

710 if allowed_types is None: 710 ↛ 715line 710 didn't jump to line 715 because the condition on line 710 was always true

711 # Use all registered handlers in registration order 

712 detection_order.extend([name for name in METADATA_HANDLERS.keys() if name != 'openhcsdata']) 

713 else: 

714 # Use filtered list, but ensure openhcsdata is first 

715 filtered_types = [name for name in allowed_types if name != 'openhcsdata' and name in METADATA_HANDLERS] 

716 detection_order.extend(filtered_types) 

717 

718 # Try detection in order - only catch expected "not found" exceptions 

719 for handler_name in detection_order: 719 ↛ 735line 719 didn't jump to line 735 because the loop on line 719 didn't complete

720 handler_class = METADATA_HANDLERS.get(handler_name) 

721 if not handler_class: 721 ↛ 722line 721 didn't jump to line 722 because the condition on line 721 was never true

722 continue 

723 

724 try: 

725 result = _try_metadata_detection(handler_class, filemanager, plate_folder) 

726 if result: 

727 logger.info(f"Auto-detected {handler_name} microscope type") 

728 return handler_name 

729 except (FileNotFoundError, MetadataNotFoundError): 

730 # Expected - this handler's metadata not found, try next 

731 logger.debug(f"{handler_name} metadata not found in {plate_folder}") 

732 continue 

733 

734 # No handler succeeded - provide detailed error message 

735 available_types = list(METADATA_HANDLERS.keys()) 

736 msg = (f"Could not auto-detect microscope type in {plate_folder}. " 

737 f"Tried: {detection_order}. " 

738 f"Available types: {available_types}. " 

739 f"Ensure metadata files are present for supported formats.") 

740 logger.error(msg) 

741 raise ValueError(msg) 

742 

743 

744# ============================================================================ 

745# Registry Export 

746# ============================================================================ 

747# Auto-created registry from MicroscopeHandler base class 

748MICROSCOPE_HANDLERS = MicroscopeHandler.__registry__