Coverage for src / puzzletree / utils / logging.py: 83.62%
80 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-12 20:35 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-12 20:35 +0000
1"""Logging utilities for puzzletree CLI application."""
3import os
4import sys
5from datetime import datetime, timezone
6from logging import DEBUG, INFO, Formatter, Logger, getLogger
7from logging.handlers import RotatingFileHandler
8from pathlib import Path
9from typing import cast
11from rich.console import Console
12from rich.logging import RichHandler
14from puzzletree.config import Config
15from puzzletree.utils.theme.theme import set_theme
18def _is_running_in_pytest() -> bool:
19 """Check if code is running inside pytest.
21 Returns:
22 True if running in pytest, False otherwise
23 """
24 # Check for pytest in sys.modules or environment variable
25 # Also check if we're being imported during pytest collection
26 return (
27 "pytest" in sys.modules
28 or "PYTEST_CURRENT_TEST" in os.environ
29 or any("pytest" in str(arg) for arg in sys.argv if isinstance(arg, str))
30 )
33def _set_up_logger(
34 name: str = "puzzletree",
35 console: Console | None = None,
36 log_level: int | None = None,
37 *,
38 use_rotating_file_handler: bool = False,
39 log_file_base_path: Path | None = None,
40) -> Logger:
41 """Setting up the logger."""
42 os.environ["NO_ALBUMENTATIONS_UPDATE"] = "1"
43 if log_level is None:
44 log_level = int(os.getenv("_FUN_LOG_LEVEL", DEBUG))
45 else:
46 # Handle string log levels and invalid values gracefully
47 try:
48 if isinstance(log_level, str):
49 log_level = int(log_level)
50 except (ValueError, TypeError):
51 # Fall back to default if conversion fails
52 log_level = DEBUG
54 module_logger = getLogger(name)
56 if len(module_logger.handlers) > 0:
57 for handler in module_logger.handlers: 57 ↛ 63line 57 didn't jump to line 63 because the loop on line 57 didn't complete
58 if handler.get_name() == "rich": 58 ↛ 57line 58 didn't jump to line 57 because the condition on line 58 was always true
59 # Set the log level even for existing loggers
60 module_logger.setLevel(level=log_level)
61 return module_logger
63 if not console: 63 ↛ 77line 63 didn't jump to line 77 because the condition on line 63 was always true
64 # In pytest, disable Rich formatting to avoid ANSI codes in test assertions
65 if _is_running_in_pytest():
66 # Use a console that outputs plain text (no colors/formatting)
67 # Write to stdout instead of stderr so CliRunner can capture it
68 console = Console(
69 file=sys.stdout,
70 force_terminal=False,
71 legacy_windows=False,
72 no_color=True,
73 )
74 else:
75 console = Console(theme=set_theme("dark"))
77 rich_handler = RichHandler(rich_tracebacks=True, console=console)
79 rich_handler.set_name("rich")
80 module_logger.addHandler(rich_handler)
82 if use_rotating_file_handler and log_file_base_path is not None:
83 current_date = datetime.now(tz=timezone.utc).strftime("%Y_%m_%d")
84 log_file_path = log_file_base_path / f"log_{current_date}.log"
85 os.makedirs(os.path.dirname(log_file_path), exist_ok=True)
87 module_logger.setLevel(level=log_level)
89 # get any
90 module_logger.propagate = False
91 # external Logger levels
93 getLogger("PIL").setLevel(level=log_level)
95 return module_logger
98def _attach_rotating_file_handler(
99 logger: Logger,
100 log_file: str,
101 maximum_log_file_size_mb: int = 10,
102 maximum_log_file_time_days: int = 3,
103) -> Logger:
104 """Attach a rotating file handler to the logger."""
105 handler = RotatingFileHandler(
106 log_file,
107 maxBytes=maximum_log_file_size_mb * 1024 * 1024,
108 backupCount=maximum_log_file_time_days,
109 )
110 handler.setFormatter(Formatter(Config().log_format))
111 handler.set_name("rotating_file_handler")
112 logger.addHandler(handler)
113 return logger
116def get_logger_console(
117 name: str = "puzzletree",
118 console: Console | None = None,
119 log_level: int | None = None,
120) -> tuple[Logger, Console]:
121 """Get logger and console.
123 Args:
124 name (str, optional): _description_. Defaults to "puzzletree".
125 console (Console | None, optional): _description_. Defaults to None.
126 log_level (int, optional): _description_. Defaults to INFO.
128 Returns:
129 tuple[Logger, Console]: _description_
130 """
131 if log_level is None:
132 log_level = int(os.getenv("_FUN_LOG_LEVEL", INFO))
133 else:
134 # Handle string log levels and invalid values gracefully
135 try:
136 if isinstance(log_level, str):
137 log_level = int(log_level)
138 except (ValueError, TypeError):
139 # Fall back to default if conversion fails
140 log_level = INFO
141 root_logger = _set_up_logger(
142 use_rotating_file_handler=True,
143 log_level=log_level,
144 )
146 if name != "puzzletree":
147 logger = getLogger(name)
148 logger.handlers = root_logger.handlers
149 logger.setLevel(level=root_logger.level)
150 logger.propagate = False
151 else:
152 logger = root_logger
153 if log_level != root_logger.level: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 logger.warning(f"Log level changed from {root_logger.level} to {log_level}")
155 logger.setLevel(level=log_level)
157 if len(root_logger.handlers) > 0: 157 ↛ 174line 157 didn't jump to line 174 because the condition on line 157 was always true
158 # Find the rich handler in the handlers list
159 for handler in root_logger.handlers: 159 ↛ 174line 159 didn't jump to line 174 because the loop on line 159 didn't complete
160 if handler.get_name() == "rich": 160 ↛ 159line 160 didn't jump to line 159 because the condition on line 160 was always true
161 rich_handler: RichHandler = cast("RichHandler", handler)
162 if _is_running_in_pytest(): 162 ↛ 170line 162 didn't jump to line 170 because the condition on line 162 was always true
163 rich_handler.console = Console(
164 file=sys.stdout,
165 force_terminal=False,
166 legacy_windows=False,
167 no_color=True,
168 )
169 # use console from handler
170 console = rich_handler.console
171 return logger, console
173 # If no console was found and none was provided, create a new one
174 if console is None:
175 # In pytest, disable Rich formatting to avoid ANSI codes in test assertions
176 if _is_running_in_pytest():
177 # Use a console that outputs plain text (no colors/formatting)
178 # Write to stdout instead of stderr so CliRunner can capture it
179 console = Console(
180 file=sys.stdout,
181 force_terminal=False,
182 legacy_windows=False,
183 no_color=True,
184 )
185 else:
186 console = Console()
188 return logger, console