Coverage for src / beautyspot / cli.py: 72%

398 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-03-11 19:10 +0900

1# src/beautyspot/cli.py 

2 

3import sys 

4import socket 

5import subprocess 

6from datetime import datetime 

7from pathlib import Path 

8from typing import Optional, Any 

9 

10import typer 

11from rich.console import Console 

12from rich.table import Table 

13from rich.panel import Panel 

14from rich.syntax import Syntax 

15from rich.progress import Progress, SpinnerColumn, TextColumn 

16 

17from beautyspot.maintenance import MaintenanceService 

18 

19app = typer.Typer( 

20 name="beautyspot", 

21 help="🌑 beautyspot - Intelligent caching for ML pipelines", 

22 add_completion=False, 

23 rich_markup_mode="rich", 

24) 

25console = Console() 

26 

27 

28# ============================================================================= 

29# Helper Functions 

30# ============================================================================= 

31 

32 

33def get_service(db_path: str, blob_dir: Optional[str] = None) -> MaintenanceService: 

34 """ 

35 Initialize MaintenanceService from CLI arguments. 

36 """ 

37 path = Path(db_path) 

38 if not path.exists(): 

39 console.print(f"[red]Error:[/red] Database not found: {db_path}") 

40 raise typer.Exit(1) 

41 

42 return MaintenanceService.from_path(path, blob_dir) 

43 

44 

45def _is_port_in_use(port: int) -> bool: 

46 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 

47 return s.connect_ex(("localhost", port)) == 0 

48 

49 

50def _find_available_port(start_port: int, max_attempts: int = 10) -> int: 

51 for i in range(max_attempts): 51 ↛ 55line 51 didn't jump to line 55 because the loop on line 51 didn't complete

52 port = start_port + i 

53 if not _is_port_in_use(port): 53 ↛ 51line 53 didn't jump to line 51 because the condition on line 53 was always true

54 return port 

55 raise RuntimeError( 

56 f"No available port found in range {start_port}-{start_port + max_attempts - 1}" 

57 ) 

58 

59 

60def _format_size(size_bytes: int | float) -> str: 

61 for unit in ["B", "KB", "MB", "GB"]: 61 ↛ 65line 61 didn't jump to line 65 because the loop on line 61 didn't complete

62 if size_bytes < 1024: 

63 return f"{size_bytes:.1f} {unit}" 

64 size_bytes /= 1024 

65 return f"{size_bytes:.1f} TB" 

66 

67 

68def _format_timestamp(timestamp: float) -> str: 

69 from datetime import timezone 

70 

71 dt = datetime.fromtimestamp(timestamp, tz=timezone.utc).astimezone() 

72 return dt.strftime("%Y-%m-%d %H:%M") 

73 

74 

75def _get_task_count(db_path: Path) -> int: 

76 """ 

77 Get task count using SQLiteTaskDB.count_tasks (no writer thread started). 

78 """ 

79 from beautyspot.db import SQLiteTaskDB 

80 

81 return SQLiteTaskDB.count_tasks(db_path) 

82 

83 

84def _list_databases(): 

85 beautyspot_dir = Path(".beautyspot") 

86 

87 if not beautyspot_dir.exists(): 

88 console.print( 

89 Panel( 

90 "[yellow]No .beautyspot/ directory found in current path.[/yellow]\n\n" 

91 "[dim]Hint: Run your cached functions first, or specify a database path:[/dim]\n" 

92 " beautyspot list ./path/to/tasks.db", 

93 title="🌑 beautyspot", 

94 border_style="yellow", 

95 ) 

96 ) 

97 raise typer.Exit(0) 

98 

99 db_files = list(beautyspot_dir.glob("**/*.db")) + list( 

100 beautyspot_dir.glob("**/*.sqlite") 

101 ) 

102 

103 if not db_files: 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true

104 console.print( 

105 Panel( 

106 "[yellow]No SQLite databases found in .beautyspot/[/yellow]\n\n" 

107 "[dim]Hint: Run your cached functions first to create a database.[/dim]", 

108 title="🌑 beautyspot", 

109 border_style="yellow", 

110 ) 

111 ) 

112 raise typer.Exit(0) 

113 

114 table = Table( 

115 title="🌑 Available Databases", 

116 show_header=True, 

117 header_style="bold magenta", 

118 border_style="blue", 

119 ) 

