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/__init__.py +302 -0
- sleap_share/auth.py +330 -0
- sleap_share/cli.py +462 -0
- sleap_share/client.py +677 -0
- sleap_share/config.py +103 -0
- sleap_share/exceptions.py +127 -0
- sleap_share/models.py +293 -0
- sleap_share-0.1.2.dist-info/METADATA +204 -0
- sleap_share-0.1.2.dist-info/RECORD +11 -0
- sleap_share-0.1.2.dist-info/WHEEL +4 -0
- sleap_share-0.1.2.dist-info/entry_points.txt +2 -0
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()
|