Coverage for src / ezplog / handlers / file.py: 71.57%
177 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-30 19:43 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-30 19:43 +0000
1# ///////////////////////////////////////////////////////////////
2# EZPL - File Logger Handler
3# Project: ezpl
4# ///////////////////////////////////////////////////////////////
6"""
7File logger handler for Ezpl logging framework.
9This module provides a file-based logging handler with advanced formatting,
10session separation, and structured output.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19from datetime import datetime
20from pathlib import Path
21from typing import Any, cast
23# Third-party imports
24from loguru import logger
25from loguru._logger import Logger as LoguruLogger
27# Local imports
28from ..core.exceptions import FileOperationError, LoggingError, ValidationError
29from ..core.interfaces import LoggingHandler
30from ..types.enums import LogLevel
31from ..utils import safe_str_convert, sanitize_for_file
33# ///////////////////////////////////////////////////////////////
34# CLASSES
35# ///////////////////////////////////////////////////////////////
38class EzLogger(LoggingHandler):
39 """
40 File logger handler with advanced formatting and session management.
42 This handler provides file-based logging with:
43 - Structured log format
44 - Session separators
45 - HTML tag sanitization
46 - Automatic file creation
47 """
49 # ///////////////////////////////////////////////////////////////
50 # INIT
51 # ///////////////////////////////////////////////////////////////
53 def __init__(
54 self,
55 log_file: Path | str,
56 level: str = "INFO",
57 rotation: str | None = None,
58 retention: str | None = None,
59 compression: str | None = None,
60 ) -> None:
61 """
62 Initialize the file logger handler.
64 Args:
65 log_file: Path to the log file
66 level: The desired logging level
67 rotation: Rotation size (e.g., "10 MB") or time (e.g., "1 day")
68 retention: Retention period (e.g., "7 days")
69 compression: Compression format (e.g., "zip", "gz")
71 Raises:
72 ValidationError: If the provided level is invalid
73 FileOperationError: If file operations fail
74 """
75 if not LogLevel.is_valid_level(level):
76 raise ValidationError(f"Invalid log level: {level}", "level", level)
78 self._level = level.upper()
79 self._level_manually_set = False
80 self._log_file = Path(log_file)
81 self._logger = logger.bind(task="logger")
82 self._logger_id: int | None = None
83 self._rotation = rotation
84 self._retention = retention
85 self._compression = compression
87 # Validate and create parent directory
88 try:
89 self._log_file.parent.mkdir(parents=True, exist_ok=True)
90 except (PermissionError, OSError) as e:
91 raise FileOperationError(
92 f"Cannot create log directory: {e}",
93 str(self._log_file.parent),
94 "create_directory",
95 ) from e
97 # Validate that the file can be created/written
98 try:
99 if not self._log_file.exists():
100 self._log_file.touch()
101 # Write test
102 with open(self._log_file, "a", encoding="utf-8") as f:
103 f.write("")
104 except (PermissionError, OSError) as e:
105 raise FileOperationError(
106 f"Cannot write to log file: {e}", str(self._log_file), "write"
107 ) from e
109 self._initialize_logger()
111 # ------------------------------------------------
112 # PRIVATE HELPER METHODS
113 # ------------------------------------------------
115 def _initialize_logger(self) -> None:
116 """
117 Initialize the file logger handler.
119 Raises:
120 LoggingError: If logger initialization fails
121 """
122 try:
123 # Remove existing handler if any
124 logger_id: int | None = self._logger_id
125 if logger_id is not None:
126 self._logger.remove(logger_id)
128 # Call loguru.add() with keyword arguments directly
129 # Note: loguru.add() accepts keyword arguments, not a dict
130 self._logger_id = self._logger.add(
131 sink=self._log_file,
132 level=self._level,
133 format=self._custom_formatter, # type: ignore[arg-type]
134 filter=lambda record: record["extra"]["task"] == "logger",
135 encoding="utf-8",
136 rotation=self._rotation if self._rotation else None,
137 retention=self._retention if self._retention else None,
138 compression=self._compression if self._compression else None,
139 )
140 except Exception as e:
141 raise LoggingError(f"Failed to initialize file logger: {e}", "file") from e
143 # ///////////////////////////////////////////////////////////////
144 # UTILS METHODS
145 # ///////////////////////////////////////////////////////////////
147 @property
148 def level(self) -> str:
149 """Return the current logging level."""
150 return self._level
152 @property
153 def level_manually_set(self) -> bool:
154 """Return whether level was set manually at runtime."""
155 return self._level_manually_set
157 @property
158 def rotation(self) -> str | None:
159 """Return current rotation setting."""
160 return self._rotation
162 @property
163 def retention(self) -> str | None:
164 """Return current retention setting."""
165 return self._retention
167 @property
168 def compression(self) -> str | None:
169 """Return current compression setting."""
170 return self._compression
172 def mark_level_as_configured(self) -> None:
173 """Mark the current level as coming from configuration (not manual set)."""
174 self._level_manually_set = False
176 def set_level(self, level: str) -> None:
177 """
178 Set the logging level.
180 Args:
181 level: The desired logging level
183 Raises:
184 ValidationError: If the provided level is invalid
185 LoggingError: If level update fails
186 """
187 if not LogLevel.is_valid_level(level):
188 raise ValidationError(f"Invalid log level: {level}", "level", level)
190 old_level = self._level
191 try:
192 self._level = level.upper()
193 self._level_manually_set = True
194 self._initialize_logger()
195 except Exception as e:
196 self._level = old_level # Rollback to previous level on failure
197 raise LoggingError(f"Failed to update log level: {e}", "file") from e
199 def log(self, level: str, message: Any) -> None:
200 """
201 Log a message with the specified level.
203 Args:
204 level: The log level
205 message: The message to log (any type, will be converted to string)
207 Raises:
208 ValidationError: If the level is invalid
209 LoggingError: If logging fails
210 """
211 if not LogLevel.is_valid_level(level):
212 raise ValidationError(f"Invalid log level: {level}", "level", level)
214 # Convert message to string robustly
215 message = safe_str_convert(message)
217 try:
218 log_method = getattr(self._logger, level.lower())
219 log_method(message)
220 except Exception as e:
221 raise LoggingError(f"Failed to log message: {e}", "file") from e
223 # ///////////////////////////////////////////////////////////////
224 # LOGGING METHODS (API primaire - delegates to loguru)
225 # ///////////////////////////////////////////////////////////////
227 def trace(self, message: Any, *args, **kwargs) -> None:
228 """Log a trace message."""
229 message = safe_str_convert(message)
230 self._logger.trace(message, *args, **kwargs)
232 def debug(self, message: Any, *args, **kwargs) -> None:
233 """Log a debug message."""
234 message = safe_str_convert(message)
235 self._logger.debug(message, *args, **kwargs)
237 def info(self, message: Any, *args, **kwargs) -> None:
238 """Log an info message."""
239 message = safe_str_convert(message)
240 self._logger.info(message, *args, **kwargs)
242 def success(self, message: Any, *args, **kwargs) -> None:
243 """Log a success message."""
244 message = safe_str_convert(message)
245 self._logger.success(message, *args, **kwargs)
247 def warning(self, message: Any, *args, **kwargs) -> None:
248 """Log a warning message."""
249 message = safe_str_convert(message)
250 self._logger.warning(message, *args, **kwargs)
252 def warn(self, message: Any, *args, **kwargs) -> None:
253 """Alias for warning(). Log a warning message."""
254 self.warning(message, *args, **kwargs)
256 def error(self, message: Any, *args, **kwargs) -> None:
257 """Log an error message."""
258 message = safe_str_convert(message)
259 self._logger.error(message, *args, **kwargs)
261 def critical(self, message: Any, *args, **kwargs) -> None:
262 """Log a critical message."""
263 message = safe_str_convert(message)
264 self._logger.critical(message, *args, **kwargs)
266 def exception(self, message: Any, *args, **kwargs) -> None:
267 """Log an exception with traceback."""
268 message = safe_str_convert(message)
269 self._logger.exception(message, *args, **kwargs)
271 # ///////////////////////////////////////////////////////////////
272 # LOGURU-SPECIFIC METHODS (delegation)
273 # ///////////////////////////////////////////////////////////////
275 def bind(self, **kwargs: Any) -> Any:
276 """Bind context variables to the logger."""
277 return self._logger.bind(**kwargs)
279 def opt(self, **kwargs: Any) -> Any:
280 """Configure logger options."""
281 return self._logger.opt(**kwargs)
283 def patch(self, patcher: Any) -> Any:
284 """Patch log records."""
285 return self._logger.patch(patcher)
287 # ///////////////////////////////////////////////////////////////
288 # GETTER - Returns the underlying loguru logger for advanced usage
289 # ///////////////////////////////////////////////////////////////
291 def get_loguru(self) -> LoguruLogger:
292 """
293 Get the underlying Loguru logger instance for advanced usage.
295 **Returns:**
297 * loguru.Logger: The loguru logger instance
299 **Raises:**
301 * LoggingError: If the logger is not initialized
302 """
303 if not self._logger: 303 ↛ 304line 303 didn't jump to line 304 because the condition on line 303 was never true
304 raise LoggingError("File logger not initialized", "file")
305 # logger.bind() returns a BoundLogger internally; cast to declared return type
306 return cast(LoguruLogger, self._logger)
308 def get_log_file(self) -> Path:
309 """
310 Get the current log file path.
312 Returns:
313 Path to the log file
314 """
315 return self._log_file
317 def get_file_size(self) -> int:
318 """
319 Get the current log file size in bytes.
321 Returns:
322 File size in bytes, or 0 if file doesn't exist or error occurs
323 """
324 try:
325 if self._log_file.exists(): 325 ↛ 327line 325 didn't jump to line 327 because the condition on line 325 was always true
326 return self._log_file.stat().st_size
327 return 0
328 except Exception:
329 return 0
331 def close(self) -> None:
332 """
333 Close the logger handler and release file handles.
335 This method removes the loguru handler to release file handles,
336 which is especially important on Windows where files can remain locked.
337 """
338 try:
339 # Remove existing handler if any
340 logger_id: int | None = self._logger_id
341 if logger_id is not None:
342 # loguru.remove() synchronously flushes and closes the file handle
343 self._logger.remove(logger_id)
344 self._logger_id = None
345 except Exception as e:
346 raise LoggingError("Failed to close logger", "file") from e
348 # ///////////////////////////////////////////////////////////////
349 # FILE OPERATIONS
350 # ///////////////////////////////////////////////////////////////
352 def add_separator(self) -> None:
353 """
354 Add a separator line to the log file for session distinction.
356 Raises:
357 FileOperationError: If writing to the log file fails
358 """
359 try:
360 current_time = datetime.now().strftime("%Y-%m-%d - %H:%M")
361 separator = f"\n\n## ==> {current_time}\n## /////////////////////////////////////////////////////////////////\n"
362 with open(self._log_file, "a", encoding="utf-8") as log_file:
363 log_file.write(separator)
364 except Exception as e:
365 raise FileOperationError(
366 f"Failed to add separator to log file: {e}",
367 str(self._log_file),
368 "write",
369 ) from e
371 # ///////////////////////////////////////////////////////////////
372 # FORMATTING METHODS
373 # ///////////////////////////////////////////////////////////////
375 def _custom_formatter(self, record: dict[str, Any]) -> str:
376 """
377 Custom formatter for file output.
379 Args:
380 record: Loguru record to format
382 Returns:
383 Formatted log message (always returns a string, never raises an exception)
384 """
385 try:
386 level = (
387 record.get("level", {}).name
388 if hasattr(record.get("level", {}), "name")
389 else "INFO"
390 )
391 log_level = LogLevel[level]
392 return self._format_message(record, log_level)
393 except Exception as e:
394 # Never raise an exception inside a formatter — return a safe error message
395 try:
396 return f"????-??-?? ??:??:?? | FORMAT_ERR | unknown:unknown:? - [FORMAT ERROR: {type(e).__name__}]\n"
397 except Exception:
398 return "????-??-?? ??:??:?? | FORMAT_ERR | unknown:unknown:? - [FORMAT ERROR]\n"
400 def _format_message(self, record: dict[str, Any], log_level: LogLevel) -> str:
401 """
402 Format a log message for file output.
404 Args:
405 record: Loguru record
406 log_level: LogLevel enum instance
408 Returns:
409 Formatted log message (always returns a valid string)
410 """
411 try:
412 # Safely format the timestamp
413 try:
414 time_obj: Any = record.get("time")
415 # Check if time_obj is a datetime-like object with strftime
416 if time_obj is not None: 416 ↛ 424line 416 didn't jump to line 424 because the condition on line 416 was always true
417 strftime_method = getattr(time_obj, "strftime", None)
418 if strftime_method is not None and callable(strftime_method): 418 ↛ 422line 418 didn't jump to line 422 because the condition on line 418 was always true
419 # Safe to call strftime - time_obj is datetime-like
420 timestamp = strftime_method("%Y-%m-%d %H:%M:%S")
421 else:
422 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
423 else:
424 timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
425 except Exception:
426 timestamp = "????-??-?? ??:??:??"
428 # Clean the message robustly
429 message = safe_str_convert(record.get("message", ""))
430 # Sanitize for file output (removes problematic characters)
431 message = sanitize_for_file(message)
433 # Clean the function name
434 fn = str(record.get("function", "unknown"))
435 fn = fn.replace("<", "").replace(">", "")
437 # Safely extract module and line
438 module = str(record.get("module", "unknown"))
439 line = str(record.get("line", "?"))
441 return (
442 f"{timestamp} | "
443 f"{log_level.label:<10} | "
444 f"{module}:{fn}:{line} - "
445 f"{message}\n"
446 )
447 except Exception as e:
448 # Safe fallback
449 try:
450 return f"????-??-?? ??:??:?? | FORMAT_ERR | unknown:unknown:? - [FORMAT ERROR: {type(e).__name__}]\n"
451 except Exception:
452 return "????-??-?? ??:??:?? | FORMAT_ERR | unknown:unknown:? - [FORMAT ERROR]\n"
454 # ///////////////////////////////////////////////////////////////
455 # REPRESENTATION METHODS
456 # ///////////////////////////////////////////////////////////////
458 def __str__(self) -> str:
459 """String representation of the file logger."""
460 return f"EzLogger(file={self._log_file}, level={self._level})"
462 def __repr__(self) -> str:
463 """Detailed string representation of the file logger."""
464 return f"EzLogger(file={self._log_file}, level={self._level}, logger_id={self._logger_id})"
467# ///////////////////////////////////////////////////////////////
468# PUBLIC API
469# ///////////////////////////////////////////////////////////////
471__all__ = [
472 "EzLogger",
473]