120 

121 table.add_column("Database", style="cyan") 

122 table.add_column("Size", style="green", justify="right") 

123 table.add_column("Modified", style="dim") 

124 table.add_column("Tasks", style="yellow", justify="right") 

125 

126 for db_path in sorted(db_files): 

127 stat = db_path.stat() 

128 size = _format_size(stat.st_size) 

129 modified = _format_timestamp(stat.st_mtime) 

130 task_count = _get_task_count(db_path) 

131 

132 table.add_row( 

133 str(db_path), 

134 size, 

135 modified, 

136 str(task_count) if task_count >= 0 else "-", 

137 ) 

138 

139 console.print(table) 

140 console.print() 

141 console.print("[dim]Hint: beautyspot list <database> to view tasks[/dim]") 

142 

143 

144def _list_tasks(db: str, limit: int, func: Optional[str]): 

145 with get_service(db) as spot: 

146 _list_tasks_inner(spot, limit, func) 

147 

148 

149def _list_tasks_inner(spot: MaintenanceService, limit: int, func: Optional[str]): 

150 df = spot.db.get_history(limit=limit) 

151 

152 if df.empty: 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true

153 console.print("[yellow]No tasks recorded yet.[/yellow]") 

154 raise typer.Exit(0) 

155 

156 if func: 

157 # Note: Filtering DataFrame returns DataFrame, so this is safe from 'if df:' issue 

158 if "func_identifier" in df.columns: 158 ↛ 163line 158 didn't jump to line 163 because the condition on line 158 was always true

159 func_mask = df["func_name"].str.contains(func, na=False) # type: ignore 

160 func_id_mask = df["func_identifier"].fillna("").str.contains(func, na=False) # type: ignore 

161 df = df[func_mask | func_id_mask] # type: ignore 

162 else: 

163 df = df[df["func_name"].str.contains(func, na=False)] # type: ignore 

164 if df.empty: 164 ↛ 165line 164 didn't jump to line 165 because the condition on line 164 was never true

165 console.print(f"[yellow]No tasks found for function: {func}[/yellow]") 

166 raise typer.Exit(0) 

167 

168 table = Table( 

169 title=f"🌑 beautyspot Tasks ({len(df)} records)", 

170 show_header=True, 

171 header_style="bold magenta", 

172 border_style="blue", 

173 ) 

174 

175 table.add_column("Function", style="cyan", no_wrap=True) 

176 table.add_column("Cache Key", style="magenta", no_wrap=True) 

177 table.add_column("Input ID", style="dim", max_width=20) 

178 table.add_column("Version", style="green") 

179 table.add_column("Type", style="yellow") 

180 table.add_column("Content", style="blue") 

181 table.add_column("Updated", style="dim") 

182 table.add_column("Expires", style="red") 

183 

184 # iterrows yields (index, Series) 

185 for _, row in df.iterrows(): 

186 input_id = ( 

187 str(row["input_id"])[:20] + "..." 

188 if len(str(row["input_id"])) > 20 

189 else str(row["input_id"]) 

190 ) 

191 

192 # [FIX] Avoid pd.notna() in conditional to satisfy strict type checkers 

193 # row.get returns Any, which might be scalar or None here. 

194 expires_at: Any = row.get("expires_at") 

195 expires_str = str(expires_at) if expires_at is not None else "-" 

196 

197 cache_key_short = str(row["cache_key"])[:8] 

198 

199 func_identifier = ( 

200 row.get("func_identifier") if "func_identifier" in row else None 

201 ) 

202 if isinstance(func_identifier, str) and func_identifier: 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true

203 func_display = func_identifier 

204 else: 

205 func_display = str(row["func_name"]) 

206 

207 table.add_row( 

208 func_display, 

209 cache_key_short, 

210 input_id, 

211 str(row["version"] or "-"), 

212 str(row["result_type"]), 

213 str(row["content_type"] or "-"), 

214 str(row["updated_at"]), 

215 expires_str, 

216 ) 

217 

218 console.print(table) 

219 

220 

221# ============================================================================= 

222# Commands 

223# ============================================================================= 

224 

225 

226@app.command("ui") 

