Coverage for openhcs/io/filemanager.py: 60.9%

220 statements  

« prev     ^ index     » next       coverage.py v7.10.3, created at 2025-08-14 05:57 +0000

1""" 

2FileManager directory operations. 

3 

4This module contains the directory-related methods of the FileManager class, 

5including directory listing, existence checking, mkdir, symlink, and mirror operations. 

6""" 

7 

8import logging 

9import os 

10from pathlib import Path 

11from typing import List, Set, Union, Tuple, Optional, Any 

12 

13from openhcs.constants.constants import DEFAULT_IMAGE_EXTENSIONS 

14from openhcs.io.base import StorageBackend 

15from openhcs.io.exceptions import PathMismatchError, StorageResolutionError 

16from openhcs.validation import validate_path_types, validate_backend_parameter 

17import traceback 

18 

19logger = logging.getLogger(__name__) 

20 

21class FileManager: 

22 

23 def __init__(self, registry): 

24 """ 

25 Initialize the file manager. 

26 

27 Args: 

28 registry: Registry for storage backends. Must be provided. 

29 

30 Raises: 

31 ValueError: If registry is not provided. 

32 

33 Note: 

34 This class is a backend-agnostic router. It maintains no default backend 

35 or fallback behavior, and all state is instance-local and declarative. 

36 Each operation must explicitly specify which backend to use. 

37 

38 Thread Safety: 

39 Each FileManager instance must be scoped to a single execution context. 

40 Do NOT share FileManager instances across pipelines or threads. 

41 For isolation, create a dedicated registry for each FileManager. 

42 """ 

43 # Validate registry parameter 

44 if registry is None: 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true

45 raise ValueError("Registry must be provided to FileManager. Default fallback has been removed.") 

46 

47 # Store registry 

48 self.registry = registry 

49 

50 

51 

52 logger.debug("FileManager initialized with registry") 

53 

54 def _get_backend(self, backend_name: str) -> StorageBackend: 

55 """ 

56 Get a backend by name. 

57 

58 This method uses the instance registry to get the backend instance directly. 

59 All FileManagers that use the same registry share the same backend instances. 

60 

61 Args: 

62 backend_name: Name of the backend to get (e.g., "disk", "memory", "zarr") 

63 

64 Returns: 

65 The backend instance 

66 

67 Raises: 

68 StorageResolutionError: If the backend is not found in the registry 

69 

70 Thread Safety: 

71 Backend instances are shared across all FileManager instances that use 

72 the same registry. This ensures shared state (especially for memory backend). 

73 """ 

74 # Normalize backend name 

75 backend_name = backend_name.lower() 

76 

77 if backend_name is None: 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true

78 raise StorageResolutionError(f"Backend '{backend_name}' not found in registry") 

79 

80 try: 

81 # Get the backend instance from the registry dictionary 

82 if backend_name not in self.registry: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true

83 raise KeyError(f"Backend '{backend_name}' not found in registry") 

84 

85 # Return the backend instance directly 

86 return self.registry[backend_name] 

87 except Exception as e: 

88 raise StorageResolutionError(f"Failed to get backend '{backend_name}': {e}") from e 

89 

90 def load(self, file_path: Union[str, Path], backend: str, **kwargs) -> Any: 

91 """ 

92 Load data from a file using the specified backend. 

93 

94 This method assumes the file path is already backend-compatible and performs no inference or fallback. 

95 All semantic validation and file format decoding must occur within the backend. 

96 

97 Args: 

98 file_path: Path to the file to load (str or Path) 

99 backend: Backend enum to use for loading (StorageBackendType.DISK, etc.) — POSITIONAL argument 

100 **kwargs: Additional keyword arguments passed to the backend's load method 

101 

102 Returns: 

103 Any: The loaded data object 

104 

105 Raises: 

106 StorageResolutionError: If the backend is not supported or load fails 

107 """ 

108 

109 try: 

110 backend_instance = self._get_backend(backend) 

111 return backend_instance.load(file_path, **kwargs) 

112 except StorageResolutionError: # Allow specific backend errors to propagate 

113 raise 

114 except Exception as e: 

115 logger.error(f"Unexpected error during load from {file_path} with backend {backend}: {e}", exc_info=True) 

116 raise StorageResolutionError( 

117 f"Failed to load file at {file_path} using backend '{backend}'" 

118 ) from e 

119 

