Coverage for src/__init__.py: 100%

115 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-08 21:05 +0000

1"""Root module.""" 

2 

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

4 

5import logging 

6import os 

7import sys 

8import warnings 

9from logging.handlers import RotatingFileHandler 

10 

11from ruamel.yaml import YAML 

12 

13DEFAULT_ROOT_DESTINATION = "./icloud" 

14DEFAULT_DRIVE_DESTINATION = "drive" 

15DEFAULT_PHOTOS_DESTINATION = "photos" 

16DEFAULT_RETRY_LOGIN_INTERVAL_SEC = 600 # 10 minutes 

17DEFAULT_SYNC_INTERVAL_SEC = 1800 # 30 minutes 

18DEFAULT_REQUEST_TIMEOUT_SEC = 30 # 30 seconds 

19# How soon to look again while Apple is still indexing a photo library. 

20# Indexing ends without notice, so on a long photos sync_interval a plain 

21# "wait for the next cycle" leaves Photos hours behind for no reason. Only 

22# ever shortens the wait: the retry is min(this, photos sync_interval). 

23PHOTOS_INDEXING_RETRY_SEC = 1800 # 30 minutes 

24# Share of a destination that obsolete-cleanup may delete in one run before 

25# it refuses and reports instead. A sync does not normally make a quarter of 

26# a library obsolete; when it appears to, a tracking bug is likelier than a 

27# real mass deletion. 0 disables the limit. 

28DEFAULT_OBSOLETE_DELETE_LIMIT_PERCENT = 25 

29DEFAULT_ENUMERATION_CHUNK_SIZE = 1000 # photos buffered per streaming chunk 

30# Oldest a buffered photo download may get before the buffer is drained 

31# early. Download URLs expire ~30-40 minutes after the listing that returned 

32# them, and on a mostly backed-up library the few photos to fetch trickle in 

33# across the whole walk. This keeps their wait short; anything that still 

34# expires is recovered by the expired-URL refresh. 

35BUFFERED_DOWNLOAD_MAX_AGE_SEC = 600 # 10 minutes 

36DEFAULT_CONFIG_FILE_NAME = "config.yaml" 

37ENV_ICLOUD_PASSWORD_KEY = "ENV_ICLOUD_PASSWORD" 

38ENV_CONFIG_FILE_PATH_KEY = "ENV_CONFIG_FILE_PATH" 

39DEFAULT_LOGGER_LEVEL = "info" 

40DEFAULT_LOG_FILE_NAME = "icloud.log" 

41# Rotation bounds for the file handler. A busy library logs one line per 

42# file *considered* each cycle, so an unbounded handler grows without 

43# limit -- multi-GB logs on large accounts. 50 MB x 3 backups keeps a 

44# useful window at a bounded 200 MB worst case. 

45DEFAULT_LOG_MAX_BYTES = 50 * 1024 * 1024 

46DEFAULT_LOG_BACKUP_COUNT = 3 

47DEFAULT_CONFIG_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), DEFAULT_CONFIG_FILE_NAME) 

48# Operator-overridable via ICLOUD_DOCKER_CONFIG_DIR. Default ``/config`` is the 

49# in-container mount point users bind their config volume to. The override 

50# lets the test suite run on hosts where ``/config`` isn't writable (macOS, 

51# sandboxes). 

52_CONFIG_DIR = os.environ.get("ICLOUD_DOCKER_CONFIG_DIR", "/config") 

53DEFAULT_COOKIE_DIRECTORY = os.path.join(_CONFIG_DIR, "session_data") 

54 

55warnings.filterwarnings("ignore", category=DeprecationWarning) 

56 

57 

58def read_config(config_path=DEFAULT_CONFIG_FILE_PATH): 

59 """Read config file.""" 

60 if not (config_path and os.path.exists(config_path)): 

61 print(f"Config file not found at {config_path}.") 

62 return None 

63 with open(file=config_path, encoding="utf-8") as config_file: 

64 config = YAML().load(config_file) 

