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/config.py ADDED
@@ -0,0 +1,103 @@
1
+ """Configuration and constants for sleap-share client."""
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from platformdirs import user_config_dir
9
+
10
+ # Environment type
11
+ Environment = Literal["production", "staging"]
12
+
13
+ # Base URLs for each environment
14
+ URLS = {
15
+ "production": "https://slp.sh",
16
+ "staging": "https://staging.slp.sh",
17
+ }
18
+
19
+ # Default environment
20
+ DEFAULT_ENV: Environment = "production"
21
+
22
+ # Environment variable for overriding default environment
23
+ ENV_VAR = "SLEAP_SHARE_ENV"
24
+
25
+ # App name for config/credential paths
26
+ APP_NAME = "sleap-share"
27
+
28
+ # Keyring service name
29
+ KEYRING_SERVICE = "sleap-share"
30
+ KEYRING_USERNAME = "api_token"
31
+
32
+ # Credentials file name (fallback when keyring unavailable)
33
+ CREDENTIALS_FILE = "credentials"
34
+
35
+ # HTTP client settings
36
+ DEFAULT_TIMEOUT = 30.0 # seconds
37
+ UPLOAD_TIMEOUT = 600.0 # 10 minutes for large uploads
38
+ DOWNLOAD_CHUNK_SIZE = 8192 # 8KB chunks for streaming
39
+
40
+ # Allowed file extensions
41
+ ALLOWED_EXTENSIONS = {".slp"}
42
+
43
+
44
+ @dataclass
45
+ class Config:
46
+ """Runtime configuration for the sleap-share client."""
47
+
48
+ env: Environment = DEFAULT_ENV
49
+ base_url: str | None = None # If set, overrides env-based URL
50
+
51
+ @property
52
+ def url(self) -> str:
53
+ """Get the base URL for the current environment."""
54
+ if self.base_url:
55
+ return self.base_url.rstrip("/")
56
+ return URLS[self.env]
57
+
58
+ @property
59
+ def config_dir(self) -> Path:
60
+ """Get the configuration directory path."""
61
+ return Path(user_config_dir(APP_NAME))
62
+
63
+ @property
64
+ def credentials_path(self) -> Path:
65
+ """Get the path to the credentials file (fallback storage)."""
66
+ return self.config_dir / CREDENTIALS_FILE
67
+
68
+
69
+ def get_env_from_environment() -> Environment:
70
+ """Get environment from environment variable, or default."""
71
+ env_value = os.environ.get(ENV_VAR, "").lower()
72
+ if env_value in ("staging", "stg"):
73
+ return "staging"
74
+ return DEFAULT_ENV
75
+
76
+
77
+ def _validate_env(env: str | None) -> Environment | None:
78
+ """Validate and convert env string to Environment type."""
79
+ if env is None:
80
+ return None
81
+ if env in ("production", "staging"):
82
+ return env # type: ignore[return-value]
83
+ return None
84
+
85
+
86
+ def get_config(
87
+ env: str | None = None,
88
+ base_url: str | None = None,
89
+ ) -> Config:
90
+ """Create a configuration object.
91
+
92
+ Args:
93
+ env: Target environment (production or staging).
94
+ If not specified, uses SLEAP_SHARE_ENV or defaults to production.
95
+ base_url: Override base URL directly (ignores env setting).
96
+
97
+ Returns:
98
+ Config object with the resolved settings.
99
+ """
100
+ validated_env = _validate_env(env)
101
+ if validated_env is None:
102
+ validated_env = get_env_from_environment()
103
+ return Config(env=validated_env, base_url=base_url)
@@ -0,0 +1,127 @@
1
+ """Custom exceptions for sleap-share client."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class SleapShareError(Exception):
7
+ """Base exception for sleap-share client errors.
8
+
9
+ Attributes:
10
+ message: Human-readable error message.
11
+ code: Machine-readable error code (e.g., "not_found", "rate_limited").
12
+ status_code: HTTP status code if applicable.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ code: str = "unknown_error",
19
+ status_code: int | None = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.code = code
24
+ self.status_code = status_code
25
+
26
+ def __str__(self) -> str:
27
+ return self.message
28
+
29
+
30
+ class AuthenticationError(SleapShareError):
31
+ """Raised when authentication fails or token is invalid."""
32
+
33
+ def __init__(
34
+ self,
35
+ message: str = "Authentication required. Run 'sleap-share login' to authenticate.",
36
+ code: str = "authentication_required",
37
+ status_code: int = 401,
38
+ ) -> None:
39
+ super().__init__(message, code, status_code)
40
+
41
+
42
+ class NotFoundError(SleapShareError):
43
+ """Raised when a requested resource is not found."""
44
+
45
+ def __init__(
46
+ self,
47
+ message: str = "The requested resource was not found.",
48
+ code: str = "not_found",
49
+ status_code: int = 404,
50
+ ) -> None:
51
+ super().__init__(message, code, status_code)
52
+
53
+
54
+ class RateLimitError(SleapShareError):
55
+ """Raised when rate limit is exceeded."""
56
+
57
+ def __init__(
58
+ self,
59
+ message: str = "Rate limit exceeded. Please try again later.",
60
+ code: str = "rate_limited",
61
+ status_code: int = 429,
62
+ retry_after: int | None = None,
63
+ ) -> None:
64
+ if retry_after:
65
+ message = f"{message} Retry after {retry_after} seconds."
66
+ super().__init__(message, code, status_code)
67
+ self.retry_after = retry_after
68
+
69
+
70
+ class ValidationError(SleapShareError):
71
+ """Raised when input validation fails."""
72
+
73
+ def __init__(
74
+ self,
75
+ message: str = "Validation error.",
76
+ code: str = "validation_error",
77
+ status_code: int = 400,
78
+ ) -> None:
79
+ super().__init__(message, code, status_code)
80
+
81
+
82
+ class PermissionError(SleapShareError):
83
+ """Raised when the user lacks permission for an operation."""
84
+
85
+ def __init__(
86
+ self,
87
+ message: str = "You do not have permission to perform this action.",
88
+ code: str = "permission_denied",
89
+ status_code: int = 403,
90
+ ) -> None:
91
+ super().__init__(message, code, status_code)
92
+
93
+
94
+ class UploadError(SleapShareError):
95
+ """Raised when file upload fails."""
96
+
97
+ def __init__(
98
+ self,
99
+ message: str = "File upload failed.",
100
+ code: str = "upload_failed",
101
+ status_code: int | None = None,
102
+ ) -> None:
103
+ super().__init__(message, code, status_code)
104
+
105
+
106
+ class DownloadError(SleapShareError):
107
+ """Raised when file download fails."""
108
+
109
+ def __init__(
110
+ self,
111
+ message: str = "File download failed.",
112
+ code: str = "download_failed",
113
+ status_code: int | None = None,
114
+ ) -> None:
115
+ super().__init__(message, code, status_code)
116
+
117
+
118
+ class NetworkError(SleapShareError):
119
+ """Raised when a network error occurs."""
120
+
121
+ def __init__(
122
+ self,
123
+ message: str = "Network error. Please check your internet connection.",
124
+ code: str = "network_error",
125
+ status_code: int | None = None,
126
+ ) -> None:
127
+ super().__init__(message, code, status_code)
sleap_share/models.py ADDED
@@ -0,0 +1,293 @@
1
+ """Data models for sleap-share client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Any
8
+
9
+
10
+ def _parse_datetime(value: str | int | datetime | None) -> datetime | None:
11
+ """Parse a datetime from ISO format string, Unix timestamp, or return as-is."""
12
+ if value is None:
13
+ return None
14
+ if isinstance(value, datetime):
15
+ return value
16
+ # Handle Unix timestamp in milliseconds
17
+ if isinstance(value, int):
18
+ try:
19
+ return datetime.fromtimestamp(value / 1000)
20
+ except (ValueError, OSError):
21
+ return None
22
+ # Handle ISO format with or without timezone
23
+ try:
24
+ # Try with timezone (Z suffix)
25
+ if value.endswith("Z"):
26
+ value = value[:-1] + "+00:00"
27
+ return datetime.fromisoformat(value)
28
+ except ValueError:
29
+ return None
30
+
31
+
32
+ @dataclass
33
+ class URLs:
34
+ """All URLs for a shortcode.
35
+
36
+ Attributes:
37
+ share_url: Landing page URL (https://slp.sh/{shortcode}).
38
+ download_url: Direct file download URL (https://slp.sh/{shortcode}/labels.slp).
39
+ metadata_url: Metadata JSON URL (https://slp.sh/{shortcode}/metadata.json).
40
+ preview_url: Preview image URL (https://slp.sh/{shortcode}/preview.png).
41
+ """
42
+
43
+ share_url: str
44
+ download_url: str
45
+ metadata_url: str
46
+ preview_url: str
47
+
48
+ @classmethod
49
+ def from_shortcode(cls, shortcode: str, base_url: str = "https://slp.sh") -> URLs:
50
+ """Create URLs from a shortcode.
51
+
52
+ Args:
53
+ shortcode: The file shortcode.
54
+ base_url: Base URL for the environment.
55
+
56
+ Returns:
57
+ URLs object with all URLs populated.
58
+ """
59
+ base = base_url.rstrip("/")
60
+ return cls(
61
+ share_url=f"{base}/{shortcode}",
62
+ download_url=f"{base}/{shortcode}/labels.slp",
63
+ metadata_url=f"{base}/{shortcode}/metadata.json",
64
+ preview_url=f"{base}/{shortcode}/preview.png",
65
+ )
66
+
67
+
68
+ @dataclass
69
+ class UploadResult:
70
+ """Result of a successful file upload.
71
+
72
+ Attributes:
73
+ shortcode: The unique shortcode for the file.
74
+ share_url: The shareable URL for the file.
75
+ data_url: Direct download URL for the file.
76
+ expires_at: When the file will expire (None if permanent).
77
+ is_permanent: Whether the file is permanently stored.
78
+ validation_status: Status of file validation ("valid", "invalid", "pending").
79
+ metadata: Optional metadata extracted from the file.
80
+ """
81
+
82
+ shortcode: str
83
+ share_url: str
84
+ data_url: str
85
+ expires_at: datetime | None
86
+ is_permanent: bool
87
+ validation_status: str
88
+ metadata: Metadata | None = None
89
+
90
+ @classmethod
91
+ def from_api_response(cls, data: dict[str, Any], base_url: str) -> UploadResult:
92
+ """Create from API response data.
93
+
94
+ Args:
95
+ data: Response data from /api/upload/complete endpoint.
96
+ base_url: Base URL for the environment.
97
+
98
+ Returns:
99
+ UploadResult object.
100
+ """
101
+ # Extract shortcode from shareUrl (API returns camelCase)
102
+ share_url = data.get("shareUrl", "")
103
+ shortcode = share_url.rstrip("/").split("/")[-1] if share_url else ""
104
+
105
+ metadata = None
106
+ if "metadata" in data and data["metadata"]:
107
+ # Map camelCase API response to snake_case for Metadata
108
+ meta_data = data["metadata"]
109
+ mapped_metadata = {
110
+ "labeled_frames_count": meta_data.get("labeledFramesCount"),
111
+ "user_instances_count": meta_data.get("userInstancesCount"),
112
+ "predicted_instances_count": meta_data.get("predictedInstancesCount"),
113
+ "tracks_count": meta_data.get("tracksCount"),
114
+ "videos_count": meta_data.get("videosCount"),
115
+ }
116
+ metadata = Metadata.from_dict(mapped_metadata)
117
+
118
+ return cls(
119
+ shortcode=shortcode,
120
+ share_url=share_url,
121
+ data_url=data.get("dataUrl", f"{base_url}/{shortcode}/labels.slp"),
122
+ expires_at=_parse_datetime(data.get("expiresAt")),
123
+ is_permanent=data.get("isPermanent", False),
124
+ validation_status=data.get("validationStatus", "unknown"),
125
+ metadata=metadata,
126
+ )
127
+
128
+
129
+ @dataclass
130
+ class FileInfo:
131
+ """Basic information about an uploaded file.
132
+
133
+ Attributes:
134
+ shortcode: The unique shortcode for the file.
135
+ filename: Original filename.
136
+ file_size: File size in bytes.
137
+ created_at: When the file was uploaded.
138
+ expires_at: When the file will expire (None if permanent).
139
+ share_url: The shareable URL for the file.
140
+ data_url: Direct download URL for the file.
141
+ """
142
+
143
+ shortcode: str
144
+ filename: str
145
+ file_size: int
146
+ created_at: datetime
147
+ expires_at: datetime | None
148
+ share_url: str
149
+ data_url: str
150
+
151
+ @classmethod
152
+ def from_api_response(cls, data: dict[str, Any], base_url: str) -> FileInfo:
153
+ """Create from API response data.
154
+
155
+ Args:
156
+ data: Response data from file listing API.
157
+ base_url: Base URL for the environment.
158
+
159
+ Returns:
160
+ FileInfo object.
161
+ """
162
+ shortcode = data["shortcode"]
163
+ urls = URLs.from_shortcode(shortcode, base_url)
164
+
165
+ created_at = _parse_datetime(data.get("created_at"))
166
+ if created_at is None:
167
+ created_at = datetime.now()
168
+
169
+ return cls(
170
+ shortcode=shortcode,
171
+ filename=data.get("filename", data.get("original_filename", "unknown")),
172
+ file_size=data.get("file_size", 0),
173
+ created_at=created_at,
174
+ expires_at=_parse_datetime(data.get("expires_at")),
175
+ share_url=urls.share_url,
176
+ data_url=urls.download_url,
177
+ )
178
+
179
+
180
+ @dataclass
181
+ class Metadata:
182
+ """Full metadata from /{shortcode}/metadata.json.
183
+
184
+ Attributes:
185
+ shortcode: The unique shortcode for the file.
186
+ original_filename: Original filename at upload.
187
+ file_size: File size in bytes.
188
+ upload_timestamp: When the file was uploaded.
189
+ expires_at: When the file will expire (None if permanent).
190
+ validation_status: Status of file validation.
191
+ labeled_frames_count: Number of labeled frames (SLP-specific).
192
+ user_instances_count: Number of user instances (SLP-specific).
193
+ predicted_instances_count: Number of predicted instances (SLP-specific).
194
+ tracks_count: Number of tracks (SLP-specific).
195
+ videos_count: Number of videos (SLP-specific).
196
+ """
197
+
198
+ shortcode: str
199
+ original_filename: str
200
+ file_size: int
201
+ upload_timestamp: datetime
202
+ expires_at: datetime | None
203
+ validation_status: str
204
+ # SLP-specific stats (may be None if validation failed)
205
+ labeled_frames_count: int | None = None
206
+ user_instances_count: int | None = None
207
+ predicted_instances_count: int | None = None
208
+ tracks_count: int | None = None
209
+ videos_count: int | None = None
210
+
211
+ @classmethod
212
+ def from_dict(cls, data: dict[str, Any]) -> Metadata:
213
+ """Create from metadata dictionary.
214
+
215
+ Args:
216
+ data: Metadata dictionary.
217
+
218
+ Returns:
219
+ Metadata object.
220
+ """
221
+ upload_timestamp = _parse_datetime(data.get("upload_timestamp"))
222
+ if upload_timestamp is None:
223
+ upload_timestamp = datetime.now()
224
+
225
+ return cls(
226
+ shortcode=data.get("shortcode", ""),
227
+ original_filename=data.get("original_filename", "unknown"),
228
+ file_size=data.get("file_size", 0),
229
+ upload_timestamp=upload_timestamp,
230
+ expires_at=_parse_datetime(data.get("expires_at")),
231
+ validation_status=data.get("validation_status", "unknown"),
232
+ labeled_frames_count=data.get("labeled_frames_count"),
233
+ user_instances_count=data.get("user_instances_count"),
234
+ predicted_instances_count=data.get("predicted_instances_count"),
235
+ tracks_count=data.get("tracks_count"),
236
+ videos_count=data.get("videos_count"),
237
+ )
238
+
239
+ def to_dict(self) -> dict[str, Any]:
240
+ """Convert to dictionary for JSON serialization."""
241
+ return {
242
+ "shortcode": self.shortcode,
243
+ "original_filename": self.original_filename,
244
+ "file_size": self.file_size,
245
+ "upload_timestamp": self.upload_timestamp.isoformat(),
246
+ "expires_at": self.expires_at.isoformat() if self.expires_at else None,
247
+ "validation_status": self.validation_status,
248
+ "labeled_frames_count": self.labeled_frames_count,
249
+ "user_instances_count": self.user_instances_count,
250
+ "predicted_instances_count": self.predicted_instances_count,
251
+ "tracks_count": self.tracks_count,
252
+ "videos_count": self.videos_count,
253
+ }
254
+
255
+
256
+ @dataclass
257
+ class User:
258
+ """Authenticated user profile.
259
+
260
+ Attributes:
261
+ id: User ID.
262
+ username: GitHub username.
263
+ email: User email.
264
+ avatar_url: URL to user's avatar image.
265
+ total_files: Total number of files uploaded.
266
+ total_storage: Total storage used in bytes.
267
+ """
268
+
269
+ id: int
270
+ username: str
271
+ email: str
272
+ avatar_url: str
273
+ total_files: int
274
+ total_storage: int
275
+
276
+ @classmethod
277
+ def from_api_response(cls, data: dict[str, Any]) -> User:
278
+ """Create from API response data.
279
+
280
+ Args:
281
+ data: Response data from /api/v1/user/me endpoint.
282
+
283
+ Returns:
284
+ User object.
285
+ """
286
+ return cls(
287
+ id=data.get("id", 0),
288
+ username=data.get("username", ""),
289
+ email=data.get("email", ""),
290
+ avatar_url=data.get("avatar_url", ""),
291
+ total_files=data.get("total_files", 0),
292
+ total_storage=data.get("total_storage", 0),
293
+ )