Coverage for src/config_parser.py: 100%

307 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-05 00:26 +0000

1"""Config file parser. 

2 

3This module provides high-level configuration retrieval functions. 

4Low-level utilities are in config_utils.py, logging in config_logging.py, 

5and filesystem operations in filesystem_utils.py per SRP. 

6""" 

7 

8__author__ = "Mandar Patil (mandarons@pm.me)" 

9 

10import multiprocessing 

11from typing import Any 

12 

13from icloudpy.services.photos import PhotoAsset 

14 

15from src import ( 

16 DEFAULT_DRIVE_DESTINATION, 

17 DEFAULT_ENUMERATION_CHUNK_SIZE, 

18 DEFAULT_PHOTOS_DESTINATION, 

19 DEFAULT_REQUEST_TIMEOUT_SEC, 

20 DEFAULT_RETRY_LOGIN_INTERVAL_SEC, 

21 DEFAULT_ROOT_DESTINATION, 

22 DEFAULT_SYNC_INTERVAL_SEC, 

23 configure_icloudpy_logging, 

24 get_logger, 

25) 

26from src.config_logging import ( 

27 log_config_debug, 

28 log_config_error, 

29 log_config_found_info, 

30 log_config_not_found_warning, 

31 log_invalid_config_value, 

32) 

33from src.config_utils import ( 

34 config_path_to_string, 

35 get_config_value, 

36 get_config_value_or_default, 

37 get_config_value_or_none, 

38 traverse_config_path, 

39) 

40from src.filesystem_utils import ensure_directory_exists, join_and_ensure_path 

41 

42# Configure icloudpy logging immediately after import 

43configure_icloudpy_logging() 

44 

45LOGGER = get_logger() 

46 

47# Cache for config values to prevent repeated warnings 

48_config_warning_cache = set() 

49 

50 

51def _log_config_warning_once(config_path: list, message: str) -> None: 

52 """Log a configuration warning only once for the given config path. 

53 

54 Args: 

55 config_path: Configuration path as list 

56 message: Warning message to log 

57 """ 

58 config_path_key = config_path_to_string(config_path) 

59 if config_path_key not in _config_warning_cache: 

60 _config_warning_cache.add(config_path_key) 

61 log_config_not_found_warning(config_path, message) 

62 

63 

64def clear_config_warning_cache() -> None: 

65 """Clear the configuration warning cache. 

66 

67 This function is primarily intended for testing purposes to ensure 

68 clean test isolation. 

69 """ 

70 _config_warning_cache.clear() 

71 

72 

73# ============================================================================= 

74# String Processing Functions 

75# ============================================================================= 

76 

77 

78def validate_and_strip_username(username: str, config_path: list[str]) -> str | None: 

79 """Validate and strip username string. 

80 

81 Args: 

82 username: Raw username string from config 

83 config_path: Config path for error logging 

84 

85 Returns: 

86 Stripped username if valid, None if empty 

87 """ 

88 username = username.strip() 

89 if len(username) == 0: 

90 log_config_error(config_path, "username is empty") 

91 return None 

92 return username 

93 

94 

95# ============================================================================= 

96# Credential Configuration Functions 

97# ============================================================================= 

98 

99 

100def get_username(config: dict) -> str | None: 

101 """Get username from config. 

102 

103 Args: 

104 config: Configuration dictionary 

105 

106 Returns: 

107 Username string if found and valid, None otherwise 

108 """ 

109 config_path = ["app", "credentials", "username"] 

110 if not traverse_config_path(config=config, config_path=config_path): 

111 log_config_error(config_path, "username is missing. Please set the username.") 

112 return None 

113 

114 username = get_config_value(config=config, config_path=config_path) 

115 return validate_and_strip_username(username, config_path) 

116 

117 

118def get_retry_login_interval(config: dict) -> int: 

119 """Return retry login interval from config. 

120 

121 Args: 

122 config: Configuration dictionary 

123 

124 Returns: 

125 Retry login interval in seconds 

126 """ 

127 config_path = ["app", "credentials", "retry_login_interval"] 

128 

129 if not traverse_config_path(config=config, config_path=config_path): 

130 retry_login_interval = DEFAULT_RETRY_LOGIN_INTERVAL_SEC 

131 log_config_not_found_warning( 

132 config_path, 

133 f"not found. Using default {retry_login_interval} seconds ...", 

134 ) 

135 else: 

136 retry_login_interval = get_config_value(config=config, config_path=config_path) 

137 log_config_found_info(f"Retrying login every {retry_login_interval} seconds.") 

138 

139 return retry_login_interval 

140 

141 

142def get_region(config: dict) -> str: 

143 """Return region from config. 

144 

145 Args: 

146 config: Configuration dictionary 

147 

148 Returns: 

149 Region string ('global' or 'china') 

150 """ 

151 config_path = ["app", "region"] 

152 region = get_config_value_or_default( 

153 config=config, config_path=config_path, default="global", 

154 ) 

155 

156 if region == "global" and not traverse_config_path( 

157 config=config, config_path=config_path, 

158 ): 

159 log_config_not_found_warning( 

160 config_path, "not found. Using default value - global ...", 

161 ) 

162 elif region not in ["global", "china"]: 