65 config["app"]["credentials"]["username"] = ( 

66 config["app"]["credentials"]["username"].strip() if config["app"]["credentials"]["username"] is not None else "" 

67 ) 

68 return config 

69 

70 

71def get_logger_config(config): 

72 """Get logger config.""" 

73 logger_config = {} 

74 # ``read_config`` returns None when config.yaml is missing, and a partial 

75 # file may have no ``app`` section. Both reached ``config["app"]`` and 

76 # raised -- from module scope, so the container could not start at all. 

77 # Shape-check every level, not just presence: a parseable but wrong YAML 

78 # (``app: 123``, ``logger: info``) otherwise raises TypeError here at 

79 # import time -- the same restart loop this guard exists to prevent. 

80 app = config.get("app") if isinstance(config, dict) else None 

81 config_app_logger = app.get("logger") if isinstance(app, dict) else None 

82 if not isinstance(config_app_logger, dict): 

83 return None 

84 logger_config["level"] = ( 

85 config_app_logger["level"].strip().lower() if "level" in config_app_logger else DEFAULT_LOGGER_LEVEL 

86 ) 

87 logger_config["filename"] = ( 

88 config_app_logger["filename"].strip().lower() if "filename" in config_app_logger else DEFAULT_LOG_FILE_NAME 

89 ) 

90 logger_config["max_bytes"] = _int_setting(config_app_logger, "max_bytes", DEFAULT_LOG_MAX_BYTES) 

91 logger_config["backup_count"] = _int_setting(config_app_logger, "backup_count", DEFAULT_LOG_BACKUP_COUNT) 

92 return logger_config 

93 

94 

95def _int_setting(section, key, default): 

96 """Read an integer logger setting, falling back to ``default`` on junk. 

97 

98 This runs while logging is being configured -- at import, before any 

99 handler exists -- so a bare ``int()`` on a typo like ``max_bytes: 50MB`` 

100 raised out of module import and, under ``restart: unless-stopped``, 

101 became a restart loop that never said which key was wrong. Say so on 

102 stdout (no logger yet) and carry on with the default. 

103 """ 

104 value = section.get(key, default) 

105 try: 

106 return int(value) 

107 except (TypeError, ValueError): 

108 print(f"app.logger.{key} must be a whole number, got {value!r}; using {default}.") 

109 return default 

110 

111 

112def log_handler_exists(logger, handler_type, **kwargs): 

113 """Check for existing log handler.""" 

114 for handler in logger.handlers: 

115 if isinstance(handler, handler_type): 

116 if handler_type is logging.FileHandler: 

117 if handler.baseFilename.endswith(kwargs["filename"]): 

118 return True 

119 elif handler_type is logging.StreamHandler: 

120 if handler.stream is kwargs["stream"]: 

121 return True 

122 return False 

123 

124 

125class ColorfulConsoleFormatter(logging.Formatter): 

126 """Console formatter for log messages.""" 

127 

128 grey = "\x1b[38;21m" 

129 blue = "\x1b[38;5;39m" 

130 yellow = "\x1b[38;5;226m" 

131 red = "\x1b[38;5;196m" 

132 bold_red = "\x1b[31;1m" 

133 reset = "\x1b[0m" 

134 

135 def __init__(self, fmt): 

136 """Construct with defaults.""" 

137 super().__init__() 

138 self.fmt = fmt 

139 self.formats = { 

140 logging.DEBUG: self.grey + self.fmt + self.reset, 

141 logging.INFO: self.blue + self.fmt + self.reset, 

142 logging.WARNING: self.yellow + self.fmt + self.reset, 

143 logging.ERROR: self.red + self.fmt + self.reset, 

144 logging.CRITICAL: self.bold_red + self.fmt + self.reset, 

145 } 

146 

147 def format(self, record): 

148 """Format the record.""" 

149 log_fmt = self.formats.get(record.levelno) 

150 formatter = logging.Formatter(log_fmt) 

151 return formatter.format(record) 

152 

153 

154def configure_icloudpy_logging(): 

