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

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 warn(self, message: Any) -> None: 

210 """Alias for warning(). Log a warning message with pattern format.""" 

211 self.warning(message) 

212 

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

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

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

216 

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

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

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

220 

221 # ------------------------------------------------ 

222 # ADDITIONAL PATTERN METHODS 

223 # ------------------------------------------------ 

224 

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

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

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

228 

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

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

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

232 

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

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

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

236 

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

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

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

240 

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

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

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

244 

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

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

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

248 

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 

254 

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 

270 

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 

275 

276 # Convert message to string safely and sanitize for console 

277 message = safe_str_convert(message) 

278 message = sanitize_for_console(message) 

279 

280 # Get pattern color 

281 pattern_color = get_pattern_color(pattern_enum) 

282 pattern_name = pattern_enum.value 

283 

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

289 

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

296 

297 # Add the message 

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

299 

300 self._console.print(text) 

301 

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 

309 

310 # /////////////////////////////////////////////////////////////// 

311 # INDENTATION MANAGEMENT 

312 # /////////////////////////////////////////////////////////////// 

313 

314 def get_indent(self) -> str: 

315 """ 

316 Get the current indentation string. 

317 

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 

329 

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) 

333 

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) 

337 

338 def reset_indent(self) -> None: 

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

340 self._indent = 0 

341 

342 @contextmanager 

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

344 """ 

345 Context manager for temporary indentation. 

346 

347 Yields: 

348 None 

349 """ 

350 try: 

351 self.add_indent() 

352 yield 

353 finally: 

354 self.del_indent() 

355 

356 # /////////////////////////////////////////////////////////////// 

357 # WIZARD ACCESS 

358 # /////////////////////////////////////////////////////////////// 

359 

360 @property 

361 def wizard(self) -> RichWizard: 

362 """ 

363 Get the Rich Wizard instance for advanced display features. 

364 

365 Returns: 

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

367 

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 

374 

375 # /////////////////////////////////////////////////////////////// 

376 # ENHANCED METHODS (Rich features) 

377 # /////////////////////////////////////////////////////////////// 

378 

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

382 

383 Args: 

384 data: List of dictionaries representing table rows 

385 title: Optional table title 

386 """ 

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

388 

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

394 

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) 

401 

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

403 """ 

404 Display a progress bar using Rich. 

405 

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 

423 

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

433 

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) 

439 

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) 

446 

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

448 # REPRESENTATION METHODS 

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

450 

451 def __str__(self) -> str: 

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

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

454 

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