central-storage-platform-compute 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 phasuwut
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: central-storage-platform-compute
3
+ Version: 0.1.0
4
+ Summary: Provider-neutral compute transfer client for Central Storage Platform
5
+ Author-email: phasuwut <phasuwut.share@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://central-storage-platform-api.phasuwut.com
8
+ Keywords: storage,s3,transfer,resume,multipart,compute
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: System :: Archiving
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # Central Storage Platform Compute client
24
+
25
+ ```bash
26
+ pip install central-storage-platform-compute
27
+ ```
28
+
29
+ The package is published to PyPI, so compute nodes (RunPod, Colab, a bare GPU box) install it without access to the platform's private repositories. It has no third-party dependencies and needs Python 3.10+. To work on the client itself, clone this repository and run `pip install -e compute-python`.
30
+
31
+ ```python
32
+ from central_storage_compute import ComputeClient
33
+
34
+ client = ComputeClient("https://api.example.invalid", token="cpt_<show-once-token>")
35
+ result = client.download("<file-id>", "./data.bin")
36
+ print(result.bytes_written, result.sha256)
37
+
38
+ upload = client.upload("./result.bin", destination="results/", mode="auto")
39
+ print(upload.file_id, upload.mode, upload.sha256)
40
+ ```
41
+
42
+ `download(mode="auto")` follows the transfer mode and concurrency returned by the API. Use `mode="parallel"` to request bounded HTTP Range workers; the client falls back to streaming mode when the storage endpoint does not support ranges. `upload(mode="auto")` tries the single path and switches to the token-scoped multipart path when the API requires it.
43
+
44
+ The token is supplied at runtime. The client never writes it or a presigned URL to a resume manifest or log. Resume manifests contain only file identity, expected size/checksum and completed byte ranges. All upload completion calls carry a fresh `Idempotency-Key`.
45
+
46
+ ## Releasing
47
+
48
+ `central-storage-platform-compute` is published to PyPI from this repository. Bump `__version__` in `src/central_storage_compute/__init__.py` (`pyproject.toml` reads the version from there), then tag:
49
+
50
+ ```bash
51
+ git tag compute-client-v0.2.0 && git push origin compute-client-v0.2.0
52
+ ```
53
+
54
+ `.github/workflows/publish-compute-client.yml` checks the tag against `__version__`, runs the tests, builds the sdist and wheel, and uploads them through PyPI Trusted Publishing — no API token is stored in the repository. PyPI never lets a version number be reused, so a bad release needs a new patch version rather than a re-upload.
@@ -0,0 +1,32 @@
1
+ # Central Storage Platform Compute client
2
+
3
+ ```bash
4
+ pip install central-storage-platform-compute
5
+ ```
6
+
7
+ The package is published to PyPI, so compute nodes (RunPod, Colab, a bare GPU box) install it without access to the platform's private repositories. It has no third-party dependencies and needs Python 3.10+. To work on the client itself, clone this repository and run `pip install -e compute-python`.
8
+
9
+ ```python
10
+ from central_storage_compute import ComputeClient
11
+
12
+ client = ComputeClient("https://api.example.invalid", token="cpt_<show-once-token>")
13
+ result = client.download("<file-id>", "./data.bin")
14
+ print(result.bytes_written, result.sha256)
15
+
16
+ upload = client.upload("./result.bin", destination="results/", mode="auto")
17
+ print(upload.file_id, upload.mode, upload.sha256)
18
+ ```
19
+
20
+ `download(mode="auto")` follows the transfer mode and concurrency returned by the API. Use `mode="parallel"` to request bounded HTTP Range workers; the client falls back to streaming mode when the storage endpoint does not support ranges. `upload(mode="auto")` tries the single path and switches to the token-scoped multipart path when the API requires it.
21
+
22
+ The token is supplied at runtime. The client never writes it or a presigned URL to a resume manifest or log. Resume manifests contain only file identity, expected size/checksum and completed byte ranges. All upload completion calls carry a fresh `Idempotency-Key`.
23
+
24
+ ## Releasing
25
+
26
+ `central-storage-platform-compute` is published to PyPI from this repository. Bump `__version__` in `src/central_storage_compute/__init__.py` (`pyproject.toml` reads the version from there), then tag:
27
+
28
+ ```bash
29
+ git tag compute-client-v0.2.0 && git push origin compute-client-v0.2.0
30
+ ```
31
+
32
+ `.github/workflows/publish-compute-client.yml` checks the tag against `__version__`, runs the tests, builds the sdist and wheel, and uploads them through PyPI Trusted Publishing — no API token is stored in the repository. PyPI never lets a version number be reused, so a bad release needs a new patch version rather than a re-upload.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "central-storage-platform-compute"
7
+ description = "Provider-neutral compute transfer client for Central Storage Platform"
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "phasuwut", email = "phasuwut.share@gmail.com" }]
13
+ keywords = ["storage", "s3", "transfer", "resume", "multipart", "compute"]
14
+ dependencies = []
15
+ dynamic = ["version"]
16
+
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: System :: Archiving",
26
+ "Typing :: Typed",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://central-storage-platform-api.phasuwut.com"
31
+
32
+ [tool.setuptools.dynamic]
33
+ version = { attr = "central_storage_compute.__version__" }
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.setuptools.package-data]
39
+ central_storage_compute = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from .client import ComputeApiError, ComputeClient, DownloadResult, UploadResult
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["ComputeApiError", "ComputeClient", "DownloadResult", "UploadResult", "__version__"]
@@ -0,0 +1,361 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import base64
6
+ import mimetypes
7
+ import os
8
+ import tempfile
9
+ import time
10
+ import urllib.error
11
+ import urllib.request
12
+ from concurrent.futures import ThreadPoolExecutor, as_completed
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from threading import Lock
16
+ from typing import BinaryIO
17
+ from uuid import uuid4
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class DownloadResult:
22
+ file_id: str
23
+ destination: Path
24
+ bytes_written: int
25
+ sha256: str
26
+ mode: str = "normal"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class UploadResult:
31
+ upload_id: str
32
+ file_id: str
33
+ source: Path
34
+ bytes_read: int
35
+ sha256: str
36
+ mode: str
37
+
38
+
39
+ class ComputeApiError(RuntimeError):
40
+ """Safe API error that never includes a bearer token or presigned URL."""
41
+
42
+ def __init__(self, status: int, code: str, message: str) -> None:
43
+ super().__init__(f"Compute API request failed ({status}): {code}")
44
+ self.status = status
45
+ self.code = code
46
+ self.message = message
47
+
48
+
49
+ class _RangeUnsupported(RuntimeError):
50
+ pass
51
+
52
+
53
+ class ComputeClient:
54
+ """Provider-neutral client; credentials and presigned URLs stay out of manifests/logs."""
55
+
56
+ def __init__(self, api_url: str, token: str, timeout: float = 30.0, retry_count: int = 3) -> None:
57
+ if not token.startswith("cpt_"):
58
+ raise ValueError("Compute token must use the cpt_ prefix")
59
+ self.api_url = api_url.rstrip("/")
60
+ self._token = token
61
+ self.timeout = timeout
62
+ self.retry_count = max(1, retry_count)
63
+
64
+ def download(
65
+ self,
66
+ file_id: str,
67
+ destination: str | os.PathLike[str],
68
+ *,
69
+ verify_sha256: str | None = None,
70
+ mode: str = "auto",
71
+ max_connections: int | None = None,
72
+ resume: bool = True,
73
+ ) -> DownloadResult:
74
+ if mode not in {"auto", "normal", "parallel"}:
75
+ raise ValueError("mode must be auto, normal, or parallel")
76
+ payload = self._request("POST", f"/api/v1/compute/files/{file_id}/download")
77
+ target = Path(destination)
78
+ target.parent.mkdir(parents=True, exist_ok=True)
79
+ expected_size = _as_int(payload.get("sizeBytes"))
80
+ configured_mode = str(payload.get("transferMode") or "NORMAL").lower()
81
+ connections = max(1, min(int(max_connections or payload.get("maxConcurrency") or 1), 32))
82
+ selected_mode = "parallel" if mode == "parallel" or (mode == "auto" and configured_mode == "parallel") else "normal"
83
+ if expected_size is None or expected_size < 1 or connections < 2:
84
+ selected_mode = "normal"
85
+ if selected_mode == "parallel":
86
+ try:
87
+ return self._download_parallel(file_id, target, str(payload["url"]), expected_size, verify_sha256, connections, resume)
88
+ except _RangeUnsupported:
89
+ selected_mode = "normal"
90
+ result = self._download_normal(file_id, target, str(payload["url"]), expected_size, verify_sha256)
91
+ return DownloadResult(result.file_id, result.destination, result.bytes_written, result.sha256, selected_mode)
92
+
93
+ def benchmark(self, file_id: str, *, sample_path: str | os.PathLike[str] | None = None, max_connections: int | None = None) -> dict[str, float | int | str | list[dict[str, float | int | str]]]:
94
+ allowed = max(1, min(int(max_connections or 8), 32))
95
+ candidates = [connections for connections in (1, 4, 8) if connections <= allowed]
96
+ runs: list[dict[str, float | int | str]] = []
97
+ for connections in candidates:
98
+ scratch = Path(sample_path) if sample_path else Path(tempfile.gettempdir()) / f"csp-benchmark-{file_id}-{connections}"
99
+ started = time.perf_counter()
100
+ result = self.download(file_id, scratch, mode="normal" if connections == 1 else "parallel", max_connections=connections, resume=False)
101
+ duration = max(time.perf_counter() - started, 0.000001)
102
+ runs.append({"connections": connections, "bytes": result.bytes_written, "seconds": duration, "bytesPerSecond": result.bytes_written / duration, "mode": result.mode, "sha256": result.sha256})
103
+ if sample_path is None:
104
+ scratch.unlink(missing_ok=True)
105
+ recommended = max(runs, key=lambda run: (float(run["bytesPerSecond"]), -int(run["connections"])))
106
+ return {"fileId": file_id, "bytes": int(recommended["bytes"]), "seconds": float(recommended["seconds"]), "bytesPerSecond": float(recommended["bytesPerSecond"]), "sha256": str(recommended["sha256"]), "mode": str(recommended["mode"]), "recommendedConcurrency": int(recommended["connections"]), "runs": runs}
107
+
108
+ def upload(
109
+ self,
110
+ source: str | os.PathLike[str],
111
+ *,
112
+ destination: str | None = None,
113
+ mime_type: str | None = None,
114
+ mode: str = "auto",
115
+ max_connections: int | None = None,
116
+ include_checksum: bool = True,
117
+ ) -> UploadResult:
118
+ if mode not in {"auto", "single", "multipart"}:
119
+ raise ValueError("mode must be auto, single, or multipart")
120
+ path = Path(source)
121
+ size = path.stat().st_size
122
+ digest = _sha256_file(path)
123
+ checksum = _base64_sha256(path) if include_checksum else None
124
+ body: dict[str, object] = {"filename": path.name, "sizeBytes": size}
125
+ if destination:
126
+ body["destination"] = destination
127
+ if mime_type or mimetypes.guess_type(path.name)[0]:
128
+ body["mimeType"] = mime_type or mimetypes.guess_type(path.name)[0]
129
+ if checksum:
130
+ body["checksum"] = checksum
131
+ payload: dict[str, object] | None = None
132
+ if mode in {"auto", "single"}:
133
+ try:
134
+ payload = self._request("POST", "/api/v1/compute/uploads/create", body)
135
+ except ComputeApiError as error:
136
+ if mode == "single" or error.code != "MULTIPART_REQUIRED":
137
+ raise
138
+ if payload is not None and str(payload.get("mode")) == "single":
139
+ self._put_file(str(payload["uploadUrl"]), path, str(payload.get("headers", {}).get("content-type", "")), checksum)
140
+ completed = self._request("POST", f"/api/v1/compute/uploads/{payload['uploadId']}/complete", {"checksum": checksum} if checksum else {}, idempotency_key=str(uuid4()))
141
+ return UploadResult(str(payload["uploadId"]), str(completed["fileId"]), path, size, digest, "single")
142
+ multipart = payload if payload is not None else self._request("POST", "/api/v1/compute/uploads/multipart/create", body)
143
+ return self._upload_multipart(path, multipart, checksum, digest, max_connections)
144
+
145
+ def _upload_multipart(self, path: Path, payload: dict[str, object], checksum: str | None, digest: str, max_connections: int | None) -> UploadResult:
146
+ upload_id = str(payload["uploadId"])
147
+ part_size = _as_int(payload.get("partSizeBytes")) or 8 * 1024 * 1024
148
+ total_parts = _as_int(payload.get("totalParts")) or ((path.stat().st_size + part_size - 1) // part_size)
149
+ concurrency = max(1, min(int(max_connections or payload.get("maxConcurrency") or 4), 32))
150
+ signed: dict[int, str] = {}
151
+ for offset in range(0, total_parts, 500):
152
+ numbers = list(range(offset + 1, min(total_parts, offset + 500) + 1))
153
+ response = self._request("POST", f"/api/v1/compute/uploads/{upload_id}/multipart/parts/sign", {"partNumbers": numbers})
154
+ for item in response.get("parts", []):
155
+ signed[int(item["partNumber"])] = str(item["uploadUrl"])
156
+
157
+ def put(part_number: int) -> tuple[int, str]:
158
+ start = (part_number - 1) * part_size
159
+ length = min(part_size, path.stat().st_size - start)
160
+ last_error: Exception | None = None
161
+ for attempt in range(self.retry_count):
162
+ try:
163
+ with path.open("rb") as source:
164
+ source.seek(start)
165
+ body = source.read(length)
166
+ etag = self._put_bytes(signed[part_number], body)
167
+ return part_number, etag
168
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
169
+ last_error = error
170
+ if attempt + 1 < self.retry_count:
171
+ time.sleep(min(2**attempt, 4))
172
+ raise RuntimeError(f"Multipart part {part_number} failed") from last_error
173
+
174
+ try:
175
+ parts: list[tuple[int, str]] = []
176
+ with ThreadPoolExecutor(max_workers=concurrency) as pool:
177
+ futures = [pool.submit(put, part_number) for part_number in range(1, total_parts + 1)]
178
+ for future in as_completed(futures):
179
+ parts.append(future.result())
180
+ completed = self._request("POST", f"/api/v1/compute/uploads/{upload_id}/multipart/complete", {"parts": [{"partNumber": number, "etag": etag} for number, etag in sorted(parts)], **({"checksum": checksum} if checksum else {})}, idempotency_key=str(uuid4()))
181
+ except Exception:
182
+ self._request("POST", f"/api/v1/compute/uploads/{upload_id}/multipart/abort", {})
183
+ raise
184
+ return UploadResult(upload_id, str(completed["fileId"]), path, path.stat().st_size, digest, "multipart")
185
+
186
+ def _put_file(self, url: str, path: Path, content_type: str, checksum: str | None) -> None:
187
+ headers = {"Content-Length": str(path.stat().st_size)}
188
+ if content_type:
189
+ headers["Content-Type"] = content_type
190
+ if checksum:
191
+ headers["x-amz-checksum-sha256"] = checksum
192
+ request = urllib.request.Request(url, method="PUT", headers=headers)
193
+ with path.open("rb") as source:
194
+ request.data = source
195
+ try:
196
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
197
+ if response.status < 200 or response.status >= 300:
198
+ raise RuntimeError(f"Compute upload failed ({response.status})")
199
+ except urllib.error.HTTPError as error:
200
+ raise RuntimeError(f"Compute upload failed ({error.code})") from error
201
+
202
+ @staticmethod
203
+ def _put_bytes(url: str, body: bytes) -> str:
204
+ request = urllib.request.Request(url, data=body, method="PUT", headers={"Content-Length": str(len(body))})
205
+ try:
206
+ with urllib.request.urlopen(request) as response:
207
+ if response.status < 200 or response.status >= 300:
208
+ raise RuntimeError(f"Compute multipart upload failed ({response.status})")
209
+ return response.headers.get("ETag", "").strip('"')
210
+ except urllib.error.HTTPError as error:
211
+ raise RuntimeError(f"Compute multipart upload failed ({error.code})") from error
212
+
213
+ def _download_normal(self, file_id: str, target: Path, url: str, expected_size: int | None, verify_sha256: str | None) -> DownloadResult:
214
+ last_error: Exception | None = None
215
+ for attempt in range(self.retry_count):
216
+ try:
217
+ if attempt:
218
+ payload = self._request("POST", f"/api/v1/compute/files/{file_id}/download")
219
+ url = str(payload["url"])
220
+ digest = hashlib.sha256()
221
+ with urllib.request.urlopen(urllib.request.Request(url), timeout=self.timeout) as response, target.open("wb") as output:
222
+ count = self._stream(response, output, digest)
223
+ self._verify_size_and_checksum(count, digest.hexdigest(), expected_size, verify_sha256)
224
+ return DownloadResult(file_id=file_id, destination=target, bytes_written=count, sha256=digest.hexdigest())
225
+ except (urllib.error.URLError, TimeoutError, OSError, ComputeApiError) as error:
226
+ last_error = error
227
+ if attempt + 1 < self.retry_count:
228
+ time.sleep(min(2**attempt, 4))
229
+ raise RuntimeError("Compute download failed after retries") from last_error
230
+
231
+ def _download_parallel(self, file_id: str, target: Path, url: str, expected_size: int, verify_sha256: str | None, connections: int, resume: bool) -> DownloadResult:
232
+ manifest = target.with_name(f".{target.name}.csp-resume.json")
233
+ chunk_size = max(1 * 1024 * 1024, min(64 * 1024 * 1024, (expected_size + connections * 8 - 1) // (connections * 8)))
234
+ chunks = [(start, min(start + chunk_size, expected_size) - 1) for start in range(0, expected_size, chunk_size)]
235
+ completed = self._load_manifest(manifest, file_id, expected_size, verify_sha256) if resume else set()
236
+ if not target.exists() or target.stat().st_size != expected_size:
237
+ with target.open("wb") as output:
238
+ output.truncate(expected_size)
239
+ completed = set()
240
+ write_lock = Lock()
241
+
242
+ def fetch(chunk: tuple[int, int]) -> tuple[int, int]:
243
+ if chunk in completed:
244
+ return chunk
245
+ start, end = chunk
246
+ last_error: Exception | None = None
247
+ for attempt in range(self.retry_count):
248
+ try:
249
+ request = urllib.request.Request(url, headers={"Range": f"bytes={start}-{end}"})
250
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
251
+ if response.status != 206:
252
+ raise _RangeUnsupported()
253
+ content_range = response.headers.get("Content-Range", "")
254
+ if not content_range.startswith(f"bytes {start}-{end}/"):
255
+ raise _RangeUnsupported()
256
+ body = response.read(end - start + 1)
257
+ if len(body) != end - start + 1:
258
+ raise IOError("Range response length mismatch")
259
+ with write_lock, target.open("r+b") as output:
260
+ output.seek(start)
261
+ output.write(body)
262
+ return chunk
263
+ except _RangeUnsupported:
264
+ raise
265
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
266
+ last_error = error
267
+ if attempt + 1 < self.retry_count:
268
+ time.sleep(min(2**attempt, 4))
269
+ raise RuntimeError("Range download failed after retries") from last_error
270
+
271
+ with ThreadPoolExecutor(max_workers=connections) as pool:
272
+ futures = [pool.submit(fetch, chunk) for chunk in chunks if chunk not in completed]
273
+ for future in as_completed(futures):
274
+ completed.add(future.result())
275
+ self._save_manifest(manifest, file_id, expected_size, verify_sha256, completed)
276
+ digest = hashlib.sha256()
277
+ count = 0
278
+ with target.open("rb") as output:
279
+ count = self._stream(output, None, digest)
280
+ self._verify_size_and_checksum(count, digest.hexdigest(), expected_size, verify_sha256)
281
+ manifest.unlink(missing_ok=True)
282
+ return DownloadResult(file_id=file_id, destination=target, bytes_written=count, sha256=digest.hexdigest(), mode="parallel")
283
+
284
+ @staticmethod
285
+ def _stream(response: BinaryIO, output: BinaryIO | None, digest: "hashlib._Hash") -> int:
286
+ count = 0
287
+ while chunk := response.read(1024 * 1024):
288
+ if output is not None:
289
+ output.write(chunk)
290
+ digest.update(chunk)
291
+ count += len(chunk)
292
+ return count
293
+
294
+ @staticmethod
295
+ def _verify_size_and_checksum(count: int, digest: str, expected_size: int | None, verify_sha256: str | None) -> None:
296
+ if expected_size is not None and count != expected_size:
297
+ raise ValueError(f"Downloaded file size does not match expected size ({expected_size})")
298
+ if verify_sha256 and digest.lower() != verify_sha256.lower():
299
+ raise ValueError("Downloaded file checksum does not match")
300
+
301
+ @staticmethod
302
+ def _load_manifest(path: Path, file_id: str, expected_size: int, checksum: str | None) -> set[tuple[int, int]]:
303
+ try:
304
+ value = json.loads(path.read_text())
305
+ if value.get("fileId") != file_id or value.get("sizeBytes") != expected_size or value.get("checksum") != checksum:
306
+ return set()
307
+ return {(int(item[0]), int(item[1])) for item in value.get("completed", [])}
308
+ except (OSError, ValueError, TypeError, KeyError):
309
+ return set()
310
+
311
+ @staticmethod
312
+ def _save_manifest(path: Path, file_id: str, expected_size: int, checksum: str | None, completed: set[tuple[int, int]]) -> None:
313
+ value = {"fileId": file_id, "sizeBytes": expected_size, "checksum": checksum, "completed": sorted([list(item) for item in completed])}
314
+ path.write_text(json.dumps(value, separators=(",", ":")))
315
+
316
+ def _request(self, method: str, path: str, body: dict[str, object] | None = None, idempotency_key: str | None = None) -> dict[str, object]:
317
+ headers = {"Authorization": f"Bearer {self._token}", "Accept": "application/json"}
318
+ request = urllib.request.Request(f"{self.api_url}{path}", method=method, headers=headers)
319
+ if body is not None:
320
+ request.data = json.dumps(body).encode()
321
+ request.add_header("Content-Type", "application/json")
322
+ if idempotency_key:
323
+ request.add_header("Idempotency-Key", idempotency_key)
324
+ try:
325
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
326
+ value = json.load(response)
327
+ if not isinstance(value, dict):
328
+ raise RuntimeError("Compute API returned an invalid response")
329
+ return value
330
+ except urllib.error.HTTPError as error:
331
+ try:
332
+ payload = json.load(error)
333
+ envelope = payload.get("error", {}) if isinstance(payload, dict) else {}
334
+ code = str(envelope.get("code", "HTTP_ERROR")) if isinstance(envelope, dict) else "HTTP_ERROR"
335
+ message = str(envelope.get("message", "Request failed")) if isinstance(envelope, dict) else "Request failed"
336
+ except (ValueError, OSError):
337
+ code, message = "HTTP_ERROR", "Request failed"
338
+ raise ComputeApiError(error.code, code, message) from error
339
+
340
+
341
+ def _as_int(value: object) -> int | None:
342
+ try:
343
+ return int(value) if value is not None else None
344
+ except (TypeError, ValueError):
345
+ return None
346
+
347
+
348
+ def _sha256_file(path: Path) -> str:
349
+ digest = hashlib.sha256()
350
+ with path.open("rb") as source:
351
+ while chunk := source.read(1024 * 1024):
352
+ digest.update(chunk)
353
+ return digest.hexdigest()
354
+
355
+
356
+ def _base64_sha256(path: Path) -> str:
357
+ digest = hashlib.sha256()
358
+ with path.open("rb") as source:
359
+ while chunk := source.read(1024 * 1024):
360
+ digest.update(chunk)
361
+ return base64.b64encode(digest.digest()).decode("ascii")
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: central-storage-platform-compute
3
+ Version: 0.1.0
4
+ Summary: Provider-neutral compute transfer client for Central Storage Platform
5
+ Author-email: phasuwut <phasuwut.share@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://central-storage-platform-api.phasuwut.com
8
+ Keywords: storage,s3,transfer,resume,multipart,compute
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: System :: Archiving
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # Central Storage Platform Compute client
24
+
25
+ ```bash
26
+ pip install central-storage-platform-compute
27
+ ```
28
+
29
+ The package is published to PyPI, so compute nodes (RunPod, Colab, a bare GPU box) install it without access to the platform's private repositories. It has no third-party dependencies and needs Python 3.10+. To work on the client itself, clone this repository and run `pip install -e compute-python`.
30
+
31
+ ```python
32
+ from central_storage_compute import ComputeClient
33
+
34
+ client = ComputeClient("https://api.example.invalid", token="cpt_<show-once-token>")
35
+ result = client.download("<file-id>", "./data.bin")
36
+ print(result.bytes_written, result.sha256)
37
+
38
+ upload = client.upload("./result.bin", destination="results/", mode="auto")
39
+ print(upload.file_id, upload.mode, upload.sha256)
40
+ ```
41
+
42
+ `download(mode="auto")` follows the transfer mode and concurrency returned by the API. Use `mode="parallel"` to request bounded HTTP Range workers; the client falls back to streaming mode when the storage endpoint does not support ranges. `upload(mode="auto")` tries the single path and switches to the token-scoped multipart path when the API requires it.
43
+
44
+ The token is supplied at runtime. The client never writes it or a presigned URL to a resume manifest or log. Resume manifests contain only file identity, expected size/checksum and completed byte ranges. All upload completion calls carry a fresh `Idempotency-Key`.
45
+
46
+ ## Releasing
47
+
48
+ `central-storage-platform-compute` is published to PyPI from this repository. Bump `__version__` in `src/central_storage_compute/__init__.py` (`pyproject.toml` reads the version from there), then tag:
49
+
50
+ ```bash
51
+ git tag compute-client-v0.2.0 && git push origin compute-client-v0.2.0
52
+ ```
53
+
54
+ `.github/workflows/publish-compute-client.yml` checks the tag against `__version__`, runs the tests, builds the sdist and wheel, and uploads them through PyPI Trusted Publishing — no API token is stored in the repository. PyPI never lets a version number be reused, so a bad release needs a new patch version rather than a re-upload.
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/central_storage_compute/__init__.py
5
+ src/central_storage_compute/client.py
6
+ src/central_storage_compute/py.typed
7
+ src/central_storage_platform_compute.egg-info/PKG-INFO
8
+ src/central_storage_platform_compute.egg-info/SOURCES.txt
9
+ src/central_storage_platform_compute.egg-info/dependency_links.txt
10
+ src/central_storage_platform_compute.egg-info/top_level.txt
11
+ tests/test_client.py
@@ -0,0 +1,74 @@
1
+ import json
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+ from unittest.mock import Mock, patch
6
+
7
+ from central_storage_compute.client import ComputeClient
8
+
9
+
10
+ class ComputeClientTests(unittest.TestCase):
11
+ def test_manifest_contains_identity_only(self) -> None:
12
+ with tempfile.TemporaryDirectory() as directory:
13
+ path = Path(directory) / "resume.json"
14
+ ComputeClient._save_manifest(path, "file-1", 10, "checksum", {(0, 9)})
15
+ value = json.loads(path.read_text())
16
+ self.assertEqual(value, {"fileId": "file-1", "sizeBytes": 10, "checksum": "checksum", "completed": [[0, 9]]})
17
+ self.assertNotIn("cpt_secret", path.read_text())
18
+ self.assertNotIn("X-Amz-Signature", path.read_text())
19
+
20
+ def test_manifest_is_discarded_when_identity_changes(self) -> None:
21
+ with tempfile.TemporaryDirectory() as directory:
22
+ path = Path(directory) / "resume.json"
23
+ ComputeClient._save_manifest(path, "file-1", 10, None, {(0, 9)})
24
+ self.assertEqual(ComputeClient._load_manifest(path, "file-2", 10, None), set())
25
+ self.assertEqual(ComputeClient._load_manifest(path, "file-1", 11, None), set())
26
+
27
+ def test_token_prefix_is_required(self) -> None:
28
+ with self.assertRaises(ValueError):
29
+ ComputeClient("https://api.example.invalid", "secret")
30
+
31
+ def test_single_upload_uses_compute_namespace_and_completion_key(self) -> None:
32
+ with tempfile.TemporaryDirectory() as directory:
33
+ source = Path(directory) / "output.txt"
34
+ source.write_text("hello")
35
+ client = ComputeClient("https://api.example.invalid", "cpt_token.secret")
36
+ client._request = Mock(side_effect=[{"uploadId": "upload-1", "mode": "single", "uploadUrl": "https://s3.example/put", "headers": {}}, {"fileId": "file-1"}])
37
+ client._put_file = Mock()
38
+
39
+ result = client.upload(source, mode="single")
40
+
41
+ self.assertEqual(result.file_id, "file-1")
42
+ self.assertEqual(result.bytes_read, 5)
43
+ self.assertEqual(result.mode, "single")
44
+ self.assertEqual(client._request.call_args_list[1].args[1], "/api/v1/compute/uploads/upload-1/complete")
45
+ self.assertIn("idempotency_key", client._request.call_args_list[1].kwargs)
46
+
47
+ def test_multipart_upload_retries_parts_and_completes_in_order(self) -> None:
48
+ with tempfile.TemporaryDirectory() as directory:
49
+ source = Path(directory) / "output.bin"
50
+ source.write_bytes(b"abcde")
51
+ client = ComputeClient("https://api.example.invalid", "cpt_token.secret")
52
+ client._request = Mock(side_effect=[{"parts": [{"partNumber": 1, "uploadUrl": "https://s3.example/1"}, {"partNumber": 2, "uploadUrl": "https://s3.example/2"}]}, {"fileId": "file-1"}])
53
+ client._put_bytes = Mock(side_effect=lambda url, _body: "etag-1" if url.endswith("/1") else "etag-2")
54
+
55
+ result = client._upload_multipart(source, {"uploadId": "upload-1", "partSizeBytes": 3, "totalParts": 2, "maxConcurrency": 2}, None, "digest", 2)
56
+
57
+ self.assertEqual(result.mode, "multipart")
58
+ completion = client._request.call_args_list[1].args[2]
59
+ self.assertEqual([part["partNumber"] for part in completion["parts"]], [1, 2])
60
+ self.assertEqual([part["etag"] for part in completion["parts"]], ["etag-1", "etag-2"])
61
+
62
+ def test_benchmark_respects_connection_limit_and_prefers_fewer_on_tie(self) -> None:
63
+ client = ComputeClient("https://api.example.invalid", "cpt_token.secret")
64
+ client.download = Mock(side_effect=lambda _file_id, destination, **kwargs: type("Result", (), {"file_id": "file-1", "destination": Path(destination), "bytes_written": 100, "sha256": "digest", "mode": "normal" if kwargs["max_connections"] == 1 else "parallel"})())
65
+
66
+ with patch("central_storage_compute.client.time.perf_counter", side_effect=[0.0, 1.0, 10.0, 11.0]):
67
+ result = client.benchmark("file-1", max_connections=4)
68
+
69
+ self.assertEqual([run["connections"] for run in result["runs"]], [1, 4])
70
+ self.assertEqual(result["recommendedConcurrency"], 1)
71
+
72
+
73
+ if __name__ == "__main__":
74
+ unittest.main()