163 log_config_error( 

164 config_path, 

165 "is invalid. Valid values are - global or china. Using default value - global ...", 

166 ) 

167 region = "global" 

168 

169 return region 

170 

171 

172# ============================================================================= 

173# Sync Interval Configuration Functions 

174# ============================================================================= 

175 

176 

177def get_sync_interval( 

178 config: dict, config_path: list[str], service_name: str, log_messages: bool = True, 

179) -> int: 

180 """Get sync interval for a service (drive or photos). 

181 

182 Extracted common logic for retrieving sync intervals. 

183 

184 Args: 

185 config: Configuration dictionary 

186 config_path: Path to sync_interval config 

187 service_name: Name of service for logging ("drive" or "photos") 

188 log_messages: Whether to log informational messages (default: True) 

189 

190 Returns: 

191 Sync interval in seconds 

192 """ 

193 sync_interval = get_config_value_or_default( 

194 config=config, 

195 config_path=config_path, 

196 default=DEFAULT_SYNC_INTERVAL_SEC, 

197 ) 

198 

199 if log_messages: 

200 if sync_interval == DEFAULT_SYNC_INTERVAL_SEC: 

201 log_config_not_found_warning( 

202 config_path, 

203 f"is not found. Using default sync_interval: {sync_interval} seconds ...", 

204 ) 

205 else: 

206 log_config_found_info( 

207 f"Syncing {service_name} every {sync_interval} seconds.", 

208 ) 

209 

210 return sync_interval 

211 

212 

213def get_drive_sync_interval(config: dict, log_messages: bool = True) -> int: 

214 """Return drive sync interval from config. 

215 

216 Args: 

217 config: Configuration dictionary 

218 log_messages: Whether to log informational messages (default: True) 

219 

220 Returns: 

221 Drive sync interval in seconds 

222 """ 

223 config_path = ["drive", "sync_interval"] 

224 return get_sync_interval( 

225 config=config, 

226 config_path=config_path, 

227 service_name="drive", 

228 log_messages=log_messages, 

229 ) 

230 

231 

232def get_drive_request_timeout(config: dict) -> int: 

233 """Return drive request timeout from config. 

234 

235 Args: 

236 config: Configuration dictionary 

237 

238 Returns: 

239 Request timeout in seconds (default: DEFAULT_REQUEST_TIMEOUT_SEC) 

240 """ 

241 config_path = ["drive", "request_timeout"] 

242 return get_config_value_or_default( 

243 config=config, 

244 config_path=config_path, 

245 default=DEFAULT_REQUEST_TIMEOUT_SEC, 

246 ) 

247 

248 

249def get_photos_sync_interval(config: dict, log_messages: bool = True) -> int: 

250 """Return photos sync interval from config. 

251 

252 Args: 

253 config: Configuration dictionary 

254 log_messages: Whether to log informational messages (default: True) 

255 

256 Returns: 

257 Photos sync interval in seconds 

258 """ 

259 config_path = ["photos", "sync_interval"] 

260 return get_sync_interval( 

261 config=config, 

262 config_path=config_path, 

263 service_name="photos", 

264 log_messages=log_messages, 

265 ) 

266 

267 

268# ============================================================================= 

269# Thread Configuration Functions 

270# ============================================================================= 

271 

272 

273def calculate_default_max_threads() -> int: 

274 """Calculate default maximum threads based on CPU cores. 

275 

276 Returns: 

277 Default max threads (min of CPU count and 8) 

278 """ 

279 return min(multiprocessing.cpu_count(), 8) 

280 

281 

282def parse_max_threads_value(max_threads_config: Any, default_max_threads: int) -> int: 

283 """Parse and validate max_threads configuration value. 

284 

285 Args: 

286 max_threads_config: Raw config value (string "auto" or integer) 

287 default_max_threads: Default value to use 

288 

289 Returns: 

290 Validated max threads value (capped at 16) 

291 """ 

292 # Handle "auto" value 

293 if isinstance(max_threads_config, str) and max_threads_config.lower() == "auto": 

294 max_threads = default_max_threads 

295 log_config_found_info( 

296 f"Using automatic thread count: {max_threads} threads (based on CPU cores).", 

297 ) 

298 elif isinstance(max_threads_config, int) and max_threads_config >= 1: 

299 max_threads = min( 

300 max_threads_config, 16, 

301 ) # Cap at 16 to avoid overwhelming servers 

302 log_config_found_info(f"Using configured max_threads: {max_threads}.") 

303 else: 

304 log_invalid_config_value( 

305 ["app", "max_threads"], 

306 max_threads_config, 

307 "'auto' or integer >= 1", 

308 ) 

309 max_threads = default_max_threads 

310 

311 return max_threads 

312 

313 

314def get_web_ui_enabled(config: dict) -> bool: 

315 """Return whether the embedded web UI should start on container boot. 

316 

317 Default: **False** — opt-in. Existing mandarons installs see no 

318 behaviour change; only users who explicitly set 

319 ``app.web_ui.enabled: true`` open the port. 

320 """ 

321 return bool( 

322 get_config_value_or_default( 

323 config=config, 

324 config_path=["app", "web_ui", "enabled"], 

325 default=False, 

326 ), 

327 ) 

328 

329 

