Coverage for src / ezpl / cli / commands / logs.py: 37.70%

276 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-13 19:35 +0000

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

2# EZPL - CLI Logs Commands 

3# Project: ezpl 

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

5 

6""" 

7CLI commands for log file operations. 

8 

9This module provides commands for viewing, searching, analyzing, 

10and managing log files. 

11""" 

12 

13from __future__ import annotations 

14 

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

16# IMPORTS 

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

18# Standard library imports 

19import json 

20import time 

21from datetime import datetime, timedelta 

22from pathlib import Path 

23 

24import click 

25 

26# Third-party imports 

27from rich.table import Table 

28 

29# Local imports 

30from ...config import ConfigurationManager 

31from .._console import console 

32from ..utils.log_parser import LogParser 

33from ..utils.log_stats import LogStatistics 

34 

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

36# HELPER FUNCTIONS 

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

38 

39 

40def _get_log_file(file: Path | None) -> Path: 

41 """ 

42 Get log file path from parameter or configuration. 

43 

44 Args: 

45 file: Optional file path from command line 

46 

47 Returns: 

48 Path to log file 

49 

50 Raises: 

51 click.ClickException: If file doesn't exist 

52 """ 

53 if file: 53 ↛ 56line 53 didn't jump to line 56 because the condition on line 53 was always true

54 log_file = Path(file) 

55 else: 

56 config_manager = ConfigurationManager() 

57 log_file = config_manager.get_log_file() 

58 

59 if not log_file.exists(): 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true

60 raise click.ClickException(f"Log file not found: {log_file}") 

61 

62 return log_file 

63 

64 

65def _get_log_dir(dir: Path | None) -> Path: 

66 """ 

67 Get log directory from parameter or configuration. 

68 

69 Args: 

70 dir: Optional directory path from command line 

71 

72 Returns: 

73 Path to log directory 

74 """ 

75 if dir: 75 ↛ 78line 75 didn't jump to line 78 because the condition on line 75 was always true

76 return Path(dir) 

77 else: 

78 config_manager = ConfigurationManager() 

79 log_dir = config_manager.get("log-dir") 

80 if isinstance(log_dir, str): 

81 return Path(log_dir) 

82 return Path(log_dir) if log_dir else Path.home() / ".ezpl" / "logs" 

83 

84 

85def _parse_size(size_str: str) -> int: 

86 """ 

87 Parse size string to bytes. 

88 

89 Args: 

90 size_str: Size string (e.g., "100MB", "1GB", "500KB") 

91 

92 Returns: 

93 Size in bytes 

94 """ 

95 size_str = size_str.upper().strip() 

