Coverage for src / ezplog / handlers / console.py: 74.72%

162 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-03 16:27 +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 

19from collections.abc import Generator 

20from contextlib import contextmanager 

21from typing import Any 

22 

23# Third-party imports 

24from rich.console import Console 

25from rich.progress import Progress, SpinnerColumn, TextColumn 

26from rich.text import Text 

27 

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 

34 

35# /////////////////////////////////////////////////////////////// 

36# CLASSES 

37# /////////////////////////////////////////////////////////////// 

38 

39 

40class EzPrinter(LoggingHandler, IndentationManager): 

41 """ 

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

43 

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

52 

53 _MAX_INDENT = 10 # Maximum indentation level 

54 

55 # /////////////////////////////////////////////////////////////// 

56 # INIT 

57 # /////////////////////////////////////////////////////////////// 

58 

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. 

68 

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 

74 

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) 

80 

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 

87 

88 # Initialize Rich Console 

89 self._console = Console() 

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

91 

92 # Initialize Rich Wizard for advanced display features 

93 self._wizard = RichWizard(self._console) 

94 

95 # /////////////////////////////////////////////////////////////// 

96 # UTILS METHODS 

97 # /////////////////////////////////////////////////////////////// 

98 

99 @property 

100 def level(self) -> str: 

101 """Return the current logging level.""" 

102 return self._level 

103 

104 @property 

105 def level_manually_set(self) -> bool: 

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

107 return self._level_manually_set 

108 

109 @property 

110 def indent_step(self) -> int: 

111 """Return the configured indentation step.""" 

112 return self._indent_step 

113 

114 @property 

115 def indent_symbol(self) -> str: 

116 """Return the configured indentation symbol.""" 

117 return self._indent_symbol 

118 

119 @property 

120 def base_indent_symbol(self) -> str: 

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

122 return self._base_indent_symbol 

123 

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 

127 

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

129 """ 

130 Set the logging level. 

131 

132 Args: 

133 level: The desired logging level 

134 

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) 

140 

141 self._level = level.upper() 

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

143 self._level_manually_set = True 

144 

145 def log(self, level: str, message: Any) -> None: 

146 """ 

147 Log a message with the specified level. 

148 

149 Args: 

150 level: The log level 

151 message: The message to log (any type, will be safely converted to string) 

152 

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) 

158 

159 # Convert message to string robustly 

160 message = safe_str_convert(message) 

161 message = sanitize_for_console(message) 

162 

163 try: 

164 level_numeric = LogLevel.get_no(level) 

165 if level_numeric < self._level_numeric: 

166 return # Level too low, skip 

167 

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) 

179 

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 

188 

189 # /////////////////////////////////////////////////////////////// 

190 # LOGGING METHODS (API primaire) 

191 # /////////////////////////////////////////////////////////////// 

192 

193 def info(self, message: Any) -> None: 

194 """Log an informational message with pattern format.""" 

195 self.print_pattern(Pattern.INFO, message, "INFO") 

196 

197 def debug(self, message: Any) -> None: 

198 """Log a debug message with pattern format.""" 

199 self.print_pattern(Pattern.DEBUG, message, "DEBUG") 

200 

201 def success(self, message: Any) -> None: 

202 """Log a success message with pattern format.""" 

203 self.print_pattern(Pattern.SUCCESS, message, "INFO") 

204 

205 def warning(self, message: Any) -> None: 

206 """Log a warning message with pattern format.""" 

207 self.print_pattern(Pattern.WARN, message, "WARNING") 

208 

209 def error(self, message: Any) -> None: 

210 """Log an error message with pattern format.""" 

211 self.print_pattern(Pattern.ERROR, message, "ERROR") 

212 

213 def critical(self, message: Any) -> None: 

214 """Log a critical message with pattern format.""" 

215 self.print_pattern(Pattern.ERROR, message, "CRITICAL") 

216 

217 # ------------------------------------------------ 

218 # ADDITIONAL PATTERN METHODS 

219 # ------------------------------------------------ 

220 

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

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

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

224 

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

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

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

228 

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

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

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

232 

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

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

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

236 

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

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

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

240 

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

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

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

244 

245 def print_pattern( 

246 self, pattern: str | Pattern, message: Any, level: str = "INFO" 

247 ) -> None: 

248 """ 

249 Display a message with pattern format: • PATTERN :: message 

250 

251 Args: 

252 pattern: Pattern name (string) or Pattern enum 

253 message: Message to display 

254 level: Log level for filtering (default: INFO) 

255 """ 

256 try: 

257 # Convert pattern to Pattern enum if string 

258 if isinstance(pattern, str): 

259 try: 

260 pattern_enum = Pattern[pattern.upper()] 

261 except KeyError: 

262 # If pattern not found, use INFO as default 

263 pattern_enum = Pattern.INFO 

264 else: 

265 pattern_enum = pattern 

266 

267 # Check if level should be displayed 

268 level_numeric = LogLevel.get_no(level) 

269 if level_numeric < self._level_numeric: 

270 return # Level too low, don't display 

271 

272 # Convert message to string safely and sanitize for console 

273 message = safe_str_convert(message) 

274 message = sanitize_for_console(message) 

275 

276 # Get pattern color 

277 pattern_color = get_pattern_color(pattern_enum) 

278 pattern_name = pattern_enum.value 

279 

280 # Build text with pattern format: • PATTERN :: message 

281 text = Text() 

282 text.append("• ", style=pattern_color) 

283 text.append(pattern_name.ljust(8), style=f"bold {pattern_color}") 

284 text.append(":: ", style="dim white") 

285 

286 # Handle indentation - add it just before the message (after ":: ") 

287 indent_str = self.get_indent() 

288 if indent_str and indent_str != "~": 

289 # Add indentation just before the message 

290 text.append(indent_str, style="dim") 

291 text.append(" ", style="dim") 

292 

293 # Add the message 

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

295 

296 self._console.print(text) 

297 

298 except Exception as e: 

299 # Robust error handling: never raise exception 

300 try: 

301 error_msg = f"[bold red]PATTERN ERROR:[/bold red] {type(e).__name__}" 

302 self._console.print(error_msg) 

303 except Exception as e: 

304 raise ValueError(f"Failed to print pattern: {e}") from e 

305 

306 # /////////////////////////////////////////////////////////////// 

307 # INDENTATION MANAGEMENT 

308 # /////////////////////////////////////////////////////////////// 

309 

310 def get_indent(self) -> str: 

311 """ 