330def get_web_ui_host(config: dict) -> str: 

331 """Web UI bind address. 

332 

333 Default ``127.0.0.1`` — the web UI accepts the user's Apple ID 

334 password on POST /auth/password with no built-in authentication and 

335 no CSRF token (the feature assumes a reverse-proxy trust boundary 

336 in front of it). Defaulting to loopback means the credential- 

337 accepting form is never exposed to LAN/public on a vanilla install. 

338 Users who run behind a reverse proxy or want explicit LAN exposure 

339 set ``app.web_ui.host: 0.0.0.0`` consciously. 

340 """ 

341 return str( 

342 get_config_value_or_default( 

343 config=config, 

344 config_path=["app", "web_ui", "host"], 

345 default="127.0.0.1", 

346 ), 

347 ) 

348 

349 

350def get_web_ui_port(config: dict) -> int: 

351 """Web UI TCP port. Default ``8080``. Coexists with mandarons' legacy 

352 ``EXPOSE 80`` (unused) — no port collision.""" 

353 return int( 

354 get_config_value_or_default( 

355 config=config, 

356 config_path=["app", "web_ui", "port"], 

357 default=8080, 

358 ), 

359 ) 

360 

361 

362def get_web_ui_public_url(config: dict) -> str | None: 

363 """Public-facing URL for the web UI, used in notifications. 

364 

365 The daemon only knows its local bind host:port; the user-facing URL 

366 (e.g. ``https://icloud.zosia.io`` behind a reverse proxy) must be 

367 declared explicitly. When unset, notifications fall back to 

368 ``http://{host}:{port}`` and log a one-shot warning at startup. 

369 """ 

370 value = get_config_value_or_default( 

371 config=config, 

372 config_path=["app", "web_ui", "public_url"], 

373 default=None, 

374 ) 

375 if value is None: 

376 return None 

377 return str(value).rstrip("/") 

378 

379 

380def get_trust_expiry_warn_days(config: dict) -> int: 

381 """Warn this many days before Apple's trust cookie expires. 

382 

383 Default 7. The check + notification fires once per cookie-lifetime 

384 when ``trust_days_remaining`` first drops below this threshold so 

385 the user can tap refresh-trust *before* the sync loop hits a 

386 failed-auth state. 

387 """ 

388 return int( 

389 get_config_value_or_default( 

390 config=config, 

391 config_path=["app", "trust_expiry_warn_days"], 

392 default=7, 

393 ), 

394 ) 

395 

396 

397def get_app_max_threads(config: dict) -> int: 

398 """Return app-level max threads from config with support for 'auto' value. 

399 

400 Args: 

401 config: Configuration dictionary 

402 

403 Returns: 

404 Maximum number of threads for parallel operations 

405 """ 

406 default_max_threads = calculate_default_max_threads() 

407 config_path = ["app", "max_threads"] 

408 

409 if not traverse_config_path(config=config, config_path=config_path): 

410 log_config_debug( 

411 f"max_threads is not found in {config_path_to_string(config_path=config_path)}. " 

412 f"Using default max_threads: {default_max_threads} (auto) ...", 

413 ) 

414 return default_max_threads 

415 

416 max_threads_config = get_config_value(config=config, config_path=config_path) 

417 return parse_max_threads_value(max_threads_config, default_max_threads) 

418 

419 

420def get_mount_marker_filename(config: dict) -> str: 

421 """Return the filename used as the mount-failsafe marker (default `.mounted`). 

422 

423 The marker is the empty/sentinel file the user touches in a destination 

424 directory to assert "this path is correctly bind-mounted". The mount 

425 checker (``sync._check_mount_marker``) refuses to sync into a destination 

426 that lacks this file when ``require_mount_marker`` is enabled. 

427 

428 Args: 

429 config: Configuration dictionary 

430 

431 Returns: 

432 Filename string (relative to each destination directory). 

433 """ 

434 config_path = ["app", "mount_marker_filename"] 

435 return str(get_config_value_or_default(config=config, config_path=config_path, default=".mounted")) 

436 

437 

438def get_usage_tracking_enabled(config: dict) -> bool: 

439 """Get usage tracking enabled setting from configuration. 

440 

441 Args: 

442 config: Configuration dictionary 

443 

444 Returns: 

445 True if usage tracking is enabled (default), False if disabled 

446 """ 

447 config_path = ["app", "usage_tracking", "enabled"] 

448 if not traverse_config_path(config=config, config_path=config_path): 

449 return True # Default to enabled if not configured 

450 

451 value = get_config_value(config=config, config_path=config_path) 

452 if isinstance(value, bool): 

453 return value 

454 

455 # Handle string values for backwards compatibility 

456 if isinstance(value, str): 

457 return value.lower() not in ("false", "no", "0", "disabled", "off") 

458 

459 # Default to enabled for any other type 

460 return True 

461 

462 

463# ============================================================================= 

464# Root Destination Functions 

465# ============================================================================= 

466 

467 

468def get_root_destination_path(config: dict) -> str: 

469 """Get root destination path from config without creating directory. 

470 

471 Args: 

472 config: Configuration dictionary 

473 

474 Returns: 

475 Root destination path string 

476 """ 

477 config_path = ["app", "root"] 

