Coverage for src/sync.py: 100%
337 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-27 05:33 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-27 05:33 +0000
1"""Sync module."""
3__author__ = "Mandar Patil <mandarons@pm.me>"
4import datetime
5import os
6from time import sleep
8from icloudpy import ICloudPyService, exceptions, utils
10from src import (
11 DEFAULT_CONFIG_FILE_PATH,
12 ENV_CONFIG_FILE_PATH_KEY,
13 ENV_ICLOUD_PASSWORD_KEY,
14 config_parser,
15 configure_icloudpy_logging,
16 get_logger,
17 notify,
18 read_config,
19 sync_drive,
20 sync_photos,
21)
22from src.sync_stats import SyncSummary
23from src.usage import alive
25# Configure icloudpy logging immediately after import
26configure_icloudpy_logging()
28LOGGER = get_logger()
31def get_api_instance(
32 username: str,
33 password: str,
34 cookie_directory: str | None = None,
35 server_region: str = "global",
36) -> ICloudPyService:
37 """
38 Create and return an iCloud API client instance.
40 Args:
41 username: iCloud username/Apple ID
42 password: iCloud password
43 cookie_directory: Directory to store authentication cookies.
44 When ``None`` (the default), resolved late from
45 ``src.DEFAULT_COOKIE_DIRECTORY`` so test fixtures that
46 redirect the constant at runtime take effect — the previous
47 ``= DEFAULT_COOKIE_DIRECTORY`` default-arg capture made the
48 constant unmockable post-import.
49 server_region: Server region ("china" or "global")
51 Returns:
52 Configured ICloudPyService instance
53 """
54 if cookie_directory is None:
55 # Read through the src module so monkey-patches of
56 # ``src.DEFAULT_COOKIE_DIRECTORY`` (e.g. by tests/conftest.py)
57 # are honoured. ``src`` is this function's parent package and
58 # already imported; using ``sys.modules`` avoids a per-call
59 # ``import src`` and makes the data flow explicit.
60 import sys
62 cookie_directory = sys.modules["src"].DEFAULT_COOKIE_DIRECTORY
63 return (
64 ICloudPyService(
65 apple_id=username,
66 password=password,
67 cookie_directory=cookie_directory,
68 home_endpoint="https://www.icloud.com.cn",
69 setup_endpoint="https://setup.icloud.com.cn/setup/ws/1",
70 )
71 if server_region == "china"
72 else ICloudPyService(
73 apple_id=username,
74 password=password,
75 cookie_directory=cookie_directory,
76 )
77 )
80class SyncState:
81 """
82 Maintains synchronization state for drive and photos.
84 This class encapsulates the countdown timers and sync flags to avoid
85 passing multiple variables between functions.
86 """
88 def __init__(self):
89 """Initialize sync state with default values."""
90 self.drive_time_remaining = 0
91 self.photos_time_remaining = 0
92 self.enable_sync_drive = True
93 self.enable_sync_photos = True
94 self.last_send = None
97def _load_configuration():
98 """
99 Load configuration from file or environment.
101 Returns:
102 Configuration dictionary
103 """
104 config_path = os.environ.get(ENV_CONFIG_FILE_PATH_KEY, DEFAULT_CONFIG_FILE_PATH)
105 return read_config(config_path=config_path)
108def _extract_sync_intervals(config, log_messages: bool = False):
109 """
110 Extract drive and photos sync intervals from configuration.
112 Args:
113 config: Configuration dictionary
114 log_messages: Whether to log informational messages (default: False for loop usage)
116 Returns:
117 tuple: (drive_sync_interval, photos_sync_interval)
118 """
119 drive_sync_interval = 0
120 photos_sync_interval = 0
122 if config and "drive" in config:
123 drive_sync_interval = config_parser.get_drive_sync_interval(
124 config=config,
125 log_messages=log_messages,
126 )
127 if config and "photos" in config:
128 photos_sync_interval = config_parser.get_photos_sync_interval(
129 config=config,
130 log_messages=log_messages,
131 )
133 return drive_sync_interval, photos_sync_interval
136def _retrieve_password(username: str):
137 """
138 Retrieve password from environment or keyring.
140 Args:
141 username: iCloud username
143 Returns:
144 Password string or None if not found
146 Raises:
147 ICloudPyNoStoredPasswordAvailableException: If password not available
148 """
149 if ENV_ICLOUD_PASSWORD_KEY in os.environ:
150 password = os.environ.get(ENV_ICLOUD_PASSWORD_KEY)
151 utils.store_password_in_keyring(username=username, password=password)
152 return password
153 else:
154 return utils.get_password_from_keyring(username=username)
157def _authenticate_and_get_api(config, username: str):
158 """
159 Authenticate user and return iCloud API instance.
161 Args:
162 config: Configuration dictionary
163 username: iCloud username
165 Returns:
166 ICloudPyService instance
168 Raises:
169 ICloudPyNoStoredPasswordAvailableException: If password not available
170 """
171 server_region = config_parser.get_region(config=config)
172 password = _retrieve_password(username)
173 return get_api_instance(
174 username=username,
175 password=password,
176 server_region=server_region,
177 )
180def _check_mount_marker(
181 destinations: list[str],
182 marker_filename: str,
183 required: bool,
184 service_name: str,
185) -> bool:
186 """Verify the failsafe marker file is present in every write destination.
188 Mirrors boredazfcuk/docker-icloudpd's ``.mounted`` pattern: protects
189 against silent bind-mount failures (typo in the host path, missing
190 share, wrong permissions) that would otherwise dump iCloud data into
191 an empty container-internal directory.
193 Takes a list of destinations because a single sync may write to more
194 than one bind-mounted directory; the marker is required in EACH write
195 destination because any one of them could be the failed mount.
197 Returns True when it is safe to proceed (marker not required, or
198 marker required and present in every destination). Returns False when
199 the marker is required and is missing from at least one destination —
200 in which case the caller should skip this sync cycle without
201 advancing the countdown so the next interval re-checks. Every
202 missing-marker failure is logged so the user can fix all of them in
203 one pass rather than discovering them one cycle at a time.
205 Args:
206 destinations: List of sync destination directories to check. Each
207 directory is checked independently. An empty list returns
208 True (nothing to check).
209 marker_filename: Filename to look for inside each destination
210 (e.g. ``.mounted``).
211 required: Whether the marker is required at all. When False this
212 is a no-op that always returns True.
213 service_name: Human-readable label used in the error log
214 (``Drive`` / ``Photos``).
216 Returns:
217 True if it is safe to proceed; False to skip this sync cycle.
218 """
219 if not required:
220 return True
221 all_present = True
222 for destination_path in destinations:
223 marker_path = os.path.join(destination_path, marker_filename)
224 if not os.path.isfile(marker_path):
225 LOGGER.error(
226 f"{service_name} mount marker missing: {marker_path} not found — "
227 f"refusing to sync. Create the marker file (`touch {marker_path}`) "
228 f"after confirming the destination is correctly mounted, then the "
229 f"next sync cycle will proceed.",
230 )
231 all_present = False
232 return all_present
235def _perform_drive_sync(config, api, sync_state: SyncState, drive_sync_interval: int):
236 """
237 Execute drive synchronization if enabled.
239 Args:
240 config: Configuration dictionary
241 api: iCloud API instance
242 sync_state: Current sync state
243 drive_sync_interval: Drive sync interval in seconds
245 Returns:
246 DriveStats object if sync was performed, None otherwise
247 """
248 if config and "drive" in config and sync_state.enable_sync_drive:
249 import time
251 from src.sync_stats import DriveStats
253 start_time = time.time()
254 stats = DriveStats()
256 destination_path = config_parser.prepare_drive_destination(config=config)
258 # Mount-marker failsafe (see _check_mount_marker). Skip this
259 # cycle when the marker isn't present. Reset the countdown to
260 # the full interval so ``_calculate_next_sync_schedule`` waits
261 # before re-checking -- without the reset, on startup
262 # ``drive_time_remaining`` is 0 and the next iteration spins
263 # at zero sleep into a tight busy loop that floods logs and
264 # burns CPU until the user touches the marker.
265 if not _check_mount_marker(
266 destinations=[destination_path],
267 marker_filename=config_parser.get_mount_marker_filename(config=config),
268 required=config_parser.get_drive_require_mount_marker(config=config),
269 service_name="Drive",
270 ):
271 sync_state.drive_time_remaining = drive_sync_interval
272 return None
274 # Count files before sync
275 files_before = set()
276 if os.path.exists(destination_path):
277 try:
278 for root, _dirs, file_list in os.walk(destination_path):
279 for file in file_list:
280 files_before.add(os.path.join(root, file))
281 except Exception:
282 pass
284 LOGGER.info("Syncing drive...")
285 files_after = sync_drive.sync_drive(config=config, drive=api.drive)
286 LOGGER.info("Drive synced")
288 # Calculate statistics
289 stats.duration_seconds = time.time() - start_time
291 # Handle case where sync_drive returns None (e.g., in tests)
292 if files_after is not None:
293 # Count newly downloaded files
294 new_files = files_after - files_before
295 stats.files_downloaded = len(new_files)
297 # Count skipped files
298 stats.files_skipped = len(files_before & files_after)
300 # Count removed files
301 if config_parser.get_drive_remove_obsolete(config=config):
302 stats.files_removed = len(files_before - files_after)
304 # Calculate bytes downloaded
305 try:
306 for file_path in new_files:
307 if os.path.exists(file_path) and os.path.isfile(file_path):
308 stats.bytes_downloaded += os.path.getsize(file_path)
309 except Exception:
310 pass
312 # Reset countdown timer to the configured interval
313 sync_state.drive_time_remaining = drive_sync_interval
314 return stats
315 return None
318def _perform_photos_sync(config, api, sync_state: SyncState, photos_sync_interval: int):
319 """
320 Execute photos synchronization if enabled.
322 Args:
323 config: Configuration dictionary
324 api: iCloud API instance
325 sync_state: Current sync state
326 photos_sync_interval: Photos sync interval in seconds
328 Returns:
329 PhotoStats object if sync was performed, None otherwise
330 """
331 if config and "photos" in config and sync_state.enable_sync_photos:
332 import time
334 from src.sync_stats import PhotoStats
336 start_time = time.time()
337 stats = PhotoStats()
339 destination_path = config_parser.prepare_photos_destination(config=config)
341 # Mount-marker failsafe (see _check_mount_marker). Skip this cycle
342 # without advancing the countdown so the next interval re-checks
343 # once the user fixes the mount + touches the marker file.
344 if not _check_mount_marker(
345 destinations=[destination_path],
346 marker_filename=config_parser.get_mount_marker_filename(config=config),
347 required=config_parser.get_photos_require_mount_marker(config=config),
348 service_name="Photos",
349 ):
350 # Same busy-loop guard as the Drive branch above: reset the
351 # countdown so the next cycle waits the configured interval
352 # before re-checking the marker.
353 sync_state.photos_time_remaining = photos_sync_interval
354 return None
356 # Count files before sync
357 files_before = set()
358 if os.path.exists(destination_path):
359 try:
360 for root, _dirs, file_list in os.walk(destination_path):
361 for file in file_list:
362 files_before.add(os.path.join(root, file))
363 except Exception:
364 pass
366 LOGGER.info("Syncing photos...")
367 sync_result = sync_photos.sync_photos(config=config, photos=api.photos)
368 LOGGER.info("Photos synced")
370 # Count files after sync
371 files_after = set()
372 if os.path.exists(destination_path):
373 try:
374 for root, _dirs, file_list in os.walk(destination_path):
375 for file in file_list:
376 files_after.add(os.path.join(root, file))
377 except Exception:
378 pass
380 # Calculate statistics
381 stats.duration_seconds = time.time() - start_time
383 # Count newly downloaded files
384 new_files = files_after - files_before
385 stats.photos_downloaded = len(new_files)
387 # Estimate hardlinked photos (approximate)
388 use_hardlinks = config_parser.get_photos_use_hardlinks(
389 config=config,
390 log_messages=False,
391 )
392 if use_hardlinks:
393 stats.photos_hardlinked = max(
394 0,
395 len(files_after) - len(files_before) - stats.photos_downloaded,
396 )
398 # Count skipped photos
399 stats.photos_skipped = len(files_before & files_after)
401 # Calculate bytes downloaded
402 try:
403 for file_path in new_files:
404 if os.path.exists(file_path) and os.path.isfile(file_path):
405 stats.bytes_downloaded += os.path.getsize(file_path)
407 # Estimate bytes saved by hardlinks
408 if use_hardlinks and stats.photos_hardlinked > 0:
409 for file_path in files_after:
410 if file_path not in new_files and os.path.isfile(file_path):
411 stats.bytes_saved_by_hardlinks += os.path.getsize(file_path)
412 except Exception:
413 pass
415 # Track failed downloads so notifications reflect errors
416 if isinstance(sync_result, tuple):
417 _, failed_downloads = sync_result
418 if failed_downloads > 0:
419 stats.errors.append(f"{failed_downloads} photo download(s) failed")
421 # Get list of synced albums (simple approximation based on directories)
422 try:
423 for item in os.listdir(destination_path):
424 item_path = os.path.join(destination_path, item)
425 if os.path.isdir(item_path):
426 stats.albums_synced.append(item)
427 except Exception:
428 pass
430 # Reset countdown timer to the configured interval
431 sync_state.photos_time_remaining = photos_sync_interval
432 return stats
433 return None
436def _perform_dry_run(config, api, check_files: int | None = None) -> None:
437 """Authenticate-and-enumerate path used when ``--dry-run`` is passed.
439 Verifies that the configured credentials, mount paths, and iCloud-side
440 state are all in working order WITHOUT writing or downloading any
441 files. Designed as the safety check users run before letting the real
442 sync loop loose on a new install.
444 Logs (at INFO level):
445 - Drive destination path + root-level item count (when Drive is configured)
446 - Photos destination path + library names (when Photos is configured)
447 - When ``check_files`` is not None: per-library would-skip /
448 size-mismatch / not-found counts (see ``migration_check``).
450 Args:
451 config: Configuration dictionary
452 api: Authenticated iCloud API instance
453 check_files: When set (``--check-files=N``), additionally walks
454 up to N photos per library and reports what a real sync
455 would do per file. ``0`` walks every photo. ``None`` skips
456 this check (cheap default for ``--dry-run`` alone).
458 Notifications, usage statistics, file writes, file deletions, and the
459 sync loop itself are all skipped.
460 """
461 LOGGER.info("DRY RUN: authentication succeeded — verifying configured services.")
463 if config and "drive" in config:
464 try:
465 # Resolved absolute path (root + destination), computed without
466 # creating anything, so users can verify the mount point. Mirrors
467 # how migration_check builds its base path.
468 drive_destination = os.path.join(
469 config_parser.get_root_destination_path(config=config),
470 config_parser.get_drive_destination_path(config=config),
471 )
472 LOGGER.info(f"DRY RUN: Drive destination: {drive_destination}")
473 root_items = list(api.drive.dir())
474 LOGGER.info(
475 f"DRY RUN: Drive root contains {len(root_items)} item(s) — "
476 "real sync would walk this tree per `drive.filters`.",
477 )
478 except Exception as e:
479 LOGGER.warning(f"DRY RUN: Drive enumeration failed: {e!s}")
480 else:
481 LOGGER.info(
482 "DRY RUN: no `drive:` section in config — Drive sync would be skipped.",
483 )
485 if config and "photos" in config:
486 try:
487 photos_destination = os.path.join(
488 config_parser.get_root_destination_path(config=config),
489 config_parser.get_photos_destination_path(config=config),
490 )
491 LOGGER.info(f"DRY RUN: Photos destination: {photos_destination}")
492 libraries = (
493 list(api.photos.libraries.keys())
494 if hasattr(api.photos, "libraries")
495 else []
496 )
497 if libraries:
498 LOGGER.info(
499 f"DRY RUN: Photos libraries available: {', '.join(libraries)}",
500 )
501 else:
502 LOGGER.info("DRY RUN: Photos libraries: (none reported by iCloud)")
503 except Exception as e:
504 LOGGER.warning(f"DRY RUN: Photos enumeration failed: {e!s}")
505 else:
506 LOGGER.info(
507 "DRY RUN: no `photos:` section in config — Photos sync would be skipped.",
508 )
510 if check_files is not None:
511 from src import migration_check
513 # Photos walker — per-library counts using mandarons' real path/size
514 # logic so the report mirrors what a real sync would skip vs download.
515 if config and "photos" in config:
516 try:
517 LOGGER.info(
518 f"DRY RUN: walking photos for file-existence check "
519 f"(--check-files={'all' if check_files == 0 else check_files} per library) ...",
520 )
521 results = migration_check.check_migration(
522 api=api,
523 config=config,
524 sample=check_files,
525 )
526 for library_name, result in results.items():
527 stats = result["stats"]
528 LOGGER.info(
529 f"DRY RUN: {library_name} (dest {result['library_dest']}): "
530 f"sampled={result['checked']} "
531 f"would_skip={stats['would_skip']} "
532 f"size_mismatch={stats['size_mismatch']} "
533 f"not_found={stats['not_found']} "
534 f"errors={stats['error']}",
535 )
536 for status, items in result["samples"].items():
537 for item in items:
538 if status == "size_mismatch":
539 path, expected, actual = item
540 LOGGER.info(
541 f"DRY RUN: sample {status}: {path} (have {actual:,}b, want {expected:,}b)",
542 )
543 else:
544 path, expected = item
545 LOGGER.info(
546 f"DRY RUN: sample {status}: {path} ({expected:,}b)",
547 )
548 except Exception as e:
549 LOGGER.warning(f"DRY RUN: photos check-files walk failed: {e!s}")
551 # Drive walker — same per-file would_skip/size_mismatch/not_found
552 # report, but walking the Drive tree (no library_destinations,
553 # mirror-tree layout). Catches misconfigured drive.destination.
554 if config and "drive" in config:
555 try:
556 drive_result = migration_check.check_drive_migration(
557 api=api,
558 config=config,
559 sample=check_files,
560 )
561 if drive_result is not None:
562 stats = drive_result["stats"]
563 LOGGER.info(
564 f"DRY RUN: Drive (dest {drive_result['drive_destination']}): "
565 f"sampled={drive_result['checked']} "
566 f"would_skip={stats['would_skip']} "
567 f"size_mismatch={stats['size_mismatch']} "
568 f"not_found={stats['not_found']} "
569 f"errors={stats['error']}",
570 )
571 for status, items in drive_result["samples"].items():
572 for item in items:
573 if status == "size_mismatch":
574 path, expected, actual = item
575 LOGGER.info(
576 f"DRY RUN: sample {status}: {path} (have {actual:,}b, want {expected:,}b)",
577 )
578 else:
579 path, expected = item
580 LOGGER.info(
581 f"DRY RUN: sample {status}: {path} ({expected:,}b)",
582 )
583 except Exception as e:
584 LOGGER.warning(f"DRY RUN: drive check-files walk failed: {e!s}")
586 LOGGER.info(
587 "DRY RUN complete — no files were written. Re-run without --dry-run to sync.",
588 )
591def _check_services_configured(config):
592 """
593 Check if any sync services are configured.
595 Args:
596 config: Configuration dictionary
598 Returns:
599 bool: True if at least one service is configured
600 """
602 return "drive" in config or "photos" in config
605def _send_usage_statistics(config, summary: SyncSummary) -> None:
606 """Send anonymized usage statistics.
608 Args:
609 config: Configuration dictionary
610 summary: Sync summary with statistics
611 """
613 # Create anonymized usage data
614 usage_data = {
615 "sync_duration": (
616 (summary.sync_end_time - summary.sync_start_time).total_seconds()
617 if summary.sync_end_time
618 else 0
619 ),
620 "has_drive_activity": bool(
621 summary.drive_stats and summary.drive_stats.has_activity(),
622 ),
623 "has_photos_activity": bool(
624 summary.photo_stats and summary.photo_stats.has_activity(),
625 ),
626 "has_errors": summary.has_errors(),
627 "timestamp": (
628 summary.sync_end_time.isoformat() if summary.sync_end_time else None
629 ),
630 }
632 # Add aggregated statistics (no personal data)
633 if summary.drive_stats:
634 usage_data["drive"] = {
635 "files_count": summary.drive_stats.files_downloaded,
636 "bytes_count": summary.drive_stats.bytes_downloaded,
637 "has_errors": summary.drive_stats.has_errors(),
638 }
640 if summary.photo_stats:
641 usage_data["photos"] = {
642 "photos_count": summary.photo_stats.photos_downloaded,
643 "bytes_count": summary.photo_stats.bytes_downloaded,
644 "hardlinks_count": summary.photo_stats.photos_hardlinked,
645 "has_errors": summary.photo_stats.has_errors(),
646 }
648 # Send to usage tracking
649 alive(config=config, data=usage_data)
652def _handle_2fa_required(config, username: str, sync_state: SyncState):
653 """
654 Handle 2FA authentication requirement.
656 Args:
657 config: Configuration dictionary
658 username: iCloud username
659 sync_state: Current sync state
661 Returns:
662 bool: True if should continue (retry), False if should exit
663 """
664 LOGGER.error("Error: 2FA is required. Please log in.")
665 sleep_for = config_parser.get_retry_login_interval(config=config)
667 if sleep_for < 0:
668 LOGGER.info("retry_login_interval is < 0, exiting ...")
669 return False
671 _log_retry_time(sleep_for)
672 server_region = config_parser.get_region(config=config)
673 sync_state.last_send = notify.send(
674 config=config,
675 username=username,
676 last_send=sync_state.last_send,
677 region=server_region,
678 )
679 sleep(sleep_for)
680 return True
683def _handle_password_error(config, username: str, sync_state: SyncState):
684 """
685 Handle password not available error.
687 Args:
688 config: Configuration dictionary
689 username: iCloud username
690 sync_state: Current sync state
692 Returns:
693 bool: True if should continue (retry), False if should exit
694 """
695 LOGGER.error(
696 "Password is not stored in keyring. Please save the password in keyring.",
697 )
698 sleep_for = config_parser.get_retry_login_interval(config=config)
700 if sleep_for < 0:
701 LOGGER.info("retry_login_interval is < 0, exiting ...")
702 return False
704 _log_retry_time(sleep_for)
705 server_region = config_parser.get_region(config=config)
706 sync_state.last_send = notify.send(
707 config=config,
708 username=username,
709 last_send=sync_state.last_send,
710 region=server_region,
711 )
712 sleep(sleep_for)
713 return True
716def _log_retry_time(sleep_for: int):
717 """
718 Log the next retry time.
720 Args:
721 sleep_for: Sleep duration in seconds
722 """
723 next_sync = (
724 datetime.datetime.now() + datetime.timedelta(seconds=sleep_for)
725 ).strftime("%c")
726 LOGGER.info(f"Retrying login at {next_sync} ...")
729def _calculate_next_sync_schedule(config, sync_state: SyncState):
730 """
731 Calculate next sync schedule and update sync state.
733 This function implements the adaptive scheduling algorithm that determines
734 which service should sync next based on countdown timers.
736 Args:
737 config: Configuration dictionary
738 sync_state: Current sync state
740 Returns:
741 int: Sleep duration in seconds
742 """
743 has_drive = config and "drive" in config
744 has_photos = config and "photos" in config
746 if not has_drive and has_photos:
747 sleep_for = sync_state.photos_time_remaining
748 sync_state.enable_sync_drive = False
749 sync_state.enable_sync_photos = True
750 elif has_drive and not has_photos:
751 sleep_for = sync_state.drive_time_remaining
752 sync_state.enable_sync_drive = True
753 sync_state.enable_sync_photos = False
754 elif (
755 has_drive
756 and has_photos
757 and sync_state.drive_time_remaining <= sync_state.photos_time_remaining
758 ):
759 # Special case: if both timers are equal and large (> 10 seconds), wait for the full interval
760 # This fixes the bug where equal large intervals cause immediate re-sync
761 if (
762 sync_state.drive_time_remaining == sync_state.photos_time_remaining
763 and sync_state.drive_time_remaining > 10
764 ):
765 sleep_for = sync_state.drive_time_remaining
766 sync_state.enable_sync_drive = True
767 sync_state.enable_sync_photos = True
768 else:
769 sleep_for = (
770 sync_state.photos_time_remaining - sync_state.drive_time_remaining
771 )
772 sync_state.photos_time_remaining -= sync_state.drive_time_remaining
773 sync_state.enable_sync_drive = True
774 sync_state.enable_sync_photos = False
775 else:
776 sleep_for = sync_state.drive_time_remaining - sync_state.photos_time_remaining
777 sync_state.drive_time_remaining -= sync_state.photos_time_remaining
778 sync_state.enable_sync_drive = False
779 sync_state.enable_sync_photos = True
781 return sleep_for
784def _log_next_sync_time(sleep_for: int):
785 """
786 Log the next scheduled sync time.
788 Args:
789 sleep_for: Sleep duration in seconds
790 """
791 next_sync = (
792 datetime.datetime.now() + datetime.timedelta(seconds=sleep_for)
793 ).strftime("%c")
794 LOGGER.info(f"Resyncing at {next_sync} ...")
797def _log_sync_intervals_at_startup(config):
798 """
799 Log sync intervals once at startup.
801 Args:
802 config: Configuration dictionary
803 """
804 if config and "drive" in config:
805 config_parser.get_drive_sync_interval(config=config, log_messages=True)
806 if config and "photos" in config:
807 config_parser.get_photos_sync_interval(config=config, log_messages=True)
810def _should_exit_oneshot_mode(config):
811 """
812 Check if should exit in oneshot mode.
814 Oneshot mode exits when ALL configured sync intervals are negative.
816 Args:
817 config: Configuration dictionary
819 Returns:
820 bool: True if should exit
821 """
823 should_exit_drive = ("drive" not in config) or (
824 config_parser.get_drive_sync_interval(config=config, log_messages=False) < 0
825 )
826 should_exit_photos = ("photos" not in config) or (
827 config_parser.get_photos_sync_interval(config=config, log_messages=False) < 0
828 )
830 return should_exit_drive and should_exit_photos
833def sync(dry_run: bool = False, check_files: int | None = None):
834 """
835 Main synchronization loop.
837 Orchestrates the entire sync process by delegating specific responsibilities
838 to focused helper functions. This function coordinates the high-level flow
839 while each helper handles a single concern.
841 Args:
842 dry_run: When True, authenticate and summarise what would be synced,
843 then exit without writing files, sending notifications, or
844 entering the sync loop. Useful for verifying credentials, mount
845 paths, and config before the real loop starts downloading.
846 check_files: Optional sample size for the per-photo file-existence
847 check during dry-run. Only meaningful with ``dry_run=True``.
848 ``None`` skips the check (cheap default). ``0`` walks every
849 photo (slow on large libraries). Positive N walks N
850 stride-sampled photos per library.
851 """
852 sync_state = SyncState()
853 startup_logged = False
855 while True:
856 config = _load_configuration()
857 # Skip telemetry on dry-run: ``alive()`` registers the installation
858 # and saves the usage cache to disk, both of which violate the
859 # "no side effects" dry-run contract. (The real sync loop still
860 # calls it on every iteration as before.)
861 if not dry_run:
862 alive(config=config)
864 # Log sync intervals once at startup
865 if not startup_logged:
866 _log_sync_intervals_at_startup(config)
867 startup_logged = True
869 drive_sync_interval, photos_sync_interval = _extract_sync_intervals(
870 config,
871 log_messages=False,
872 )
873 username = config_parser.get_username(config=config) if config else None
875 if username:
876 try:
877 api = _authenticate_and_get_api(config, username)
879 # Dry-run path: authenticate, enumerate, log, exit.
880 # Skips the entire sync + notification + retry pipeline.
881 if dry_run:
882 if api.requires_2sa:
883 LOGGER.info(
884 "DRY RUN: 2FA required — finish interactive auth first "
885 "(see README), then re-run with --dry-run.",
886 )
887 else:
888 _perform_dry_run(config, api, check_files=check_files)
889 return
891 if not api.requires_2sa:
892 # Create summary for this sync cycle
893 summary = SyncSummary()
895 # Perform syncs and collect statistics
896 drive_stats = _perform_drive_sync(
897 config,
898 api,
899 sync_state,
900 drive_sync_interval,
901 )
902 photos_stats = _perform_photos_sync(
903 config,
904 api,
905 sync_state,
906 photos_sync_interval,
907 )
909 # Populate summary with statistics
910 summary.drive_stats = drive_stats
911 summary.photo_stats = photos_stats
912 summary.sync_end_time = datetime.datetime.now()
914 # Send usage statistics (anonymized summary data)
915 try:
916 _send_usage_statistics(config, summary)
917 except Exception as e:
918 LOGGER.debug(f"Failed to send usage statistics: {e!s}")
920 # Send sync summary notification if configured
921 # Only send notification when both enabled services have synced in this cycle
922 # Gracefully handle notification failures to not break sync
923 has_drive_config = config and "drive" in config
924 has_photos_config = config and "photos" in config
926 should_send_notification = False
927 if has_drive_config and has_photos_config:
928 # Both services configured - send notification only when both have synced
929 should_send_notification = (
930 drive_stats is not None and photos_stats is not None
931 )
932 elif has_drive_config and not has_photos_config:
933 # Only drive configured - send when drive synced
934 should_send_notification = drive_stats is not None
935 elif has_photos_config and not has_drive_config:
936 # Only photos configured - send when photos synced
937 should_send_notification = photos_stats is not None
939 if should_send_notification:
940 try:
941 notify.send_sync_summary(config=config, summary=summary)
942 except Exception as e:
943 LOGGER.debug(
944 f"Failed to send sync summary notification: {e!s}",
945 )
947 if not _check_services_configured(config):
948 LOGGER.warning(
949 "Nothing to sync. Please add drive: and/or photos: section in config.yaml file.",
950 )
951 else:
952 if not _handle_2fa_required(config, username, sync_state):
953 break
954 continue
956 except exceptions.ICloudPyNoStoredPasswordAvailableException:
957 if not _handle_password_error(config, username, sync_state):
958 break
959 continue
961 sleep_for = _calculate_next_sync_schedule(config, sync_state)
962 _log_next_sync_time(sleep_for)
964 if _should_exit_oneshot_mode(config):
965 LOGGER.info(
966 "All configured sync intervals are negative, exiting oneshot mode...",
967 )
968 break
970 sleep(sleep_for)