sleap-share 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sleap_share/cli.py ADDED
@@ -0,0 +1,462 @@
1
+ """Command-line interface for sleap-share."""
2
+
3
+ import json
4
+ import webbrowser
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import httpx
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.progress import (
14
+ BarColumn,
15
+ DownloadColumn,
16
+ Progress,
17
+ SpinnerColumn,
18
+ TaskProgressColumn,
19
+ TextColumn,
20
+ TransferSpeedColumn,
21
+ )
22
+ from rich.table import Table
23
+
24
+ from . import __version__
25
+ from .auth import clear_token, load_token, run_device_auth_flow
26
+ from .client import SleapShareClient
27
+ from .config import get_config
28
+ from .exceptions import AuthenticationError, NotFoundError, SleapShareError
29
+
30
+ # Typer app
31
+ app = typer.Typer(
32
+ name="sleap-share",
33
+ help="Upload and share SLEAP datasets with slp.sh",
34
+ add_completion=False,
35
+ no_args_is_help=True,
36
+ )
37
+
38
+ # Rich console
39
+ console = Console()
40
+ err_console = Console(stderr=True)
41
+
42
+
43
+ def _get_env_option() -> Any:
44
+ """Get the --env option for environment targeting."""
45
+ return typer.Option(
46
+ None,
47
+ "--env",
48
+ "-e",
49
+ help="Target environment (production or staging)",
50
+ )
51
+
52
+
53
+ def _format_size(size_bytes: int) -> str:
54
+ """Format file size in human-readable format."""
55
+ size = float(size_bytes)
56
+ for unit in ["B", "KB", "MB", "GB"]:
57
+ if size < 1024:
58
+ return f"{size:.1f} {unit}"
59
+ size /= 1024
60
+ return f"{size:.1f} TB"
61
+
62
+
63
+ def _format_date(dt: datetime | None) -> str:
64
+ """Format datetime for display."""
65
+ if dt is None:
66
+ return "Never"
67
+ return dt.strftime("%Y-%m-%d %H:%M")
68
+
69
+
70
+ @app.command()
71
+ def login(
72
+ env: str | None = _get_env_option(),
73
+ ) -> None:
74
+ """Authenticate with SLEAP Share via browser."""
75
+ config = get_config(env=env)
76
+
77
+ with httpx.Client() as http_client:
78
+ try:
79
+ run_device_auth_flow(http_client, config, console)
80
+ except AuthenticationError as e:
81
+ err_console.print(f"[red]Error:[/red] {e.message}")
82
+ raise typer.Exit(1)
83
+ except KeyboardInterrupt:
84
+ console.print("\n[yellow]Login cancelled.[/yellow]")
85
+ raise typer.Exit(1)
86
+
87
+
88
+ @app.command()
89
+ def logout(
90
+ env: str | None = _get_env_option(),
91
+ ) -> None:
92
+ """Clear stored authentication credentials."""
93
+ config = get_config(env=env)
94
+
95
+ if clear_token(config):
96
+ console.print("[green]Logged out successfully.[/green]")
97
+ else:
98
+ console.print("[yellow]No credentials found.[/yellow]")
99
+
100
+
101
+ @app.command()
102
+ def whoami(
103
+ env: str | None = _get_env_option(),
104
+ ) -> None:
105
+ """Show the current authenticated user."""
106
+ config = get_config(env=env)
107
+ token = load_token(config)
108
+
109
+ if not token:
110
+ console.print("[yellow]Not logged in.[/yellow]")
111
+ console.print("Run [bold]sleap-share login[/bold] to authenticate.")
112
+ raise typer.Exit(1)
113
+
114
+ try:
115
+ client = SleapShareClient(token=token, env=env)
116
+ user = client.whoami()
117
+
118
+ console.print()
119
+ console.print(f"[bold]Username:[/bold] {user.username}")
120
+ console.print(f"[bold]Email:[/bold] {user.email}")
121
+ console.print(f"[bold]Files:[/bold] {user.total_files}")
122
+ console.print(f"[bold]Storage:[/bold] {_format_size(user.total_storage)}")
123
+ console.print()
124
+
125
+ except SleapShareError as e:
126
+ err_console.print(f"[red]Error:[/red] {e.message}")
127
+ raise typer.Exit(1)
128
+
129
+
130
+ @app.command()
131
+ def upload(
132
+ file: Path = typer.Argument(..., help="Path to .slp file to upload"),
133
+ permanent: bool = typer.Option(
134
+ False, "--permanent", "-p", help="Request permanent storage (superusers only)"
135
+ ),
136
+ open_browser: bool = typer.Option(
137
+ False, "--open", "-o", help="Open share URL in browser after upload"
138
+ ),
139
+ env: str | None = _get_env_option(),
140
+ ) -> None:
141
+ """Upload a .slp file to SLEAP Share."""
142
+ if not file.exists():
143
+ err_console.print(f"[red]Error:[/red] File not found: {file}")
144
+ raise typer.Exit(1)
145
+
146
+ if file.suffix.lower() != ".slp":
147
+ err_console.print("[red]Error:[/red] Only .slp files are supported.")
148
+ raise typer.Exit(1)
149
+
150
+ try:
151
+ client = SleapShareClient(env=env)
152
+ file_size = file.stat().st_size
153
+
154
+ # Track current status for display updates
155
+ current_status = {"value": "uploading"}
156
+
157
+ with Progress(
158
+ SpinnerColumn(),
159
+ TextColumn("[progress.description]{task.description}"),
160
+ BarColumn(),
161
+ TaskProgressColumn(),
162
+ DownloadColumn(),
163
+ TransferSpeedColumn(),
164
+ console=console,
165
+ ) as progress:
166
+ task = progress.add_task("Uploading...", total=file_size)
167
+
168
+ def update_progress(sent: int, total: int) -> None:
169
+ progress.update(task, completed=sent)
170
+
171
+ def update_status(status: str) -> None:
172
+ current_status["value"] = status
173
+ if status == "validating":
174
+ # Switch to indeterminate spinner for validation
175
+ progress.update(
176
+ task,
177
+ description="[cyan]Validating...[/cyan]",
178
+ total=None, # Indeterminate
179
+ )
180
+
181
+ result = client.upload(
182
+ file,
183
+ permanent=permanent,
184
+ progress_callback=update_progress,
185
+ status_callback=update_status,
186
+ )
187
+
188
+ # Display result
189
+ console.print()
190
+ console.print("[bold green]Upload complete![/bold green]")
191
+ console.print()
192
+ console.print(
193
+ f"[bold]Share URL:[/bold] [link={result.share_url}]{result.share_url}[/link]"
194
+ )
195
+
196
+ if result.expires_at:
197
+ console.print(f"[bold]Expires:[/bold] {_format_date(result.expires_at)}")
198
+ else:
199
+ console.print("[bold]Expires:[/bold] [green]Never (permanent)[/green]")
200
+
201
+ # Show metadata if available
202
+ if result.metadata:
203
+ m = result.metadata
204
+ stats = []
205
+ if m.labeled_frames_count is not None:
206
+ stats.append(f"{m.labeled_frames_count} labeled frames")
207
+ if m.user_instances_count is not None:
208
+ stats.append(f"{m.user_instances_count} user instances")
209
+ if m.predicted_instances_count is not None:
210
+ stats.append(f"{m.predicted_instances_count} predictions")
211
+ if m.videos_count is not None:
212
+ stats.append(f"{m.videos_count} videos")
213
+
214
+ if stats:
215
+ console.print(f"[bold]Dataset:[/bold] {', '.join(stats)}")
216
+
217
+ console.print()
218
+
219
+ if open_browser:
220
+ webbrowser.open(result.share_url)
221
+
222
+ except SleapShareError as e:
223
+ err_console.print(f"[red]Error:[/red] {e.message}")
224
+ raise typer.Exit(1)
225
+
226
+
227
+ @app.command()
228
+ def download(
229
+ shortcode: str = typer.Argument(..., help="Shortcode or URL of file to download"),
230
+ output: Path | None = typer.Option(
231
+ None, "--output", "-o", help="Output path (file or directory)"
232
+ ),
233
+ overwrite: bool | None = typer.Option(
234
+ None,
235
+ "--overwrite/--no-overwrite",
236
+ "-f",
237
+ help="Overwrite existing files. Default: overwrite if -o is a file, otherwise append (1), (2), etc.",
238
+ ),
239
+ env: str | None = _get_env_option(),
240
+ ) -> None:
241
+ """Download a file from SLEAP Share."""
242
+ try:
243
+ client = SleapShareClient(env=env)
244
+
245
+ # Get file info first for size
246
+ info = client.get_info(shortcode)
247
+
248
+ with Progress(
249
+ TextColumn("[progress.description]{task.description}"),
250
+ BarColumn(),
251
+ TaskProgressColumn(),
252
+ DownloadColumn(),
253
+ TransferSpeedColumn(),
254
+ console=console,
255
+ ) as progress:
256
+ task = progress.add_task("Downloading...", total=info.file_size)
257
+
258
+ def update_progress(received: int, total: int) -> None:
259
+ progress.update(task, completed=received)
260
+
261
+ output_path = client.download(
262
+ shortcode,
263
+ output=output,
264
+ progress_callback=update_progress,
265
+ overwrite=overwrite,
266
+ )
267
+
268
+ console.print()
269
+ console.print(f"[bold green]Downloaded:[/bold green] {output_path}")
270
+ console.print()
271
+
272
+ except NotFoundError:
273
+ err_console.print("[red]Error:[/red] File not found or expired.")
274
+ raise typer.Exit(1)
275
+ except SleapShareError as e:
276
+ err_console.print(f"[red]Error:[/red] {e.message}")
277
+ raise typer.Exit(1)
278
+
279
+
280
+ @app.command("list")
281
+ def list_files(
282
+ limit: int = typer.Option(20, "--limit", "-n", help="Maximum files to show"),
283
+ env: str | None = _get_env_option(),
284
+ ) -> None:
285
+ """List your uploaded files."""
286
+ config = get_config(env=env)
287
+ token = load_token(config)
288
+
289
+ if not token:
290
+ console.print("[yellow]Not logged in.[/yellow]")
291
+ console.print("Run [bold]sleap-share login[/bold] to authenticate.")
292
+ raise typer.Exit(1)
293
+
294
+ try:
295
+ client = SleapShareClient(token=token, env=env)
296
+ files = client.list_files(limit=limit)
297
+
298
+ if not files:
299
+ console.print("[yellow]No files found.[/yellow]")
300
+ return
301
+
302
+ table = Table(title="Your Files")
303
+ table.add_column("Shortcode", style="cyan")
304
+ table.add_column("Filename")
305
+ table.add_column("Size", justify="right")
306
+ table.add_column("Created")
307
+ table.add_column("Expires")
308
+
309
+ for f in files:
310
+ table.add_row(
311
+ f.shortcode,
312
+ f.filename,
313
+ _format_size(f.file_size),
314
+ _format_date(f.created_at),
315
+ _format_date(f.expires_at),
316
+ )
317
+
318
+ console.print()
319
+ console.print(table)
320
+ console.print()
321
+
322
+ except SleapShareError as e:
323
+ err_console.print(f"[red]Error:[/red] {e.message}")
324
+ raise typer.Exit(1)
325
+
326
+
327
+ @app.command()
328
+ def info(
329
+ shortcode: str = typer.Argument(..., help="Shortcode or URL of file"),
330
+ as_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
331
+ env: str | None = _get_env_option(),
332
+ ) -> None:
333
+ """Show information about a file."""
334
+ try:
335
+ client = SleapShareClient(env=env)
336
+ metadata = client.get_metadata(shortcode)
337
+ urls = client.get_urls(shortcode)
338
+
339
+ if as_json:
340
+ console.print(json.dumps(metadata.to_dict(), indent=2))
341
+ return
342
+
343
+ console.print()
344
+ console.print(Panel(f"[bold]{metadata.original_filename}[/bold]", expand=False))
345
+ console.print()
346
+
347
+ console.print(f"[bold]Shortcode:[/bold] {metadata.shortcode}")
348
+ console.print(f"[bold]Size:[/bold] {_format_size(metadata.file_size)}")
349
+ console.print(
350
+ f"[bold]Uploaded:[/bold] {_format_date(metadata.upload_timestamp)}"
351
+ )
352
+ console.print(f"[bold]Expires:[/bold] {_format_date(metadata.expires_at)}")
353
+ console.print(f"[bold]Status:[/bold] {metadata.validation_status}")
354
+ console.print()
355
+
356
+ console.print(
357
+ f"[bold]Share URL:[/bold] [link={urls.share_url}]{urls.share_url}[/link]"
358
+ )
359
+ console.print(
360
+ f"[bold]Download:[/bold] [link={urls.download_url}]{urls.download_url}[/link]"
361
+ )
362
+ console.print()
363
+
364
+ # Show SLP stats if available
365
+ if metadata.labeled_frames_count is not None:
366
+ console.print("[bold]Dataset Statistics:[/bold]")
367
+ if metadata.labeled_frames_count is not None:
368
+ console.print(f" Labeled frames: {metadata.labeled_frames_count}")
369
+ if metadata.user_instances_count is not None:
370
+ console.print(f" User instances: {metadata.user_instances_count}")
371
+ if metadata.predicted_instances_count is not None:
372
+ console.print(f" Predictions: {metadata.predicted_instances_count}")
373
+ if metadata.tracks_count is not None:
374
+ console.print(f" Tracks: {metadata.tracks_count}")
375
+ if metadata.videos_count is not None:
376
+ console.print(f" Videos: {metadata.videos_count}")
377
+ console.print()
378
+
379
+ except NotFoundError:
380
+ err_console.print("[red]Error:[/red] File not found or expired.")
381
+ raise typer.Exit(1)
382
+ except SleapShareError as e:
383
+ err_console.print(f"[red]Error:[/red] {e.message}")
384
+ raise typer.Exit(1)
385
+
386
+
387
+ @app.command()
388
+ def preview(
389
+ shortcode: str = typer.Argument(..., help="Shortcode or URL of file"),
390
+ output: Path | None = typer.Option(
391
+ None, "--output", "-o", help="Output path for preview image"
392
+ ),
393
+ env: str | None = _get_env_option(),
394
+ ) -> None:
395
+ """Download the preview image for a file."""
396
+ try:
397
+ client = SleapShareClient(env=env)
398
+
399
+ # Default output name
400
+ if output is None:
401
+ sc = shortcode.split("/")[-1] # Extract shortcode from URL
402
+ output = Path(f"{sc}_preview.png")
403
+
404
+ output_path = client.get_preview(shortcode, output=output)
405
+
406
+ console.print(f"[bold green]Preview saved:[/bold green] {output_path!s}")
407
+
408
+ except NotFoundError:
409
+ err_console.print("[red]Error:[/red] Preview not available for this file.")
410
+ raise typer.Exit(1)
411
+ except SleapShareError as e:
412
+ err_console.print(f"[red]Error:[/red] {e.message}")
413
+ raise typer.Exit(1)
414
+
415
+
416
+ @app.command()
417
+ def delete(
418
+ shortcode: str = typer.Argument(..., help="Shortcode or URL of file to delete"),
419
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
420
+ env: str | None = _get_env_option(),
421
+ ) -> None:
422
+ """Delete a file you own."""
423
+ config = get_config(env=env)
424
+ token = load_token(config)
425
+
426
+ if not token:
427
+ console.print("[yellow]Not logged in.[/yellow]")
428
+ console.print("Run [bold]sleap-share login[/bold] to authenticate.")
429
+ raise typer.Exit(1)
430
+
431
+ if not yes:
432
+ confirm = typer.confirm(f"Delete file {shortcode}?")
433
+ if not confirm:
434
+ console.print("[yellow]Cancelled.[/yellow]")
435
+ raise typer.Exit(0)
436
+
437
+ try:
438
+ client = SleapShareClient(token=token, env=env)
439
+ client.delete(shortcode)
440
+ console.print(f"[bold green]Deleted:[/bold green] {shortcode}")
441
+
442
+ except NotFoundError:
443
+ err_console.print("[red]Error:[/red] File not found.")
444
+ raise typer.Exit(1)
445
+ except SleapShareError as e:
446
+ err_console.print(f"[red]Error:[/red] {e.message}")
447
+ raise typer.Exit(1)
448
+
449
+
450
+ @app.command()
451
+ def version() -> None:
452
+ """Show version information."""
453
+ console.print(f"sleap-share {__version__}")
454
+
455
+
456
+ def main() -> None:
457
+ """Main entry point for the CLI."""
458
+ app()
459
+
460
+
461
+ if __name__ == "__main__":
462
+ main()