Coverage for src / ezpl / handlers / console.py: 74.30%
163 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-13 19:35 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-13 19:35 +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
19from collections.abc import Generator
20from contextlib import contextmanager
21from typing import Any
23# Third-party imports
24from rich.console import Console
25from rich.progress import Progress, SpinnerColumn, TextColumn
26from rich.text import Text
28# Local imports
29from ..core.exceptions import ValidationError
30from ..core.interfaces import IndentationManager, LoggingHandler
31from ..types.enums import LogLevel, Pattern, get_pattern_color
32from ..utils import safe_str_convert, sanitize_for_console
33from .wizard import RichWizard
35# ///////////////////////////////////////////////////////////////
36# CLASSES
37# ///////////////////////////////////////////////////////////////
40class EzPrinter(LoggingHandler, IndentationManager):
41 """
42 Console printer handler with advanced formatting and indentation support using Rich.
44 This handler provides console-based logging with:
45 - Color-coded log levels using Rich
46 - Indentation management
47 - Robust character handling (Rich handles special characters automatically)
48 - Context manager support
49 - Pattern-based logging (SUCCESS, ERROR, WARN, TIP, etc.)
50 - Access to RichWizard for advanced display features
51 """
53 MAX_INDENT = 10 # Maximum indentation level
55 # ///////////////////////////////////////////////////////////////
56 # INIT
57 # ///////////////////////////////////////////////////////////////
59 def __init__(
60 self,
61 level: str = "INFO",
62 indent_step: int = 3,
63 indent_symbol: str = ">",
64 base_indent_symbol: str = "~",
65 ) -> None:
66 """
67 Initialize the console printer handler.
69 Args:
70 level: The desired logging level
71 indent_step: Number of spaces for each indentation level
72 indent_symbol: Symbol for indentation levels
73 base_indent_symbol: Symbol for the base indentation
75 Raises:
76 ValidationError: If the provided level is invalid
77 """
78 if not LogLevel.is_valid_level(level): 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true
79 raise ValidationError(f"Invalid log level: {level}", "level", level)
81 self._level = level.upper()
82 self._level_manually_set = False
83 self._indent = 0
84 self._indent_step = indent_step
85 self._indent_symbol = indent_symbol
86 self._base_indent_symbol = base_indent_symbol
88 # Initialize Rich Console
89 self._console = Console()
90 self._level_numeric = LogLevel.get_no(self._level)
92 # Initialize Rich Wizard for advanced display features
93 self._wizard = RichWizard(self._console)
95 # ///////////////////////////////////////////////////////////////
96 # UTILS METHODS
97 # ///////////////////////////////////////////////////////////////
99 @property
100 def level(self) -> str:
101 """Return the current logging level."""
102 return self._level
104 @property
105 def level_manually_set(self) -> bool:
106 """Return whether level was set manually at runtime."""
107 return self._level_manually_set
109 @property
110 def indent_step(self) -> int:
111 """Return the configured indentation step."""
112 return self._indent_step
114 @property
115 def indent_symbol(self) -> str:
116 """Return the configured indentation symbol."""
117 return self._indent_symbol
119 @property
120 def base_indent_symbol(self) -> str:
121 """Return the configured base indentation symbol."""
122 return self._base_indent_symbol
124 def mark_level_as_configured(self) -> None:
125 """Mark the current level as coming from configuration (not manual set)."""
126 self._level_manually_set = False
128 def set_level(self, level: str) -> None:
129 """
130 Set the logging level.
132 Args:
133 level: The desired logging level
135 Raises:
136 ValidationError: If the provided level is invalid
137 """
138 if not LogLevel.is_valid_level(level):
139 raise ValidationError(f"Invalid log level: {level}", "level", level)
141 self._level = level.upper()
142 self._level_numeric = LogLevel.get_no(self._level)
143 self._level_manually_set = True
145 def log(self, level: str, message: Any) -> None:
146 """
147 Log a message with the specified level.
149 Args:
150 level: The log level
151 message: The message to log (any type, will be safely converted to string)
153 Raises:
154 ValidationError: If the level is invalid
155 """
156 if not LogLevel.is_valid_level(level):
157 raise ValidationError(f"Invalid log level: {level}", "level", level)
159 # Convert message to string robustly
160 message = safe_str_convert(message)
161 message = sanitize_for_console(message)
163 try:
164 level_numeric = LogLevel.get_no(level)
165 if level_numeric < self._level_numeric:
166 return # Level too low, skip
168 # Map log levels to patterns for consistent output
169 pattern_map = {
170 "DEBUG": Pattern.DEBUG,
171 "INFO": Pattern.INFO,
172 "SUCCESS": Pattern.SUCCESS,
173 "WARNING": Pattern.WARN,
174 "ERROR": Pattern.ERROR,
175 "CRITICAL": Pattern.ERROR, # Critical also uses ERROR pattern
176 }
177 pattern_enum = pattern_map.get(level.upper(), Pattern.INFO)
178 self.print_pattern(pattern_enum, message, level)
180 except Exception as e:
181 # Never raise an exception, just log the error safely
182 try:
183 self._console.print(
184 f"[bold red]LOGGING ERROR:[/bold red] {type(e).__name__}"
185 )
186 except Exception as e:
187 raise ValueError(f"Failed to print logging error: {e}") from e
189 # ///////////////////////////////////////////////////////////////
190 # LOGGING METHODS (API primaire)
191 # ///////////////////////////////////////////////////////////////
193 def info(self, message: Any) -> None:
194 """Log an informational message with pattern format."""
195 self.print_pattern(Pattern.INFO, message, "INFO")
197 def debug(self, message: Any) -> None:
198 """Log a debug message with pattern format."""
199 self.print_pattern(Pattern.DEBUG, message, "DEBUG")
201 def success(self, message: Any) -> None:
202 """Log a success message with pattern format."""
203 self.print_pattern(Pattern.SUCCESS, message, "INFO")
205 def warning(self, message: Any) -> None:
206 """Log a warning message with pattern format."""
207 self.print_pattern(Pattern.WARN, message, "WARNING")
209 def warn(self, message: Any) -> None:
210 """Alias for warning(). Log a warning message with pattern format."""
211 self.warning(message)
213 def error(self, message: Any) -> None:
214 """Log an error message with pattern format."""
215 self.print_pattern(Pattern.ERROR, message, "ERROR")
217 def critical(self, message: Any) -> None:
218 """Log a critical message with pattern format."""
219 self.print_pattern(Pattern.ERROR, message, "CRITICAL")
221 # ------------------------------------------------
222 # ADDITIONAL PATTERN METHODS
223 # ------------------------------------------------
225 def tip(self, message: Any) -> None:
226 """Display a tip message with pattern format."""
227 self.print_pattern(Pattern.TIP, message, "INFO")
229 def system(self, message: Any) -> None:
230 """Display a system message with pattern format."""
231 self.print_pattern(Pattern.SYSTEM, message, "INFO")
233 def install(self, message: Any) -> None:
234 """Display an installation message with pattern format."""
235 self.print_pattern(Pattern.INSTALL, message, "INFO")
237 def detect(self, message: Any) -> None:
238 """Display a detection message with pattern format."""
239 self.print_pattern(Pattern.DETECT, message, "INFO")
241 def config(self, message: Any) -> None:
242 """Display a configuration message with pattern format."""
243 self.print_pattern(Pattern.CONFIG, message, "INFO")
245 def deps(self, message: Any) -> None:
246 """Display a dependencies message with pattern format."""
247 self.print_pattern(Pattern.DEPS, message, "INFO")
249 def print_pattern(
250 self, pattern: str | Pattern, message: Any, level: str = "INFO"
251 ) -> None:
252 """
253 Display a message with pattern format: • PATTERN :: message
255 Args:
256 pattern: Pattern name (string) or Pattern enum
257 message: Message to display
258 level: Log level for filtering (default: INFO)
259 """
260 try:
261 # Convert pattern to Pattern enum if string
262 if isinstance(pattern, str):
263 try:
264 pattern_enum = Pattern[pattern.upper()]
265 except KeyError:
266 # If pattern not found, use INFO as default
267 pattern_enum = Pattern.INFO
268 else:
269 pattern_enum = pattern
271 # Check if level should be displayed
272 level_numeric = LogLevel.get_no(level)
273 if level_numeric < self._level_numeric:
274 return # Level too low, don't display
276 # Convert message to string safely and sanitize for console
277 message = safe_str_convert(message)
278 message = sanitize_for_console(message)
280 # Get pattern color
281 pattern_color = get_pattern_color(pattern_enum)
282 pattern_name = pattern_enum.value
284 # Build text with pattern format: • PATTERN :: message
285 text = Text()
286 text.append("• ", style=pattern_color)
287 text.append(pattern_name.ljust(8), style=f"bold {pattern_color}")
288 text.append(":: ", style="dim white")
290 # Handle indentation - add it just before the message (after ":: ")
291 indent_str = self.get_indent()
292 if indent_str and indent_str != "~":
293 # Add indentation just before the message
294 text.append(indent_str, style="dim")
295 text.append(" ", style="dim")
297 # Add the message
298 text.append(str(message), style="white")
300 self._console.print(text)
302 except Exception as e:
303 # Robust error handling: never raise exception
304 try:
305 error_msg = f"[bold red]PATTERN ERROR:[/bold red] {type(e).__name__}"
306 self._console.print(error_msg)
307 except Exception as e:
308 raise ValueError(f"Failed to print pattern: {e}") from e
310 # ///////////////////////////////////////////////////////////////
311 # INDENTATION MANAGEMENT
312 # ///////////////////////////////////////////////////////////////
314 def get_indent(self) -> str:
315 """
316 Get the current indentation string.
318 Returns:
319 The current indentation string
320 """
321 try:
322 indent_spaces = " " * (self._indent * self._indent_step)
323 if self._indent > 0:
324 return f"{indent_spaces}{self._indent_symbol}"
325 else:
326 return self._base_indent_symbol
327 except Exception:
328 return "~" # Safe fallback
330 def add_indent(self) -> None:
331 """Increase the indentation level by one (with maximum limit)."""
332 self._indent = min(self._indent + 1, self.MAX_INDENT)
334 def del_indent(self) -> None:
335 """Decrease the indentation level by one, ensuring it doesn't go below zero."""
336 self._indent = max(0, self._indent - 1)
338 def reset_indent(self) -> None:
339 """Reset the indentation level to zero."""
340 self._indent = 0
342 @contextmanager
343 def manage_indent(self) -> Generator[None, None, None]:
344 """
345 Context manager for temporary indentation.
347 Yields:
348 None
349 """
350 try:
351 self.add_indent()
352 yield
353 finally:
354 self.del_indent()
356 # ///////////////////////////////////////////////////////////////
357 # WIZARD ACCESS
358 # ///////////////////////////////////////////////////////////////
360 @property
361 def wizard(self) -> RichWizard:
362 """
363 Get the Rich Wizard instance for advanced display features.
365 Returns:
366 RichWizard instance for panels, tables, JSON, etc.
368 Example:
369 >>> printer.wizard.success_panel("Success", "Operation completed")
370 >>> printer.wizard.status_table("Status", data)
371 >>> printer.wizard.dependency_table({"tool": "1.0.0"})
372 """
373 return self._wizard
375 # ///////////////////////////////////////////////////////////////
376 # ENHANCED METHODS (Rich features)
377 # ///////////////////////////////////////////////////////////////
379 def print_table(self, data: list[dict[str, Any]], title: str | None = None) -> None:
380 """
381 Display a table using Rich (delegates to RichWizard).
383 Args:
384 data: List of dictionaries representing table rows
385 title: Optional table title
386 """
387 self._wizard.table(data, title=title)
389 def print_panel(
390 self, content: str, title: str | None = None, style: str = "blue"
391 ) -> None:
392 """
393 Display a panel using Rich (delegates to RichWizard).
395 Args:
396 content: Panel content
397 title: Optional panel title
398 style: Panel style (Rich style string, used as border_style)
399 """
400 self._wizard.panel(content, title=title, border_style=style)
402 def print_progress(self, *args, **kwargs) -> None:
403 """
404 Display a progress bar using Rich.
406 Note: This is a placeholder. For full progress functionality,
407 users should use Rich's Progress context manager directly.
408 """
409 try:
410 with Progress(
411 SpinnerColumn(),
412 TextColumn("[progress.description]{task.description}"),
413 *args,
414 **kwargs,
415 ):
416 # Placeholder - users should use Rich.Progress context manager directly
417 pass
418 except Exception as e:
419 try:
420 self._console.print(f"[red]Progress error:[/red] {type(e).__name__}")
421 except Exception as e:
422 raise ValueError(f"Failed to print progress: {e}") from e
424 def print_json(
425 self,
426 data: str | dict | list,
427 title: str | None = None,
428 indent: int | None = None,
429 highlight: bool = True,
430 ) -> None:
431 """
432 Display JSON data in a formatted and syntax-highlighted way using Rich (delegates to RichWizard).
434 Args:
435 data: JSON data to display (dict, list, or JSON string)
436 title: Optional title for the JSON display
437 indent: Number of spaces for indentation (default: 2)
438 highlight: Whether to enable syntax highlighting (default: True)
440 Examples:
441 >>> printer.print_json({"name": "Alice", "age": 30})
442 >>> printer.print_json('{"key": "value"}', title="Config")
443 >>> printer.print_json([1, 2, 3], indent=4)
444 """
445 self._wizard.json(data, title=title, indent=indent, highlight=highlight)
447 # ///////////////////////////////////////////////////////////////
448 # REPRESENTATION METHODS
449 # ///////////////////////////////////////////////////////////////
451 def __str__(self) -> str:
452 """String representation of the console printer."""
453 return f"EzPrinter(level={self._level}, indent={self._indent})"
455 def __repr__(self) -> str:
456 """Detailed string representation of the console printer."""
457 return f"EzPrinter(level={self._level}, indent={self._indent}, indent_step={self._indent_step})"