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/client.py ADDED
@@ -0,0 +1,677 @@
1
+ """HTTP client for SLEAP Share API."""
2
+
3
+ import re
4
+ from collections.abc import Callable, Iterator
5
+ from pathlib import Path
6
+ from typing import Any, BinaryIO, Self
7
+
8
+ import httpx
9
+
10
+ from .auth import load_token
11
+ from .config import (
12
+ ALLOWED_EXTENSIONS,
13
+ DEFAULT_TIMEOUT,
14
+ DOWNLOAD_CHUNK_SIZE,
15
+ UPLOAD_TIMEOUT,
16
+ get_config,
17
+ )
18
+ from .exceptions import (
19
+ AuthenticationError,
20
+ NetworkError,
21
+ NotFoundError,
22
+ PermissionError,
23
+ RateLimitError,
24
+ SleapShareError,
25
+ UploadError,
26
+ ValidationError,
27
+ )
28
+ from .models import FileInfo, Metadata, UploadResult, URLs, User
29
+
30
+ # Type aliases for callbacks
31
+ ProgressCallback = Callable[[int, int], None]
32
+ StatusCallback = Callable[[str], None]
33
+
34
+
35
+ def _get_unique_path(path: Path) -> Path:
36
+ """Get a unique file path by appending (1), (2), etc. if file exists.
37
+
38
+ Args:
39
+ path: The desired file path.
40
+
41
+ Returns:
42
+ A path that doesn't exist. If the original path doesn't exist,
43
+ returns it unchanged. Otherwise returns path with (N) suffix.
44
+
45
+ Example:
46
+ >>> _get_unique_path(Path("labels.slp"))
47
+ Path("labels.slp") # if doesn't exist
48
+ >>> _get_unique_path(Path("labels.slp"))
49
+ Path("labels (1).slp") # if labels.slp exists
50
+ """
51
+ if not path.exists():
52
+ return path
53
+
54
+ stem = path.stem
55
+ suffix = path.suffix
56
+ parent = path.parent
57
+
58
+ counter = 1
59
+ while True:
60
+ new_path = parent / f"{stem} ({counter}){suffix}"
61
+ if not new_path.exists():
62
+ return new_path
63
+ counter += 1
64
+
65
+
66
+ def _extract_shortcode(shortcode_or_url: str) -> str:
67
+ """Extract shortcode from URL or return as-is.
68
+
69
+ Args:
70
+ shortcode_or_url: Either a shortcode or full URL.
71
+ Supported formats:
72
+ - aBcDeF (shortcode only)
73
+ - https://slp.sh/aBcDeF
74
+ - http://slp.sh/aBcDeF
75
+ - slp.sh/aBcDeF (no protocol)
76
+ - staging.slp.sh/aBcDeF
77
+
78
+ Returns:
79
+ The extracted shortcode.
80
+ """
81
+ # Match URLs with protocol: https://slp.sh/aBcDeF or https://staging.slp.sh/aBcDeF
82
+ url_pattern = r"https?://[^/]+/([a-zA-Z0-9]+)(?:/.*)?$"
83
+ match = re.match(url_pattern, shortcode_or_url)
84
+ if match:
85
+ return match.group(1)
86
+
87
+ # Match URLs without protocol: slp.sh/aBcDeF or staging.slp.sh/aBcDeF
88
+ no_protocol_pattern = r"^(?:[\w.-]+\.)?slp\.sh/([a-zA-Z0-9]+)(?:/.*)?$"
89
+ match = re.match(no_protocol_pattern, shortcode_or_url)
90
+ if match:
91
+ return match.group(1)
92
+
93
+ return shortcode_or_url
94
+
95
+
96
+ def _handle_response_error(response: httpx.Response) -> None:
97
+ """Handle HTTP error responses by raising appropriate exceptions.
98
+
99
+ Args:
100
+ response: The HTTP response to check.
101
+
102
+ Raises:
103
+ Various SleapShareError subclasses based on status code.
104
+ """
105
+ if response.is_success:
106
+ return
107
+
108
+ status = response.status_code
109
+ try:
110
+ data = response.json()
111
+ message = data.get("error", data.get("message", response.text))
112
+ except Exception:
113
+ message = response.text
114
+
115
+ if status == 401:
116
+ raise AuthenticationError(message)
117
+ elif status == 403:
118
+ raise PermissionError(message)
119
+ elif status == 404:
120
+ raise NotFoundError(message)
121
+ elif status == 429:
122
+ retry_after = response.headers.get("Retry-After")
123
+ retry_seconds = int(retry_after) if retry_after else None
124
+ raise RateLimitError(message, retry_after=retry_seconds)
125
+ elif status == 400:
126
+ raise ValidationError(message)
127
+ else:
128
+ raise SleapShareError(message, code="api_error", status_code=status)
129
+
130
+
131
+ class SleapShareClient:
132
+ """Client for interacting with the SLEAP Share API.
133
+
134
+ This client handles authentication, file uploads/downloads, and all
135
+ API operations. It can be used directly or through the module-level
136
+ convenience functions.
137
+
138
+ Args:
139
+ token: API token for authenticated operations. If not provided,
140
+ attempts to load from storage.
141
+ env: Target environment ("production" or "staging").
142
+ base_url: Override base URL directly (ignores env).
143
+
144
+ Example:
145
+ >>> client = SleapShareClient() # Uses stored token
146
+ >>> result = client.upload("labels.slp")
147
+ >>> print(result.share_url)
148
+ https://slp.sh/aBcDeF
149
+
150
+ >>> client = SleapShareClient(env="staging")
151
+ >>> files = client.list_files()
152
+ """
153
+
154
+ def __init__(
155
+ self,
156
+ token: str | None = None,
157
+ env: str | None = None,
158
+ base_url: str | None = None,
159
+ ) -> None:
160
+ self.config = get_config(env=env, base_url=base_url)
161
+
162
+ # Load token if not provided
163
+ if token is None:
164
+ token = load_token(self.config)
165
+ self._token = token
166
+
167
+ # Create HTTP client
168
+ self._client = httpx.Client(
169
+ timeout=DEFAULT_TIMEOUT,
170
+ follow_redirects=True,
171
+ )
172
+
173
+ @property
174
+ def is_authenticated(self) -> bool:
175
+ """Check if the client has a valid token."""
176
+ return self._token is not None
177
+
178
+ def _get_headers(self, authenticated: bool = False) -> dict[str, str]:
179
+ """Get headers for API requests.
180
+
181
+ Args:
182
+ authenticated: Whether to include auth token.
183
+
184
+ Returns:
185
+ Headers dictionary.
186
+ """
187
+ headers = {"Content-Type": "application/json"}
188
+ if authenticated and self._token:
189
+ headers["Authorization"] = f"Bearer {self._token}"
190
+ return headers
191
+
192
+ def upload(
193
+ self,
194
+ file_path: str | Path,
195
+ permanent: bool = False,
196
+ progress_callback: ProgressCallback | None = None,
197
+ status_callback: StatusCallback | None = None,
198
+ ) -> UploadResult:
199
+ """Upload a .slp file to SLEAP Share.
200
+
201
+ Args:
202
+ file_path: Path to the .slp file to upload.
203
+ permanent: Request permanent storage (requires superuser).
204
+ progress_callback: Optional callback for upload progress.
205
+ Called with (bytes_sent, total_bytes).
206
+ status_callback: Optional callback for status updates.
207
+ Called with status strings: "uploading", "validating".
208
+
209
+ Returns:
210
+ UploadResult with shortcode, URLs, and metadata.
211
+
212
+ Raises:
213
+ FileNotFoundError: If the file does not exist.
214
+ ValidationError: If the file is not a .slp file.
215
+ UploadError: If the upload fails.
216
+ """
217
+ path = Path(file_path)
218
+
219
+ # Validate file exists
220
+ if not path.exists():
221
+ raise FileNotFoundError(f"File not found: {path}")
222
+
223
+ # Validate extension
224
+ if path.suffix.lower() not in ALLOWED_EXTENSIONS:
225
+ raise ValidationError(
226
+ f"Only .slp files are supported. Got: {path.suffix}",
227
+ code="invalid_file_type",
228
+ )
229
+
230
+ file_size = path.stat().st_size
231
+ filename = path.name
232
+
233
+ try:
234
+ # Step 1: Initialize upload
235
+ init_response = self._client.post(
236
+ f"{self.config.url}/api/upload/init",
237
+ json={
238
+ "filename": filename,
239
+ "fileSize": file_size,
240
+ "permanent": permanent,
241
+ },
242
+ headers=self._get_headers(authenticated=self.is_authenticated),
243
+ )
244
+ _handle_response_error(init_response)
245
+ init_data = init_response.json()
246
+
247
+ upload_url = init_data["uploadUrl"]
248
+ shortcode = init_data["shortcode"]
249
+
250
+ # Step 2: Upload file to presigned URL
251
+ if status_callback:
252
+ status_callback("uploading")
253
+
254
+ # Use explicit timeout with long read timeout for R2 response
255
+ upload_timeout = httpx.Timeout(
256
+ connect=30.0,
257
+ read=UPLOAD_TIMEOUT,
258
+ write=UPLOAD_TIMEOUT,
259
+ pool=30.0,
260
+ )
261
+
262
+ with open(path, "rb") as f:
263
+ if progress_callback:
264
+ # Use iterator for progress tracking
265
+ upload_content: BinaryIO | _ProgressIterator = _ProgressIterator(
266
+ f, file_size, progress_callback
267
+ )
268
+ else:
269
+ upload_content = f
270
+
271
+ upload_response = self._client.put(
272
+ upload_url,
273
+ content=upload_content,
274
+ headers={
275
+ "Content-Type": "application/octet-stream",
276
+ "Content-Length": str(file_size),
277
+ },
278
+ timeout=upload_timeout,
279
+ )
280
+
281
+ if not upload_response.is_success:
282
+ raise UploadError(
283
+ f"Failed to upload file: {upload_response.text}",
284
+ status_code=upload_response.status_code,
285
+ )
286
+
287
+ # Step 3: Complete upload (may take time for metadata extraction)
288
+ if status_callback:
289
+ status_callback("validating")
290
+ complete_response = self._client.post(
291
+ f"{self.config.url}/api/upload/complete",
292
+ json={"shortcode": shortcode},
293
+ headers=self._get_headers(authenticated=self.is_authenticated),
294
+ timeout=UPLOAD_TIMEOUT, # Longer timeout for metadata extraction
295
+ )
296
+ _handle_response_error(complete_response)
297
+ complete_data = complete_response.json()
298
+
299
+ return UploadResult.from_api_response(complete_data, self.config.url)
300
+
301
+ except httpx.RequestError as e:
302
+ raise NetworkError(f"Network error during upload: {e}") from e
303
+
304
+ def download(
305
+ self,
306
+ shortcode_or_url: str,
307
+ output: str | Path | None = None,
308
+ progress_callback: ProgressCallback | None = None,
309
+ overwrite: bool | None = None,
310
+ ) -> Path:
311
+ """Download a file from SLEAP Share.
312
+
313
+ Args:
314
+ shortcode_or_url: Shortcode or full URL of the file.
315
+ output: Output path. Can be a directory or file path.
316
+ If None, saves to current directory.
317
+ progress_callback: Optional callback for download progress.
318
+ Called with (bytes_received, total_bytes).
319
+ overwrite: Whether to overwrite existing files.
320
+ If None (default), overwrites when output is an explicit file path,
321
+ but appends (1), (2), etc. when output is None or a directory.
322
+
323
+ Returns:
324
+ Path to the downloaded file.
325
+
326
+ Raises:
327
+ NotFoundError: If the file does not exist.
328
+ DownloadError: If the download fails.
329
+ """
330
+ shortcode = _extract_shortcode(shortcode_or_url)
331
+ download_url = f"{self.config.url}/{shortcode}/labels.slp"
332
+
333
+ try:
334
+ # Get file info for filename
335
+ metadata = self.get_metadata(shortcode)
336
+ filename = metadata.original_filename
337
+
338
+ # Determine output path and whether to allow overwrite
339
+ if output is None:
340
+ output_path = Path.cwd() / filename
341
+ # Default: don't overwrite when using auto filename
342
+ should_overwrite = overwrite if overwrite is not None else False
343
+ else:
344
+ output_path = Path(output)
345
+ if output_path.is_dir():
346
+ output_path = output_path / filename
347
+ # Default: don't overwrite when output is a directory
348
+ should_overwrite = overwrite if overwrite is not None else False
349
+ else:
350
+ # Default: overwrite when explicit filename given
351
+ should_overwrite = overwrite if overwrite is not None else True
352
+
353
+ # Avoid overwriting existing files (unless explicitly allowed)
354
+ if not should_overwrite:
355
+ output_path = _get_unique_path(output_path)
356
+
357
+ # Stream download
358
+ with self._client.stream("GET", download_url) as response:
359
+ _handle_response_error(response)
360
+
361
+ total_size = int(response.headers.get("Content-Length", 0))
362
+ bytes_received = 0
363
+
364
+ with open(output_path, "wb") as f:
365
+ for chunk in response.iter_bytes(chunk_size=DOWNLOAD_CHUNK_SIZE):
366
+ f.write(chunk)
367
+ bytes_received += len(chunk)
368
+ if progress_callback:
369
+ progress_callback(bytes_received, total_size)
370
+
371
+ return output_path
372
+
373
+ except httpx.RequestError as e:
374
+ raise NetworkError(f"Network error during download: {e}") from e
375
+
376
+ def get_info(self, shortcode_or_url: str) -> FileInfo:
377
+ """Get basic file information.
378
+
379
+ Args:
380
+ shortcode_or_url: Shortcode or full URL of the file.
381
+
382
+ Returns:
383
+ FileInfo with basic file details.
384
+
385
+ Raises:
386
+ NotFoundError: If the file does not exist.
387
+ """
388
+ metadata = self.get_metadata(shortcode_or_url)
389
+ shortcode = _extract_shortcode(shortcode_or_url)
390
+ urls = URLs.from_shortcode(shortcode, self.config.url)
391
+
392
+ return FileInfo(
393
+ shortcode=shortcode,
394
+ filename=metadata.original_filename,
395
+ file_size=metadata.file_size,
396
+ created_at=metadata.upload_timestamp,
397
+ expires_at=metadata.expires_at,
398
+ share_url=urls.share_url,
399
+ data_url=urls.download_url,
400
+ )
401
+
402
+ def get_metadata(self, shortcode_or_url: str) -> Metadata:
403
+ """Get full metadata for a file.
404
+
405
+ Args:
406
+ shortcode_or_url: Shortcode or full URL of the file.
407
+
408
+ Returns:
409
+ Metadata with all available fields.
410
+
411
+ Raises:
412
+ NotFoundError: If the file does not exist.
413
+ """
414
+ shortcode = _extract_shortcode(shortcode_or_url)
415
+ # Use the API endpoint which returns both file-level and SLP metadata
416
+ metadata_url = f"{self.config.url}/api/metadata/{shortcode}"
417
+
418
+ try:
419
+ response = self._client.get(metadata_url)
420
+ _handle_response_error(response)
421
+ data = response.json()
422
+
423
+ # Map API response (camelCase) to client model (snake_case)
424
+ mapped_data: dict[str, Any] = {
425
+ "shortcode": data.get("shortcode", shortcode),
426
+ "original_filename": data.get("originalFilename", "unknown"),
427
+ "file_size": data.get("fileSize", 0),
428
+ "upload_timestamp": data.get("uploadedAt"),
429
+ "expires_at": data.get("expiresAt"),
430
+ "validation_status": data.get("validationStatus", "unknown"),
431
+ }
432
+
433
+ # Extract SLP-specific metadata if present
434
+ slp_metadata = data.get("metadata", {})
435
+ if slp_metadata:
436
+ mapped_data["labeled_frames_count"] = slp_metadata.get(
437
+ "labeledFramesCount"
438
+ )
439
+ mapped_data["user_instances_count"] = slp_metadata.get(
440
+ "userInstancesCount"
441
+ )
442
+ mapped_data["predicted_instances_count"] = slp_metadata.get(
443
+ "predictedInstancesCount"
444
+ )
445
+ mapped_data["tracks_count"] = slp_metadata.get("tracksCount")
446
+ mapped_data["videos_count"] = slp_metadata.get("videosCount")
447
+
448
+ return Metadata.from_dict(mapped_data)
449
+
450
+ except httpx.RequestError as e:
451
+ raise NetworkError(f"Network error fetching metadata: {e}") from e
452
+
453
+ def get_preview(
454
+ self,
455
+ shortcode_or_url: str,
456
+ output: str | Path | None = None,
457
+ ) -> bytes | Path:
458
+ """Get preview image for a file.
459
+
460
+ Args:
461
+ shortcode_or_url: Shortcode or full URL of the file.
462
+ output: Optional path to save the image to.
463
+
464
+ Returns:
465
+ Image bytes if output is None, otherwise path to saved file.
466
+
467
+ Raises:
468
+ NotFoundError: If the file or preview does not exist.
469
+ """
470
+ shortcode = _extract_shortcode(shortcode_or_url)
471
+ preview_url = f"{self.config.url}/{shortcode}/preview.png"
472
+
473
+ try:
474
+ response = self._client.get(preview_url)
475
+ _handle_response_error(response)
476
+
477
+ if output is None:
478
+ return response.content
479
+
480
+ output_path = Path(output)
481
+ output_path.write_bytes(response.content)
482
+ return output_path
483
+
484
+ except httpx.RequestError as e:
485
+ raise NetworkError(f"Network error fetching preview: {e}") from e
486
+
487
+ def get_urls(self, shortcode_or_url: str) -> URLs:
488
+ """Get all URLs for a shortcode.
489
+
490
+ Args:
491
+ shortcode_or_url: Shortcode or full URL of the file.
492
+
493
+ Returns:
494
+ URLs object with share, download, metadata, and preview URLs.
495
+ """
496
+ shortcode = _extract_shortcode(shortcode_or_url)
497
+ return URLs.from_shortcode(shortcode, self.config.url)
498
+
499
+ def get_download_url(self, shortcode_or_url: str) -> str:
500
+ """Get the direct download URL for a file.
501
+
502
+ Args:
503
+ shortcode_or_url: Shortcode or full URL of the file.
504
+
505
+ Returns:
506
+ Direct download URL.
507
+ """
508
+ return self.get_urls(shortcode_or_url).download_url
509
+
510
+ def get_preview_url(self, shortcode_or_url: str) -> str:
511
+ """Get the preview image URL for a file.
512
+
513
+ Args:
514
+ shortcode_or_url: Shortcode or full URL of the file.
515
+
516
+ Returns:
517
+ Preview image URL.
518
+ """
519
+ return self.get_urls(shortcode_or_url).preview_url
520
+
521
+ def open(self, shortcode_or_url: str) -> str:
522
+ """Get a URL suitable for lazy loading / virtual file access.
523
+
524
+ This returns the download URL which supports HTTP range requests,
525
+ allowing HDF5 clients (h5py ros3, fsspec) to stream bytes on-demand.
526
+
527
+ Args:
528
+ shortcode_or_url: Shortcode or full URL of the file.
529
+
530
+ Returns:
531
+ URL for lazy loading with HTTP range request support.
532
+
533
+ Example:
534
+ >>> url = client.open("aBcDeF")
535
+ >>> import sleap_io
536
+ >>> labels = sleap_io.load_slp(url) # Streams on-demand!
537
+ """
538
+ return self.get_download_url(shortcode_or_url)
539
+
540
+ def whoami(self) -> User:
541
+ """Get the current authenticated user's profile.
542
+
543
+ Returns:
544
+ User object with profile information.
545
+
546
+ Raises:
547
+ AuthenticationError: If not authenticated.
548
+ """
549
+ if not self.is_authenticated:
550
+ raise AuthenticationError(
551
+ "Not authenticated. Run 'sleap-share login' first."
552
+ )
553
+
554
+ try:
555
+ response = self._client.get(
556
+ f"{self.config.url}/api/v1/user/me",
557
+ headers=self._get_headers(authenticated=True),
558
+ )
559
+ _handle_response_error(response)
560
+ return User.from_api_response(response.json())
561
+
562
+ except httpx.RequestError as e:
563
+ raise NetworkError(f"Network error fetching user info: {e}") from e
564
+
565
+ def list_files(self, limit: int = 50) -> list[FileInfo]:
566
+ """List the authenticated user's uploaded files.
567
+
568
+ Args:
569
+ limit: Maximum number of files to return.
570
+
571
+ Returns:
572
+ List of FileInfo objects.
573
+
574
+ Raises:
575
+ AuthenticationError: If not authenticated.
576
+ """
577
+ if not self.is_authenticated:
578
+ raise AuthenticationError(
579
+ "Not authenticated. Run 'sleap-share login' first."
580
+ )
581
+
582
+ try:
583
+ response = self._client.get(
584
+ f"{self.config.url}/api/v1/user/files",
585
+ params={"limit": limit},
586
+ headers=self._get_headers(authenticated=True),
587
+ )
588
+ _handle_response_error(response)
589
+ data = response.json()
590
+
591
+ return [
592
+ FileInfo.from_api_response(item, self.config.url)
593
+ for item in data.get("files", [])
594
+ ]
595
+
596
+ except httpx.RequestError as e:
597
+ raise NetworkError(f"Network error fetching files: {e}") from e
598
+
599
+ def delete(self, shortcode_or_url: str) -> bool:
600
+ """Delete a file owned by the authenticated user.
601
+
602
+ Args:
603
+ shortcode_or_url: Shortcode or full URL of the file.
604
+
605
+ Returns:
606
+ True if deletion was successful.
607
+
608
+ Raises:
609
+ AuthenticationError: If not authenticated.
610
+ PermissionError: If the file is not owned by the user.
611
+ NotFoundError: If the file does not exist.
612
+ """
613
+ if not self.is_authenticated:
614
+ raise AuthenticationError(
615
+ "Not authenticated. Run 'sleap-share login' first."
616
+ )
617
+
618
+ shortcode = _extract_shortcode(shortcode_or_url)
619
+
620
+ try:
621
+ response = self._client.delete(
622
+ f"{self.config.url}/api/v1/files/{shortcode}",
623
+ headers=self._get_headers(authenticated=True),
624
+ )
625
+ _handle_response_error(response)
626
+ return True
627
+
628
+ except httpx.RequestError as e:
629
+ raise NetworkError(f"Network error deleting file: {e}") from e
630
+
631
+ def close(self) -> None:
632
+ """Close the HTTP client."""
633
+ self._client.close()
634
+
635
+ def __enter__(self) -> Self:
636
+ return self
637
+
638
+ def __exit__(self, *args: Any) -> None:
639
+ self.close()
640
+
641
+
642
+ class _ProgressIterator:
643
+ """Iterator that yields file chunks while reporting progress.
644
+
645
+ This provides an iterator interface that httpx can use for streaming
646
+ uploads while calling a callback to report progress.
647
+ """
648
+
649
+ def __init__(
650
+ self,
651
+ file: BinaryIO,
652
+ total_size: int,
653
+ callback: ProgressCallback,
654
+ chunk_size: int = 64 * 1024, # 64KB chunks
655
+ ) -> None:
656
+ self._file = file
657
+ self._total_size = total_size
658
+ self._callback = callback
659
+ self._chunk_size = chunk_size
660
+ self._bytes_read = 0
661
+
662
+ def __iter__(self) -> Iterator[bytes]:
663
+ """Iterate over file chunks."""
664
+ return self
665
+
666
+ def __next__(self) -> bytes:
667
+ """Read next chunk and report progress."""
668
+ chunk = self._file.read(self._chunk_size)
669
+ if not chunk:
670
+ raise StopIteration
671
+ self._bytes_read += len(chunk)
672
+ self._callback(self._bytes_read, self._total_size)
673
+ return chunk
674
+
675
+ def __len__(self) -> int:
676
+ """Return the total size for httpx content-length detection."""
677
+ return self._total_size