96 multipliers = {"KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4} 

97 

98 for unit, multiplier in multipliers.items(): 

99 if size_str.endswith(unit): 

100 try: 

101 value = float(size_str[: -len(unit)]) 

102 return int(value * multiplier) 

103 except ValueError as e: 

104 raise click.ClickException(f"Invalid size format: {size_str}") from e 

105 

106 # Try to parse as bytes 

107 try: 

108 return int(size_str) 

109 except ValueError as e: 

110 raise click.ClickException(f"Invalid size format: {size_str}") from e 

111 

112 

113# /////////////////////////////////////////////////////////////// 

114# COMMAND GROUP 

115# /////////////////////////////////////////////////////////////// 

116 

117 

118@click.group(name="logs", help="📊 Manage and view log files") 

119def logs_group() -> None: 

120 """ 

121 Log file management commands. 

122 

123 View, search, analyze, and manage Ezpl log files. 

124 """ 

125 

126 

127# /////////////////////////////////////////////////////////////// 

128# COMMANDS 

129# /////////////////////////////////////////////////////////////// 

130 

131 

132@logs_group.command(name="view", help="View log file contents") 

133@click.option( 

134 "--file", 

135 "-f", 

136 type=click.Path(exists=True, path_type=Path), 

137 help="Path to log file (default: from config)", 

138) 

139@click.option("--lines", "-n", type=int, default=50, help="Number of lines to display") 

140@click.option( 

141 "--level", 

142 "-l", 

143 type=str, 

144 help="Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", 

145) 

146@click.option( 

147 "--follow", 

148 "-F", 

149 is_flag=True, 

150 help="Follow log file (like tail -f)", 

151) 

152def view_command( 

153 file: Path | None, lines: int, level: str | None, follow: bool 

154) -> None: 

155 """ 

156 View log file contents. 

157 

158 Display log entries from the specified file with optional filtering. 

159 """ 

160 try: 

161 log_file = _get_log_file(file) 

162 parser = LogParser(log_file) 

163 

164 if follow: 164 ↛ 166line 164 didn't jump to line 166 because the condition on line 164 was never true

165 # Follow mode (tail -f) 

166 console.print(f"[cyan]Following {log_file}...[/cyan]") 

167 console.print("[dim]Press Ctrl+C to stop[/dim]\n") 

168 

169 try: 

170 with open(log_file, encoding="utf-8") as f: 

171 # Go to end of file 

172 f.seek(0, 2) 

173 

174 while True: 

175 line = f.readline() 

176 if line: 

177 entry = parser.parse_line(line, 0) 

178 if entry: # noqa: SIM102 

179 if not level or entry.level.upper() == level.upper(): 

180 console.print(entry.raw_line) 

181 else: 

182 time.sleep(0.1) 

183 except KeyboardInterrupt: 

184 console.print("\n[yellow]Stopped following[/yellow]") 

185 else: 

186 # Regular view 

187 entries = parser.parse_lines(max_lines=lines) 

188 

189 if level: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true

190 entries = [e for e in entries if e.level.upper() == level.upper()] 

191 

192 if not entries: 192 ↛ 193line 192 didn't jump to line 193 because the condition on line 192 was never true

193 console.print("[yellow]No log entries found[/yellow]") 

194 return 

195 

196 # Display entries 

197 for entry in entries: 

198 console.print(entry.raw_line) 

199 

200 except click.ClickException: 

201 raise 

202 except (OSError, ValueError, json.JSONDecodeError) as e: 

203 raise click.ClickException(str(e)) from e 

204 

205 

206@logs_group.command(name="search", help="Search log entries") 

207@click.option( 

208 "--file", 

209 "-f", 

210 type=click.Path(exists=True, path_type=Path), 

211 help="Path to log file (default: from config)", 

212) 

213@click.option( 

214 "--pattern", 

215 "-p", 

216 type=str, 

217 required=True, 

218 help="Search pattern (regex supported)", 

219) 

220@click.option( 

221 "--level", 

222 "-l", 

223 type=str, 

224 help="Filter by log level", 

225) 

226@click.option( 

227 "--case-sensitive", 

228 "-c", 

229 is_flag=True, 

230 help="Case-sensitive search", 

231) 

232def search_command( 

233 file: Path | None, 

234 pattern: str, 

235 level: str | None, 

236 case_sensitive: bool, 

237) -> None: 

238 """ 

239 Search for log entries matching a pattern. 

240 

241 Search through log files using regex patterns with optional level filtering. 

242 """ 

243 try: 

244 log_file = _get_log_file(file) 

245 parser = LogParser(log_file) 

246 

247 # Search entries 

248 results = list(parser.search(pattern, case_sensitive=case_sensitive)) 

249 

250 # Filter by level if specified 

251 if level: 251 ↛ 252line 251 didn't jump to line 252 because the condition on line 251 was never true

252 results = [e for e in results if e.level.upper() == level.upper()] 

253 

254 if not results: 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true

255 console.print( 

256 f"[yellow]No entries found matching pattern: {pattern}[/yellow]" 

257 ) 

258 return 

259 

260 # Display results 

261 console.print(f"[green]Found {len(results)} matching entries:[/green]\n") 

262 for entry in results: 

263 console.print(entry.raw_line) 

264 

265 except click.ClickException: 

266 raise 

267 except (OSError, ValueError, json.JSONDecodeError) as e: 

268 raise click.ClickException(str(e)) from e 

269 

270 

271@logs_group.command(name="stats", help="Display log statistics") 

272@click.option( 

273 "--file", 

274 "-f", 

275 type=click.Path(exists=True, path_type=Path), 

276 help="Path to log file (default: from config)", 

277) 

278@click.option( 

279 "--format", 

280 "-F", 

281 type=click.Choice(["table", "json"], case_sensitive=False), 

282 default="table", 

283 help="Output format", 

284) 

285def stats_command(file: Path | None, format: str) -> None: 

286 """ 

287 Display statistics about log files. 

288 

289 Show counts by level, file size, date ranges, and temporal distribution. 

290 """ 

291 try: 

292 log_file = _get_log_file(file) 

293 stats = LogStatistics(log_file) 

294 all_stats = stats.get_all_stats() 

295 

296 if format == "json": 296 ↛ 298line 296 didn't jump to line 298 because the condition on line 296 was never true

297 # JSON output 

298 console.print(json.dumps(all_stats, indent=2, default=str)) 

299 else: 

300 # Table output 

301 # File info 

302 file_info = all_stats["file_info"] 

303 info_table = Table( 

304 title="File Information", show_header=True, header_style="bold blue" 

305 ) 

306 info_table.add_column("Property", style="cyan") 

307 info_table.add_column("Value", style="white") 

308 

309 info_table.add_row("Path", file_info["file_path"]) 

310 info_table.add_row( 

311 "Size", f"{file_info['size_mb']} MB ({file_info['size_bytes']} bytes)" 

312 ) 

313 info_table.add_row("Entries", str(file_info["line_count"])) 

314 

315 if file_info["date_range"]: 315 ↛ 334line 315 didn't jump to line 334 because the condition on line 315 was always true

316 date_range = file_info["date_range"] 

317 info_table.add_row( 

318 "First Entry", 

319 ( 

320 date_range["first"].strftime("%Y-%m-%d %H:%M:%S") 

321 if date_range["first"] 

322 else "N/A" 

323 ), 

324 ) 

325 info_table.add_row( 

326 "Last Entry", 

327 ( 

328 date_range["last"].strftime("%Y-%m-%d %H:%M:%S") 

329 if date_range["last"] 

330 else "N/A" 

331 ), 

332 ) 

333 

334 console.print(info_table) 

335 

336 # Level counts 

337 level_counts = all_stats["level_counts"] 

338 if level_counts: 338 ↛ exitline 338 didn't return from function 'stats_command' because the condition on line 338 was always true

339 level_table = Table( 

340 title="Level Distribution", 

341 show_header=True, 

342 header_style="bold blue", 

343 ) 

344 level_table.add_column("Level", style="cyan") 

345 level_table.add_column("Count", style="green") 

346 

347 for level, count in sorted( 

348 level_counts.items(), key=lambda x: x[1], reverse=True 

349 ): 

350 level_table.add_row(level, str(count)) 

351 

352 console.print("\n") 

353 console.print(level_table) 

354 

355 except click.ClickException: 

356 raise 

357 except (OSError, ValueError, json.JSONDecodeError) as e: 

358 raise click.ClickException(str(e)) from e 

359 

360 

361@logs_group.command(name="tail", help="Display last lines of log file") 

362@click.option( 

363 "--file", 

364 "-f", 

365 type=click.Path(exists=True, path_type=Path), 

366 help="Path to log file (default: from config)", 

367) 

368@click.option("--lines", "-n", type=int, default=20, help="Number of lines to display") 

369@click.option( 

370 "--follow", 

371 "-F", 

372 is_flag=True, 

373 help="Follow log file (like tail -f)", 

374) 

375def tail_command(file: Path | None, lines: int, follow: bool) -> None: 

376 """ 

377 Display the last lines of a log file. 

378 

379 Similar to Unix 'tail' command with optional follow mode. 

380 """ 

381 try: 

382 log_file = _get_log_file(file) 

383 parser = LogParser(log_file) 

384 

385 if follow: 

386 # Use view command's follow mode 

387 view_command(file, lines=lines, level=None, follow=True) 

388 else: 

389 # Get last N lines 

390 entries = parser.get_last_lines(lines) 

391 

392 if not entries: 

393 console.print("[yellow]No log entries found[/yellow]") 

394 return 

395 

396 # Display entries 

397 for entry in entries: 

398 console.print(entry.raw_line) 

399 

400 except click.ClickException: 

401 raise 

402 except (OSError, ValueError, json.JSONDecodeError) as e: 

403 raise click.ClickException(str(e)) from e 

404 

405 

406@logs_group.command(name="list", help="List log files") 

407@click.option( 

408 "--dir", 

409 "-d", 

410 type=click.Path(exists=True, path_type=Path), 

411 help="Directory to search (default: from config)", 

412) 

413def list_command(dir: Path | None) -> None: 

414 """ 

415 List available log files. 

416 

417 Display all log files in the configured log directory. 

418 """ 

419 try: 

420 log_dir = _get_log_dir(dir) 

421 

422 if not log_dir.exists(): 422 ↛ 423line 422 didn't jump to line 423 because the condition on line 422 was never true

423 console.print(f"[yellow]Log directory does not exist: {log_dir}[/yellow]") 

424 return 

425 

426 # Find all log files 

427 log_files = sorted( 

428 log_dir.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True 

429 ) 

430 

431 if not log_files: 431 ↛ 432line 431 didn't jump to line 432 because the condition on line 431 was never true

432 console.print(f"[yellow]No log files found in {log_dir}[/yellow]") 

433 return 

434 

435 # Display as table 

436 table = Table( 

437 title=f"Log Files in {log_dir}", show_header=True, header_style="bold blue" 

438 ) 

439 table.add_column("File", style="cyan") 

440 table.add_column("Size", style="green") 

441 table.add_column("Modified", style="white") 

442 

443 for log_file in log_files: 

444 try: 

445 size = log_file.stat().st_size 

446 size_mb = size / (1024 * 1024) 

447 modified = datetime.fromtimestamp(log_file.stat().st_mtime) 

448 table.add_row( 

449 log_file.name, 

450 f"{size_mb:.2f} MB", 

451 modified.strftime("%Y-%m-%d %H:%M:%S"), 

452 ) 

453 except OSError as e: 

454 console.print(f"[bold red]Error:[/bold red] {e}") 

455 

456 console.print(table) 

457 

458 except click.ClickException: 

459 raise 

460 except (OSError, ValueError, json.JSONDecodeError) as e: 

461 raise click.ClickException(str(e)) from e 

462 

463 

464@logs_group.command(name="clean", help="Clean old log files") 

465@click.option( 

466 "--file", 

467 "-f", 

468 type=click.Path(exists=True, path_type=Path), 

469 help="Specific file to clean", 

470) 

471@click.option( 

472 "--days", 

473 "-d", 

474 type=int, 

475 help="Delete files older than N days", 

476) 

477@click.option( 

478 "--size", 

479 "-s", 

480 type=str, 

481 help="Delete files larger than SIZE (e.g., '100MB')", 

482) 

483@click.option( 

484 "--confirm", 

485 "-y", 

486 is_flag=True, 

487 help="Skip confirmation prompt", 

488) 

489def clean_command( 

490 file: Path | None, days: int | None, size: str | None, confirm: bool 

491) -> None: 

492 """ 

493 Clean old or large log files. 

494 

495 Remove log files based on age or size criteria. 

496 """ 

497 try: 

498 if file: 

499 # Clean specific file 

500 files_to_clean = [Path(file)] 

501 else: 

502 # Clean from directory 

503 log_dir = _get_log_dir(None) 

504 if not log_dir.exists(): 

505 console.print( 

506 f"[yellow]Log directory does not exist: {log_dir}[/yellow]" 

507 ) 

508 return 

509 

510 files_to_clean = list(log_dir.glob("*.log")) 

511 

512 if not files_to_clean: 

513 console.print("[yellow]No log files to clean[/yellow]") 

514 return 

515 

516 # Filter by criteria 

517 files_to_delete = [] 

518 

519 for log_file in files_to_clean: 

520 should_delete = False 

521 

522 if days: 

523 # Check age 

524 file_age = datetime.now() - datetime.fromtimestamp( 

525 log_file.stat().st_mtime 

526 ) 

527 if file_age > timedelta(days=days): 

528 should_delete = True 

529 

530 if size: 

531 # Check size 

532 file_size = log_file.stat().st_size 

533 size_bytes = _parse_size(size) 

534 if file_size > size_bytes: 

535 should_delete = True 

536 

537 if should_delete: 

538 files_to_delete.append(log_file) 

539 

540 if not files_to_delete: 

541 console.print("[green]No files match the cleanup criteria[/green]") 

542 return 

543 

544 # Confirm deletion 

545 if not confirm: 

546 console.print( 

547 f"[yellow]Files to be deleted ({len(files_to_delete)}):[/yellow]" 

548 ) 

549 for f in files_to_delete: 

550 console.print(f" - {f}") 

551 

552 if not click.confirm("\nAre you sure you want to delete these files?"): 

553 console.print("[yellow]Cleanup cancelled[/yellow]") 

554 return 

555 

556 # Delete files 

557 deleted_count = 0 

558 for log_file in files_to_delete: 

559 try: 

560 log_file.unlink() 

561 deleted_count += 1 

562 console.print(f"[green]✓[/green] Deleted: {log_file}") 

563 except OSError as e: 

564 console.print(f"[red]✗[/red] Failed to delete {log_file}: {e}") 

565 

566 console.print(f"\n[green]Deleted {deleted_count} file(s)[/green]") 

567 

568 except click.ClickException: 

569 raise 

570 except (OSError, ValueError, json.JSONDecodeError) as e: 

571 raise click.ClickException(str(e)) from e 

572 

573 

574@logs_group.command(name="export", help="Export log file") 

575@click.option( 

576 "--file", 

577 "-f", 

578 type=click.Path(exists=True, path_type=Path), 

579 help="Path to log file (default: from config)", 

580) 

581@click.option( 

582 "--format", 

583 "-F", 

584 type=click.Choice(["json", "csv", "txt"], case_sensitive=False), 

585 default="json", 

586 help="Export format", 

587) 

588@click.option( 

589 "--output", 

590 "-o", 

591 type=click.Path(path_type=Path), 

592 help="Output file path (default: stdout)", 

593) 

594def export_command(file: Path | None, format: str, output: Path | None) -> None: 

595 """ 

596 Export log file to different formats. 

597 

598 Convert log files to JSON, CSV, or plain text format. 

599 """ 

600 try: 

601 log_file = _get_log_file(file) 

602 parser = LogParser(log_file) 

603 entries = list(parser.parse()) 

604 

605 if not entries: 

606 console.print("[yellow]No log entries to export[/yellow]") 

607 return 

608 

609 # Export based on format 

610 if format == "json": 

611 data = [entry.to_dict() for entry in entries] 

612 content = json.dumps(data, indent=2, default=str) 

613 elif format == "csv": 

614 import csv 

615 from io import StringIO 

616 

617 output_buffer = StringIO() 

618 writer = csv.DictWriter( 

619 output_buffer, 

620 fieldnames=[ 

621 "timestamp", 

622 "level", 

623 "module", 

624 "function", 

625 "line", 

626 "message", 

627 ], 

628 ) 

629 writer.writeheader() 

630 for entry in entries: 

631 writer.writerow(entry.to_dict()) 

632 content = output_buffer.getvalue() 

633 else: # txt 

634 content = "\n".join(entry.raw_line for entry in entries) 

635 

636 # Write output 

637 if output: 

638 output_path = Path(output) 

639 output_path.parent.mkdir(parents=True, exist_ok=True) 

640 with open(output_path, "w", encoding="utf-8") as f: 

641 f.write(content) 

642 console.print(f"[green]✓[/green] Exported to {output_path}") 

643 else: 

644 console.print(content) 

645 

646 except click.ClickException: 

647 raise 

648 except (OSError, ValueError, json.JSONDecodeError) as e: 

649 raise click.ClickException(str(e)) from e