licos-platform-sdk 0.1.0__tar.gz

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,44 @@
1
+ # Rust
2
+ /target/
3
+ **/*.rs.bk
4
+ Cargo.lock
5
+
6
+ # Node
7
+ node_modules/
8
+ packages/*/dist/
9
+
10
+ # IDE
11
+ .idea/
12
+ .vscode/
13
+ *.swp
14
+ *.swo
15
+
16
+ # OS
17
+ .DS_Store
18
+ Thumbs.db
19
+
20
+ # Environment
21
+ .env
22
+ .env.local
23
+
24
+ # Workspace
25
+ /workspace/
26
+
27
+ # Test runtime data
28
+ /test/licos-data/
29
+ /test/screenshots/
30
+ /test/vue-app/
31
+
32
+ # Build
33
+ *.log
34
+ .licos
35
+
36
+ /tmp
37
+
38
+ /Docs/hermes-agent
39
+ /Docs/OpenCode
40
+ /Docs/平台API
41
+
42
+ *.codex-*
43
+
44
+ project-20260423_130440
@@ -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,13 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "licos-platform-sdk"
7
+ version = "0.1.0"
8
+ description = "LICOS platform SDK for storage upload, download, and preview"
9
+ requires-python = ">=3.10"
10
+ dependencies = []
11
+
12
+ [tool.hatch.build.targets.wheel]
13
+ packages = ["src/licos_platform_sdk"]
@@ -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,151 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import threading
7
+ import unittest
8
+ from contextlib import contextmanager
9
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
+ from pathlib import Path
11
+ import sys
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
14
+
15
+ from licos_platform_sdk import download, download_url, preview_url, upload_project
16
+
17
+
18
+ class _TestHandler(BaseHTTPRequestHandler):
19
+ server_version = "licos-platform-sdk-test"
20
+ protocol_version = "HTTP/1.1"
21
+
22
+ def log_message(self, format: str, *args) -> None: # noqa: A003
23
+ return
24
+
25
+ def do_POST(self) -> None: # noqa: N802
26
+ length = int(self.headers.get("Content-Length", "0"))
27
+ body = self.rfile.read(length)
28
+ self.server.last_request = {
29
+ "method": "POST",
30
+ "path": self.path,
31
+ "authorization": self.headers.get("Authorization"),
32
+ "content_type": self.headers.get("Content-Type"),
33
+ "body": body,
34
+ }
35
+ payload = {
36
+ "code": 0,
37
+ "message": "ok",
38
+ "data": {
39
+ "bucket": "user-demo-project",
40
+ "object_key": "2026/04/20/uuid/report.txt",
41
+ "file_name": "report.txt",
42
+ "content_type": "text/plain",
43
+ "size_bytes": 11,
44
+ "preview_url": "http://preview.local/file",
45
+ "download_url": "http://preview.local/file?download=1",
46
+ },
47
+ }
48
+ raw = json.dumps(payload).encode("utf-8")
49
+ self.send_response(200)
50
+ self.send_header("Content-Type", "application/json")
51
+ self.send_header("Content-Length", str(len(raw)))
52
+ self.end_headers()
53
+ self.wfile.write(raw)
54
+
55
+ def do_GET(self) -> None: # noqa: N802
56
+ if self.path == "/public/file.txt":
57
+ self.server.last_request = {
58
+ "method": "GET",
59
+ "path": self.path,
60
+ "authorization": self.headers.get("Authorization"),
61
+ }
62
+ body = b"hello world"
63
+ self.send_response(200)
64
+ self.send_header("Content-Type", "text/plain")
65
+ self.send_header("Content-Length", str(len(body)))
66
+ self.end_headers()
67
+ self.wfile.write(body)
68
+ return
69
+ self.send_error(404)
70
+
71
+
72
+ @contextmanager
73
+ def _temporary_env(values: dict[str, str]):
74
+ previous = {key: os.environ.get(key) for key in values}
75
+ os.environ.update(values)
76
+ try:
77
+ yield
78
+ finally:
79
+ for key, value in previous.items():
80
+ if value is None:
81
+ os.environ.pop(key, None)
82
+ else:
83
+ os.environ[key] = value
84
+
85
+
86
+ class StorageSdkTests(unittest.TestCase):
87
+ def setUp(self) -> None:
88
+ self.server = ThreadingHTTPServer(("127.0.0.1", 0), _TestHandler)
89
+ self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
90
+ self.thread.start()
91
+ self.base_url = f"http://127.0.0.1:{self.server.server_port}"
92
+
93
+ def tearDown(self) -> None:
94
+ self.server.shutdown()
95
+ self.thread.join(timeout=5)
96
+ self.server.server_close()
97
+
98
+ def test_preview_and_download_url_helpers(self) -> None:
99
+ self.assertEqual(
100
+ preview_url(
101
+ "user-bucket",
102
+ "2026/04/20/report final.txt",
103
+ base_url="http://platform.local/",
104
+ ),
105
+ "http://platform.local/api/v1/public/storage/user-bucket/2026/04/20/report%20final.txt",
106
+ )
107
+ self.assertEqual(
108
+ download_url("user-bucket", "folder/a.txt", base_url="http://platform.local"),
109
+ "http://platform.local/api/v1/public/storage/user-bucket/folder/a.txt?download=1",
110
+ )
111
+
112
+ def test_upload_project_uses_env_identity_and_bearer_token(self) -> None:
113
+ with tempfile.TemporaryDirectory() as tmp_dir:
114
+ file_path = Path(tmp_dir) / "report.txt"
115
+ file_path.write_text("hello world", encoding="utf-8")
116
+
117
+ with _temporary_env(
118
+ {
119
+ "LICOS_ORCHESTRATOR_API_BASE_URL": self.base_url,
120
+ "LICOS_USER_TOKEN": "jwt-token",
121
+ "LICOS_USER_ID": "demo-user",
122
+ "LICOS_PROJECT_ID": "demo-project",
123
+ }
124
+ ):
125
+ result = upload_project(file_path)
126
+
127
+ self.assertEqual(result.bucket, "user-demo-project")
128
+ self.assertEqual(
129
+ self.server.last_request["path"],
130
+ "/api/v1/storage/upload/project/demo-user/demo-project",
131
+ )
132
+ self.assertEqual(self.server.last_request["authorization"], "Bearer jwt-token")
133
+ self.assertIn(b'filename="report.txt"', self.server.last_request["body"])
134
+ self.assertIn(b"hello world", self.server.last_request["body"])
135
+
136
+ def test_download_writes_file_content(self) -> None:
137
+ with tempfile.TemporaryDirectory() as tmp_dir:
138
+ output = Path(tmp_dir) / "downloaded.txt"
139
+ saved_path = download(
140
+ f"{self.base_url}/public/file.txt",
141
+ output,
142
+ token="jwt-token",
143
+ )
144
+
145
+ self.assertEqual(saved_path, output)
146
+ self.assertEqual(output.read_text(encoding="utf-8"), "hello world")
147
+ self.assertEqual(self.server.last_request["authorization"], "Bearer jwt-token")
148
+
149
+
150
+ if __name__ == "__main__":
151
+ unittest.main()