Coverage for src/__init__.py: 100%

114 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-26 21:28 +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# Share of a destination that obsolete-cleanup may delete in one run before 

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

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

22# real mass deletion. 0 disables the limit. 

23DEFAULT_OBSOLETE_DELETE_LIMIT_PERCENT = 25 

24DEFAULT_ENUMERATION_CHUNK_SIZE = 1000 # photos buffered per streaming chunk 

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

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

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

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

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

30BUFFERED_DOWNLOAD_MAX_AGE_SEC = 600 # 10 minutes 

31DEFAULT_CONFIG_FILE_NAME = "config.yaml" 

32ENV_ICLOUD_PASSWORD_KEY = "ENV_ICLOUD_PASSWORD" 

33ENV_CONFIG_FILE_PATH_KEY = "ENV_CONFIG_FILE_PATH" 

34DEFAULT_LOGGER_LEVEL = "info" 

35DEFAULT_LOG_FILE_NAME = "icloud.log" 

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

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

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

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

40DEFAULT_LOG_MAX_BYTES = 50 * 1024 * 1024 

41DEFAULT_LOG_BACKUP_COUNT = 3 

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

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

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

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

46# sandboxes). 

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

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

49 

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

51 

52 

53def read_config(config_path=DEFAULT_CONFIG_FILE_PATH): 

54 """Read config file.""" 

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

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

57 return None 

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

59 config = YAML().load(config_file) 

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

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

62 ) 

63 return config 

64 

65 

66def get_logger_config(config): 

67 """Get logger config.""" 

68 logger_config = {} 

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

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

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

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

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

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

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

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

77 if not isinstance(config_app_logger, dict): 

78 return None 

79 logger_config["level"] = ( 

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

81 ) 

82 logger_config["filename"] = ( 

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

84 ) 

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

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

87 return logger_config 

88 

89 

90def _int_setting(section, key, default): 

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

92 

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

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

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

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

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

98 """ 

99 value = section.get(key, default) 

100 try: 

101 return int(value) 

102 except (TypeError, ValueError): 

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

104 return default 

105 

106 

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

108 """Check for existing log handler.""" 

109 for handler in logger.handlers: 

110 if isinstance(handler, handler_type): 

111 if handler_type is logging.FileHandler: 

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

113 return True 

114 elif handler_type is logging.StreamHandler: 

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

116 return True 

117 return False 

118 

119 

120class ColorfulConsoleFormatter(logging.Formatter): 

121 """Console formatter for log messages.""" 

122 

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

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

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

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

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

128 reset = "\x1b[0m" 

129 

130 def __init__(self, fmt): 

131 """Construct with defaults.""" 

132 super().__init__() 

133 self.fmt = fmt 

134 self.formats = { 

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

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

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

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

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

140 } 

141 

142 def format(self, record): 

143 """Format the record.""" 

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

145 formatter = logging.Formatter(log_fmt) 

146 return formatter.format(record) 

147 

148 

149def configure_icloudpy_logging(): 

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

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

152 if logger_config: 

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

154 

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

156 icloudpy_loggers = [ 

157 logging.getLogger("icloudpy"), 

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

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

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

161 ] 

162 for icloudpy_logger in icloudpy_loggers: 

163 icloudpy_logger.setLevel(level=level_name) 

164 # Enable propagation so messages go to root logger handlers 

165 icloudpy_logger.propagate = True 

166 # Remove any existing handlers to avoid duplicates 

167 icloudpy_logger.handlers.clear() 

168 

169 

170def get_logger(): 

171 """Return logger.""" 

172 logger = logging.getLogger() 

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

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

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

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

177 try: 

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

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

180 config = None 

181 logger_config = get_logger_config(config=config) 

182 if logger_config: 

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

184 logger.setLevel(level=level_name) 

185 

186 # Create handlers once and add them to root logger 

187 file_handler = None 

188 console_handler = None 

189 

190 if not log_handler_exists( 

191 logger=logger, 

192 handler_type=logging.FileHandler, 

193 filename=logger_config["filename"], 

194 ): 

195 # Rotating rather than plain FileHandler: see 

196 # DEFAULT_LOG_MAX_BYTES. max_bytes=0 disables rotation for 

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

198 file_handler = RotatingFileHandler( 

199 logger_config["filename"], 

200 maxBytes=logger_config["max_bytes"], 

201 backupCount=logger_config["backup_count"], 

202 ) 

203 file_handler.setFormatter( 

204 logging.Formatter( 

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

206 ), 

207 ) 

208 logger.addHandler(file_handler) 

209 

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

211 console_handler = logging.StreamHandler(sys.stdout) 

212 console_handler.setFormatter( 

213 ColorfulConsoleFormatter( 

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

215 ), 

216 ) 

217 logger.addHandler(console_handler) 

218 

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

220 icloudpy_loggers = [ 

221 logging.getLogger("icloudpy"), 

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

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

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

225 ] 

226 for icloudpy_logger in icloudpy_loggers: 

227 icloudpy_logger.setLevel(level=level_name) 

228 # Enable propagation so messages go to root logger handlers 

229 icloudpy_logger.propagate = True 

230 # Remove any existing handlers to avoid duplicates 

231 icloudpy_logger.handlers.clear() 

232 return logger 

233 

234 

235LOGGER = get_logger()