Coverage for src/main.py: 100%
19 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-05 00:26 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-05 00:26 +0000
1"""Main module.
3Starts the embedded web UI thread (when ``app.web_ui.enabled``) and then
4enters the sync loop. Both run in the same process so they share the
5keyring + session-data filesystem state.
6"""
8__author__ = "Mandar Patil (mandarons@pm.me)"
10import argparse
11import os
13from src import (
14 DEFAULT_CONFIG_FILE_PATH,
15 ENV_CONFIG_FILE_PATH_KEY,
16 config_parser,
17 get_logger,
18 read_config,
19 sync,
20 web,
21)
23LOGGER = get_logger()
26def _load_config_safely():
27 """Best-effort config load -- returns ``None`` if config is missing or
28 partial. The web UI surfaces 'setup needed' states; the sync loop
29 handles missing config independently."""
30 config_path = os.environ.get(ENV_CONFIG_FILE_PATH_KEY, DEFAULT_CONFIG_FILE_PATH)
31 if not os.path.isfile(config_path):
32 return None
33 try:
34 return read_config(config_path=config_path)
35 except (KeyError, AttributeError, TypeError) as e:
36 LOGGER.warning(f"main: read_config failed (partial config?): {e!s}")
37 return None
40def run(dry_run: bool = False, check_files: int | None = None) -> None:
41 """Entry point. Spawn the web UI thread if enabled, then run the sync
42 loop. ``dry_run`` / ``check_files`` are threaded straight through to
43 ``sync.sync`` (the web thread is a daemon, so a dry run still exits)."""
44 config = _load_config_safely()
45 if config and config_parser.get_web_ui_enabled(config=config):
46 web.start_in_thread(
47 host=config_parser.get_web_ui_host(config=config),
48 port=config_parser.get_web_ui_port(config=config),
49 )
50 sync.sync(dry_run=dry_run, check_files=check_files)
53if __name__ == "__main__": # pragma: no cover -- script entry, not test-callable
54 parser = argparse.ArgumentParser(
55 prog="icloud-docker",
56 description="iCloud Drive + Photos backup loop. See config.yaml for runtime settings.",
57 )
58 parser.add_argument(
59 "--dry-run",
60 action="store_true",
61 help=(
62 "Authenticate, summarise what would be synced, then exit "
63 "without downloading or modifying any files. Useful for "
64 "verifying credentials + mount paths + config before the "
65 "real sync loop is allowed to run."
66 ),
67 )
68 parser.add_argument(
69 "--check-files",
70 type=int,
71 default=None,
72 metavar="N",
73 help=(
74 "Only meaningful with --dry-run. Walks N photos per library "
75 "and reports per-library counts of would_skip / size_mismatch "
76 "/ not_found / error against your on-disk tree. Use this "
77 "BEFORE a real sync to confirm a boredazfcuk → mandarons (or "
78 "any cross-tool) migration will recognise existing files "
79 "instead of re-downloading them. Pass 0 to walk every photo "
80 "(slow on large libraries — recommend 50–200 first)."
81 ),
82 )
83 args = parser.parse_args()
85 # Validate the --check-files / --dry-run combination before handing off
86 # to run(). Without these guards, `--check-files 10` (no
87 # --dry-run) starts the normal sync loop and silently ignores the
88 # flag, and `--check-files -1` is treated as "walk everything" by
89 # the migration walkers (since `if sample > 0` is the cap-check) —
90 # both of which trip up users expecting fail-fast feedback.
91 if args.check_files is not None:
92 if not args.dry_run:
93 parser.error("--check-files requires --dry-run")
94 if args.check_files < 0:
95 parser.error(
96 "--check-files must be a non-negative integer "
97 "(0 means walk everything, N > 0 caps the walk at N)",
98 )
100 run(dry_run=args.dry_run, check_files=args.check_files)