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.
@@ -0,0 +1,302 @@
1
+ """sleap-share: Python client for SLEAP Share.
2
+
3
+ Upload and share SLEAP datasets with slp.sh.
4
+
5
+ Example:
6
+ >>> import sleap_share
7
+ >>> result = sleap_share.upload("labels.slp")
8
+ >>> print(result.share_url)
9
+ https://slp.sh/aBcDeF
10
+
11
+ >>> sleap_share.download("aBcDeF", output="./")
12
+
13
+ >>> client = sleap_share.Client()
14
+ >>> files = client.list_files()
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING
21
+
22
+ from .client import SleapShareClient
23
+ from .config import Environment
24
+ from .exceptions import (
25
+ AuthenticationError,
26
+ DownloadError,
27
+ NetworkError,
28
+ NotFoundError,
29
+ PermissionError,
30
+ RateLimitError,
31
+ SleapShareError,
32
+ UploadError,
33
+ ValidationError,
34
+ )
35
+ from .models import FileInfo, Metadata, UploadResult, URLs, User
36
+
37
+ if TYPE_CHECKING:
38
+ from .client import ProgressCallback
39
+
40
+ __version__ = "0.1.2"
41
+
42
+ # Convenience alias
43
+ Client = SleapShareClient
44
+
45
+ __all__ = [
46
+ # Version
47
+ "__version__",
48
+ # Main client
49
+ "Client",
50
+ "SleapShareClient",
51
+ # Models
52
+ "FileInfo",
53
+ "Metadata",
54
+ "UploadResult",
55
+ "URLs",
56
+ "User",
57
+ # Exceptions
58
+ "SleapShareError",
59
+ "AuthenticationError",
60
+ "DownloadError",
61
+ "NetworkError",
62
+ "NotFoundError",
63
+ "PermissionError",
64
+ "RateLimitError",
65
+ "UploadError",
66
+ "ValidationError",
67
+ # Module-level functions
68
+ "upload",
69
+ "download",
70
+ "get_info",
71
+ "get_metadata",
72
+ "get_preview",
73
+ "get_preview_url",
74
+ "get_download_url",
75
+ "get_urls",
76
+ "open",
77
+ ]
78
+
79
+
80
+ # Module-level convenience functions
81
+ # These create a temporary client for single operations
82
+
83
+
84
+ def upload(
85
+ file_path: str | Path,
86
+ *,
87
+ permanent: bool = False,
88
+ progress_callback: ProgressCallback | None = None,
89
+ env: Environment | None = None,
90
+ ) -> UploadResult:
91
+ """Upload a .slp file to SLEAP Share.
92
+
93
+ Args:
94
+ file_path: Path to the .slp file to upload.
95
+ permanent: Request permanent storage (requires superuser).
96
+ progress_callback: Optional callback for upload progress.
97
+ Called with (bytes_sent, total_bytes).
98
+ env: Target environment ("production" or "staging").
99
+
100
+ Returns:
101
+ UploadResult with shortcode, URLs, and metadata.
102
+
103
+ Example:
104
+ >>> result = sleap_share.upload("labels.slp")
105
+ >>> print(result.share_url)
106
+ https://slp.sh/aBcDeF
107
+ """
108
+ with SleapShareClient(env=env) as client:
109
+ return client.upload(
110
+ file_path, permanent=permanent, progress_callback=progress_callback
111
+ )
112
+
113
+
114
+ def download(
115
+ shortcode_or_url: str,
116
+ *,
117
+ output: str | Path | None = None,
118
+ progress_callback: ProgressCallback | None = None,
119
+ env: Environment | None = None,
120
+ ) -> Path:
121
+ """Download a file from SLEAP Share.
122
+
123
+ Args:
124
+ shortcode_or_url: Shortcode or full URL of the file.
125
+ output: Output path. Can be a directory or file path.
126
+ progress_callback: Optional callback for download progress.
127
+ env: Target environment ("production" or "staging").
128
+
129
+ Returns:
130
+ Path to the downloaded file.
131
+
132
+ Example:
133
+ >>> sleap_share.download("aBcDeF", output="./data/")
134
+ """
135
+ with SleapShareClient(env=env) as client:
136
+ return client.download(
137
+ shortcode_or_url, output=output, progress_callback=progress_callback
138
+ )
139
+
140
+
141
+ def get_info(
142
+ shortcode_or_url: str,
143
+ *,
144
+ env: Environment | None = None,
145
+ ) -> FileInfo:
146
+ """Get basic file information.
147
+
148
+ Args:
149
+ shortcode_or_url: Shortcode or full URL of the file.
150
+ env: Target environment ("production" or "staging").
151
+
152
+ Returns:
153
+ FileInfo with basic file details.
154
+
155
+ Example:
156
+ >>> info = sleap_share.get_info("aBcDeF")
157
+ >>> print(info.filename, info.file_size)
158
+ """
159
+ with SleapShareClient(env=env) as client:
160
+ return client.get_info(shortcode_or_url)
161
+
162
+
163
+ def get_metadata(
164
+ shortcode_or_url: str,
165
+ *,
166
+ env: Environment | None = None,
167
+ ) -> Metadata:
168
+ """Get full metadata for a file.
169
+
170
+ Args:
171
+ shortcode_or_url: Shortcode or full URL of the file.
172
+ env: Target environment ("production" or "staging").
173
+
174
+ Returns:
175
+ Metadata with all available fields including SLP statistics.
176
+
177
+ Example:
178
+ >>> metadata = sleap_share.get_metadata("aBcDeF")
179
+ >>> print(metadata.labeled_frames_count)
180
+ """
181
+ with SleapShareClient(env=env) as client:
182
+ return client.get_metadata(shortcode_or_url)
183
+
184
+
185
+ def get_preview(
186
+ shortcode_or_url: str,
187
+ *,
188
+ output: str | Path | None = None,
189
+ env: Environment | None = None,
190
+ ) -> bytes | Path:
191
+ """Get preview image for a file.
192
+
193
+ Args:
194
+ shortcode_or_url: Shortcode or full URL of the file.
195
+ output: Optional path to save the image to.
196
+ env: Target environment ("production" or "staging").
197
+
198
+ Returns:
199
+ Image bytes if output is None, otherwise path to saved file.
200
+
201
+ Example:
202
+ >>> preview_bytes = sleap_share.get_preview("aBcDeF")
203
+ >>> sleap_share.get_preview("aBcDeF", output="preview.png")
204
+ """
205
+ with SleapShareClient(env=env) as client:
206
+ return client.get_preview(shortcode_or_url, output=output)
207
+
208
+
209
+ def get_preview_url(
210
+ shortcode_or_url: str,
211
+ *,
212
+ env: Environment | None = None,
213
+ ) -> str:
214
+ """Get the preview image URL for a file.
215
+
216
+ Args:
217
+ shortcode_or_url: Shortcode or full URL of the file.
218
+ env: Target environment ("production" or "staging").
219
+
220
+ Returns:
221
+ Preview image URL.
222
+
223
+ Example:
224
+ >>> url = sleap_share.get_preview_url("aBcDeF")
225
+ >>> print(url)
226
+ https://slp.sh/aBcDeF/preview.png
227
+ """
228
+ with SleapShareClient(env=env) as client:
229
+ return client.get_preview_url(shortcode_or_url)
230
+
231
+
232
+ def get_download_url(
233
+ shortcode_or_url: str,
234
+ *,
235
+ env: Environment | None = None,
236
+ ) -> str:
237
+ """Get the direct download URL for a file.
238
+
239
+ Args:
240
+ shortcode_or_url: Shortcode or full URL of the file.
241
+ env: Target environment ("production" or "staging").
242
+
243
+ Returns:
244
+ Direct download URL with HTTP range request support.
245
+
246
+ Example:
247
+ >>> url = sleap_share.get_download_url("aBcDeF")
248
+ >>> print(url)
249
+ https://slp.sh/aBcDeF/labels.slp
250
+ """
251
+ with SleapShareClient(env=env) as client:
252
+ return client.get_download_url(shortcode_or_url)
253
+
254
+
255
+ def get_urls(
256
+ shortcode_or_url: str,
257
+ *,
258
+ env: Environment | None = None,
259
+ ) -> URLs:
260
+ """Get all URLs for a shortcode.
261
+
262
+ Args:
263
+ shortcode_or_url: Shortcode or full URL of the file.
264
+ env: Target environment ("production" or "staging").
265
+
266
+ Returns:
267
+ URLs object with share, download, metadata, and preview URLs.
268
+
269
+ Example:
270
+ >>> urls = sleap_share.get_urls("aBcDeF")
271
+ >>> print(urls.share_url)
272
+ https://slp.sh/aBcDeF
273
+ """
274
+ with SleapShareClient(env=env) as client:
275
+ return client.get_urls(shortcode_or_url)
276
+
277
+
278
+ def open(
279
+ shortcode_or_url: str,
280
+ *,
281
+ env: Environment | None = None,
282
+ ) -> str:
283
+ """Get a URL suitable for lazy loading / virtual file access.
284
+
285
+ This returns the download URL which supports HTTP range requests,
286
+ allowing HDF5 clients (h5py ros3, fsspec, sleap-io) to stream bytes
287
+ on-demand without downloading the entire file.
288
+
289
+ Args:
290
+ shortcode_or_url: Shortcode or full URL of the file.
291
+ env: Target environment ("production" or "staging").
292
+
293
+ Returns:
294
+ URL for lazy loading with HTTP range request support.
295
+
296
+ Example:
297
+ >>> import sleap_io
298
+ >>> labels = sleap_io.load_slp(sleap_share.open("aBcDeF"))
299
+ >>> print(labels.skeletons) # Only fetches skeleton data
300
+ """
301
+ with SleapShareClient(env=env) as client:
302
+ return client.open(shortcode_or_url)
sleap_share/auth.py ADDED
@@ -0,0 +1,330 @@
1
+ """Authentication and token storage for sleap-share client."""
2
+
3
+ import time
4
+ import webbrowser
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.progress import Progress, SpinnerColumn, TextColumn
11
+
12
+ from .config import KEYRING_SERVICE, KEYRING_USERNAME, Config, get_config
13
+ from .exceptions import AuthenticationError
14
+
15
+
16
+ def _try_keyring_available() -> bool:
17
+ """Check if keyring is available and functional."""
18
+ try:
19
+ import keyring
20
+ from keyring.errors import KeyringError
21
+
22
+ # Try a test operation to see if keyring works
23
+ try:
24
+ keyring.get_password(KEYRING_SERVICE, "__test__")
25
+ return True
26
+ except KeyringError:
27
+ return False
28
+ except ImportError:
29
+ return False
30
+
31
+
32
+ def save_token(token: str, config: Config | None = None) -> str:
33
+ """Save API token securely.
34
+
35
+ Attempts to use system keyring first, falls back to file storage.
36
+
37
+ Args:
38
+ token: The API token to save.
39
+ config: Configuration object (uses default if not provided).
40
+
41
+ Returns:
42
+ Description of where the token was stored.
43
+ """
44
+ if config is None:
45
+ config = get_config()
46
+
47
+ # Try keyring first
48
+ if _try_keyring_available():
49
+ try:
50
+ import keyring
51
+
52
+ keyring.set_password(KEYRING_SERVICE, KEYRING_USERNAME, token)
53
+ return "system keyring"
54
+ except Exception:
55
+ pass # Fall through to file storage
56
+
57
+ # Fallback to file storage
58
+ return _save_token_to_file(token, config)
59
+
60
+
61
+ def _save_token_to_file(token: str, config: Config) -> str:
62
+ """Save token to file with secure permissions.
63
+
64
+ Args:
65
+ token: The API token to save.
66
+ config: Configuration object.
67
+
68
+ Returns:
69
+ Description of where the token was stored.
70
+ """
71
+ cred_path = config.credentials_path
72
+
73
+ # Create parent directory if needed
74
+ cred_path.parent.mkdir(parents=True, exist_ok=True)
75
+
76
+ # Write token with secure permissions
77
+ cred_path.write_text(token)
78
+ cred_path.chmod(0o600)
79
+
80
+ return str(cred_path)
81
+
82
+
83
+ def load_token(config: Config | None = None) -> str | None:
84
+ """Load API token from storage.
85
+
86
+ Attempts keyring first, then file storage.
87
+
88
+ Args:
89
+ config: Configuration object (uses default if not provided).
90
+
91
+ Returns:
92
+ The stored token, or None if not found.
93
+ """
94
+ if config is None:
95
+ config = get_config()
96
+
97
+ # Try keyring first
98
+ if _try_keyring_available():
99
+ try:
100
+ import keyring
101
+
102
+ token = keyring.get_password(KEYRING_SERVICE, KEYRING_USERNAME)
103
+ if token:
104
+ return token
105
+ except Exception:
106
+ pass
107
+
108
+ # Try file storage
109
+ return _load_token_from_file(config)
110
+
111
+
112
+ def _load_token_from_file(config: Config) -> str | None:
113
+ """Load token from file.
114
+
115
+ Args:
116
+ config: Configuration object.
117
+
118
+ Returns:
119
+ The stored token, or None if not found.
120
+ """
121
+ cred_path = config.credentials_path
122
+
123
+ if cred_path.exists():
124
+ try:
125
+ return cred_path.read_text().strip()
126
+ except Exception:
127
+ return None
128
+ return None
129
+
130
+
131
+ def clear_token(config: Config | None = None) -> bool:
132
+ """Clear stored API token from all storage locations.
133
+
134
+ Args:
135
+ config: Configuration object (uses default if not provided).
136
+
137
+ Returns:
138
+ True if any token was cleared, False otherwise.
139
+ """
140
+ if config is None:
141
+ config = get_config()
142
+
143
+ cleared = False
144
+
145
+ # Clear from keyring
146
+ if _try_keyring_available():
147
+ try:
148
+ import keyring
149
+
150
+ keyring.delete_password(KEYRING_SERVICE, KEYRING_USERNAME)
151
+ cleared = True
152
+ except Exception:
153
+ pass
154
+
155
+ # Clear from file
156
+ cred_path = config.credentials_path
157
+ if cred_path.exists():
158
+ try:
159
+ cred_path.unlink()
160
+ cleared = True
161
+ except Exception:
162
+ pass
163
+
164
+ return cleared
165
+
166
+
167
+ def device_auth_start(
168
+ http_client: httpx.Client,
169
+ config: Config,
170
+ ) -> dict[str, Any]:
171
+ """Start device authorization flow.
172
+
173
+ Args:
174
+ http_client: HTTP client instance.
175
+ config: Configuration object.
176
+
177
+ Returns:
178
+ Response containing device_code, user_code, verification_url, etc.
179
+
180
+ Raises:
181
+ AuthenticationError: If the request fails.
182
+ """
183
+ try:
184
+ response = http_client.post(f"{config.url}/api/auth/cli/start")
185
+ response.raise_for_status()
186
+ result: dict[str, Any] = response.json()
187
+ return result
188
+ except httpx.HTTPStatusError as e:
189
+ raise AuthenticationError(
190
+ f"Failed to start device authorization: {e.response.text}",
191
+ code="auth_start_failed",
192
+ status_code=e.response.status_code,
193
+ ) from e
194
+ except httpx.RequestError as e:
195
+ raise AuthenticationError(
196
+ f"Network error during device authorization: {e}",
197
+ code="network_error",
198
+ ) from e
199
+
200
+
201
+ def device_auth_poll(
202
+ http_client: httpx.Client,
203
+ config: Config,
204
+ device_code: str,
205
+ ) -> dict[str, Any]:
206
+ """Poll for device authorization completion.
207
+
208
+ Args:
209
+ http_client: HTTP client instance.
210
+ config: Configuration object.
211
+ device_code: Device code from start response.
212
+
213
+ Returns:
214
+ Response containing status, and token if authorized.
215
+
216
+ Raises:
217
+ AuthenticationError: If the request fails.
218
+ """
219
+ try:
220
+ response = http_client.post(
221
+ f"{config.url}/api/auth/cli/poll",
222
+ json={"device_code": device_code},
223
+ )
224
+ response.raise_for_status()
225
+ result: dict[str, Any] = response.json()
226
+ return result
227
+ except httpx.HTTPStatusError as e:
228
+ raise AuthenticationError(
229
+ f"Failed to poll device authorization: {e.response.text}",
230
+ code="auth_poll_failed",
231
+ status_code=e.response.status_code,
232
+ ) from e
233
+ except httpx.RequestError as e:
234
+ raise AuthenticationError(
235
+ f"Network error during device authorization: {e}",
236
+ code="network_error",
237
+ ) from e
238
+
239
+
240
+ def run_device_auth_flow(
241
+ http_client: httpx.Client,
242
+ config: Config,
243
+ console: Console | None = None,
244
+ ) -> tuple[str, str]:
245
+ """Run the full device authorization flow.
246
+
247
+ Args:
248
+ http_client: HTTP client instance.
249
+ config: Configuration object.
250
+ console: Rich console for output (optional).
251
+
252
+ Returns:
253
+ Tuple of (token, username) on success.
254
+
255
+ Raises:
256
+ AuthenticationError: If authorization fails or expires.
257
+ """
258
+ if console is None:
259
+ console = Console()
260
+
261
+ # Start device auth
262
+ start_response = device_auth_start(http_client, config)
263
+
264
+ device_code = start_response["device_code"]
265
+ user_code = start_response["user_code"]
266
+ verification_url = start_response["verification_url"]
267
+ interval = start_response.get("interval", 5)
268
+ expires_in = start_response.get("expires_in", 600)
269
+
270
+ # Display instructions
271
+ console.print()
272
+ console.print(
273
+ Panel(
274
+ f"[bold]1.[/bold] Open: [link={verification_url}]{verification_url}[/link]\n"
275
+ f"[bold]2.[/bold] Enter code: [bold cyan]{user_code}[/bold cyan]",
276
+ title="[bold green]Authenticate with SLEAP Share[/bold green]",
277
+ border_style="green",
278
+ )
279
+ )
280
+ console.print()
281
+
282
+ # Try to open browser
283
+ try:
284
+ webbrowser.open(verification_url)
285
+ console.print("[dim]Opening browser...[/dim]")
286
+ except Exception:
287
+ pass
288
+
289
+ # Poll for completion
290
+ start_time = time.time()
291
+ with Progress(
292
+ SpinnerColumn(),
293
+ TextColumn("[progress.description]{task.description}"),
294
+ console=console,
295
+ transient=True,
296
+ ) as progress:
297
+ progress.add_task("Waiting for browser authorization...", total=None)
298
+
299
+ while time.time() - start_time < expires_in:
300
+ time.sleep(interval)
301
+
302
+ poll_response = device_auth_poll(http_client, config, device_code)
303
+ status = poll_response.get("status")
304
+
305
+ if status == "success":
306
+ token = poll_response["token"]
307
+ username = poll_response.get("username", "")
308
+
309
+ # Save token
310
+ storage_location = save_token(token, config)
311
+
312
+ progress.stop()
313
+ console.print()
314
+ console.print(f"[bold green]Logged in as {username}[/bold green]")
315
+ console.print(f"[dim]Credentials stored in {storage_location}[/dim]")
316
+
317
+ return token, username
318
+
319
+ elif status == "expired":
320
+ raise AuthenticationError(
321
+ "Authorization expired. Please try again.",
322
+ code="auth_expired",
323
+ )
324
+
325
+ # status == "pending" - continue polling
326
+
327
+ raise AuthenticationError(
328
+ "Authorization timed out. Please try again.",
329
+ code="auth_timeout",
330
+ )