227def ui_cmd( 

228 db: str = typer.Argument(..., help="Path to SQLite database file"), 

229 port: int = typer.Option(8501, "--port", "-p", help="Streamlit server port"), 

230 auto_port: bool = typer.Option( 

231 True, "--auto-port/--no-auto-port", help="Auto-find available port" 

232 ), 

233): 

234 """ 

235 🚀 Launch the interactive dashboard. 

236 """ 

237 db_path = Path(db) 

238 if not db_path.exists(): 

239 console.print(f"[red]Error:[/red] Database not found: {db}") 

240 raise typer.Exit(1) 

241 

242 actual_port = port 

243 if _is_port_in_use(port): 

244 if auto_port: 

245 try: 

246 actual_port = _find_available_port(port + 1) 

247 console.print( 

248 f"[yellow]Port {port} is in use. Using port {actual_port} instead.[/yellow]" 

249 ) 

250 except RuntimeError as e: 

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

252 raise typer.Exit(1) 

253 else: 

254 console.print(f"[red]Error:[/red] Port {port} is already in use.") 

255 raise typer.Exit(1) 

256 

257 console.print( 

258 Panel.fit( 

259 f"[bold green]Starting beautyspot Dashboard[/bold green]\n\n" 

260 f"📁 Database: [cyan]{db}[/cyan]\n" 

261 f"🌐 Port: [cyan]{actual_port}[/cyan]\n" 

262 f"🔗 URL: [cyan]http://localhost:{actual_port}[/cyan]\n\n" 

263 f"[dim]Press Ctrl+C to stop[/dim]", 

264 title="🌑 beautyspot", 

265 border_style="blue", 

266 ) 

267 ) 

268 

269 dashboard_path = Path(__file__).parent / "dashboard.py" 

270 

271 try: 

272 subprocess.run( 

273 [ 

274 sys.executable, 

275 "-m", 

276 "streamlit", 

277 "run", 

278 str(dashboard_path), 

279 "--server.port", 

280 str(actual_port), 

281 "--server.headless", 

282 "true", 

283 "--", 

284 "--db", 

285 str(db_path.absolute()), 

286 ], 

287 check=True, 

288 ) 

289 except KeyboardInterrupt: 

290 console.print("\n[yellow]Dashboard stopped.[/yellow]") 

291 except subprocess.CalledProcessError as e: 

292 console.print(f"[red]Error:[/red] Failed to start dashboard: {e}") 

293 raise typer.Exit(1) 

294 

295 

296@app.command("list") 

297def list_cmd( 

298 db: Optional[str] = typer.Argument(None, help="Path to SQLite database file"), 

299 limit: int = typer.Option(20, "--limit", "-n", help="Number of records to show"), 

300 func: Optional[str] = typer.Option( 

301 None, "--func", "-f", help="Filter by function name" 

302 ), 

303): 

304 """ 

305 📋 List cached tasks or available databases. 

306 """ 

307 if db is None: 

308 _list_databases() 

309 return 

310 

311 _list_tasks(db, limit, func) 

312 

313 

314@app.command("show") 

315def show_cmd( 

316 db: str = typer.Argument(..., help="Path to SQLite database file"), 

317 cache_key: str = typer.Argument(..., help="Cache key (full or prefix) to inspect"), 

318): 

319 """ 

320 🔍 Show details of a specific cached task. 

321 """ 

322 with get_service(db) as service: 

323 _show_cmd_inner(service, cache_key) 

324 

325 

326def _show_cmd_inner(service: MaintenanceService, cache_key: str): 

327 # Prefix-based key resolution 

328 resolved = service.resolve_key_prefix(cache_key) 

329 

330 if resolved is None: 

331 console.print(f"[red]Error:[/red] Cache key not found: {cache_key}") 

332 raise typer.Exit(1) 

333 

334 if isinstance(resolved, list): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true

335 console.print( 

336 f"[yellow]Ambiguous key prefix '{cache_key}'. Candidates:[/yellow]" 

337 ) 

338 for cand in resolved[:10]: 

339 console.print(f" - {cand}") 

340 if len(resolved) > 10: 

341 console.print(f" [dim]... and {len(resolved) - 10} more[/dim]") 

342 raise typer.Exit(1) 

343 

344 real_key = resolved 

345 

346 result = service.get_task_detail(real_key, include_expired=True) 