120 def save(self, data: Any, output_path: Union[str, Path], backend: str, **kwargs) -> None: 

121 """ 

122 Save data to a file using the specified backend. 

123 

124 This method performs no semantic transformation, format inference, or fallback logic. 

125 It assumes the output path and data are valid and structurally aligned with the backend’s expectations. 

126 

127 Args: 

128 data: The data object to save (e.g., np.ndarray, torch.Tensor, dict, etc.) 

129 output_path: Destination path to write to (str or Path) 

130 backend: Backend enum to use for saving (StorageBackendType.DISK, etc.) — POSITIONAL argument 

131 **kwargs: Additional keyword arguments passed to the backend's save method 

132 

133 Raises: 

134 StorageResolutionError: If the backend is not supported or save fails 

135 """ 

136 

137 try: 

138 backend_instance = self._get_backend(backend) 

139 backend_instance.save(data, output_path, **kwargs) 

140 except StorageResolutionError: # Allow specific backend errors to propagate if they are StorageResolutionError 

141 raise 

142 except Exception as e: 

143 logger.error(f"Unexpected error during save to {output_path} with backend {backend}: {e}", exc_info=True) 

144 raise StorageResolutionError( 

145 f"Failed to save data to {output_path} using backend '{backend}'" 

146 ) from e 

147 

148 def load_batch(self, file_paths: List[Union[str, Path]], backend: str, **kwargs) -> List[Any]: 

149 """ 

150 Load multiple files using the specified backend. 

151 

152 Args: 

153 file_paths: List of file paths to load 

154 backend: Backend to use for loading 

155 **kwargs: Additional keyword arguments passed to the backend's load_batch method 

156 

157 Returns: 

158 List of loaded data objects in the same order as file_paths 

159 

160 Raises: 

161 StorageResolutionError: If the backend is not supported or load fails 

162 """ 

163 try: 

164 backend_instance = self._get_backend(backend) 

165 return backend_instance.load_batch(file_paths, **kwargs) 

166 except StorageResolutionError: 

167 raise 

168 except Exception as e: 

169 logger.error(f"Unexpected error during batch load with backend {backend}: {e}", exc_info=True) 

170 raise StorageResolutionError( 

171 f"Failed to load batch of {len(file_paths)} files using backend '{backend}'" 

172 ) from e 

173 

174 def save_batch(self, data_list: List[Any], output_paths: List[Union[str, Path]], backend: str, **kwargs) -> None: 

175 """ 

176 Save multiple data objects using the specified backend. 

177 

178 Args: 

179 data_list: List of data objects to save 

180 output_paths: List of destination paths (must match length of data_list) 

181 backend: Backend to use for saving 

182 **kwargs: Additional keyword arguments passed to the backend's save_batch method 

183 

184 Raises: 

185 StorageResolutionError: If the backend is not supported or save fails 

186 ValueError: If data_list and output_paths have different lengths 

187 """ 

188 try: 

189 backend_instance = self._get_backend(backend) 

190 backend_instance.save_batch(data_list, output_paths, **kwargs) 

191 except StorageResolutionError: 

192 raise 

193 except Exception as e: 

194 logger.error(f"Unexpected error during batch save with backend {backend}: {e}", exc_info=True) 

195 raise StorageResolutionError( 

196 f"Failed to save batch of {len(data_list)} files using backend '{backend}'" 

197 ) from e 

198 

199 def list_image_files(self, directory: Union[str, Path], backend: str, 

200 pattern: str = None, extensions: Set[str] = DEFAULT_IMAGE_EXTENSIONS, recursive: bool = False) -> List[str]: 

201 """ 

202 List all image files in a directory using the specified backend. 

203 

204 This method performs no semantic validation, normalization, or naming enforcement on the input path. 

205 It assumes the caller has provided a valid, backend-compatible path and merely dispatches it for execution. 

206 

207 Note: ONLY backend is a POSITIONAL argument. Other parameters may remain as kwargs. 

208 

209 Args: 

210 directory: Directory to search (str or Path) 

211 backend: Backend to use for listing ('disk', 'memory', 'zarr') - POSITIONAL 

212 pattern: Pattern to filter files (e.g., "*.tif") - can be keyword arg 

213 extensions: Set of file extensions to filter by - can be keyword arg 

214 recursive: Whether to search recursively - can be keyword arg 

215 

216 Returns: 

217 List of string paths for image files found 

218 

219 Raises: 

220 StorageResolutionError: If the backend is not supported 

221 TypeError: If directory is not a valid path type 

222 PathMismatchError: If the path scheme doesn't match the expected scheme for the backend 

223 """ 

