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.
- central_storage_platform_compute-0.1.0/LICENSE +21 -0
- central_storage_platform_compute-0.1.0/PKG-INFO +54 -0
- central_storage_platform_compute-0.1.0/README.md +32 -0
- central_storage_platform_compute-0.1.0/pyproject.toml +39 -0
- central_storage_platform_compute-0.1.0/setup.cfg +4 -0
- central_storage_platform_compute-0.1.0/src/central_storage_compute/__init__.py +5 -0
- central_storage_platform_compute-0.1.0/src/central_storage_compute/client.py +361 -0
- central_storage_platform_compute-0.1.0/src/central_storage_compute/py.typed +0 -0
- central_storage_platform_compute-0.1.0/src/central_storage_platform_compute.egg-info/PKG-INFO +54 -0
- central_storage_platform_compute-0.1.0/src/central_storage_platform_compute.egg-info/SOURCES.txt +11 -0
- central_storage_platform_compute-0.1.0/src/central_storage_platform_compute.egg-info/dependency_links.txt +1 -0
- central_storage_platform_compute-0.1.0/src/central_storage_platform_compute.egg-info/top_level.txt +1 -0
- central_storage_platform_compute-0.1.0/tests/test_client.py +74 -0
|
@@ -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,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")
|
|
File without changes
|
|
@@ -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.
|
central_storage_platform_compute-0.1.0/src/central_storage_platform_compute.egg-info/SOURCES.txt
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
central_storage_platform_compute-0.1.0/src/central_storage_platform_compute.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
central_storage_compute
|
|
@@ -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()
|