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

1# /////////////////////////////////////////////////////////////// 

2# EZPL - Console Printer Handler 

3# Project: ezpl 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Console printer handler for Ezpl logging framework. 

8 

9This module provides a console-based logging handler with advanced formatting, 

10indentation management, and color support using Rich. 

11""" 

12 

13from __future__ import annotations 

14 

15# /////////////////////////////////////////////////////////////// 

16# IMPORTS 

17# /////////////////////////////////////////////////////////////// 

18# Standard library imports 

19import sys 

20from collections.abc import Generator 

21from contextlib import contextmanager 

22from typing import Any 

23 

24# Third-party imports 

25from rich.console import Console 

26from rich.progress import Progress, SpinnerColumn, TextColumn 

27from rich.text import Text 

28 

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 

35 

36# /////////////////////////////////////////////////////////////// 

37# CLASSES 

38# /////////////////////////////////////////////////////////////// 

39 

40 

41class EzPrinter(LoggingHandler, IndentationManager): 

42 """ 

43 Console printer handler with advanced formatting and indentation support using Rich. 

44 

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 """ 

53 

54 _MAX_INDENT = 10 # Maximum indentation level 

55 

56 # /////////////////////////////////////////////////////////////// 

57 # INIT 

58 # /////////////////////////////////////////////////////////////// 

59 

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. 

69 

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 

75 

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) 

81 

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 

88 

89 # Initialize Rich Console 

90 self._console = Console() 

91 self._level_numeric = LogLevel.get_no(self._level) 

92 

93 # Initialize Rich Wizard for advanced display features 

94 self._wizard = RichWizard(self._console) 

95 

96 # /////////////////////////////////////////////////////////////// 

97 # UTILS METHODS 

98 # /////////////////////////////////////////////////////////////// 

99 

100 @property 

101 def level(self) -> str: 

102 """Return the current logging level.""" 

103 return self._level 

104 

105 @property 

106 def level_manually_set(self) -> bool: 

107 """Return whether level was set manually at runtime.""" 

108 return self._level_manually_set 

109 

110 @property 

111 def indent_step(self) -> int: 

112 """Return the configured indentation step.""" 

113 return self._indent_step 

114 

115 @property 

116 def indent_symbol(self) -> str: 

117 """Return the configured indentation symbol.""" 

118 return self._indent_symbol 

119 

120 @property 

121 def base_indent_symbol(self) -> str: 

122 """Return the configured base indentation symbol.""" 

123 return self._base_indent_symbol 

124 

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 

128 

129 def set_level(self, level: str) -> None: 

130 """ 

131 Set the logging level. 

132 

133 Args: 

134 level: The desired logging level 

135 

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) 

141 

142 self._level = level.upper() 

143 self._level_numeric = LogLevel.get_no(self._level) 

144 self._level_manually_set = True 

145 

146 def log(self, level: str, message: Any, *args: Any, **kwargs: Any) -> None: 

147 """ 

148 Log a message with the specified level. 

149 

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 

155 

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) 

161 

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) 

166 

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 

171 

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) 

184 

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 

193 

194 # /////////////////////////////////////////////////////////////// 

195 # PRIVATE HELPER METHODS 

196 # /////////////////////////////////////////////////////////////// 

197 

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. 

204 

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 

215 

216 def _print_traceback_if_active(self) -> None: 

217 """ 

218 Render the active exception traceback, if any. 

219 

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) 

227 

228 # /////////////////////////////////////////////////////////////// 

229 # LOGGING METHODS (API primaire) 

230 # /////////////////////////////////////////////////////////////// 

231 

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") 

236 

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") 

241 

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") 

246 

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") 

251 

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") 

256 

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. 

261 

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() 

272 

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. 

277 

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() 

288 

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) 

292 

293 # ------------------------------------------------ 

294 # ADDITIONAL PATTERN METHODS 

295 # ------------------------------------------------ 

296 

297 def tip(self, message: Any) -> None: 

298 """Display a tip message with pattern format.""" 

299 self.print_pattern(Pattern.TIP, message, "INFO") 

300 

301 def system(self, message: Any) -> None: 

302 """Display a system message with pattern format.""" 

303 self.print_pattern(Pattern.SYSTEM, message, "INFO") 

304 

305 def install(self, message: Any) -> None: 

306 """Display an installation message with pattern format.""" 

307 self.print_pattern(Pattern.INSTALL, message, "INFO") 

308 

309 def detect(self, message: Any) -> None: 

310 """Display a detection message with pattern format.""" 

311 self.print_pattern(Pattern.DETECT, message, "INFO") 

312 

313 def config(self, message: Any) -> None: 

314 """Display a configuration message with pattern format.""" 

315 self.print_pattern(Pattern.CONFIG, message, "INFO") 

316 

317 def deps(self, message: Any) -> None: 

318 """Display a dependencies message with pattern format.""" 

319 self.print_pattern(Pattern.DEPS, message, "INFO") 

320 

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 

326 

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 

342 

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 

347 

348 # Convert message to string safely and sanitize for console 

349 message = safe_str_convert(message) 

350 message = sanitize_for_console(message) 

351 

352 # Get pattern color 

353 pattern_color = get_pattern_color(pattern_enum) 

354 pattern_name = pattern_enum.value 

355 

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") 

361 

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") 

368 

369 # Add the message 

370 text.append(str(message), style="white") 

371 

372 self._console.print(text) 

373 

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 

381 

382 # /////////////////////////////////////////////////////////////// 

383 # INDENTATION MANAGEMENT 

384 # /////////////////////////////////////////////////////////////// 

385 

386 def get_indent(self) -> str: 

387 """ 

388 Get the current indentation string. 

389 

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 

401 

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) 

405 

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) 

409 

410 def reset_indent(self) -> None: 

411 """Reset the indentation level to zero.""" 

412 self._indent = 0 

413 

414 @contextmanager 

415 def manage_indent(self) -> Generator[None]: 

416 """ 

417 Context manager for temporary indentation. 

418 

419 Yields: 

420 None 

421 """ 

422 try: 

423 self.add_indent() 

424 yield 

425 finally: 

426 self.del_indent() 

427 

428 # /////////////////////////////////////////////////////////////// 

429 # WIZARD ACCESS 

430 # /////////////////////////////////////////////////////////////// 

431 

432 @property 

433 def wizard(self) -> RichWizard: 

434 """ 

435 Get the Rich Wizard instance for advanced display features. 

436 

437 Returns: 

438 RichWizard instance for panels, tables, JSON, etc. 

439 

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 

446 

447 # /////////////////////////////////////////////////////////////// 

448 # ENHANCED METHODS (Rich features) 

449 # /////////////////////////////////////////////////////////////// 

450 

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). 

454 

455 Args: 

456 data: List of dictionaries representing table rows 

457 title: Optional table title 

458 """ 

459 self._wizard.table(data, title=title) 

460 

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). 

466 

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) 

473 

474 def print_progress(self, *args, **kwargs) -> None: 

475 """ 

476 Display a progress bar using Rich. 

477 

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 

495 

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). 

505 

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) 

511 

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) 

518 

519 # /////////////////////////////////////////////////////////////// 

520 # REPRESENTATION METHODS 

521 # /////////////////////////////////////////////////////////////// 

522 

523 def __str__(self) -> str: 

524 """String representation of the console printer.""" 

525 return f"EzPrinter(level={self._level}, indent={self._indent})" 

526 

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})" 

530 

531 

532# /////////////////////////////////////////////////////////////// 

533# PUBLIC API 

534# /////////////////////////////////////////////////////////////// 

535 

536__all__ = [ 

537 "EzPrinter", 

538]