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/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
|