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.
- licos_platform_sdk-0.1.0/.gitignore +44 -0
- licos_platform_sdk-0.1.0/PKG-INFO +5 -0
- licos_platform_sdk-0.1.0/pyproject.toml +13 -0
- licos_platform_sdk-0.1.0/src/licos_platform_sdk/__init__.py +25 -0
- licos_platform_sdk-0.1.0/src/licos_platform_sdk/storage.py +323 -0
- licos_platform_sdk-0.1.0/tests/test_storage.py +151 -0
|
@@ -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,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()
|