Coverage for src/ezplog/handlers/console.py: 83.26%
191 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-16 19:04 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-10-16 19:04 +0000
1# ///////////////////////////////////////////////////////////////
2# EZPL - Console Printer Handler
3# Project: ezpl
4# ///////////////////////////////////////////////////////////////
6"""
7Console printer handler for Ezpl logging framework.
9This module provides a console-based logging handler with advanced formatting,
10indentation management, and color support using Rich.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19import sys
20from collections.abc import Generator
21from contextlib import contextmanager
22from typing import Any
24# Third-party imports
25from rich.console import Console
26from rich.progress import Progress, SpinnerColumn, TextColumn
27from rich.text import Text
29# Local imports
30from ..core.exceptions import ValidationError
31from ..core.interfaces import IndentationManager, LoggingHandler
32from ..types.enums import LogLevel, Pattern, get_pattern_color
33from ..utils import safe_str_convert, sanitize_for_console
34from .wizard import RichWizard
36# ///////////////////////////////////////////////////////////////
37# CLASSES
38# ///////////////////////////////////////////////////////////////
41class EzPrinter(LoggingHandler, IndentationManager):
42 """
43 Console printer handler with advanced formatting and indentation support using Rich.
45 This handler provides console-based logging with:
46 - Color-coded log levels using Rich
47 - Indentation management
48 - Robust character handling (Rich handles special characters automatically)
49 - Context manager support
50 - Pattern-based logging (SUCCESS, ERROR, WARN, TIP, etc.)
51 - Access to RichWizard for advanced display features
52 """
54 _MAX_INDENT = 10 # Maximum indentation level
56 # ///////////////////////////////////////////////////////////////
57 # INIT
58 # ///////////////////////////////////////////////////////////////
60 def __init__(
61 self,
62 level: str = "INFO",
63 indent_step: int = 3,
64 indent_symbol: str = ">",
65 base_indent_symbol: str = "~",
66 ) -> None:
67 """
68 Initialize the console printer handler.
70 Args:
71 level: The desired logging level
72 indent_step: Number of spaces for each indentation level
73 indent_symbol: Symbol for indentation levels
74 base_indent_symbol: Symbol for the base indentation
76 Raises:
77 ValidationError: If the provided level is invalid
78 """
79 if not LogLevel.is_valid_level(level): 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true
80 raise ValidationError(f"Invalid log level: {level}", "level", level)
82 self._level = level.upper()
83 self._level_manually_set = False
84 self._indent = 0
85 self._indent_step = indent_step
86 self._indent_symbol = indent_symbol
87 self._base_indent_symbol = base_indent_symbol
89 # Initialize Rich Console
90 self._console = Console()
91 self._level_numeric = LogLevel.get_no(self._level)
93 # Initialize Rich Wizard for advanced display features
94 self._wizard = RichWizard(self._console)
96 # ///////////////////////////////////////////////////////////////
97 # UTILS METHODS
98 # ///////////////////////////////////////////////////////////////
100 @property
101 def level(self) -> str:
102 """Return the current logging level."""
103 return self._level
105 @property
106 def level_manually_set(self) -> bool:
107 """Return whether level was set manually at runtime."""
108 return self._level_manually_set
110 @property
111 def indent_step(self) -> int:
112 """Return the configured indentation step."""
113 return self._indent_step
115 @property
116 def indent_symbol(self) -> str:
117 """Return the configured indentation symbol."""
118 return self._indent_symbol
120 @property
121 def base_indent_symbol(self) -> str:
122 """Return the configured base indentation symbol."""
123 return self._base_indent_symbol
125 def mark_level_as_configured(self) -> None:
126 """Mark the current level as coming from configuration (not manual set)."""
127 self._level_manually_set = False
129 def set_level(self, level: str) -> None:
130 """
131 Set the logging level.
133 Args:
134 level: The desired logging level
136 Raises:
137 ValidationError: If the provided level is invalid
138 """
139 if not LogLevel.is_valid_level(level):
140 raise ValidationError(f"Invalid log level: {level}", "level", level)
142 self._level = level.upper()
143 self._level_numeric = LogLevel.get_no(self._level)
144 self._level_manually_set = True
146 def log(self, level: str, message: Any, *args: Any, **kwargs: Any) -> None:
147 """
148 Log a message with the specified level.
150 Args:
151 level: The log level
152 message: The message to log (any type, will be safely converted to string)
153 *args: Positional arguments for loguru-style `{}` formatting
154 **kwargs: Keyword arguments for loguru-style `{}` formatting
156 Raises:
157 ValidationError: If the level is invalid
158 """
159 if not LogLevel.is_valid_level(level): 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true
160 raise ValidationError(f"Invalid log level: {level}", "level", level)
162 # Convert message to string robustly
163 message = safe_str_convert(message)
164 message = sanitize_for_console(message)
165 message = self._apply_format(message, args, kwargs)
167 try:
168 level_numeric = LogLevel.get_no(level)
169 if level_numeric < self._level_numeric: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
170 return # Level too low, skip
172 # Map log levels to patterns for consistent output
173 pattern_map = {
174 "TRACE": Pattern.TRACE,
175 "DEBUG": Pattern.DEBUG,
176 "INFO": Pattern.INFO,
177 "SUCCESS": Pattern.SUCCESS,
178 "WARNING": Pattern.WARN,
179 "ERROR": Pattern.ERROR,
180 "CRITICAL": Pattern.ERROR, # Critical also uses ERROR pattern
181 }
182 pattern_enum = pattern_map.get(level.upper(), Pattern.INFO)
183 self.print_pattern(pattern_enum, message, level)
185 except Exception as e:
186 # Never raise an exception, just log the error safely
187 try:
188 self._console.print(
189 f"[bold red]LOGGING ERROR:[/bold red] {type(e).__name__}"
190 )
191 except Exception as e:
192 raise ValueError(f"Failed to print logging error: {e}") from e
194 # ///////////////////////////////////////////////////////////////
195 # PRIVATE HELPER METHODS
196 # ///////////////////////////////////////////////////////////////
198 @staticmethod
199 def _apply_format(
200 message: str, args: tuple[Any, ...], kwargs: dict[str, Any]
201 ) -> str:
202 """
203 Apply loguru-style `{}` formatting when arguments are supplied.
205 Returns the message untouched when no arguments are given, so that
206 literal braces are preserved. Any formatting failure falls back to the
207 raw message: a logging call must never break the caller.
208 """
209 if not args and not kwargs:
210 return message
211 try:
212 return message.format(*args, **kwargs)
213 except (IndexError, KeyError, ValueError):
214 return message
216 def _print_traceback_if_active(self) -> None:
217 """
218 Render the active exception traceback, if any.
220 `Console.print_exception()` raises when no exception is being handled,
221 so the call is guarded. Local variables are never shown: a traceback
222 may end up in a log file, and locals routinely hold secrets.
223 """
224 if sys.exc_info()[0] is None:
225 return
226 self._console.print_exception(show_locals=False)
228 # ///////////////////////////////////////////////////////////////
229 # LOGGING METHODS (API primaire)
230 # ///////////////////////////////////////////////////////////////
232 def info(self, message: Any, *args: Any, **kwargs: Any) -> None:
233 """Log an informational message with pattern format."""
234 message = self._apply_format(safe_str_convert(message), args, kwargs)
235 self.print_pattern(Pattern.INFO, message, "INFO")
237 def trace(self, message: Any, *args: Any, **kwargs: Any) -> None:
238 """Log a trace message with pattern format."""
239 message = self._apply_format(safe_str_convert(message), args, kwargs)
240 self.print_pattern(Pattern.TRACE, message, "TRACE")
242 def debug(self, message: Any, *args: Any, **kwargs: Any) -> None:
243 """Log a debug message with pattern format."""
244 message = self._apply_format(safe_str_convert(message), args, kwargs)
245 self.print_pattern(Pattern.DEBUG, message, "DEBUG")
247 def success(self, message: Any, *args: Any, **kwargs: Any) -> None:
248 """Log a success message with pattern format."""
249 message = self._apply_format(safe_str_convert(message), args, kwargs)
250 self.print_pattern(Pattern.SUCCESS, message, "INFO")
252 def warning(self, message: Any, *args: Any, **kwargs: Any) -> None:
253 """Log a warning message with pattern format."""
254 message = self._apply_format(safe_str_convert(message), args, kwargs)
255 self.print_pattern(Pattern.WARN, message, "WARNING")
257 def error(
258 self, message: Any, *args: Any, exc_info: bool = False, **kwargs: Any
259 ) -> None:
260 """Log an error message with pattern format.
262 Args:
263 message: Message to display.
264 *args: Positional arguments for `{}` formatting.
265 exc_info: If True, also render the active exception traceback.
266 **kwargs: Keyword arguments for `{}` formatting.
267 """
268 message = self._apply_format(safe_str_convert(message), args, kwargs)
269 self.print_pattern(Pattern.ERROR, message, "ERROR")
270 if exc_info:
271 self._print_traceback_if_active()
273 def critical(
274 self, message: Any, *args: Any, exc_info: bool = False, **kwargs: Any
275 ) -> None:
276 """Log a critical message with pattern format.
278 Args:
279 message: Message to display.
280 *args: Positional arguments for `{}` formatting.
281 exc_info: If True, also render the active exception traceback.
282 **kwargs: Keyword arguments for `{}` formatting.
283 """
284 message = self._apply_format(safe_str_convert(message), args, kwargs)
285 self.print_pattern(Pattern.ERROR, message, "CRITICAL")
286 if exc_info: 286 ↛ 287line 286 didn't jump to line 287 because the condition on line 286 was never true
287 self._print_traceback_if_active()
289 def exception(self, message: Any, *args: Any, **kwargs: Any) -> None:
290 """Log an error message followed by the active exception traceback."""
291 self.error(message, *args, exc_info=True, **kwargs)
293 # ------------------------------------------------
294 # ADDITIONAL PATTERN METHODS
295 # ------------------------------------------------
297 def tip(self, message: Any) -> None:
298 """Display a tip message with pattern format."""
299 self.print_pattern(Pattern.TIP, message, "INFO")
301 def system(self, message: Any) -> None:
302 """Display a system message with pattern format."""
303 self.print_pattern(Pattern.SYSTEM, message, "INFO")
305 def install(self, message: Any) -> None:
306 """Display an installation message with pattern format."""
307 self.print_pattern(Pattern.INSTALL, message, "INFO")
309 def detect(self, message: Any) -> None:
310 """Display a detection message with pattern format."""
311 self.print_pattern(Pattern.DETECT, message, "INFO")
313 def config(self, message: Any) -> None:
314 """Display a configuration message with pattern format."""
315 self.print_pattern(Pattern.CONFIG, message, "INFO")
317 def deps(self, message: Any) -> None:
318 """Display a dependencies message with pattern format."""
319 self.print_pattern(Pattern.DEPS, message, "INFO")
321 def print_pattern(
322 self, pattern: str | Pattern, message: Any, level: str = "INFO"
323 ) -> None:
324 """
325 Display a message with pattern format: • PATTERN :: message
327 Args:
328 pattern: Pattern name (string) or Pattern enum
329 message: Message to display
330 level: Log level for filtering (default: INFO)
331 """
332 try:
333 # Convert pattern to Pattern enum if string
334 if isinstance(pattern, str):
335 try:
336 pattern_enum = Pattern[pattern.upper()]
337 except KeyError:
338 # If pattern not found, use INFO as default
339 pattern_enum = Pattern.INFO
340 else:
341 pattern_enum = pattern
343 # Check if level should be displayed
344 level_numeric = LogLevel.get_no(level)
345 if level_numeric < self._level_numeric:
346 return # Level too low, don't display
348 # Convert message to string safely and sanitize for console
349 message = safe_str_convert(message)
350 message = sanitize_for_console(message)
352 # Get pattern color
353 pattern_color = get_pattern_color(pattern_enum)
354 pattern_name = pattern_enum.value
356 # Build text with pattern format: • PATTERN :: message
357 text = Text()
358 text.append("• ", style=pattern_color)
359 text.append(pattern_name.ljust(8), style=f"bold {pattern_color}")
360 text.append(":: ", style="dim white")
362 # Handle indentation - add it just before the message (after ":: ")
363 indent_str = self.get_indent()
364 if indent_str and indent_str != "~":
365 # Add indentation just before the message
366 text.append(indent_str, style="dim")
367 text.append(" ", style="dim")
369 # Add the message
370 text.append(str(message), style="white")
372 self._console.print(text)
374 except Exception as e:
375 # Robust error handling: never raise exception
376 try:
377 error_msg = f"[bold red]PATTERN ERROR:[/bold red] {type(e).__name__}"
378 self._console.print(error_msg)
379 except Exception as e:
380 raise ValueError(f"Failed to print pattern: {e}") from e
382 # ///////////////////////////////////////////////////////////////
383 # INDENTATION MANAGEMENT
384 # ///////////////////////////////////////////////////////////////
386 def get_indent(self) -> str:
387 """
388 Get the current indentation string.
390 Returns:
391 The current indentation string
392 """
393 try:
394 indent_spaces = " " * (self._indent * self._indent_step)
395 if self._indent > 0:
396 return f"{indent_spaces}{self._indent_symbol}"
397 else:
398 return self._base_indent_symbol
399 except Exception:
400 return "~" # Safe fallback
402 def add_indent(self) -> None:
403 """Increase the indentation level by one (with maximum limit)."""
404 self._indent = min(self._indent + 1, self._MAX_INDENT)
406 def del_indent(self) -> None:
407 """Decrease the indentation level by one, ensuring it doesn't go below zero."""
408 self._indent = max(0, self._indent - 1)
410 def reset_indent(self) -> None:
411 """Reset the indentation level to zero."""
412 self._indent = 0
414 @contextmanager
415 def manage_indent(self) -> Generator[None]:
416 """
417 Context manager for temporary indentation.
419 Yields:
420 None
421 """
422 try:
423 self.add_indent()
424 yield
425 finally:
426 self.del_indent()
428 # ///////////////////////////////////////////////////////////////
429 # WIZARD ACCESS
430 # ///////////////////////////////////////////////////////////////
432 @property
433 def wizard(self) -> RichWizard:
434 """
435 Get the Rich Wizard instance for advanced display features.
437 Returns:
438 RichWizard instance for panels, tables, JSON, etc.
440 Example:
441 >>> printer.wizard.success_panel("Success", "Operation completed")
442 >>> printer.wizard.status_table("Status", data)
443 >>> printer.wizard.dependency_table({"tool": "1.0.0"})
444 """
445 return self._wizard
447 # ///////////////////////////////////////////////////////////////
448 # ENHANCED METHODS (Rich features)
449 # ///////////////////////////////////////////////////////////////
451 def print_table(self, data: list[dict[str, Any]], title: str | None = None) -> None:
452 """
453 Display a table using Rich (delegates to RichWizard).
455 Args:
456 data: List of dictionaries representing table rows
457 title: Optional table title
458 """
459 self._wizard.table(data, title=title)
461 def print_panel(
462 self, content: str, title: str | None = None, style: str = "blue"
463 ) -> None:
464 """
465 Display a panel using Rich (delegates to RichWizard).
467 Args:
468 content: Panel content
469 title: Optional panel title
470 style: Panel style (Rich style string, used as border_style)
471 """
472 self._wizard.panel(content, title=title, border_style=style)
474 def print_progress(self, *args, **kwargs) -> None:
475 """
476 Display a progress bar using Rich.
478 Note: This is a placeholder. For full progress functionality,
479 users should use Rich's Progress context manager directly.
480 """
481 try:
482 with Progress(
483 SpinnerColumn(),
484 TextColumn("[progress.description]{task.description}"),
485 *args,
486 **kwargs,
487 ):
488 # Placeholder - users should use Rich.Progress context manager directly
489 pass
490 except Exception as e:
491 try:
492 self._console.print(f"[red]Progress error:[/red] {type(e).__name__}")
493 except Exception as e:
494 raise ValueError(f"Failed to print progress: {e}") from e
496 def print_json(
497 self,
498 data: str | dict | list,
499 title: str | None = None,
500 indent: int | None = None,
501 highlight: bool = True,
502 ) -> None:
503 """
504 Display JSON data in a formatted and syntax-highlighted way using Rich (delegates to RichWizard).
506 Args:
507 data: JSON data to display (dict, list, or JSON string)
508 title: Optional title for the JSON display
509 indent: Number of spaces for indentation (default: 2)
510 highlight: Whether to enable syntax highlighting (default: True)
512 Examples:
513 >>> printer.print_json({"name": "Alice", "age": 30})
514 >>> printer.print_json('{"key": "value"}', title="Config")
515 >>> printer.print_json([1, 2, 3], indent=4)
516 """
517 self._wizard.json(data, title=title, indent=indent, highlight=highlight)
519 # ///////////////////////////////////////////////////////////////
520 # REPRESENTATION METHODS
521 # ///////////////////////////////////////////////////////////////
523 def __str__(self) -> str:
524 """String representation of the console printer."""
525 return f"EzPrinter(level={self._level}, indent={self._indent})"
527 def __repr__(self) -> str:
528 """Detailed string representation of the console printer."""
529 return f"EzPrinter(level={self._level}, indent={self._indent}, indent_step={self._indent_step})"
532# ///////////////////////////////////////////////////////////////
533# PUBLIC API
534# ///////////////////////////////////////////////////////////////
536__all__ = [
537 "EzPrinter",
538]