478 root_destination = get_config_value_or_default( 

479 config=config, 

480 config_path=config_path, 

481 default=DEFAULT_ROOT_DESTINATION, 

482 ) 

483 

484 if not traverse_config_path(config=config, config_path=config_path): 

485 log_config_not_found_warning( 

486 config_path, 

487 f"root destination is missing. Using default root destination: {root_destination}", 

488 ) 

489 

490 return root_destination 

491 

492 

493def prepare_root_destination(config: dict) -> str: 

494 """Prepare root destination by creating directory if needed. 

495 

496 Args: 

497 config: Configuration dictionary 

498 

499 Returns: 

500 Absolute path to root destination directory 

501 """ 

502 log_config_debug("Checking root destination ...") 

503 root_destination = get_root_destination_path(config) 

504 return ensure_directory_exists(root_destination) 

505 

506 

507# ============================================================================= 

508# Drive Configuration Functions 

509# ============================================================================= 

510 

511 

512def get_drive_destination_path(config: dict) -> str: 

513 """Get drive destination path from config without creating directory. 

514 

515 Args: 

516 config: Configuration dictionary 

517 

518 Returns: 

519 Drive destination path string 

520 """ 

521 config_path = ["drive", "destination"] 

522 drive_destination = get_config_value_or_default( 

523 config=config, 

524 config_path=config_path, 

525 default=DEFAULT_DRIVE_DESTINATION, 

526 ) 

527 

528 if not traverse_config_path(config=config, config_path=config_path): 

529 log_config_not_found_warning( 

530 config_path, 

531 f"destination is missing. Using default drive destination: {drive_destination}.", 

532 ) 

533 

534 return drive_destination 

535 

536 

537def prepare_drive_destination(config: dict) -> str: 

538 """Prepare drive destination path by creating directory if needed. 

539 

540 Args: 

541 config: Configuration dictionary 

542 

543 Returns: 

544 Absolute path to drive destination directory 

545 """ 

546 log_config_debug("Checking drive destination ...") 

547 root_path = prepare_root_destination(config=config) 

548 drive_destination = get_drive_destination_path(config) 

549 return join_and_ensure_path(root_path, drive_destination) 

550 

551 

552def get_drive_remove_obsolete(config: dict) -> bool: 

553 """Return drive remove obsolete flag from config. 

554 

555 Args: 

556 config: Configuration dictionary 

557 

558 Returns: 

559 True if obsolete files should be removed, False otherwise 

560 """ 

561 config_path = ["drive", "remove_obsolete"] 

562 drive_remove_obsolete = get_config_value_or_default( 

563 config=config, config_path=config_path, default=False, 

564 ) 

565 

566 if not drive_remove_obsolete: 

567 _log_config_warning_once( 

568 config_path, 

569 "remove_obsolete is not found. Not removing the obsolete files and folders.", 

570 ) 

571 else: 

572 log_config_debug( 

573 f"{'R' if drive_remove_obsolete else 'Not R'}emoving obsolete files and folders ...", 

574 ) 

575 

576 return drive_remove_obsolete 

577 

578 

579def get_drive_require_mount_marker(config: dict) -> bool: 

580 """Return whether Drive sync requires the mount-failsafe marker file. 

581 

582 When True, ``sync._check_mount_marker`` refuses to start a Drive sync 

583 until the marker file (see ``get_mount_marker_filename``) exists in 

584 the Drive destination directory. Protects against silent bind-mount 

585 failures dumping iCloud Drive content into the wrong place. 

586 

587 Default: False (preserves historical behaviour — no breaking change). 

588 

589 Args: 

590 config: Configuration dictionary 

591 

592 Returns: 

593 True if the marker is required before each Drive sync. 

594 """ 

595 config_path = ["drive", "require_mount_marker"] 

596 return bool(get_config_value_or_default(config=config, config_path=config_path, default=False)) 

597 

598 

599# ============================================================================= 

600# Photos Configuration Functions 

601# ============================================================================= 

602 

603 

604def get_photos_enumeration_chunk_size(config: dict | None) -> int: 

605 """Tasks to buffer before draining via execute_parallel_downloads. 

606 

607 Smaller = lower peak memory, more per-chunk HTTP setup overhead. 

608 Larger = higher peak memory, fewer chunks. Default 1000 keeps 

609 resident set at ~10 MB on typical libraries while still amortising 

610 connection setup. Tested empirically on a 111K-photo library: 

611 1000 sustained < 1 GB resident through the full enumeration. 

612 

613 Args: 

614 config: Configuration dictionary (None falls back to default). 

615 

616 Returns: 

617 Positive integer chunk size, defaulting to 1000. 

618 """ 

619 raw = get_config_value_or_default( 

620 config=config or {}, 

621 config_path=["photos", "enumeration_chunk_size"], 

622 default=DEFAULT_ENUMERATION_CHUNK_SIZE, 

623 ) 

624 try: 

625 value = int(raw) 

626 except (TypeError, ValueError): 

627 return DEFAULT_ENUMERATION_CHUNK_SIZE 

628 return value if value > 0 else DEFAULT_ENUMERATION_CHUNK_SIZE 

629 

630 

631def get_photos_destination_path(config: dict) -> str: 