347 if result is None: 347 ↛ 348line 347 didn't jump to line 348 because the condition on line 347 was never true

348 console.print(f"[red]Error:[/red] Failed to retrieve details for: {real_key}") 

349 raise typer.Exit(1) 

350 

351 expires_at = result.get("expires_at") 

352 

353 detail_text = ( 

354 f"[bold]Cache Key:[/bold] [cyan]{real_key}[/cyan]\n" 

355 f"[bold]Result Type:[/bold] [yellow]{result.get('result_type')}[/yellow]\n" 

356 f"[bold]Result Value:[/bold] {result.get('result_value') or '-'}\n" 

357 f"[bold]Has Blob Data:[/bold] {'Yes' if result.get('result_data') else 'No'}\n" 

358 f"[bold]Expires At:[/bold] [red]{expires_at if expires_at else '-'}[/red]" 

359 ) 

360 

361 console.print( 

362 Panel( 

363 detail_text, 

364 title="🔍 Task Details", 

365 border_style="green", 

366 ) 

367 ) 

368 

369 data = result.get("decoded_data") 

370 

371 if data is not None: 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true

372 try: 

373 import json 

374 

375 if isinstance(data, (dict, list)): 

376 json_str = json.dumps(data, indent=2, ensure_ascii=False, default=str) 

377 syntax = Syntax(json_str, "json", theme="monokai", line_numbers=True) 

378 console.print( 

379 Panel(syntax, title="📦 Data Preview (JSON)", border_style="blue") 

380 ) 

381 elif isinstance(data, str): 

382 preview = data[:1000] + "..." if len(data) > 1000 else data 

383 console.print( 

384 Panel( 

385 preview, title="📦 Data Preview (String)", border_style="blue" 

386 ) 

387 ) 

388 else: 

389 console.print( 

390 Panel( 

391 f"[dim]Type: {type(data).__name__}[/dim]\n{str(data)[:1000]}", 

392 title="📦 Data Preview (Object)", 

393 border_style="blue", 

394 ) 

395 ) 

396 

397 except Exception as e: 

398 console.print(f"[yellow]Error displaying data: {e}[/yellow]") 

399 

400 elif result.get("result_data") is not None or ( 400 ↛ 403line 400 didn't jump to line 403 because the condition on line 400 was never true

401 result.get("result_type") == "FILE" and result.get("result_value") 

402 ): 

403 console.print( 

404 "[yellow]Could not decode blob data (Serialization format mismatch or missing file).[/yellow]" 

405 ) 

406 

407 

408@app.command("stats") 

409def stats_cmd( 

410 db: str = typer.Argument(..., help="Path to SQLite database file"), 

411): 

412 """ 

413 📊 Show cache statistics. 

414 """ 

415 with get_service(db) as service: 

416 _stats_cmd_inner(service) 

417 

418 

419def _stats_cmd_inner(service: MaintenanceService): 

420 try: 

421 df = service.get_history(limit=10000) 

422 except ImportError as e: 

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

424 raise typer.Exit(1) 

425 

426 if df.empty: 

427 console.print("[yellow]No tasks recorded yet.[/yellow]") 

428 raise typer.Exit(0) 

429 

430 total_tasks = len(df) 

431 if "func_identifier" in df.columns and df["func_identifier"].notna().any(): # type: ignore 

432 func_col = "func_identifier" 

433 else: 

434 func_col = "func_name" 

435 unique_functions = df[func_col].nunique() # type: ignore 

436 result_types = df["result_type"].value_counts().to_dict() # type: ignore 

437 content_types = df["content_type"].value_counts().to_dict() # type: ignore 

438 

439 summary = ( 

440 f"[bold]Total Tasks:[/bold] [cyan]{total_tasks:,}[/cyan]\n" 

441 f"[bold]Unique Functions:[/bold] [cyan]{unique_functions}[/cyan]" 

442 ) 

443 console.print(Panel(summary, title="📊 Overview", border_style="green")) 

444 

445 if result_types: 445 ↛ 453line 445 didn't jump to line 453 because the condition on line 445 was always true

446 rt_table = Table(title="Result Types", border_style="blue") 

447 rt_table.add_column("Type", style="yellow") 

448 rt_table.add_column("Count", style="cyan", justify="right") 

449 for rt, count in result_types.items(): 

