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

1# src/beautyspot/cli.py

2

3 import sys

4 import socket

5 import subprocess

6 from datetime import datetime

7 from pathlib import Path

8 from typing import Optional, Any

9

10 import typer

11 from rich.console import Console

12 from rich.table import Table

13 from rich.panel import Panel

14 from rich.syntax import Syntax

15 from rich.progress import Progress, SpinnerColumn, TextColumn

16

17 from beautyspot.maintenance import MaintenanceService

18

19 app = typer.Typer(

20 name="beautyspot",

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

22 add_completion=False,

23 rich_markup_mode="rich",

24)

25 console = Console()

26

27

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

29# Helper Functions

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

31

32

33 def 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

45 def _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

50 def _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

60 def _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

68 def _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

75 def _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

84 def _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

144 def _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

149 def _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")

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

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

315 def 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

326 def _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")

409 def 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

419 def _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")

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

518 def 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

544 def _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")

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

748 def 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

783 def _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")

859 def 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

878 def main():

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

880 app()

881

882

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

884 main()