632 """Get photos destination path from config without creating directory. 

633 

634 Args: 

635 config: Configuration dictionary 

636 

637 Returns: 

638 Photos destination path string 

639 """ 

640 config_path = ["photos", "destination"] 

641 photos_destination = get_config_value_or_default( 

642 config=config, 

643 config_path=config_path, 

644 default=DEFAULT_PHOTOS_DESTINATION, 

645 ) 

646 

647 if not traverse_config_path(config=config, config_path=config_path): 

648 log_config_not_found_warning( 

649 config_path, 

650 f"destination is missing. Using default photos destination: {config_path_to_string(config_path)}", 

651 ) 

652 

653 return photos_destination 

654 

655 

656def prepare_photos_destination(config: dict) -> str: 

657 """Prepare photos destination path by creating directory if needed. 

658 

659 Args: 

660 config: Configuration dictionary 

661 

662 Returns: 

663 Absolute path to photos destination directory 

664 """ 

665 log_config_debug("Checking photos destination ...") 

666 root_path = prepare_root_destination(config=config) 

667 photos_destination = get_photos_destination_path(config) 

668 return join_and_ensure_path(root_path, photos_destination) 

669 

670 

671def get_photos_all_albums(config: dict) -> bool: 

672 """Return flag to download all albums from config. 

673 

674 Args: 

675 config: Configuration dictionary 

676 

677 Returns: 

678 True if all albums should be synced, False otherwise 

679 """ 

680 config_path = ["photos", "all_albums"] 

681 download_all = get_config_value_or_default( 

682 config=config, config_path=config_path, default=False, 

683 ) 

684 

685 if download_all: 

686 log_config_found_info("Syncing all albums.") 

687 

688 return download_all 

689 

690 

691def get_photos_use_hardlinks(config: dict, log_messages: bool = True) -> bool: 

692 """Return flag to use hard links for duplicate photos from config. 

693 

694 Args: 

695 config: Configuration dictionary 

696 log_messages: Whether to log informational messages (default: True) 

697 

698 Returns: 

699 True if hard links should be used, False otherwise 

700 """ 

701 config_path = ["photos", "use_hardlinks"] 

702 use_hardlinks = get_config_value_or_default( 

703 config=config, config_path=config_path, default=False, 

704 ) 

705 

706 if use_hardlinks and log_messages: 

707 log_config_found_info("Using hard links for duplicate photos.") 

708 

709 return use_hardlinks 

710 

711 

712def get_photos_remove_obsolete(config: dict) -> bool: 

713 """Return photos remove obsolete flag from config. 

714 

715 Args: 

716 config: Configuration dictionary 

717 

718 Returns: 

719 True if obsolete files should be removed, False otherwise 

720 """ 

721 config_path = ["photos", "remove_obsolete"] 

722 photos_remove_obsolete = get_config_value_or_default( 

723 config=config, config_path=config_path, default=False, 

724 ) 

725 

726 if not photos_remove_obsolete: 

727 _log_config_warning_once( 

728 config_path, 

729 "remove_obsolete is not found. Not removing the obsolete files and folders.", 

730 ) 

731 else: 

732 log_config_debug( 

733 f"{'R' if photos_remove_obsolete else 'Not R'}emoving obsolete files and folders ...", 

734 ) 

735 

736 return photos_remove_obsolete 

737 

738 

739def get_photos_require_mount_marker(config: dict) -> bool: 

740 """Return whether Photos sync requires the mount-failsafe marker file. 

741 

742 When True, ``sync._check_mount_marker`` refuses to start a Photos sync 

743 until the marker file (see ``get_mount_marker_filename``) exists in 

744 the Photos destination directory. Protects against silent bind-mount 

745 failures dumping the entire iCloud photo library into the wrong place. 

746 

747 Default: False (preserves historical behaviour — no breaking change). 

748 

749 Args: 

750 config: Configuration dictionary 

751 

752 Returns: 

753 True if the marker is required before each Photos sync. 

754 """ 

755 config_path = ["photos", "require_mount_marker"] 

756 return bool(get_config_value_or_default(config=config, config_path=config_path, default=False)) 

757 

758 

759def get_photos_folder_format(config: dict) -> str | None: 

760 """Return filename format or None. 

761 

762 Args: 

763 config: Configuration dictionary 

764 

765 Returns: 

766 Folder format string if configured, None otherwise 

767 """ 

768 config_path = ["photos", "folder_format"] 

769 fmt = get_config_value_or_none(config=config, config_path=config_path) 

770 

771 if fmt: 

772 log_config_found_info(f"Using format {fmt}.") 

773 

774 return fmt 

775 

776 

777# ============================================================================= 

778# Photos Filter Configuration Functions 

779# ============================================================================= 

780 

781 

782def validate_file_sizes(file_sizes: list[str]) -> list[str]: 

783 """Validate and filter file sizes against valid options. 

784 

785 Accepts any key in ``PhotoAsset.PHOTO_VERSION_LOOKUP``, including the 

786 ``live_video_*`` keys: add ``live_video_original`` to ``file_sizes`` to 

787 download the paired ``.mov`` of a Live Photo (and ``live_video_medium`` / 

788 ``live_video_thumb`` for smaller variants). Photos that aren't Live Photos 

789 simply don't have those versions and are skipped. 

790 

791 Args: 

792 file_sizes: List of file size strings to validate 

793 

794 Returns: 

795 List of valid file sizes (defaults to ["original"] if all invalid) 

796 """ 

