Coverage for main/logger.py: 95%
66 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-03 15:51 +0000
1import sys
2from datetime import datetime
3import logging
4import os
5import gzip
6import shutil
7from logging.handlers import BaseRotatingHandler
8from pathlib import Path
10from utils import get_app_dir
13class CompressedRotatingFileHandler(BaseRotatingHandler):
14 """
15 A rotating log file handler that compresses rotated files.
17 This handler behaves like a standard rotating file handler, but when a
18 rollover occurs, the old log file is compressed into a `.gz` archive.
19 It also limits the number of backups retained.
21 Attributes
22 ----------
23 maxBytes : int
24 Maximum size in bytes before a rollover is triggered.
25 backupCount : int
26 Maximum number of compressed backup files to retain.
27 log_dir : Path
28 Directory where log files are stored.
29 """
31 def __init__(self, filename, mode="a", maxBytes=10 * 1024 * 1024,
32 backupCount=5, encoding=None, delay=False):
33 """
34 Initialize the handler and ensure the log directory exists.
36 Parameters
37 ----------
38 filename : str or Path
39 Path to the main log file.
40 mode : str, optional
41 File mode (default is "a").
42 maxBytes : int, optional
43 Maximum size in bytes before triggering a rollover.
44 backupCount : int, optional
45 Number of compressed backups to retain.
46 encoding : str, optional
47 Encoding for the log file.
48 delay : bool, optional
49 If True, file opening is deferred until the first write.
50 """
51 self.maxBytes = maxBytes
52 self.backupCount = backupCount
53 self.log_dir = Path(filename).parent
55 # Ensure log directory exists
56 self.log_dir.mkdir(parents=True, exist_ok=True)
58 super().__init__(filename, mode, encoding=encoding, delay=delay)
60 def shouldRollover(self, record):
61 """
62 Determine whether the log file should be rotated.
64 Checks if the size of the log file plus the next log message
65 would exceed the `maxBytes` limit.
67 Parameters
68 ----------
69 record : logging.LogRecord
70 The log record about to be written.
72 Returns
73 -------
74 bool
75 True if rollover should occur, False otherwise.
76 """
77 if self.stream is None:
78 self.stream = self._open()
80 if self.maxBytes > 0:
81 msg = f"{self.format(record)}\n"
82 self.stream.seek(0, os.SEEK_END)
83 if self.stream.tell() + len(msg.encode(self.encoding or "utf-8")) >= self.maxBytes:
84 return True
85 return False
87 def doRollover(self):
88 """
89 Perform the rollover, compress the old file, and maintain only recent backups.
91 The current log file is closed, compressed into `.gz`, and deleted.
92 Then only the most recent `backupCount` compressed files are kept.
93 """
94 if self.stream:
95 self.stream.close()
96 self.stream = None
98 # Create timestamped filename
99 ts = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
100 rotated_file = self.log_dir / f"{Path(self.baseFilename).name}.{ts}.gz"
102 # Compress current file
103 if os.path.exists(self.baseFilename):
104 with open(self.baseFilename, "rb") as f_in, gzip.open(rotated_file, "wb") as f_out:
105 shutil.copyfileobj(f_in, f_out)
106 os.remove(self.baseFilename)
108 # Keep only the latest N compressed backups
109 files = sorted(
110 (f for f in self.log_dir.glob("*.gz") if f.is_file()),
111 key=lambda f: f.stat().st_mtime
112 )
113 if len(files) > self.backupCount:
114 for old_file in files[:-self.backupCount]:
115 old_file.unlink()
117 # Reopen new empty log file
118 if not self.delay:
119 self.stream = self._open()
122def setup_logger(console,
123 logger_name="GliAAns-UI",
124 logfile=None,
125 level=logging.ERROR,
126 maxBytes=10 * 1024 * 1024,
127 backupCount=5):
128 """
129 Configure and return a logger with optional console output.
131 Parameters
132 ----------
133 console : bool
134 If True, also log messages to the console (stdout).
135 logger_name : str, optional
136 Name of the logger (default: "GliAAns-UI").
137 logfile : str or Path, optional
138 Path to the log file. If None, defaults to `~/.log/log.txt` inside
139 the app directory.
140 level : int, optional
141 Logging level (default: logging.ERROR).
142 maxBytes : int, optional
143 Maximum log file size before rotation.
144 backupCount : int, optional
145 Number of compressed backups to retain.
147 Returns
148 -------
149 logging.Logger
150 The configured logger instance.
151 """
152 # Determine log directory under the application path
153 app_dir = get_app_dir()
154 log_dir = app_dir / ".log"
155 log_dir.mkdir(parents=True, exist_ok=True)
157 if logfile is None:
158 logfile = log_dir / "log.txt"
160 logger = logging.getLogger(logger_name)
161 logger.setLevel(level)
163 # Remove existing handlers to prevent duplicates
164 if logger.hasHandlers():
165 logger.handlers.clear()
167 # File handler with compression and rotation
168 handler = CompressedRotatingFileHandler(
169 logfile,
170 maxBytes=maxBytes,
171 backupCount=backupCount
172 )
173 file_formatter = logging.Formatter(
174 "%(asctime)s\t%(levelname)s\t%(filename)s:%(lineno)d\t%(funcName)s\t%(message)s"
175 )
176 handler.setFormatter(file_formatter)
177 logger.addHandler(handler)
179 # Optional console handler for interactive debugging
180 if console:
181 console_handler = logging.StreamHandler(sys.stdout)
182 console_formatter = logging.Formatter(
183 "%(levelname)s\t%(filename)s:%(lineno)d\t%(funcName)s\t%(message)s"
184 )
185 console_handler.setLevel(logging.DEBUG)
186 console_handler.setFormatter(console_formatter)
187 logger.addHandler(console_handler)
189 return logger
192def get_logger(logger_name="GliAAns-UI"):
193 """
194 Retrieve an existing logger instance by name.
196 Parameters
197 ----------
198 logger_name : str, optional
199 The logger name (default: "GliAAns-UI").
201 Returns
202 -------
203 logging.Logger
204 The requested logger instance.
205 """
206 return logging.getLogger(logger_name)
209def set_log_level(level, logger_name="GliAAns-UI"):
210 """
211 Set the log level for an existing logger.
213 Parameters
214 ----------
215 level : int
216 Logging level (e.g., logging.DEBUG, logging.INFO).
217 logger_name : str, optional
218 Name of the logger (default: "GliAAns-UI").
219 """
220 logging.getLogger(logger_name).setLevel(level)