224 # Get backend instance 

225 backend_instance = self._get_backend(backend) 

226 

227 # List image files and apply natural sorting 

228 from openhcs.core.utils import natural_sort 

229 files = backend_instance.list_files(str(directory), pattern, extensions, recursive) 

230 return natural_sort(files) 

231 

232 

233 def list_files(self, directory: Union[str, Path], backend: str, 

234 pattern: str = None, extensions: Set[str] = None, recursive: bool = False) -> List[str]: 

235 """ 

236 List all files in a directory using the specified backend. 

237 

238 This method performs no semantic validation, normalization, or naming enforcement on the input path. 

239 It assumes the caller has provided a valid, backend-compatible path and merely dispatches it for execution. 

240 

241 Note: ONLY backend is a POSITIONAL argument. Other parameters may remain as kwargs. 

242 

243 Args: 

244 directory: Directory to search (str or Path) 

245 backend: Backend to use for listing ('disk', 'memory', 'zarr') - POSITIONAL 

246 pattern: Pattern to filter files (e.g., "*.txt") - can be keyword arg 

247 extensions: Set of file extensions to filter by - can be keyword arg 

248 recursive: Whether to search recursively - can be keyword arg 

249 

250 Returns: 

251 List of string paths for files found 

252 

253 Raises: 

254 StorageResolutionError: If the backend is not supported 

255 TypeError: If directory is not a valid path type 

256 PathMismatchError: If the path scheme doesn't match the expected scheme for the backend 

257 """ 

258 # Get backend instance 

259 backend_instance = self._get_backend(backend) 

260 

261 # List files and apply natural sorting 

262 from openhcs.core.utils import natural_sort 

263 files = backend_instance.list_files(str(directory), pattern, extensions, recursive) 

264 return natural_sort(files) 

265 

266 

267 def find_file_recursive(self, directory: Union[str, Path], filename: str, backend: str) -> Union[str, None]: 

268 """ 

269 Find a file recursively in a directory using the specified backend. 

270 

271 This is a convenience method that uses list_files with recursive=True and filters for the specific filename. 

272 

273 Args: 

274 directory: Directory to search (str or Path) 

275 filename: Name of the file to find 

276 backend: Backend to use for listing ('disk', 'memory', 'zarr') - POSITIONAL 

277 

278 Returns: 

279 String path to the file if found, None otherwise 

280 

281 Raises: 

282 StorageResolutionError: If the backend is not supported 

283 TypeError: If directory is not a valid path type 

284 PathMismatchError: If the path scheme doesn't match the expected scheme for the backend 

285 """ 

286 # List all files recursively 

287 all_files = self.list_files(directory, backend, recursive=True) 

288 

289 # Filter for the specific filename 

290 for file_path in all_files: 

291 if Path(file_path).name == filename: 

292 return file_path 

293 

294 # File not found 

295 return None 

296 

297 

298 def list_dir(self, path: Union[str, Path], backend: str) -> List[str]: 

299 if not isinstance(path, (str, Path)): 299 ↛ 300line 299 didn't jump to line 300 because the condition on line 299 was never true

300 raise TypeError(f"Expected str or Path, got {type(path)}") 

301 

302 path = str(path) 

303 backend_instance = self._get_backend(backend) 

304 

305 try: 

306 # Get directory listing and apply natural sorting 

307 from openhcs.core.utils import natural_sort 

308 entries = backend_instance.list_dir(str(path)) 

309 return natural_sort(entries) 

310 except (FileNotFoundError, NotADirectoryError): 

311 # Let these bubble up for structural truth-checking 

312 raise 

313 except Exception as e: 

314 # Optional trace wrapper, no type mutation 

315 raise RuntimeError(f"Unexpected failure in list_dir({path}) for backend {backend}") from e 

316 

317 def ensure_directory(self, directory: Union[str, Path], backend: str) -> str: 