450 rt_table.add_row(str(rt), str(count)) 

451 console.print(rt_table) 

452 

453 if content_types: 453 ↛ 454line 453 didn't jump to line 454 because the condition on line 453 was never true

454 ct_table = Table(title="Content Types", border_style="blue") 

455 ct_table.add_column("Type", style="blue") 

456 ct_table.add_column("Count", style="cyan", justify="right") 

457 for ct, count in content_types.items(): 

458 # [FIX] Avoid pd.notna(ct) in conditional 

459 ct_str = str(ct) if ct else "-" 

460 ct_table.add_row(ct_str, str(count)) 

461 console.print(ct_table) 

462 

463 top_funcs = df[func_col].value_counts().head(10).to_dict() # type: ignore 

464 if top_funcs: 464 ↛ exitline 464 didn't return from function '_stats_cmd_inner' because the condition on line 464 was always true

465 func_table = Table(title="Top Functions", border_style="blue") 

466 func_table.add_column("Function", style="cyan") 

467 func_table.add_column("Count", style="green", justify="right") 

468 for func_name, count in top_funcs.items(): 

469 func_table.add_row(str(func_name), str(count)) 

470 console.print(func_table) 

471 

472 

473@app.command("clear") 

474def clear_cmd( 

475 db: str = typer.Argument(..., help="Path to SQLite database file"), 

476 func: Optional[str] = typer.Option( 

477 None, "--func", "-f", help="Clear only specific function" 

478 ), 

479 force: bool = typer.Option(False, "--force", "-y", help="Skip confirmation"), 

480 clean_blobs: bool = typer.Option( 

481 True, 

482 "--clean-blobs/--no-clean-blobs", 

483 help="Also remove orphaned blob files after clearing", 

484 ), 

485): 

486 """ 

487 🗑️ Clear cached tasks. 

488 """ 

489 if func: 

490 msg = f"Clear all cached tasks for function [cyan]{func}[/cyan]?" 

491 else: 

492 msg = "[bold red]Clear ALL cached tasks?[/bold red]" 

493 

494 if not force: 

495 confirm = typer.confirm(msg) 

496 if not confirm: 496 ↛ 500line 496 didn't jump to line 500 because the condition on line 496 was always true

497 console.print("[yellow]Aborted.[/yellow]") 

498 raise typer.Exit(0) 

499 

500 with get_service(db) as service: 

501 deleted = service.clear(func) 

502 console.print(f"[green]✓ Deleted {deleted} tasks.[/green]") 

503 

504 if clean_blobs: 504 ↛ exitline 504 didn't jump to the function exit

505 console.print("\n[dim]Running blob cleanup...[/dim]") 

506 

507 orphans = service.scan_garbage() 

508 if orphans: 508 ↛ 509line 508 didn't jump to line 509 because the condition on line 508 was never true

509 _, deleted_orphans = service.clean_garbage(orphans) 

510 console.print( 

511 f"[green]✓ Deleted {deleted_orphans} orphaned blob files.[/green]" 

512 ) 

513 else: 

514 console.print("[green]✓ No orphaned blob files found.[/green]") 

515 

516 

517@app.command("clean") 

518def clean_cmd( 

519 db: str = typer.Argument(..., help="Path to SQLite database file"), 

520 blob_dir: Optional[str] = typer.Option( 

521 None, 

522 "--blob-dir", 

523 "-b", 

524 help="Path to blob directory (auto-detected if not specified)", 

525 ), 

526 dry_run: bool = typer.Option( 

527 False, 

528 "--dry-run", 

529 "-n", 

530 help="Show what would be deleted without actually deleting", 

531 ), 

532 force: bool = typer.Option(False, "--force", "-y", help="Skip confirmation"), 

533): 

534 """ 

535 🧹 Clean orphaned blob files (garbage collection). 

536 

537 Removes files in the storage directory that are NOT referenced by any task in the database. 

538 Use this to clean up leftover files after manual DB operations or errors. 

539 """ 

540 with get_service(db, blob_dir) as service: 

541 _clean_cmd_inner(service, dry_run, force) 

542 

543 

544def _clean_cmd_inner(service: MaintenanceService, dry_run: bool, force: bool): 

545 orphans = service.scan_garbage() 

546 

547 if not orphans: 