797 valid_file_sizes = list(PhotoAsset.PHOTO_VERSION_LOOKUP.keys()) 

798 validated_sizes = [] 

799 

800 for file_size in file_sizes: 

801 if file_size not in valid_file_sizes: 

802 log_invalid_config_value( 

803 ["photos", "filters", "file_sizes"], 

804 file_size, 

805 ",".join(valid_file_sizes), 

806 ) 

807 else: 

808 validated_sizes.append(file_size) 

809 

810 return validated_sizes if validated_sizes else ["original"] 

811 

812 

813def get_photos_library_destinations(config: dict) -> dict[str, str]: 

814 """Get per-library destination subdirectory mapping from photos config. 

815 

816 Optional config block (under top-level ``photos``): 

817 

818 .. code-block:: yaml 

819 

820 photos: 

821 destination: photos 

822 library_destinations: 

823 PrimarySync: personal 

824 SharedLibrary: shared 

825 

826 When set, photos from each library are written to 

827 ``<photos.destination>/<library_destinations[library]>/...`` instead of 

828 sharing one destination tree. When unset (the default), all libraries 

829 share the single ``photos.destination`` path — preserving the historical 

830 behaviour of mandarons/icloud-docker. 

831 

832 Returns: 

833 Dict mapping library name → subdirectory relative to ``photos.destination``. 

834 Returns ``{}`` if not configured (backward-compatible default). 

835 """ 

836 config_path = ["photos", "library_destinations"] 

837 mapping = get_config_value_or_none(config=config, config_path=config_path) 

838 if not mapping or not isinstance(mapping, dict): 

839 return {} 

840 return {str(k): str(v) for k, v in mapping.items()} 

841 

842 

843def get_photos_libraries_filter(config: dict, base_config_path: list[str]) -> list[str] | None: 

844 """Get libraries filter from photos config. 

845 

846 Args: 

847 config: Configuration dictionary 

848 base_config_path: Base path to filters section 

849 

850 Returns: 

851 List of library names if configured, None otherwise 

852 """ 

853 config_path = base_config_path + ["libraries"] 

854 libraries = get_config_value_or_none(config=config, config_path=config_path) 

855 

856 if not libraries or len(libraries) == 0: 

857 log_config_not_found_warning( 

858 config_path, "not found. Downloading all libraries ...", 

859 ) 

860 return None 

861 

862 return libraries 

863 

864 

865def get_photos_albums_filter( 

866 config: dict, base_config_path: list[str], 

867) -> list[str] | None: 

868 """Get albums filter from photos config. 

869 

870 Args: 

871 config: Configuration dictionary 

872 base_config_path: Base path to filters section 

873 

874 Returns: 

875 List of album names if configured, None otherwise 

876 """ 

877 config_path = base_config_path + ["albums"] 

878 albums = get_config_value_or_none(config=config, config_path=config_path) 

879 

880 if not albums or len(albums) == 0: 

881 log_config_not_found_warning( 

882 config_path, "not found. Downloading all albums ...", 

883 ) 

884 return None 

885 

886 return albums 

887 

888 

889def get_photos_file_sizes_filter( 

890 config: dict, base_config_path: list[str], 

891) -> list[str]: 

892 """Get file sizes filter from photos config. 

893 

894 Args: 

895 config: Configuration dictionary 

896 base_config_path: Base path to filters section 

897 

898 Returns: 

899 List of file size options (defaults to ["original"]) 

900 """ 

901 config_path = base_config_path + ["file_sizes"] 

902 

903 if not traverse_config_path(config=config, config_path=config_path): 

904 log_config_not_found_warning( 

905 config_path, "not found. Downloading original size photos ...", 

906 ) 

907 return ["original"] 

908 

909 file_sizes = get_config_value(config=config, config_path=config_path) 

910 return validate_file_sizes(file_sizes) 

911 

912 

913def get_photos_extensions_filter( 

914 config: dict, base_config_path: list[str], 

915) -> list[str] | None: 

916 """Get extensions filter from photos config. 

917 

918 Args: 

919 config: Configuration dictionary 

920 base_config_path: Base path to filters section 

921 

922 Returns: 

923 List of file extensions if configured, None otherwise 

924 """ 

925 config_path = base_config_path + ["extensions"] 

926 extensions = get_config_value_or_none(config=config, config_path=config_path) 

927 

928 if not extensions or len(extensions) == 0: 

929 log_config_not_found_warning( 

930 config_path, "not found. Downloading all extensions ...", 

931 ) 

932 return None 

933 

934 return extensions 

935 

936 

937def get_photos_filters(config: dict) -> dict[str, Any]: 

938 """Return photos filters from config. 

939 

940 Args: 

941 config: Configuration dictionary 

942 

943 Returns: 

944 Dictionary containing filter configuration for photos 

945 """ 

946 photos_filters = { 

947 "libraries": None, 

948 "albums": None, 

949 "file_sizes": ["original"], 

950 "extensions": None, 

951 } 

952 

953 base_config_path = ["photos", "filters"] 

954 

955 # Check for filters section existence 