318 """ 

319 Ensure a directory exists, creating it if necessary. 

320 

321 This method performs no semantic validation, normalization, or naming enforcement on the input path. 

322 It assumes the caller has provided a valid, backend-compatible path and merely dispatches it for execution. 

323 

324 Note: ONLY backend is a POSITIONAL argument. All parameters are required. 

325 

326 Args: 

327 directory: Directory to ensure exists (str or Path) 

328 backend: Backend to use for directory operations ('disk', 'memory', 'zarr') - POSITIONAL 

329 

330 Returns: 

331 String path to the directory 

332 

333 Raises: 

334 StorageResolutionError: If the backend is not supported 

335 TypeError: If directory is not a valid path type 

336 PathMismatchError: If the path scheme doesn't match the expected scheme for the backend 

337 """ 

338 # Get backend instance 

339 backend_instance = self._get_backend(backend) 

340 

341 # Ensure directory 

342 return backend_instance.ensure_directory(str(directory)) 

343 

344 

345 

346 def exists(self, path: Union[str, Path], backend: str) -> bool: 

347 """ 

348 Check if a path exists. 

349 

350 This method performs no semantic validation, normalization, or naming enforcement on the input path. 

351 It assumes the caller has provided a valid, backend-compatible path and merely dispatches it for execution. 

352 

353 Note: ONLY backend is a POSITIONAL argument. All parameters are required. 

354 

355 Args: 

356 path: Path to check (str or Path) 

357 backend: Backend to use for checking ('disk', 'memory', 'zarr') - POSITIONAL 

358 

359 Returns: 

360 True if the path exists, False otherwise 

361 

362 Raises: 

363 StorageResolutionError: If the backend is not supported 

364 TypeError: If path is not a valid path type 

365 PathMismatchError: If the path scheme doesn't match the expected scheme for the backend 

366 """ 

367 # Get backend instance 

368 backend_instance = self._get_backend(backend) 

369 

370 # Check if path exists 

371 return backend_instance.exists(str(path)) 

372 

373 

374 def mirror_directory_with_symlinks( 

375 self, 

376 source_dir: Union[str, Path], 

377 target_dir: Union[str, Path], 

378 backend: str, 

379 recursive: bool = True, 

380 overwrite_symlinks_only: bool = False 

381 ) -> int: 

382 """ 

383 Mirror a directory structure from source to target and create symlinks to all files. 

384 

385 This method performs no semantic validation, normalization, or naming enforcement on the input paths. 

386 It assumes the caller has provided valid, backend-compatible paths and merely dispatches them for execution. 

387 

388 By default, this method will NOT overwrite existing files. Use overwrite_symlinks_only=True to allow 

389 overwriting existing symlinks (but not regular files). 

390 

391 Note: ONLY backend is a POSITIONAL argument. Other parameters may remain as kwargs. 

392 

393 Args: 

394 source_dir: Path to the source directory to mirror (str or Path) 

395 target_dir: Path to the target directory where the mirrored structure will be created (str or Path) 

396 backend: Backend to use for mirroring ('disk', 'memory', 'zarr') - POSITIONAL 

397 recursive: Whether to recursively mirror subdirectories - can be keyword arg 

398 overwrite_symlinks_only: If True, allows overwriting existing symlinks but blocks overwriting regular files. 

399 If False (default), no overwriting is allowed. - can be keyword arg 

400 

401 Returns: 

402 int: Number of symlinks created 

403 

404 Raises: 

405 StorageResolutionError: If the backend is not supported 

406 FileExistsError: If target files exist and overwrite_symlinks_only=False, or if trying to overwrite regular files 

407 TypeError: If source_dir or target_dir is not a valid path type 

408 PathMismatchError: If the path scheme doesn't match the expected scheme for the backend 

409 """ 

410 # Get backend instance 

411 backend_instance = self._get_backend(backend) 

412 # Mirror the directory structure and create symlinks for files recursively 

413 self.ensure_directory(target_dir, backend) 

414 try: 

415 # Ensure target directory exists 

416 

417 # Count symlinks 

418 symlink_count = 0 

419 

420 # Get all directories under source_dir (including source_dir itself) 

421 

422 _, all_files = self.collect_dirs_and_files(source_dir, backend, recursive=True) 

423 

424 # 1. Ensure base target exists 

425 self.ensure_directory(target_dir, backend) 

426 

427 # 2. Symlink all file paths 

428 for file_path in all_files: 

429 rel_path = Path(file_path).relative_to(Path(source_dir)) 

430 symlink_path = Path(target_dir) / rel_path 