548 console.print( 

549 Panel( 

550 "[green]✓ No orphaned files found.[/green]", 

551 title="🧹 Clean", 

552 border_style="green", 

553 ) 

554 ) 

555 raise typer.Exit(0) 

556 

557 table = Table( 

558 title=f"🧹 Orphaned Files ({len(orphans)} files)", 

559 show_header=True, 

560 header_style="bold magenta", 

561 border_style="yellow", 

562 ) 

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

564 

565 for orphan in orphans[:20]: 

566 table.add_row(Path(orphan).name) 

567 

568 if len(orphans) > 20: 568 ↛ 569line 568 didn't jump to line 569 because the condition on line 568 was never true

569 table.add_row(f"... and {len(orphans) - 20} more files") 

570 

571 console.print(table) 

572 

573 if dry_run: 

574 console.print( 

575 f"\n[yellow]Dry run:[/yellow] Found {len(orphans)} orphaned files." 

576 ) 

577 raise typer.Exit(0) 

578 

579 if not force: 579 ↛ 580line 579 didn't jump to line 580 because the condition on line 579 was never true

580 confirm = typer.confirm(f"Delete {len(orphans)} orphaned files?") 

581 if not confirm: 

582 console.print("[yellow]Aborted.[/yellow]") 

583 raise typer.Exit(0) 

584 

585 with Progress( 

586 SpinnerColumn(), 

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

588 console=console, 

589 ) as progress: 

590 task = progress.add_task("Cleaning garbage...", total=len(orphans)) 

591 _, deleted_orphans = service.clean_garbage(orphans=orphans) 

592 progress.update(task, completed=len(orphans)) 

593 

594 console.print(f"[green]✓ Deleted {deleted_orphans} orphaned blob files.[/green]") 

595 

596 

597@app.command("gc") 

598def gc_cmd( 

599 dry_run: bool = typer.Option( 

600 False, 

601 "--dry-run", 

602 "-n", 

603 help="Show what would be deleted without actually deleting", 

604 ), 

605 force: bool = typer.Option(False, "--force", "-y", help="Skip confirmation"), 

606 expired: bool = typer.Option( 

607 True, "--expired/--no-expired", help="Remove expired tasks from DBs" 

608 ), 

609): 

610 """ 

611 🗑️ Garbage Collect: Clean up expired tasks and orphan storage. 

612 

613 Performs two types of cleanup: 

614 1. [bold]Expired Tasks[/bold]: Removes tasks where `expires_at < NOW` from all databases. 

615 2. [bold]Zombie Projects[/bold]: Removes blob directories that have no corresponding .db file. 

616 """ 

617 workspace = Path(".beautyspot") 

618 if not workspace.exists(): 618 ↛ 619line 618 didn't jump to line 619 because the condition on line 618 was never true

619 console.print("[yellow]No .beautyspot directory found.[/yellow]") 

620 raise typer.Exit(0) 

621 

622 # --- 1. Expired Tasks Cleanup --- 

623 if expired: 623 ↛ 690line 623 didn't jump to line 690 because the condition on line 623 was always true

624 db_files = list(workspace.glob("**/*.db")) + list(workspace.glob("**/*.sqlite")) 

625 

626 if db_files: 

627 console.print( 

628 f"[bold]Checking {len(db_files)} databases for expired tasks...[/bold]" 

629 ) 

630 

631 if dry_run: 

632 console.print( 

633 "[yellow]Dry run:[/yellow] Would scan and remove expired tasks." 

634 ) 

635 else: 

636 total_expired = 0 

637 for db_path in db_files: 

638 try: 

639 with get_service(str(db_path)) as service: 

640 count = service.delete_expired_tasks() 

641 if count > 0: 641 ↛ 642line 641 didn't jump to line 642 because the condition on line 641 was never true

642 console.print( 

643 f" [green]✓ {db_path.stem}: Removed {count} expired tasks[/green]" 

644 ) 

645 total_expired += count 

646 except Exception as e: 

647 console.print(f" [red]x {db_path.stem}: Error ({e})[/red]") 

648 

649 if total_expired == 0: 649 ↛ 653line 649 didn't jump to line 653 because the condition on line 649 was always true

650 console.print(" [dim]No expired tasks found.[/dim]") 

651 