312 Get the current indentation string. 

313 

314 Returns: 

315 The current indentation string 

316 """ 

317 try: 

318 indent_spaces = " " * (self._indent * self._indent_step) 

319 if self._indent > 0: 

320 return f"{indent_spaces}{self._indent_symbol}" 

321 else: 

322 return self._base_indent_symbol 

323 except Exception: 

324 return "~" # Safe fallback 

325 

326 def add_indent(self) -> None: 

327 """Increase the indentation level by one (with maximum limit).""" 

328 self._indent = min(self._indent + 1, self._MAX_INDENT) 

329 

330 def del_indent(self) -> None: 

331 """Decrease the indentation level by one, ensuring it doesn't go below zero.""" 

332 self._indent = max(0, self._indent - 1) 

333 

334 def reset_indent(self) -> None: 

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

336 self._indent = 0 

337 

338 @contextmanager 

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

340 """ 

341 Context manager for temporary indentation. 

342 

343 Yields: 

344 None 

345 """ 

346 try: 

347 self.add_indent() 

348 yield 

349 finally: 

350 self.del_indent() 

351 

352 # /////////////////////////////////////////////////////////////// 

353 # WIZARD ACCESS 

354 # /////////////////////////////////////////////////////////////// 

355 

356 @property 

357 def wizard(self) -> RichWizard: 

358 """ 

359 Get the Rich Wizard instance for advanced display features. 

360 

361 Returns: 

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

363 

364 Example: 

365 >>> printer.wizard.success_panel("Success", "Operation completed") 

366 >>> printer.wizard.status_table("Status", data) 

367 >>> printer.wizard.dependency_table({"tool": "1.0.0"}) 

368 """ 

369 return self._wizard 

370 

371 # /////////////////////////////////////////////////////////////// 

372 # ENHANCED METHODS (Rich features) 

373 # /////////////////////////////////////////////////////////////// 

374 

375 def print_table(self, data: list[dict[str, Any]], title: str | None = None) -> None: 

376 """ 

377 Display a table using Rich (delegates to RichWizard). 

378 

379 Args: 

380 data: List of dictionaries representing table rows 

381 title: Optional table title 

382 """ 

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

384 

385 def print_panel( 

386 self, content: str, title: str | None = None, style: str = "blue" 

387 ) -> None: 

388 """ 

389 Display a panel using Rich (delegates to RichWizard). 

390 

391 Args: 

392 content: Panel content 

393 title: Optional panel title 

394 style: Panel style (Rich style string, used as border_style) 

395 """ 

396 self._wizard.panel(content, title=title, border_style=style) 

397 

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

399 """ 

400 Display a progress bar using Rich. 

401 

402 Note: This is a placeholder. For full progress functionality, 

403 users should use Rich's Progress context manager directly. 

404 """ 

405 try: 

406 with Progress( 

407 SpinnerColumn(), 

408 TextColumn("[progress.description]{task.description}"), 

409 *args, 

410 **kwargs, 

411 ): 

412 # Placeholder - users should use Rich.Progress context manager directly 

413 pass 

414 except Exception as e: 

415 try: 

416 self._console.print(f"[red]Progress error:[/red] {type(e).__name__}") 

417 except Exception as e: 

418 raise ValueError(f"Failed to print progress: {e}") from e 

419 

420 def print_json( 

421 self, 

422 data: str | dict | list, 

423 title: str | None = None, 

424 indent: int | None = None, 

425 highlight: bool = True, 

426 ) -> None: 

427 """ 

428 Display JSON data in a formatted and syntax-highlighted way using Rich (delegates to RichWizard). 

429 

430 Args: 

431 data: JSON data to display (dict, list, or JSON string) 

432 title: Optional title for the JSON display 

433 indent: Number of spaces for indentation (default: 2) 

434 highlight: Whether to enable syntax highlighting (default: True) 

435 

436 Examples: 

437 >>> printer.print_json({"name": "Alice", "age": 30}) 

438 >>> printer.print_json('{"key": "value"}', title="Config") 

439 >>> printer.print_json([1, 2, 3], indent=4) 

440 """ 

441 self._wizard.json(data, title=title, indent=indent, highlight=highlight) 

442 

443 # /////////////////////////////////////////////////////////////// 

444 # REPRESENTATION METHODS 

445 # /////////////////////////////////////////////////////////////// 

446 

447 def __str__(self) -> str: 

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

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

450 

451 def __repr__(self) -> str: 

452 """Detailed string representation of the console printer.""" 

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

454 

455 

456# /////////////////////////////////////////////////////////////// 

457# PUBLIC API 

458# /////////////////////////////////////////////////////////////// 

459 

460__all__ = [ 

461 "EzPrinter", 

462]