431 self.create_symlink(file_path, str(symlink_path), backend, overwrite_symlinks_only=overwrite_symlinks_only) 

432 symlink_count += 1 

433 

434 return symlink_count 

435 

436 except Exception as e: 

437 raise StorageResolutionError(f"Failed to mirror directory {source_dir} to {target_dir} with backend {backend}") from e 

438 

439 def create_symlink( 

440 self, 

441 source_path: Union[str, Path], 

442 symlink_path: Union[str, Path], 

443 backend: str, 

444 overwrite_symlinks_only: bool = False 

445 ) -> bool: 

446 """ 

447 Create a symbolic link from source_path to symlink_path. 

448 

449 This method performs no semantic validation, normalization, or naming enforcement on the input paths. 

450 It assumes the caller has provided valid, backend-compatible paths and merely dispatches them for execution. 

451 

452 Note: ONLY backend is a POSITIONAL argument. All parameters are required. 

453 

454 Args: 

455 source_path: Path to the source file or directory (str or Path) 

456 symlink_path: Path where the symlink should be created (str or Path) 

457 backend: Backend to use for symlink creation ('disk', 'memory', 'zarr') - POSITIONAL 

458 overwrite_symlinks_only: If True, only allow overwriting existing symlinks (not regular files) 

459 

460 Returns: 

461 bool: True if successful, False otherwise 

462 

463 Raises: 

464 StorageResolutionError: If the backend is not supported 

465 FileExistsError: If target exists and is not a symlink when overwrite_symlinks_only=True 

466 VFSTypeError: If source_path or symlink_path cannot be converted to internal path format 

467 PathMismatchError: If the path scheme doesn't match the expected scheme for the backend 

468 """ 

469 # Get backend instance 

470 backend_instance = self._get_backend(backend) 

471 

472 # Check if target exists and handle overwrite policy 

473 try: 

474 if backend_instance.exists(str(symlink_path)): 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true

475 if overwrite_symlinks_only: 

476 # Check if existing target is a symlink 

477 if not self.is_symlink(symlink_path, backend): 

478 raise FileExistsError( 

479 f"Target exists and is not a symlink (overwrite_symlinks_only=True): {symlink_path}" 

480 ) 

481 # Target is a symlink, allow overwrite 

482 backend_instance.create_symlink(str(source_path), str(symlink_path), overwrite=True) 

483 else: 

484 # No overwrite allowed 

485 raise FileExistsError(f"Target already exists: {symlink_path}") 

486 else: 

487 # Target doesn't exist, create new symlink 

488 backend_instance.create_symlink(str(source_path), str(symlink_path), overwrite=False) 

489 

490 return True 

491 except FileExistsError: 

492 # Re-raise FileExistsError from our check or from backend 

493 raise 

494 except Exception as e: 

495 raise StorageResolutionError( 

496 f"Failed to create symlink from {source_path} to {symlink_path} with backend {backend}" 

497 ) from e 

498 

499 def delete(self, path: Union[str, Path], backend: str, recursive: bool = False) -> bool: 

500 """ 

501 Delete a file or directory. 

502 

503 This method performs no semantic validation, normalization, or naming enforcement on the input path. 

504 It assumes the caller has provided a valid, backend-compatible path and merely dispatches it for execution. 

505 

506 Note: ONLY backend is a POSITIONAL argument. All parameters are required. 

507 

508 Args: 

509 path: Path to the file or directory to delete (str or Path) 

510 backend: Backend to use for deletion ('disk', 'memory', 'zarr') - POSITIONAL 

511 

512 Returns: 

513 True if successful, False otherwise 

514 

515 Raises: 

516 StorageResolutionError: If the backend is not supported 

517 FileNotFoundError: If the file does not exist 

518 TypeError: If the path is not a valid path type 

519 """ 

520 # Get backend instance 

521 backend_instance = self._get_backend(backend) 

522 

523 # Delete the file or directory 

524 try: 

525 # No virtual path conversion needed 

526 return backend_instance.delete(str(path)) 

527 except Exception as e: 

528 raise StorageResolutionError( 

529 f"Failed to delete {path} with backend {backend}" 

530 ) from e 

531 

532 def delete_all(self, path: Union[str, Path], backend: str) -> bool: 