652 # --- 1.5. Per-project orphan blob cleanup --- 

653 console.print() 

654 console.print("[bold]Cleaning orphan blobs per project...[/bold]") 

655 

656 total_orphan_blobs = 0 

657 for db_path in db_files: 

658 try: 

659 with get_service(str(db_path)) as service: 

660 # タスクが存在しないDBはスキップ 

661 # (空DBに対して scan_garbage を走らせると、 

662 # 全blobが孤立と誤判定される) 

663 try: 

664 df = service.get_history(limit=1) 

665 if df.empty: 665 ↛ 670line 665 didn't jump to line 670 because the condition on line 665 was always true

666 continue 

667 except Exception: 

668 continue 

669 

670 if dry_run: 

671 orphan_blobs = service.scan_garbage() 

672 if orphan_blobs: 

673 console.print( 

674 f" [yellow]{db_path.stem}: {len(orphan_blobs)} orphan blobs (dry run)[/yellow]" 

675 ) 

676 total_orphan_blobs += len(orphan_blobs) 

677 else: 

678 _, deleted_blobs = service.clean_garbage() 

679 if deleted_blobs > 0: 

680 console.print( 

681 f" [green]✓ {db_path.stem}: Removed {deleted_blobs} orphan blobs[/green]" 

682 ) 

683 total_orphan_blobs += deleted_blobs 

684 except Exception as e: 

685 console.print(f" [red]x {db_path.stem}: Error ({e})[/red]") 

686 

687 if total_orphan_blobs == 0: 687 ↛ 690line 687 didn't jump to line 690 because the condition on line 687 was always true

688 console.print(" [dim]No orphan blobs found.[/dim]") 

689 

690 console.print() 

691 

692 # --- 2. Zombie Projects Cleanup --- 

693 orphans = MaintenanceService.scan_orphan_projects(workspace) 

694 

695 if not orphans: 

696 console.print( 

697 Panel( 

698 "[green]✓ No orphan storage directories found.[/green]", 

699 title="🗑️ Garbage Collection (Zombies)", 

700 border_style="green", 

701 ) 

702 ) 

703 raise typer.Exit(0) 

704 

705 table = Table( 

706 title=f"Found {len(orphans)} orphan storage directories", 

707 show_header=False, 

708 box=None, 

709 ) 

710 table.add_column("Path", style="red") 

711 

712 for path in orphans: 

713 table.add_row(f"- {path}") 

714 

715 console.print(table) 

716 console.print() 

717 

718 if dry_run: 

719 console.print("[yellow]Dry run:[/yellow] No changes made to zombie projects.") 

720 raise typer.Exit(0) 

721 

722 if not force: 722 ↛ 723line 722 didn't jump to line 723 because the condition on line 722 was never true

723 confirm = typer.confirm(f"Delete these {len(orphans)} directories?") 

724 if not confirm: 

725 console.print("[yellow]Aborted.[/yellow]") 

726 raise typer.Exit(0) 

727 

728 deleted_count = 0 

729 with Progress( 

730 SpinnerColumn(), 

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

732 console=console, 

733 ) as progress: 

734 task = progress.add_task("Cleaning up...", total=len(orphans)) 

735 

736 for path in orphans: 

737 try: 

738 MaintenanceService.delete_project_storage(path) 

739 deleted_count += 1 

740 except Exception: 

741 pass 

742 progress.advance(task) 

743 

744 console.print(f"[green]✓ Cleaned up {deleted_count} orphan projects.[/green]") 

745 

746 

747@app.command("prune") 

748def prune_cmd( 

749 db: str = typer.Argument(..., help="Path to SQLite database file"), 

750 days: int = typer.Option( 

751 ..., "--days", "-d", help="Delete tasks older than N days" 

752 ), 

753 func: Optional[str] = typer.Option( 

754 None, "--func", "-f", help="Prune only specific function" 

755 ), 

756 dry_run: bool = typer.Option( 

757 False, 

758 "--dry-run", 

759 "-n", 

760 help="Show what would be deleted without actually deleting", 

761 ), 

762 force: bool = typer.Option(False, "--force", "-y", help="Skip confirmation"), 

763 clean_blobs: bool = typer.Option( 

764 True, 

765 "--clean-blobs/--no-clean-blobs", 

766 help="Also remove orphaned blob files after pruning", 

767 ), 

768): 

