licos-platform-sdk 0.1.0__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,25 @@
|
|
|
1
|
+
from .storage import (
|
|
2
|
+
ApiError,
|
|
3
|
+
ConfigurationError,
|
|
4
|
+
PlatformSdkError,
|
|
5
|
+
StorageClient,
|
|
6
|
+
UploadResult,
|
|
7
|
+
download,
|
|
8
|
+
download_url,
|
|
9
|
+
preview_url,
|
|
10
|
+
upload_project,
|
|
11
|
+
upload_user,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ApiError",
|
|
16
|
+
"ConfigurationError",
|
|
17
|
+
"PlatformSdkError",
|
|
18
|
+
"StorageClient",
|
|
19
|
+
"UploadResult",
|
|
20
|
+
"download",
|
|
21
|
+
"download_url",
|
|
22
|
+
"preview_url",
|
|
23
|
+
"upload_project",
|
|
24
|
+
"upload_user",
|
|
25
|
+
]
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import mimetypes
|
|
5
|
+
import os
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PlatformSdkError(RuntimeError):
|
|
16
|
+
"""Base SDK error."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ConfigurationError(PlatformSdkError):
|
|
20
|
+
"""Raised when required runtime configuration is missing."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApiError(PlatformSdkError):
|
|
24
|
+
"""Raised when the platform API returns an error."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
message: str,
|
|
29
|
+
*,
|
|
30
|
+
status: int | None = None,
|
|
31
|
+
code: int | None = None,
|
|
32
|
+
details: Any | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status = status
|
|
36
|
+
self.code = code
|
|
37
|
+
self.details = details
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class UploadResult:
|
|
42
|
+
bucket: str
|
|
43
|
+
object_key: str
|
|
44
|
+
file_name: str
|
|
45
|
+
content_type: str
|
|
46
|
+
size_bytes: int
|
|
47
|
+
preview_url: str
|
|
48
|
+
download_url: str
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_dict(cls, payload: dict[str, Any]) -> "UploadResult":
|
|
52
|
+
return cls(
|
|
53
|
+
bucket=str(payload["bucket"]),
|
|
54
|
+
object_key=str(payload["object_key"]),
|
|
55
|
+
file_name=str(payload["file_name"]),
|
|
56
|
+
content_type=str(payload["content_type"]),
|
|
57
|
+
size_bytes=int(payload["size_bytes"]),
|
|
58
|
+
preview_url=str(payload["preview_url"]),
|
|
59
|
+
download_url=str(payload["download_url"]),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
return asdict(self)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _require_env(name: str) -> str:
|
|
67
|
+
value = os.environ.get(name)
|
|
68
|
+
if value:
|
|
69
|
+
return value
|
|
70
|
+
raise ConfigurationError(f"Missing required environment variable: {name}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _normalize_base_url(base_url: str | None) -> str:
|
|
74
|
+
value = base_url or os.environ.get("LICOS_ORCHESTRATOR_API_BASE_URL")
|
|
75
|
+
if not value:
|
|
76
|
+
raise ConfigurationError(
|
|
77
|
+
"Missing required environment variable: LICOS_ORCHESTRATOR_API_BASE_URL"
|
|
78
|
+
)
|
|
79
|
+
return value.rstrip("/")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_token(token: str | None) -> str:
|
|
83
|
+
return token or _require_env("LICOS_USER_TOKEN")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve_user_id(user_id: str | None) -> str:
|
|
87
|
+
return user_id or _require_env("LICOS_USER_ID")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_project_id(project_id: str | None) -> str:
|
|
91
|
+
return project_id or _require_env("LICOS_PROJECT_ID")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _quote_storage_path_segment(value: str) -> str:
|
|
95
|
+
return urllib.parse.quote(value, safe="")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _quote_object_key(value: str) -> str:
|
|
99
|
+
return "/".join(_quote_storage_path_segment(part) for part in value.split("/"))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _guess_content_type(file_path: Path) -> str:
|
|
103
|
+
guessed, _ = mimetypes.guess_type(file_path.name)
|
|
104
|
+
return guessed or "application/octet-stream"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _encode_multipart(file_path: Path, file_bytes: bytes, content_type: str) -> tuple[bytes, str]:
|
|
108
|
+
boundary = f"----LICOSBoundary{uuid4().hex}"
|
|
109
|
+
disposition = (
|
|
110
|
+
f'Content-Disposition: form-data; name="file"; filename="{file_path.name}"\r\n'
|
|
111
|
+
)
|
|
112
|
+
payload = b"".join(
|
|
113
|
+
[
|
|
114
|
+
f"--{boundary}\r\n".encode("utf-8"),
|
|
115
|
+
disposition.encode("utf-8"),
|
|
116
|
+
f"Content-Type: {content_type}\r\n\r\n".encode("utf-8"),
|
|
117
|
+
file_bytes,
|
|
118
|
+
b"\r\n",
|
|
119
|
+
f"--{boundary}--\r\n".encode("utf-8"),
|
|
120
|
+
]
|
|
121
|
+
)
|
|
122
|
+
return payload, boundary
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class StorageClient:
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
base_url: str | None = None,
|
|
130
|
+
token: str | None = None,
|
|
131
|
+
user_id: str | None = None,
|
|
132
|
+
project_id: str | None = None,
|
|
133
|
+
timeout: int = 60,
|
|
134
|
+
opener: urllib.request.OpenerDirector | None = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
self.base_url = base_url
|
|
137
|
+
self.token = token
|
|
138
|
+
self.user_id = user_id
|
|
139
|
+
self.project_id = project_id
|
|
140
|
+
self.timeout = timeout
|
|
141
|
+
self._opener = opener or urllib.request.build_opener()
|
|
142
|
+
|
|
143
|
+
def preview_url(self, bucket: str, object_key: str) -> str:
|
|
144
|
+
base = _normalize_base_url(self.base_url)
|
|
145
|
+
return (
|
|
146
|
+
f"{base}/api/v1/public/storage/"
|
|
147
|
+
f"{_quote_storage_path_segment(bucket)}/{_quote_object_key(object_key)}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def download_url(self, bucket: str, object_key: str) -> str:
|
|
151
|
+
return f"{self.preview_url(bucket, object_key)}?download=1"
|
|
152
|
+
|
|
153
|
+
def upload_user(self, file_path: str | os.PathLike[str]) -> UploadResult:
|
|
154
|
+
base = _normalize_base_url(self.base_url)
|
|
155
|
+
return self._upload(
|
|
156
|
+
f"{base}/api/v1/storage/upload/user",
|
|
157
|
+
file_path=file_path,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def upload_project(
|
|
161
|
+
self,
|
|
162
|
+
file_path: str | os.PathLike[str],
|
|
163
|
+
*,
|
|
164
|
+
project_id: str | None = None,
|
|
165
|
+
user_id: str | None = None,
|
|
166
|
+
) -> UploadResult:
|
|
167
|
+
base = _normalize_base_url(self.base_url)
|
|
168
|
+
resolved_user_id = _resolve_user_id(user_id or self.user_id)
|
|
169
|
+
resolved_project_id = _resolve_project_id(project_id or self.project_id)
|
|
170
|
+
return self._upload(
|
|
171
|
+
(
|
|
172
|
+
f"{base}/api/v1/storage/upload/project/"
|
|
173
|
+
f"{_quote_storage_path_segment(resolved_user_id)}/"
|
|
174
|
+
f"{_quote_storage_path_segment(resolved_project_id)}"
|
|
175
|
+
),
|
|
176
|
+
file_path=file_path,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def download(
|
|
180
|
+
self,
|
|
181
|
+
url: str,
|
|
182
|
+
output_path: str | os.PathLike[str],
|
|
183
|
+
) -> Path:
|
|
184
|
+
request = urllib.request.Request(
|
|
185
|
+
url,
|
|
186
|
+
method="GET",
|
|
187
|
+
headers={
|
|
188
|
+
"Authorization": f"Bearer {_resolve_token(self.token)}",
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
try:
|
|
192
|
+
with self._opener.open(request, timeout=self.timeout) as response:
|
|
193
|
+
payload = response.read()
|
|
194
|
+
except urllib.error.HTTPError as exc:
|
|
195
|
+
details = exc.read().decode("utf-8", errors="replace")
|
|
196
|
+
raise ApiError(
|
|
197
|
+
f"Platform download failed: HTTP {exc.code}",
|
|
198
|
+
status=exc.code,
|
|
199
|
+
details=details,
|
|
200
|
+
) from exc
|
|
201
|
+
|
|
202
|
+
output = Path(output_path)
|
|
203
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
output.write_bytes(payload)
|
|
205
|
+
return output
|
|
206
|
+
|
|
207
|
+
def _upload(self, url: str, *, file_path: str | os.PathLike[str]) -> UploadResult:
|
|
208
|
+
path = Path(file_path)
|
|
209
|
+
file_bytes = path.read_bytes()
|
|
210
|
+
content_type = _guess_content_type(path)
|
|
211
|
+
body, boundary = _encode_multipart(path, file_bytes, content_type)
|
|
212
|
+
request = urllib.request.Request(
|
|
213
|
+
url,
|
|
214
|
+
data=body,
|
|
215
|
+
method="POST",
|
|
216
|
+
headers={
|
|
217
|
+
"Authorization": f"Bearer {_resolve_token(self.token)}",
|
|
218
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
219
|
+
"Accept": "application/json",
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
payload = self._request_json(request)
|
|
223
|
+
return UploadResult.from_dict(payload)
|
|
224
|
+
|
|
225
|
+
def _request_json(self, request: urllib.request.Request) -> dict[str, Any]:
|
|
226
|
+
try:
|
|
227
|
+
with self._opener.open(request, timeout=self.timeout) as response:
|
|
228
|
+
body = response.read().decode("utf-8")
|
|
229
|
+
status = response.status
|
|
230
|
+
except urllib.error.HTTPError as exc:
|
|
231
|
+
body = exc.read().decode("utf-8", errors="replace")
|
|
232
|
+
payload = _parse_json(body)
|
|
233
|
+
raise ApiError(
|
|
234
|
+
_extract_message(payload, fallback=f"Platform API failed: HTTP {exc.code}"),
|
|
235
|
+
status=exc.code,
|
|
236
|
+
code=_extract_code(payload),
|
|
237
|
+
details=_extract_details(payload),
|
|
238
|
+
) from exc
|
|
239
|
+
|
|
240
|
+
payload = _parse_json(body)
|
|
241
|
+
code = _extract_code(payload)
|
|
242
|
+
if status >= 400 or code != 0:
|
|
243
|
+
raise ApiError(
|
|
244
|
+
_extract_message(payload, fallback=f"Platform API failed: HTTP {status}"),
|
|
245
|
+
status=status,
|
|
246
|
+
code=code,
|
|
247
|
+
details=_extract_details(payload),
|
|
248
|
+
)
|
|
249
|
+
data = payload.get("data")
|
|
250
|
+
if not isinstance(data, dict):
|
|
251
|
+
raise ApiError("Platform API returned invalid data payload", status=status, code=code)
|
|
252
|
+
return data
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _parse_json(raw: str) -> dict[str, Any]:
|
|
256
|
+
try:
|
|
257
|
+
payload = json.loads(raw)
|
|
258
|
+
except json.JSONDecodeError as exc:
|
|
259
|
+
raise ApiError("Platform API returned non-JSON response", details=raw) from exc
|
|
260
|
+
if not isinstance(payload, dict):
|
|
261
|
+
raise ApiError("Platform API returned invalid JSON envelope", details=payload)
|
|
262
|
+
return payload
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _extract_code(payload: dict[str, Any]) -> int | None:
|
|
266
|
+
value = payload.get("code")
|
|
267
|
+
return int(value) if isinstance(value, (int, float)) else None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _extract_message(payload: dict[str, Any], *, fallback: str) -> str:
|
|
271
|
+
value = payload.get("message")
|
|
272
|
+
return value if isinstance(value, str) and value else fallback
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _extract_details(payload: dict[str, Any]) -> Any:
|
|
276
|
+
return payload.get("data", payload)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _default_client(**kwargs: Any) -> StorageClient:
|
|
280
|
+
return StorageClient(**kwargs)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def preview_url(bucket: str, object_key: str, *, base_url: str | None = None) -> str:
|
|
284
|
+
return _default_client(base_url=base_url).preview_url(bucket, object_key)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def download_url(bucket: str, object_key: str, *, base_url: str | None = None) -> str:
|
|
288
|
+
return _default_client(base_url=base_url).download_url(bucket, object_key)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def upload_user(
|
|
292
|
+
file_path: str | os.PathLike[str],
|
|
293
|
+
*,
|
|
294
|
+
base_url: str | None = None,
|
|
295
|
+
token: str | None = None,
|
|
296
|
+
) -> UploadResult:
|
|
297
|
+
return _default_client(base_url=base_url, token=token).upload_user(file_path)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def upload_project(
|
|
301
|
+
file_path: str | os.PathLike[str],
|
|
302
|
+
*,
|
|
303
|
+
base_url: str | None = None,
|
|
304
|
+
token: str | None = None,
|
|
305
|
+
project_id: str | None = None,
|
|
306
|
+
user_id: str | None = None,
|
|
307
|
+
) -> UploadResult:
|
|
308
|
+
return _default_client(
|
|
309
|
+
base_url=base_url,
|
|
310
|
+
token=token,
|
|
311
|
+
project_id=project_id,
|
|
312
|
+
user_id=user_id,
|
|
313
|
+
).upload_project(file_path)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def download(
|
|
317
|
+
url: str,
|
|
318
|
+
output_path: str | os.PathLike[str],
|
|
319
|
+
*,
|
|
320
|
+
base_url: str | None = None,
|
|
321
|
+
token: str | None = None,
|
|
322
|
+
) -> Path:
|
|
323
|
+
return _default_client(base_url=base_url, token=token).download(url, output_path)
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
licos_platform_sdk/__init__.py,sha256=REJ7rkegRgkM-VZEIeOsrBoUO19ibMZ6RAAFfsorGAo,426
|
|
2
|
+
licos_platform_sdk/storage.py,sha256=kcnQfJwoItcgv_SEzy_VfIuZUKhxolbVG7BESPqSg-Y,10150
|
|
3
|
+
licos_platform_sdk-0.1.0.dist-info/METADATA,sha256=eVFVvfD4AeCgxscPElsme7dW5pxG0nv7QrTu6ZP8QRE,156
|
|
4
|
+
licos_platform_sdk-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
5
|
+
licos_platform_sdk-0.1.0.dist-info/RECORD,,
|