155 """Configure icloudpy logging to match app logging level.""" 

156 logger_config = get_logger_config(config=read_config(config_path=os.environ.get(ENV_CONFIG_FILE_PATH_KEY, DEFAULT_CONFIG_FILE_PATH))) 

157 if logger_config: 

158 level_name = logging.getLevelName(level=logger_config["level"].upper()) 

159 

160 # Configure icloudpy loggers to use the same level and enable propagation 

161 icloudpy_loggers = [ 

162 logging.getLogger("icloudpy"), 

163 logging.getLogger("icloudpy.base"), 

164 logging.getLogger("icloudpy.services"), 

165 logging.getLogger("icloudpy.services.photos"), 

166 ] 

167 for icloudpy_logger in icloudpy_loggers: 

168 icloudpy_logger.setLevel(level=level_name) 

169 # Enable propagation so messages go to root logger handlers 

170 icloudpy_logger.propagate = True 

171 # Remove any existing handlers to avoid duplicates 

172 icloudpy_logger.handlers.clear() 

173 

174 

175def get_logger(): 

176 """Return logger.""" 

177 logger = logging.getLogger() 

178 # This runs at import time, so anything raised here stops the container 

179 # before it can report why -- and `restart: unless-stopped` turns that 

180 # into a restart loop. A config too broken to parse is the sync loop's 

181 # problem to report; here it just means default logging. 

182 try: 

183 config = read_config(config_path=os.environ.get(ENV_CONFIG_FILE_PATH_KEY, DEFAULT_CONFIG_FILE_PATH)) 

184 except Exception: # noqa: BLE001 -- import-time: never crash on a bad config 

185 config = None 

186 logger_config = get_logger_config(config=config) 

187 if logger_config: 

188 level_name = logging.getLevelName(level=logger_config["level"].upper()) 

189 logger.setLevel(level=level_name) 

190 

191 # Create handlers once and add them to root logger 

192 file_handler = None 

193 console_handler = None 

194 

195 if not log_handler_exists( 

196 logger=logger, 

197 handler_type=logging.FileHandler, 

198 filename=logger_config["filename"], 

199 ): 

200 # Rotating rather than plain FileHandler: see 

201 # DEFAULT_LOG_MAX_BYTES. max_bytes=0 disables rotation for 

202 # operators who hand the file to an external logrotate. 

203 file_handler = RotatingFileHandler( 

204 logger_config["filename"], 

205 maxBytes=logger_config["max_bytes"], 

206 backupCount=logger_config["backup_count"], 

207 ) 

208 file_handler.setFormatter( 

209 logging.Formatter( 

210 "%(asctime)s :: %(levelname)s :: %(name)s :: %(filename)s :: %(lineno)d :: %(message)s", 

211 ), 

212 ) 

213 logger.addHandler(file_handler) 

214 

215 if not log_handler_exists(logger=logger, handler_type=logging.StreamHandler, stream=sys.stdout): 

216 console_handler = logging.StreamHandler(sys.stdout) 

217 console_handler.setFormatter( 

218 ColorfulConsoleFormatter( 

219 "%(asctime)s :: %(levelname)s :: %(name)s :: %(filename)s :: %(lineno)d :: %(message)s", 

220 ), 

221 ) 

222 logger.addHandler(console_handler) 

223 

224 # Configure icloudpy loggers to use the same level and enable propagation 

225 icloudpy_loggers = [ 

226 logging.getLogger("icloudpy"), 

227 logging.getLogger("icloudpy.base"), 

228 logging.getLogger("icloudpy.services"), 

229 logging.getLogger("icloudpy.services.photos"), 

230 ] 

231 for icloudpy_logger in icloudpy_loggers: 

232 icloudpy_logger.setLevel(level=level_name) 

233 # Enable propagation so messages go to root logger handlers 

234 icloudpy_logger.propagate = True 

235 # Remove any existing handlers to avoid duplicates 

236 icloudpy_logger.handlers.clear() 

237 return logger 

238 

239 

240LOGGER = get_logger()