533 """ 

534 Recursively delete a file, symlink, or directory at the given path. 

535  

536 This method performs no fallback, coercion, or resolution — it dispatches to the backend. 

537 All resolution and deletion behavior must be encoded in the backend's `delete_all()` method. 

538  

539 Args: 

540 path: The path to delete 

541 backend: The backend key (e.g., 'disk', 'memory', 'zarr') 

542  

543 Returns: 

544 True if successful 

545  

546 Raises: 

547 StorageResolutionError: If the backend operation fails 

548 FileNotFoundError: If the path does not exist 

549 TypeError: If the path is not a str or Path 

550 """ 

551 backend_instance = self._get_backend(backend) 

552 path_str = str(path) 

553 

554 try: 

555 backend_instance.delete_all(path_str) 

556 return True 

557 except Exception as e: 

558 raise StorageResolutionError( 

559 f"Failed to delete_all({path_str}) using backend '{backend}'" 

560 ) from e 

561 

562 

563 def copy(self, source_path: Union[str, Path], dest_path: Union[str, Path], backend: str) -> bool: 

564 """ 

565 Copy a file, directory, or symlink from source_path to dest_path using the given backend. 

566 

567 - Will NOT overwrite existing files/directories. 

568 - Handles symlinks as first-class objects (not dereferenced). 

569 - Raises on broken links or mismatched structure. 

570 

571 Raises: 

572 FileExistsError: If destination exists 

573 FileNotFoundError: If source does not exist 

574 StorageResolutionError: On backend failure 

575 """ 

576 backend_instance = self._get_backend(backend) 

577 

578 try: 

579 # Prevent overwriting 

580 if backend_instance.exists(dest_path): 

581 raise FileExistsError(f"Destination already exists: {dest_path}") 

582 

583 # Ensure destination parent exists 

584 dest_parent = Path(dest_path).parent 

585 self.ensure_directory(dest_parent, backend) 

586 

587 # Delegate to backend-native copy 

588 return backend_instance.copy(str(source_path), str(dest_path)) 

589 except Exception as e: 

590 raise StorageResolutionError( 

591 f"Failed to copy from {source_path} to {dest_path} on backend {backend}" 

592 ) from e 

593 

594 

595 def move(self, source_path: Union[str, Path], dest_path: Union[str, Path], backend: str, 

596 replace_symlinks: bool = False) -> bool: 

597 """ 

598 Move a file, directory, or symlink from source_path to dest_path. 

599 

600 - Will NOT overwrite by default. 

601 - Preserves symbolic identity (moves links as links). 

602 - Uses backend-native move if available. 

603 - Can optionally replace existing symlinks when replace_symlinks=True. 

604 

605 Args: 

606 source_path: Source file or directory path 

607 dest_path: Destination file or directory path 

608 backend: Backend to use for the operation 

609 replace_symlinks: If True, allows overwriting existing symlinks at destination. 

610 If False (default), raises FileExistsError if destination exists. 

611 

612 Raises: 

613 FileExistsError: If destination exists and replace_symlinks=False, or if 

614 destination exists and is not a symlink when replace_symlinks=True 

615 FileNotFoundError: If source is missing 

616 StorageResolutionError: On backend failure 

617 """ 

618 backend_instance = self._get_backend(backend) 

619 

620 try: 

621 # Handle destination existence based on replace_symlinks setting 

622 if backend_instance.exists(dest_path): 622 ↛ 623line 622 didn't jump to line 623 because the condition on line 622 was never true

623 if replace_symlinks: 

624 # Check if destination is a symlink 

625 if backend_instance.is_symlink(dest_path): 

626 logger.debug("Destination is a symlink, removing before move: %s", dest_path) 

627 backend_instance.delete(dest_path) 

628 else: 

629 # Destination exists but is not a symlink 

630 raise FileExistsError(f"Destination already exists and is not a symlink: {dest_path}") 

631 else: 

632 # replace_symlinks=False, don't allow any overwriting 

633 raise FileExistsError(f"Destination already exists: {dest_path}") 

634 

635 dest_parent = Path(dest_path).parent 

636 self.ensure_directory(dest_parent, backend) 

637 return backend_instance.move(str(source_path), str(dest_path)) 

638 

639 except Exception as e: 

640 raise StorageResolutionError( 

641 f"Failed to move from {source_path} to {dest_path} on backend {backend}" 

642 ) from e 

643 

644 def collect_dirs_and_files( 

645 self, 

646 base_dir: Union[str, Path], 

647 backend: str, 

648 recursive: bool = True 

649 ) -> Tuple[List[str], List[str]]: 

