Coverage for src/__init__.py: 100%
95 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"""Root module."""
3__author__ = "Mandar Patil (mandarons@pm.me)"
5import logging
6import os
7import sys
8import warnings
10from ruamel.yaml import YAML
12DEFAULT_ROOT_DESTINATION = "./icloud"
13DEFAULT_DRIVE_DESTINATION = "drive"
14DEFAULT_PHOTOS_DESTINATION = "photos"
15DEFAULT_RETRY_LOGIN_INTERVAL_SEC = 600 # 10 minutes
16DEFAULT_SYNC_INTERVAL_SEC = 1800 # 30 minutes
17DEFAULT_REQUEST_TIMEOUT_SEC = 30 # 30 seconds
18DEFAULT_ENUMERATION_CHUNK_SIZE = 1000 # photos buffered per streaming chunk
19DEFAULT_CONFIG_FILE_NAME = "config.yaml"
20ENV_ICLOUD_PASSWORD_KEY = "ENV_ICLOUD_PASSWORD"
21ENV_CONFIG_FILE_PATH_KEY = "ENV_CONFIG_FILE_PATH"
22DEFAULT_LOGGER_LEVEL = "info"
23DEFAULT_LOG_FILE_NAME = "icloud.log"
24DEFAULT_CONFIG_FILE_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), DEFAULT_CONFIG_FILE_NAME)
25# Operator-overridable via ICLOUD_DOCKER_CONFIG_DIR. Default ``/config`` is the
26# in-container mount point users bind their config volume to. The override
27# lets the test suite run on hosts where ``/config`` isn't writable (macOS,
28# sandboxes).
29_CONFIG_DIR = os.environ.get("ICLOUD_DOCKER_CONFIG_DIR", "/config")
30DEFAULT_COOKIE_DIRECTORY = os.path.join(_CONFIG_DIR, "session_data")
32warnings.filterwarnings("ignore", category=DeprecationWarning)
35def read_config(config_path=DEFAULT_CONFIG_FILE_PATH):
36 """Read config file."""
37 if not (config_path and os.path.exists(config_path)):
38 print(f"Config file not found at {config_path}.")
39 return None
40 with open(file=config_path, encoding="utf-8") as config_file:
41 config = YAML().load(config_file)
42 config["app"]["credentials"]["username"] = (
43 config["app"]["credentials"]["username"].strip() if config["app"]["credentials"]["username"] is not None else ""
44 )
45 return config
48def get_logger_config(config):
49 """Get logger config."""
50 logger_config = {}
51 if "logger" not in config["app"]:
52 return None
53 config_app_logger = config["app"]["logger"]
54 logger_config["level"] = (
55 config_app_logger["level"].strip().lower() if "level" in config_app_logger else DEFAULT_LOGGER_LEVEL
56 )
57 logger_config["filename"] = (
58 config_app_logger["filename"].strip().lower() if "filename" in config_app_logger else DEFAULT_LOG_FILE_NAME
59 )
60 return logger_config
63def log_handler_exists(logger, handler_type, **kwargs):
64 """Check for existing log handler."""
65 for handler in logger.handlers:
66 if isinstance(handler, handler_type):
67 if handler_type is logging.FileHandler:
68 if handler.baseFilename.endswith(kwargs["filename"]):
69 return True
70 elif handler_type is logging.StreamHandler:
71 if handler.stream is kwargs["stream"]:
72 return True
73 return False
76class ColorfulConsoleFormatter(logging.Formatter):
77 """Console formatter for log messages."""
79 grey = "\x1b[38;21m"
80 blue = "\x1b[38;5;39m"
81 yellow = "\x1b[38;5;226m"
82 red = "\x1b[38;5;196m"
83 bold_red = "\x1b[31;1m"
84 reset = "\x1b[0m"
86 def __init__(self, fmt):
87 """Construct with defaults."""
88 super().__init__()
89 self.fmt = fmt
90 self.formats = {
91 logging.DEBUG: self.grey + self.fmt + self.reset,
92 logging.INFO: self.blue + self.fmt + self.reset,
93 logging.WARNING: self.yellow + self.fmt + self.reset,
94 logging.ERROR: self.red + self.fmt + self.reset,
95 logging.CRITICAL: self.bold_red + self.fmt + self.reset,
96 }
98 def format(self, record):
99 """Format the record."""
100 log_fmt = self.formats.get(record.levelno)
101 formatter = logging.Formatter(log_fmt)
102 return formatter.format(record)
105def configure_icloudpy_logging():
106 """Configure icloudpy logging to match app logging level."""
107 logger_config = get_logger_config(config=read_config(config_path=os.environ.get(ENV_CONFIG_FILE_PATH_KEY, DEFAULT_CONFIG_FILE_PATH)))
108 if logger_config:
109 level_name = logging.getLevelName(level=logger_config["level"].upper())
111 # Configure icloudpy loggers to use the same level and enable propagation
112 icloudpy_loggers = [
113 logging.getLogger("icloudpy"),
114 logging.getLogger("icloudpy.base"),
115 logging.getLogger("icloudpy.services"),
116 logging.getLogger("icloudpy.services.photos"),
117 ]
118 for icloudpy_logger in icloudpy_loggers:
119 icloudpy_logger.setLevel(level=level_name)
120 # Enable propagation so messages go to root logger handlers
121 icloudpy_logger.propagate = True
122 # Remove any existing handlers to avoid duplicates
123 icloudpy_logger.handlers.clear()
126def get_logger():
127 """Return logger."""
128 logger = logging.getLogger()
129 logger_config = get_logger_config(config=read_config(config_path=os.environ.get(ENV_CONFIG_FILE_PATH_KEY, DEFAULT_CONFIG_FILE_PATH)))
130 if logger_config:
131 level_name = logging.getLevelName(level=logger_config["level"].upper())
132 logger.setLevel(level=level_name)
134 # Create handlers once and add them to root logger
135 file_handler = None
136 console_handler = None
138 if not log_handler_exists(
139 logger=logger,
140 handler_type=logging.FileHandler,
141 filename=logger_config["filename"],
142 ):
143 file_handler = logging.FileHandler(logger_config["filename"])
144 file_handler.setFormatter(
145 logging.Formatter(
146 "%(asctime)s :: %(levelname)s :: %(name)s :: %(filename)s :: %(lineno)d :: %(message)s",
147 ),
148 )
149 logger.addHandler(file_handler)
151 if not log_handler_exists(logger=logger, handler_type=logging.StreamHandler, stream=sys.stdout):
152 console_handler = logging.StreamHandler(sys.stdout)
153 console_handler.setFormatter(
154 ColorfulConsoleFormatter(
155 "%(asctime)s :: %(levelname)s :: %(name)s :: %(filename)s :: %(lineno)d :: %(message)s",
156 ),
157 )
158 logger.addHandler(console_handler)
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 return logger
176LOGGER = get_logger()