956 if not traverse_config_path(config=config, config_path=base_config_path): 

957 log_config_not_found_warning( 

958 base_config_path, 

959 "not found. Downloading all libraries and albums with original size ...", 

960 ) 

961 return photos_filters 

962 

963 # Parse individual filter components 

964 photos_filters["libraries"] = get_photos_libraries_filter(config, base_config_path) 

965 photos_filters["albums"] = get_photos_albums_filter(config, base_config_path) 

966 photos_filters["file_sizes"] = get_photos_file_sizes_filter( 

967 config, base_config_path, 

968 ) 

969 photos_filters["extensions"] = get_photos_extensions_filter( 

970 config, base_config_path, 

971 ) 

972 

973 return photos_filters 

974 

975 

976# ============================================================================= 

977# SMTP Configuration Functions 

978# ============================================================================= 

979 

980 

981def get_smtp_config_value( 

982 config: dict, key: str, warn_if_missing: bool = True, 

983) -> str | None: 

984 """Get SMTP configuration value with optional warning. 

985 

986 Common helper for SMTP config retrieval to reduce duplication. 

987 

988 Args: 

989 config: Configuration dictionary 

990 key: SMTP config key name 

991 warn_if_missing: Whether to log warning if not found 

992 

993 Returns: 

994 Config value if found, None otherwise 

995 """ 

996 config_path = ["app", "smtp", key] 

997 value = get_config_value_or_none(config=config, config_path=config_path) 

998 

999 if value is None and warn_if_missing: 

1000 log_config_not_found_warning(config_path, f"{key} is not found.") 

1001 

1002 return value 

1003 

1004 

1005def get_smtp_email(config: dict) -> str | None: 

1006 """Return smtp from email from config. 

1007 

1008 Args: 

1009 config: Configuration dictionary 

1010 

1011 Returns: 

1012 SMTP email address if configured, None otherwise 

1013 """ 

1014 return get_smtp_config_value(config, "email", warn_if_missing=False) 

1015 

1016 

1017def get_smtp_username(config: dict) -> str | None: 

1018 """Return smtp username from the config, if set. 

1019 

1020 Args: 

1021 config: Configuration dictionary 

1022 

1023 Returns: 

1024 SMTP username if configured, None otherwise 

1025 """ 

1026 return get_smtp_config_value(config, "username", warn_if_missing=False) 

1027 

1028 

1029def get_smtp_to_email(config: dict) -> str | None: 

1030 """Return smtp to email from config, defaults to from email. 

1031 

1032 Args: 

1033 config: Configuration dictionary 

1034 

1035 Returns: 

1036 SMTP 'to' email address, falling back to 'from' email if not specified 

1037 """ 

1038 to_email = get_smtp_config_value(config, "to", warn_if_missing=False) 

1039 return to_email if to_email else get_smtp_email(config=config) 

1040 

1041 

1042def get_smtp_password(config: dict) -> str | None: 

1043 """Return smtp password from config. 

1044 

1045 Args: 

1046 config: Configuration dictionary 

1047 

1048 Returns: 

1049 SMTP password if configured, None otherwise 

1050 """ 

1051 return get_smtp_config_value(config, "password", warn_if_missing=True) 

1052 

1053 

1054def get_smtp_host(config: dict) -> str | None: 

1055 """Return smtp host from config. 

1056 

1057 Args: 

1058 config: Configuration dictionary 

1059 

1060 Returns: 

1061 SMTP host if configured, None otherwise 

1062 """ 

1063 return get_smtp_config_value(config, "host", warn_if_missing=True) 

1064 

1065 

1066def get_smtp_port(config: dict) -> int | None: 

1067 """Return smtp port from config. 

1068 

1069 Args: 

1070 config: Configuration dictionary 

1071 

1072 Returns: 

1073 SMTP port if configured, None otherwise 

1074 """ 

1075 return get_smtp_config_value(config, "port", warn_if_missing=True) # type: ignore[return-value] 

1076 

1077 

1078def get_smtp_no_tls(config: dict) -> bool: 

1079 """Return smtp no_tls flag from config. 

1080 

1081 Args: 

1082 config: Configuration dictionary 

1083 

1084 Returns: 

1085 True if TLS should be disabled, False otherwise 

1086 """ 

1087 no_tls = get_smtp_config_value(config, "no_tls", warn_if_missing=True) 

1088 return no_tls if no_tls is not None else False # type: ignore[return-value] 

1089 

1090 

1091# ============================================================================= 

1092# Notification Service Configuration Functions 

1093# ============================================================================= 

1094 

1095 

1096def get_notification_config_value(config: dict, service: str, key: str) -> str | None: 

1097 """Get notification service configuration value. 

1098 

1099 Common helper for notification service config retrieval. 

1100 

1101 Args: 

1102 config: Configuration dictionary 

1103 service: Service name (telegram, discord, pushover) 

1104 key: Config key name 

1105 

1106 Returns: 

1107 Config value if found, None otherwise 

1108 """ 

1109 config_path = ["app", service, key] 

1110 value = get_config_value_or_none(config=config, config_path=config_path) 

1111 

1112 if value is None: 

1113 log_config_not_found_warning(config_path, f"{key} is not found.") 

1114 

1115 return value 

