Coverage for src/main.py: 100%

19 statements  

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

1"""Main module. 

2 

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""" 

7 

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

9 

10import argparse 

11import os 

12 

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) 

22 

23LOGGER = get_logger() 

24 

25 

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 

38 

39 

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) 

51 

52 

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() 

84 

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 ) 

99 

100 run(dry_run=args.dry_run, check_files=args.check_files)