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/__init__.py
ADDED
|
@@ -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
|
+
)
|