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
« prev ^ index » next coverage.py v7.13.2, created at 2026-03-11 19:10 +0900
1# src/beautyspot/cli.py
3import sys
4import socket
5import subprocess
6from datetime import datetime
7from pathlib import Path
8from typing import Optional, Any
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
17from beautyspot.maintenance import MaintenanceService
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()
28# =============================================================================
29# Helper Functions
30# =============================================================================
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)
42 return MaintenanceService.from_path(path, blob_dir)
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
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 )
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"
68def _format_timestamp(timestamp: float) -> str:
69 from datetime import timezone
71 dt = datetime.fromtimestamp(timestamp, tz=timezone.utc).astimezone()
72 return dt.strftime("%Y-%m-%d %H:%M")
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
81 return SQLiteTaskDB.count_tasks(db_path)
84def _list_databases():
85 beautyspot_dir = Path(".beautyspot")
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)
99 db_files = list(beautyspot_dir.glob("**/*.db")) + list(
100 beautyspot_dir.glob("**/*.sqlite")
101 )
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)
114 table = Table(
115 title="🌑 Available Databases",
116 show_header=True,
117 header_style="bold magenta",
118 border_style="blue",
119 )
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")
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)
132 table.add_row(
133 str(db_path),
134 size,
135 modified,
136 str(task_count) if task_count >= 0 else "-",
137 )
139 console.print(table)
140 console.print()
141 console.print("[dim]Hint: beautyspot list <database> to view tasks[/dim]")
144def _list_tasks(db: str, limit: int, func: Optional[str]):
145 with get_service(db) as spot:
146 _list_tasks_inner(spot, limit, func)
149def _list_tasks_inner(spot: MaintenanceService, limit: int, func: Optional[str]):
150 df = spot.db.get_history(limit=limit)
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)
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)
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 )
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")
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 )
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 "-"
197 cache_key_short = str(row["cache_key"])[:8]
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"])
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 )
218 console.print(table)
221# =============================================================================
222# Commands
223# =============================================================================
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)
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)
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 )
269 dashboard_path = Path(__file__).parent / "dashboard.py"
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)
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
311 _list_tasks(db, limit, func)
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)
326def _show_cmd_inner(service: MaintenanceService, cache_key: str):
327 # Prefix-based key resolution
328 resolved = service.resolve_key_prefix(cache_key)
330 if resolved is None:
331 console.print(f"[red]Error:[/red] Cache key not found: {cache_key}")
332 raise typer.Exit(1)
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)
344 real_key = resolved
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)
351 expires_at = result.get("expires_at")
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 )
361 console.print(
362 Panel(
363 detail_text,
364 title="🔍 Task Details",
365 border_style="green",
366 )
367 )
369 data = result.get("decoded_data")
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
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 )
397 except Exception as e:
398 console.print(f"[yellow]Error displaying data: {e}[/yellow]")
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 )
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)
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)
426 if df.empty:
427 console.print("[yellow]No tasks recorded yet.[/yellow]")
428 raise typer.Exit(0)
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
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"))
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)
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)
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)
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]"
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)
500 with get_service(db) as service:
501 deleted = service.clear(func)
502 console.print(f"[green]✓ Deleted {deleted} tasks.[/green]")
504 if clean_blobs: 504 ↛ exitline 504 didn't jump to the function exit
505 console.print("\n[dim]Running blob cleanup...[/dim]")
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]")
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).
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)
544def _clean_cmd_inner(service: MaintenanceService, dry_run: bool, force: bool):
545 orphans = service.scan_garbage()
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)
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")
565 for orphan in orphans[:20]:
566 table.add_row(Path(orphan).name)
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")
571 console.print(table)
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)
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)
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))
594 console.print(f"[green]✓ Deleted {deleted_orphans} orphaned blob files.[/green]")
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.
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)
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"))
626 if db_files:
627 console.print(
628 f"[bold]Checking {len(db_files)} databases for expired tasks...[/bold]"
629 )
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]")
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]")
652 # --- 1.5. Per-project orphan blob cleanup ---
653 console.print()
654 console.print("[bold]Cleaning orphan blobs per project...[/bold]")
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
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]")
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]")
690 console.print()
692 # --- 2. Zombie Projects Cleanup ---
693 orphans = MaintenanceService.scan_orphan_projects(workspace)
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)
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")
712 for path in orphans:
713 table.add_row(f"- {path}")
715 console.print(table)
716 console.print()
718 if dry_run:
719 console.print("[yellow]Dry run:[/yellow] No changes made to zombie projects.")
720 raise typer.Exit(0)
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)
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))
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)
744 console.print(f"[green]✓ Cleaned up {deleted_count} orphan projects.[/green]")
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).
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)
779 with get_service(db) as service:
780 _prune_cmd_inner(service, days, func, dry_run, force, clean_blobs)
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)
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)
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")
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 )
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 )
828 console.print(table)
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)
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)
842 deleted = service.prune(days, func)
843 console.print(f"[green]✓ Deleted {deleted} tasks.[/green]")
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]")
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]")
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"
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 )
878def main():
879 """Entry point for the CLI."""
880 app()
883if __name__ == "__main__": 883 ↛ 884line 883 didn't jump to line 884 because the condition on line 883 was never true
884 main()