1116 

1117 

1118def get_telegram_bot_token(config: dict) -> str | None: 

1119 """Return telegram bot token from config. 

1120 

1121 Args: 

1122 config: Configuration dictionary 

1123 

1124 Returns: 

1125 Telegram bot token if configured, None otherwise 

1126 """ 

1127 return get_notification_config_value(config, "telegram", "bot_token") 

1128 

1129 

1130def get_telegram_chat_id(config: dict) -> str | None: 

1131 """Return telegram chat id from config. 

1132 

1133 Args: 

1134 config: Configuration dictionary 

1135 

1136 Returns: 

1137 Telegram chat ID if configured, None otherwise 

1138 """ 

1139 return get_notification_config_value(config, "telegram", "chat_id") 

1140 

1141 

1142def get_discord_webhook_url(config: dict) -> str | None: 

1143 """Return discord webhook_url from config. 

1144 

1145 Args: 

1146 config: Configuration dictionary 

1147 

1148 Returns: 

1149 Discord webhook URL if configured, None otherwise 

1150 """ 

1151 return get_notification_config_value(config, "discord", "webhook_url") 

1152 

1153 

1154def get_discord_username(config: dict) -> str | None: 

1155 """Return discord username from config. 

1156 

1157 Args: 

1158 config: Configuration dictionary 

1159 

1160 Returns: 

1161 Discord username if configured, None otherwise 

1162 """ 

1163 return get_notification_config_value(config, "discord", "username") 

1164 

1165 

1166def get_pushover_user_key(config: dict) -> str | None: 

1167 """Return Pushover user key from config. 

1168 

1169 Args: 

1170 config: Configuration dictionary 

1171 

1172 Returns: 

1173 Pushover user key if configured, None otherwise 

1174 """ 

1175 return get_notification_config_value(config, "pushover", "user_key") 

1176 

1177 

1178def get_pushover_api_token(config: dict) -> str | None: 

1179 """Return Pushover API token from config. 

1180 

1181 Args: 

1182 config: Configuration dictionary 

1183 

1184 Returns: 

1185 Pushover API token if configured, None otherwise 

1186 """ 

1187 return get_notification_config_value(config, "pushover", "api_token") 

1188 

1189 

1190def get_pushover_notification_priority(config: dict) -> int | None: 

1191 """Return Pushover notification priority from config. 

1192 

1193 Args: 

1194 config: Configuration dictionary 

1195 

1196 Returns: 

1197 Pushover notification priority if configured, None otherwise 

1198 """ 

1199 config_path = ["app", "pushover", "priority"] 

1200 return get_config_value_or_none(config=config, config_path=config_path) 

1201 

1202 

1203# ============================================================================= 

1204# Sync Summary Notification Configuration Functions 

1205# ============================================================================= 

1206 

1207 

1208def get_sync_summary_enabled(config: dict) -> bool: 

1209 """Return whether sync summary notifications are enabled. 

1210 

1211 Args: 

1212 config: Configuration dictionary 

1213 

1214 Returns: 

1215 True if sync summary is enabled, False otherwise (default: False) 

1216 """ 

1217 config_path = ["app", "notifications", "sync_summary", "enabled"] 

1218 if not traverse_config_path(config=config, config_path=config_path): 

1219 return False 

1220 

1221 value = get_config_value(config=config, config_path=config_path) 

1222 return bool(value) if value is not None else False 

1223 

1224 

1225def get_sync_summary_on_success(config: dict) -> bool: 

1226 """Return whether to send summary on successful syncs. 

1227 

1228 Args: 

1229 config: Configuration dictionary 

1230 

1231 Returns: 

1232 True if should send on success, False otherwise (default: True) 

1233 """ 

1234 config_path = ["app", "notifications", "sync_summary", "on_success"] 

1235 if not traverse_config_path(config=config, config_path=config_path): 

1236 return True # Default to True if not configured 

1237 

1238 value = get_config_value(config=config, config_path=config_path) 

1239 return bool(value) if value is not None else True 

1240 

1241 

1242def get_sync_summary_on_error(config: dict) -> bool: 

1243 """Return whether to send summary when errors occur. 

1244 

1245 Args: 

1246 config: Configuration dictionary 

1247 

1248 Returns: 

1249 True if should send on error, False otherwise (default: True) 

1250 """ 

1251 config_path = ["app", "notifications", "sync_summary", "on_error"] 

1252 if not traverse_config_path(config=config, config_path=config_path): 

1253 return True # Default to True if not configured 

1254 

1255 value = get_config_value(config=config, config_path=config_path) 

1256 return bool(value) if value is not None else True 

1257 

1258 

1259def get_sync_summary_min_downloads(config: dict) -> int: 

1260 """Return minimum downloads required to trigger notification. 

1261 

1262 Args: 

1263 config: Configuration dictionary 

1264 

1265 Returns: 

1266 Minimum downloads threshold (default: 1) 

1267 """ 

1268 config_path = ["app", "notifications", "sync_summary", "min_downloads"] 

1269 if not traverse_config_path(config=config, config_path=config_path): 

1270 return 1 # Default to 1 if not configured 

1271 

1272 value = get_config_value(config=config, config_path=config_path) 

1273 return int(value) if value is not None else 1