650 """ 

651 Collect all valid directories and files starting from base_dir using breadth-first traversal. 

652 

653 Returns: 

654 (dirs, files): Lists of string paths for directories and files 

655 """ 

656 from collections import deque 

657 

658 base_dir = str(base_dir) 

659 # Use deque for breadth-first traversal (FIFO instead of LIFO) 

660 queue = deque([base_dir]) 

661 dirs: List[str] = [] 

662 files: List[str] = [] 

663 

664 while queue: 

665 current_path = queue.popleft() # FIFO for breadth-first 

666 

667 try: 

668 entries = self.list_dir(current_path, backend) 

669 dirs.append(current_path) 

670 except (NotADirectoryError, FileNotFoundError): 

671 files.append(current_path) 

672 continue 

673 except Exception as e: 

674 print(f"[collect_dirs_and_files] Unexpected error at {current_path}: {type(e).__name__}{e}") 

675 continue # Fail-safe: skip unexpected issues 

676 

677 if entries is None: 677 ↛ 679line 677 didn't jump to line 679 because the condition on line 677 was never true

678 # Defensive fallback — entries must be iterable 

679 print(f"[collect_dirs_and_files] WARNING: list_dir() returned None at {current_path}") 

680 continue 

681 

682 for entry in entries: 

683 full_path = str(Path(current_path) / entry) 

684 try: 

685 self.list_dir(full_path, backend) 

686 dirs.append(full_path) 

687 if recursive: 687 ↛ 682line 687 didn't jump to line 682 because the condition on line 687 was always true

688 queue.append(full_path) # Add to end of queue for breadth-first 

689 except (NotADirectoryError, FileNotFoundError): 

690 files.append(full_path) 

691 except Exception as e: 

692 print(f"[collect_dirs_and_files] Skipping {full_path}: {type(e).__name__}{e}") 

693 continue 

694 

695 # Apply natural sorting to both dirs and files before returning 

696 from openhcs.core.utils import natural_sort 

697 return natural_sort(dirs), natural_sort(files) 

698 

699 def is_file(self, path: Union[str, Path], backend: str) -> bool: 

700 """ 

701 Check if a given path is a file using the specified backend. 

702 

703 Args: 

704 path: Path to check (raw string or Path) 

705 backend: Backend key ('disk', 'memory', 'zarr') — must be positional 

706 

707 Returns: 

708 bool: True if the path is a file, False otherwise (including if path doesn't exist) 

709 """ 

710 try: 

711 backend_instance = self._get_backend(backend) 

712 return backend_instance.is_file(path) 

713 except Exception: 

714 # Return False for any error (file not found, is a directory, backend issues) 

715 return False 

716 

717 def is_dir(self, path: Union[str, Path], backend: str) -> bool: 

718 """ 

719 Check if a given path is a directory using the specified backend. 

720 

721 Args: 

722 path: Path to check (raw string or Path) 

723 backend: Backend key ('disk', 'memory', 'zarr') — must be positional 

724 

725 Returns: 

726 bool: True if the path is a directory, False if it's a file or doesn't exist 

727 

728 Raises: 

729 StorageResolutionError: If resolution fails or backend misbehaves 

730 """ 

731 try: 

732 backend_instance = self._get_backend(backend) 

733 return backend_instance.is_dir(path) 

734 except (FileNotFoundError, NotADirectoryError): 

735 # Return False for files or non-existent paths instead of raising 

736 return False 

737 except Exception as e: 

738 raise StorageResolutionError( 

739 f"Failed to check if {path} is a directory with backend '{backend}'" 

740 ) from e 

741 

742 def is_symlink(self, path: Union[str, Path], backend: str) -> bool: 

743 """ 

744 Check if a given path is a symbolic link using the specified backend. 

745 

746 Args: 

747 path: Path to check (raw string or Path) 

748 backend: Backend key ('disk', 'memory', 'zarr') — must be positional 

749 

750 Returns: 

751 bool: True if the path is a symbolic link, False otherwise (including if path doesn't exist) 

752 """ 

753 try: 

754 backend_instance = self._get_backend(backend) 

755 return backend_instance.is_symlink(str(path)) 

756 except Exception: 

757 # Return False for any error (file not found, not a symlink, backend issues) 

758 return False 

759