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
+ Metadata-Version: 2.4
2
+ Name: licos-platform-sdk
3
+ Version: 0.1.0
4
+ Summary: LICOS platform SDK for storage upload, download, and preview
5
+ Requires-Python: >=3.10
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any