|
| 1 | +import os |
1 | 2 | import sys |
2 | | -from typing import Optional |
3 | 3 | import logging |
4 | | -from datetime import datetime |
5 | | -from logging.handlers import RotatingFileHandler |
| 4 | +from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler |
| 5 | + |
| 6 | +from pythonjsonlogger import jsonlogger |
6 | 7 |
|
7 | 8 | __all__ = [ |
8 | | - 'DATE_FORMAT', |
9 | | - 'LOG_FORMAT', |
10 | 9 | 'get_logger', |
11 | | - 'get_timestamped_log_path', |
12 | 10 | 'setup_logging', |
| 11 | + 'stop_async_listener', |
13 | 12 | ] |
14 | 13 |
|
15 | | -# Default format as per the requirements, adding levelname and module name for context. |
16 | | -LOG_FORMAT = '[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s' |
17 | | -DATE_FORMAT = '%Y-%m-%d %H:%M:%S' |
| 14 | +_listener = None # Global reference for QueueListener |
18 | 15 |
|
19 | 16 |
|
20 | | -def setup_logging( |
21 | | - level: int = logging.INFO, |
22 | | - log_file: Optional[str] = None, |
23 | | - max_bytes: int = 10 * 1024 * 1024, # 10 MB |
24 | | - backup_count: int = 5, |
25 | | -): |
26 | | - """Configures the root logger for the application with log rotation. |
| 17 | +def setup_logging(config: dict) -> None: |
| 18 | + """Set up logging using a DictConfig (OmegaConf) or dict. |
27 | 19 |
|
28 | | - This sets up handlers for console and optional file logging. |
29 | | - It uses a standardized format for all log messages and rotates |
30 | | - log files when they reach a specified size. |
| 20 | + Supports rotation, timestamped files, console+file handlers, logger exclusion, and optional JSON logs. |
31 | 21 |
|
32 | | - :param level: The minimum logging level to capture (e.g., logging.INFO). |
33 | | - :param log_file: Optional path to a file for log output. |
34 | | - :param max_bytes: The maximum size in bytes for a log file before it is rotated. |
35 | | - :param backup_count: The number of backup log files to keep. |
| 22 | + Args: |
| 23 | + config (dict): Logging configuration dictionary or DictConfig. |
36 | 24 | """ |
37 | | - formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT) |
38 | | - |
| 25 | + # Support both DictConfig and dict |
| 26 | + cfg = config if isinstance(config, dict) else dict(config) |
| 27 | + log_dir = cfg.get('log_dir', './log') |
| 28 | + app_name = cfg.get('app_name', 'saezlab_core') |
| 29 | + log_level = getattr(logging, cfg.get('level', 'INFO').upper(), logging.INFO) |
| 30 | + log_format = cfg.get( |
| 31 | + 'format', '[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s' |
| 32 | + ) |
| 33 | + # Support max_megabytes (preferred) or fallback to max_bytes for backward compatibility |
| 34 | + if 'max_megabytes' in cfg: |
| 35 | + max_bytes = int(cfg.get('max_megabytes', 10)) * 1024 * 1024 |
| 36 | + else: |
| 37 | + max_bytes = cfg.get('max_bytes', 10 * 1024 * 1024) |
| 38 | + backup_count = cfg.get('backup_count', 5) |
| 39 | + timestamp = cfg.get('timestamp') |
| 40 | + use_json = cfg.get('json_logs', False) |
| 41 | + timezone = cfg.get('timezone', 'UTC') |
| 42 | + import queue |
| 43 | + from datetime import datetime |
| 44 | + |
| 45 | + try: |
| 46 | + from zoneinfo import ZoneInfo |
| 47 | + |
| 48 | + tzinfo = ZoneInfo(timezone) |
| 49 | + except ImportError: |
| 50 | + # For Python <3.9, fallback to UTC |
| 51 | + import pytz |
| 52 | + |
| 53 | + tzinfo = pytz.timezone(timezone) if timezone != 'UTC' else None |
| 54 | + if not timestamp: |
| 55 | + timestamp = datetime.now(tz=tzinfo).strftime('%Y-%m-%d') |
| 56 | + async_logging = cfg.get('async_logging', False) |
| 57 | + if not os.path.exists(log_dir): |
| 58 | + os.makedirs(log_dir, exist_ok=True) |
| 59 | + log_file = os.path.join(log_dir, f'{app_name}_{timestamp}.log') |
| 60 | + |
| 61 | + # Custom formatter to inject timezone-aware asctime |
| 62 | + class TZFormatter(logging.Formatter): |
| 63 | + def __init__( |
| 64 | + self, |
| 65 | + fmt: str | None = None, |
| 66 | + datefmt: str | None = None, |
| 67 | + tz: object = None, |
| 68 | + ) -> None: |
| 69 | + super().__init__(fmt=fmt, datefmt=datefmt) |
| 70 | + self.tz = tz |
| 71 | + |
| 72 | + def formatTime( |
| 73 | + self, record: logging.LogRecord, datefmt: str | None = None |
| 74 | + ) -> str: |
| 75 | + dt = datetime.fromtimestamp(record.created, tz=self.tz) |
| 76 | + if datefmt: |
| 77 | + return dt.strftime(datefmt) |
| 78 | + return dt.isoformat() |
| 79 | + |
| 80 | + if use_json: |
| 81 | + json_format = '%(asctime)s %(levelname)s %(name)s %(message)s' |
| 82 | + formatter = jsonlogger.JsonFormatter(json_format) |
| 83 | + formatter.formatTime = ( |
| 84 | + lambda record, datefmt=None: TZFormatter().formatTime( |
| 85 | + record, datefmt |
| 86 | + ) |
| 87 | + ) |
| 88 | + else: |
| 89 | + formatter = TZFormatter(log_format, tz=tzinfo) |
39 | 90 | handlers = [] |
40 | | - |
41 | | - # Console handler |
42 | 91 | console_handler = logging.StreamHandler(sys.stdout) |
43 | 92 | console_handler.setFormatter(formatter) |
44 | | - handlers.append(console_handler) |
45 | | - |
46 | | - # Rotating file handler (if a path is provided) |
47 | | - if log_file: |
48 | | - # Use RotatingFileHandler for automatic log rotation. |
49 | | - file_handler = RotatingFileHandler( |
50 | | - log_file, maxBytes=max_bytes, backupCount=backup_count |
51 | | - ) |
52 | | - file_handler.setFormatter(formatter) |
53 | | - handlers.append(file_handler) |
54 | | - |
55 | | - # The `force=True` argument removes any existing handlers |
56 | | - # on the root logger, ensuring our configuration is the only one. |
57 | | - logging.basicConfig(level=level, handlers=handlers, force=True) |
58 | | - |
59 | | - # Set up a hook for unhandled exceptions to be logged automatically. |
60 | | - # This addresses the "Log traceback" requirement. |
61 | | - def handle_exception(exc_type, exc_value, exc_traceback): |
62 | | - if issubclass(exc_type, KeyboardInterrupt): |
63 | | - sys.__excepthook__(exc_type, exc_value, exc_traceback) |
64 | | - return |
65 | | - logging.getLogger().critical( |
66 | | - 'Unhandled exception', exc_info=(exc_type, exc_value, exc_traceback) |
| 93 | + file_handler = RotatingFileHandler( |
| 94 | + log_file, maxBytes=max_bytes, backupCount=backup_count |
| 95 | + ) |
| 96 | + file_handler.setFormatter(formatter) |
| 97 | + |
| 98 | + global _listener |
| 99 | + if async_logging: |
| 100 | + log_queue = queue.Queue(-1) |
| 101 | + queue_handler = QueueHandler(log_queue) |
| 102 | + handlers = [queue_handler] |
| 103 | + _listener = QueueListener(log_queue, console_handler, file_handler) |
| 104 | + _listener.start() |
| 105 | + else: |
| 106 | + handlers = [console_handler, file_handler] |
| 107 | + |
| 108 | + logging.basicConfig(level=log_level, handlers=handlers, force=True) |
| 109 | + |
| 110 | + # Exclude or set log level for specified loggers |
| 111 | + exclude_loggers = cfg.get('exclude_loggers', []) |
| 112 | + for logger_name in exclude_loggers: |
| 113 | + logger = logging.getLogger(logger_name) |
| 114 | + logger.setLevel(logging.WARNING) |
| 115 | + logger.propagate = ( |
| 116 | + False # Prevents messages from being passed to the root logger |
67 | 117 | ) |
68 | 118 |
|
69 | | - sys.excepthook = handle_exception |
70 | 119 |
|
| 120 | +def stop_async_listener() -> None: |
| 121 | + """Stop the async QueueListener if running (flushes all logs).""" |
| 122 | + global _listener |
| 123 | + if _listener is not None: |
| 124 | + _listener.stop() |
| 125 | + _listener = None |
71 | 126 |
|
72 | | -def get_timestamped_log_path(log_dir: str, app_name: str) -> str: |
73 | | - """Generates a timestamped log file path. |
74 | 127 |
|
75 | | - :param log_dir: The directory where the log file should be stored. |
76 | | - :param app_name: The base name for the log file. |
77 | | - :return: A string representing the full path to the log file. |
78 | | - """ |
79 | | - timestamp = datetime.now().strftime('%Y-%m-%d') |
80 | | - return f'{log_dir}/{app_name}_{timestamp}.log' |
| 128 | +def get_logger(name: str) -> logging.Logger: |
| 129 | + """Get a logger for a given component/module. |
81 | 130 |
|
| 131 | + Usage: |
| 132 | + log = get_logger(__name__) or get_logger('my_component'). |
82 | 133 |
|
83 | | -def get_logger(name: str) -> logging.Logger: |
84 | | - """Returns a logger instance for the given name. |
| 134 | + Args: |
| 135 | + name (str): The logger name (usually __name__ or a component name). |
85 | 136 |
|
86 | | - This is the primary function that developers will use to get a |
87 | | - pre-configured logger within their modules. |
| 137 | + Returns: |
| 138 | + logging.Logger: The logger instance. |
88 | 139 | """ |
89 | 140 | return logging.getLogger(name) |
0 commit comments