769 """ 

770 🗓️ Prune old cached tasks (time-based expiration). 

771 

772 Deletes task records older than the specified number of days. 

773 By default, it also removes the associated blob files (implied --clean-blobs). 

774 """ 

775 if days < 1: 

776 console.print("[red]Error:[/red] --days must be at least 1") 

777 raise typer.Exit(1) 

778 

779 with get_service(db) as service: 

780 _prune_cmd_inner(service, days, func, dry_run, force, clean_blobs) 

781 

782 

783def _prune_cmd_inner( 

784 service: MaintenanceService, 

785 days: int, 

786 func: Optional[str], 

787 dry_run: bool, 

788 force: bool, 

789 clean_blobs: bool, 

790): 

791 tasks_to_delete = service.get_prunable_tasks(days, func) 

792 

793 if not tasks_to_delete: 

794 target_msg = f" for function '{func}'" if func else "" 

795 console.print( 

796 Panel( 

797 f"[green]✓ No tasks older than {days} days{target_msg}.[/green]", 

798 title="🗓️ Prune", 

799 border_style="green", 

800 ) 

801 ) 

802 raise typer.Exit(0) 

803 

804 table = Table( 

805 title=f"🗓️ Tasks to Prune ({len(tasks_to_delete)} tasks)", 

806 show_header=True, 

807 header_style="bold magenta", 

808 border_style="yellow", 

809 ) 

810 table.add_column("Function", style="cyan") 

811 table.add_column("Cache Key", style="dim", max_width=20) 

812 table.add_column("Updated", style="yellow") 

813 

814 for cache_key, func_name, updated_at in tasks_to_delete[:15]: 

815 table.add_row( 

816 str(func_name), 

817 str(cache_key)[:20] + "..." if len(str(cache_key)) > 20 else str(cache_key), 

818 str(updated_at), 

819 ) 

820 

821 if len(tasks_to_delete) > 15: 821 ↛ 822line 821 didn't jump to line 822 because the condition on line 821 was never true

822 table.add_row( 

823 f"[dim]... and {len(tasks_to_delete) - 15} more tasks[/dim]", 

824 "", 

825 "", 

826 ) 

827 

828 console.print(table) 

829 

830 if dry_run: 

831 console.print( 

832 f"\n[yellow]Dry run:[/yellow] Would delete {len(tasks_to_delete)} tasks" 

833 ) 

834 raise typer.Exit(0) 

835 

836 if not force: 836 ↛ 837line 836 didn't jump to line 837 because the condition on line 836 was never true

837 confirm = typer.confirm(f"Delete {len(tasks_to_delete)} tasks?") 

838 if not confirm: 

839 console.print("[yellow]Aborted.[/yellow]") 

840 raise typer.Exit(0) 

841 

842 deleted = service.prune(days, func) 

843 console.print(f"[green]✓ Deleted {deleted} tasks.[/green]") 

844 

845 if clean_blobs: 845 ↛ 846line 845 didn't jump to line 846 because the condition on line 845 was never true

846 console.print("\n[dim]Running blob cleanup...[/dim]") 

847 

848 orphans = service.scan_garbage() 

849 if orphans: 

850 _, deleted_orphans = service.clean_garbage(orphans) 

851 console.print( 

852 f"[green]✓ Deleted {deleted_orphans} orphaned blob files.[/green]" 

853 ) 

854 else: 

855 console.print("[green]✓ No orphaned blob files found.[/green]") 

856 

857 

858@app.command("version") 

859def version_cmd(): 

860 """ 

861 ℹ️ Show version information. 

862 """ 

863 try: 

864 from beautyspot import __version__ 

865 except ImportError: 

866 __version__ = "unknown" 

867 

868 console.print( 

869 Panel.fit( 

870 f"[bold]beautyspot[/bold] version [cyan]{__version__}[/cyan]\n\n" 

871 "[dim]Intelligent caching for ML pipelines[/dim]", 

872 title="🌑", 

873 border_style="blue", 

874 ) 

875 ) 

876 

877 

878def main(): 

879 """Entry point for the CLI.""" 

880 app() 

881 

882 

883if __name__ == "__main__": 883 ↛ 884line 883 didn't jump to line 884 because the condition on line 883 was never true

884 main()