gridlinegpu 0.1.3__tar.gz → 0.1.4__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.
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/PKG-INFO +24 -3
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/README.md +23 -2
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/pyproject.toml +1 -1
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/src/gridlinegpu/cli.py +11 -0
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/src/gridlinegpu/client.py +252 -24
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/src/gridlinegpu.egg-info/PKG-INFO +24 -3
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/tests/test_cli.py +9 -0
- gridlinegpu-0.1.4/tests/test_client.py +403 -0
- gridlinegpu-0.1.3/tests/test_client.py +0 -159
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/setup.cfg +0 -0
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/src/gridlinegpu/__init__.py +0 -0
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/src/gridlinegpu/__main__.py +0 -0
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/src/gridlinegpu/cli_auth.py +0 -0
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/src/gridlinegpu/errors.py +0 -0
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/src/gridlinegpu.egg-info/SOURCES.txt +0 -0
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/src/gridlinegpu.egg-info/dependency_links.txt +0 -0
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/src/gridlinegpu.egg-info/entry_points.txt +0 -0
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/src/gridlinegpu.egg-info/requires.txt +0 -0
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/src/gridlinegpu.egg-info/top_level.txt +0 -0
- {gridlinegpu-0.1.3 → gridlinegpu-0.1.4}/tests/test_cli_auth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gridlinegpu
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Python client for Gridline YAML workload routing
|
|
5
5
|
Author: Gridline GPU
|
|
6
6
|
License: Proprietary
|
|
@@ -23,7 +23,7 @@ the `gridline` command in the same Python environment on macOS and Linux.
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
python -m pip install 'gridlinegpu>=0.1.
|
|
26
|
+
python -m pip install 'gridlinegpu>=0.1.4,<0.2'
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
Run browser setup once. It creates a 30-day scoped token in a private file, attempts to
|
|
@@ -36,7 +36,9 @@ gridline setup --apply
|
|
|
36
36
|
|
|
37
37
|
Use a YAML file beside its referenced inputs. Task, model, engine, providers, output,
|
|
38
38
|
budget, and deadline belong in YAML rather than Python arguments.
|
|
39
|
-
|
|
39
|
+
Server-advertised artifact limits govern each submission. Newer Gridline deployments
|
|
40
|
+
use direct, signed resumable Storage uploads for large local inputs; older deployments
|
|
41
|
+
may still cap inputs at 40 MiB and outputs at 8 MiB.
|
|
40
42
|
|
|
41
43
|
```python
|
|
42
44
|
from gridlinegpu import GridlineClient
|
|
@@ -86,3 +88,22 @@ new key. `outputs()` verifies byte count and SHA-256 before creating private fil
|
|
|
86
88
|
Cancellation requests stop; cleanup and final provider billing can remain pending.
|
|
87
89
|
For finite jobs, `wait()` returns when outputs are complete, even if cleanup and billing
|
|
88
90
|
leave `outcome` pending. Check `status()` later for final settlement.
|
|
91
|
+
|
|
92
|
+
When worker progress is enabled on the hosted API/control service and supported by the
|
|
93
|
+
deployed worker image, `status()` also returns optional `progress`:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
status = client.status(job_id)
|
|
97
|
+
progress = status.get("progress")
|
|
98
|
+
if progress:
|
|
99
|
+
for event in progress["events"]:
|
|
100
|
+
print(event["stage"], event["observedAt"])
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Events are bounded, ordered worker reports for `progress["attemptId"]` and its fence.
|
|
104
|
+
Stages include input staging, model preparation/loading, inference start/completion,
|
|
105
|
+
and output upload. `observedAt` is the server's receipt time. Poll no faster than every
|
|
106
|
+
five seconds; deduplicate by attempt/fence/stage. Missing progress or skipped stages
|
|
107
|
+
mean unavailable evidence, not failure or an inferred stage. Inference completion
|
|
108
|
+
does not imply stored outputs, job success, cleanup, or settled billing. `wait()` keeps
|
|
109
|
+
its existing completion behavior and does not stream progress callbacks.
|
|
@@ -8,7 +8,7 @@ the `gridline` command in the same Python environment on macOS and Linux.
|
|
|
8
8
|
## Install
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
|
-
python -m pip install 'gridlinegpu>=0.1.
|
|
11
|
+
python -m pip install 'gridlinegpu>=0.1.4,<0.2'
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
Run browser setup once. It creates a 30-day scoped token in a private file, attempts to
|
|
@@ -21,7 +21,9 @@ gridline setup --apply
|
|
|
21
21
|
|
|
22
22
|
Use a YAML file beside its referenced inputs. Task, model, engine, providers, output,
|
|
23
23
|
budget, and deadline belong in YAML rather than Python arguments.
|
|
24
|
-
|
|
24
|
+
Server-advertised artifact limits govern each submission. Newer Gridline deployments
|
|
25
|
+
use direct, signed resumable Storage uploads for large local inputs; older deployments
|
|
26
|
+
may still cap inputs at 40 MiB and outputs at 8 MiB.
|
|
25
27
|
|
|
26
28
|
```python
|
|
27
29
|
from gridlinegpu import GridlineClient
|
|
@@ -71,3 +73,22 @@ new key. `outputs()` verifies byte count and SHA-256 before creating private fil
|
|
|
71
73
|
Cancellation requests stop; cleanup and final provider billing can remain pending.
|
|
72
74
|
For finite jobs, `wait()` returns when outputs are complete, even if cleanup and billing
|
|
73
75
|
leave `outcome` pending. Check `status()` later for final settlement.
|
|
76
|
+
|
|
77
|
+
When worker progress is enabled on the hosted API/control service and supported by the
|
|
78
|
+
deployed worker image, `status()` also returns optional `progress`:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
status = client.status(job_id)
|
|
82
|
+
progress = status.get("progress")
|
|
83
|
+
if progress:
|
|
84
|
+
for event in progress["events"]:
|
|
85
|
+
print(event["stage"], event["observedAt"])
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Events are bounded, ordered worker reports for `progress["attemptId"]` and its fence.
|
|
89
|
+
Stages include input staging, model preparation/loading, inference start/completion,
|
|
90
|
+
and output upload. `observedAt` is the server's receipt time. Poll no faster than every
|
|
91
|
+
five seconds; deduplicate by attempt/fence/stage. Missing progress or skipped stages
|
|
92
|
+
mean unavailable evidence, not failure or an inferred stage. Inference completion
|
|
93
|
+
does not imply stored outputs, job success, cleanup, or settled billing. `wait()` keeps
|
|
94
|
+
its existing completion behavior and does not stream progress callbacks.
|
|
@@ -85,6 +85,15 @@ def _emit(value: Any, output: TextIO) -> None:
|
|
|
85
85
|
print(json.dumps(value, indent=2, sort_keys=True, default=str), file=output)
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def _warn_unmeasured_duration(review: dict[str, Any], errors: TextIO) -> None:
|
|
89
|
+
quote = review.get("result", {}).get("quote", {})
|
|
90
|
+
selected = quote.get("selected", {}) if isinstance(quote, dict) else {}
|
|
91
|
+
duration = selected.get("expectedDurationMs", {}) if isinstance(selected, dict) else {}
|
|
92
|
+
if isinstance(duration, dict) and duration.get("source") == "customer_declared":
|
|
93
|
+
print("Gridline: task-cost ranking uses customer-declared duration, not measured "
|
|
94
|
+
"GPU-specific performance; estimate is not guaranteed.", file=errors)
|
|
95
|
+
|
|
96
|
+
|
|
88
97
|
def _confirm(message: str, *, yes: bool, input_stream: TextIO,
|
|
89
98
|
output: TextIO) -> None:
|
|
90
99
|
if yes:
|
|
@@ -140,6 +149,7 @@ def main(argv: Sequence[str] | None = None, *, input_stream: TextIO = sys.stdin,
|
|
|
140
149
|
result = client.validate(args.file)
|
|
141
150
|
elif action == "plan":
|
|
142
151
|
result = client.plan(args.file).review
|
|
152
|
+
_warn_unmeasured_duration(result, errors)
|
|
143
153
|
elif action == "run":
|
|
144
154
|
if not args.apply:
|
|
145
155
|
raise GridlineError("Paid launch requires --apply", code="apply_required")
|
|
@@ -151,6 +161,7 @@ def main(argv: Sequence[str] | None = None, *, input_stream: TextIO = sys.stdin,
|
|
|
151
161
|
raise ValueError("--idempotency-key must contain 1–128 safe characters")
|
|
152
162
|
planned = client.plan(args.file)
|
|
153
163
|
_emit(planned.review, output)
|
|
164
|
+
_warn_unmeasured_duration(planned.review, errors)
|
|
154
165
|
if planned.quote is None:
|
|
155
166
|
raise GridlineError("Router abstained; inspect plan coverage", code="plan_abstained")
|
|
156
167
|
_confirm(f"Launch with approved ceiling ${amount}?", yes=args.yes,
|
|
@@ -5,6 +5,7 @@ All task settings live in YAML. Methods never select a GPU or retry a paid launc
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import base64
|
|
8
9
|
import hashlib
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
@@ -15,9 +16,9 @@ from dataclasses import dataclass
|
|
|
15
16
|
from datetime import datetime, timezone
|
|
16
17
|
from decimal import Decimal
|
|
17
18
|
from pathlib import Path
|
|
18
|
-
from typing import Any
|
|
19
|
+
from typing import Any, Iterable
|
|
19
20
|
from urllib.error import HTTPError, URLError
|
|
20
|
-
from urllib.parse import quote, urlparse
|
|
21
|
+
from urllib.parse import quote, urljoin, urlparse
|
|
21
22
|
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
22
23
|
|
|
23
24
|
import yaml
|
|
@@ -29,7 +30,9 @@ _ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$")
|
|
|
29
30
|
_KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
|
30
31
|
_SHA = re.compile(r"^[0-9a-f]{64}$")
|
|
31
32
|
_MAX_YAML = 65_536
|
|
32
|
-
_MAX_DOWNLOAD =
|
|
33
|
+
_MAX_DOWNLOAD = 128 * 1024 * 1024
|
|
34
|
+
_TUS_CHUNK = 6 * 1024 * 1024
|
|
35
|
+
_TUS_PATH = "/storage/v1/upload/resumable/sign"
|
|
33
36
|
_USER_AGENT = "GridlinePythonSDK/1"
|
|
34
37
|
|
|
35
38
|
|
|
@@ -113,12 +116,18 @@ class GridlineClient:
|
|
|
113
116
|
if not available.get("planningAvailable"):
|
|
114
117
|
raise GridlineError("YAML planning is unavailable for this account", code="planning_unavailable")
|
|
115
118
|
inputs = _local_inputs(document)
|
|
116
|
-
|
|
119
|
+
required = validated.get("requiredAttachments")
|
|
120
|
+
names = [name for name, _ in inputs]
|
|
121
|
+
if (not isinstance(required, list)
|
|
122
|
+
or any(not isinstance(name, str) for name in required + names)
|
|
123
|
+
or len(required) != len(set(required))
|
|
124
|
+
or len(names) != len(set(names))
|
|
125
|
+
or sorted(required) != sorted(names)):
|
|
117
126
|
raise GridlineError("Input names differ from server validation", code="remote_contract_invalid")
|
|
118
127
|
if inputs and not available.get("artifactStagingAvailable"):
|
|
119
128
|
raise GridlineError("Input staging is unavailable for this account", code="staging_unavailable")
|
|
120
129
|
ceiling = available.get("maximumAttachmentBytes")
|
|
121
|
-
if
|
|
130
|
+
if type(ceiling) is not int or not 1 <= ceiling <= 512 * 1024 * 1024:
|
|
122
131
|
raise GridlineError("Invalid server attachment limit", code="remote_contract_invalid")
|
|
123
132
|
bindings = [self._stage(path.parent, name, item, ceiling) for name, item in inputs]
|
|
124
133
|
body = {"schemaVersion": 1, "yaml": source, "attachmentBindings": bindings}
|
|
@@ -249,7 +258,8 @@ class GridlineClient:
|
|
|
249
258
|
if not isinstance(name, str) or name in ("", ".", "..") or Path(name).name != name:
|
|
250
259
|
raise GridlineError("Unsafe output name", code="remote_contract_invalid")
|
|
251
260
|
digest, size = item.get("sha256"), item.get("bytes")
|
|
252
|
-
if not isinstance(digest, str) or not _SHA.fullmatch(digest)
|
|
261
|
+
if (not isinstance(digest, str) or not _SHA.fullmatch(digest)
|
|
262
|
+
or not isinstance(size, int) or size < 0 or size > _MAX_DOWNLOAD):
|
|
253
263
|
raise GridlineError("Invalid output digest or size", code="remote_contract_invalid")
|
|
254
264
|
grant = self._json("GET", f"{_API}/jobs/{quote(job_id)}/output/{quote(artifact_id)}/grant")
|
|
255
265
|
if (grant.get("jobId"), grant.get("artifactId"), grant.get("sha256"), grant.get("byteLength")) != (job_id, artifact_id, digest, size):
|
|
@@ -257,17 +267,41 @@ class GridlineClient:
|
|
|
257
267
|
access = grant.get("download", {})
|
|
258
268
|
if not _future(access.get("expiresAt")):
|
|
259
269
|
raise GridlineError("Output grant expired", code="grant_expired")
|
|
260
|
-
data = self._bytes("GET", access.get("url"), maximum=_MAX_DOWNLOAD)
|
|
261
|
-
if len(data) != size or _sha(data) != digest:
|
|
262
|
-
raise GridlineError("Output checksum mismatch", code="output_hash_mismatch")
|
|
263
270
|
destination = target / name
|
|
264
|
-
|
|
265
|
-
os.chmod(destination, 0o600)
|
|
266
|
-
handle.write(data)
|
|
271
|
+
self._download_verified(access.get("url"), destination, size, digest)
|
|
267
272
|
downloaded.append({"name": name, "path": str(destination), "sha256": digest})
|
|
268
273
|
return {"jobId": job_id, "state": result.get("state"), "downloaded": downloaded,
|
|
269
274
|
"missingRequiredOutputs": manifest.get("missingRequiredOutputs", [])}
|
|
270
275
|
|
|
276
|
+
def _download_verified(self, url: str | None, destination: Path, size: int, digest: str) -> None:
|
|
277
|
+
"""Stream to private temp file; expose destination only after digest matches."""
|
|
278
|
+
if not isinstance(url, str) or urlparse(url).scheme != "https":
|
|
279
|
+
raise GridlineError("Grant URL must use HTTPS", code="remote_contract_invalid")
|
|
280
|
+
temporary = destination.with_name(f".{destination.name}.{uuid.uuid4().hex}.partial")
|
|
281
|
+
request = Request(url, headers={"User-Agent": _USER_AGENT}, method="GET")
|
|
282
|
+
count = 0
|
|
283
|
+
checksum = hashlib.sha256()
|
|
284
|
+
try:
|
|
285
|
+
with temporary.open("xb") as handle:
|
|
286
|
+
os.chmod(temporary, 0o600)
|
|
287
|
+
with self._opener.open(request, timeout=self._timeout) as response:
|
|
288
|
+
while chunk := response.read(65_536):
|
|
289
|
+
count += len(chunk)
|
|
290
|
+
if count > size:
|
|
291
|
+
raise GridlineError("Output exceeded declared size", code="output_hash_mismatch")
|
|
292
|
+
checksum.update(chunk)
|
|
293
|
+
handle.write(chunk)
|
|
294
|
+
if count != size or checksum.hexdigest() != digest:
|
|
295
|
+
raise GridlineError("Output checksum mismatch", code="output_hash_mismatch")
|
|
296
|
+
os.link(temporary, destination)
|
|
297
|
+
except HTTPError as error:
|
|
298
|
+
raise GridlineError(f"Output download failed: HTTP {error.code}",
|
|
299
|
+
code="http_error", status=error.code) from error
|
|
300
|
+
except URLError as error:
|
|
301
|
+
raise GridlineError("Output download failed", code="network_error") from error
|
|
302
|
+
finally:
|
|
303
|
+
temporary.unlink(missing_ok=True)
|
|
304
|
+
|
|
271
305
|
def cancel(self, job_id: str) -> dict[str, Any]:
|
|
272
306
|
job_id = _id(job_id, "jobId")
|
|
273
307
|
result = self._json("POST", f"{_API}/jobs/{quote(job_id)}/cancel")
|
|
@@ -277,28 +311,165 @@ class GridlineClient:
|
|
|
277
311
|
|
|
278
312
|
def _stage(self, base: Path, name: str, item: dict[str, Any], ceiling: int) -> dict[str, Any]:
|
|
279
313
|
path = _local_path(base, item["path"])
|
|
280
|
-
|
|
281
|
-
digest
|
|
282
|
-
if item.get("sha256") not in (None, digest) or item.get("bytes") not in (None, len(data)):
|
|
314
|
+
size, digest = _bounded_file_identity(path, ceiling)
|
|
315
|
+
if item.get("sha256") not in (None, digest) or item.get("bytes") not in (None, size):
|
|
283
316
|
raise GridlineError(f"Input {name} differs from YAML digest or size", code="input_changed")
|
|
284
317
|
format_name = item["format"]
|
|
285
318
|
reserved = self._json(
|
|
286
319
|
"POST", f"{_API}/attachments",
|
|
287
|
-
{"schemaVersion": 1, "format": format_name, "sha256": digest, "byteLength":
|
|
320
|
+
{"schemaVersion": 1, "format": format_name, "sha256": digest, "byteLength": size},
|
|
288
321
|
key=f"sdk-attachment-{_sha((format_name + digest).encode())}",
|
|
289
322
|
)
|
|
290
323
|
artifact_id = _id(reserved.get("artifactId"), "artifactId")
|
|
291
|
-
if (reserved.get("sha256"), reserved.get("byteLength"), reserved.get("format")) != (digest,
|
|
324
|
+
if (reserved.get("sha256"), reserved.get("byteLength"), reserved.get("format")) != (digest, size, format_name):
|
|
292
325
|
raise GridlineError("Attachment reservation mismatch", code="remote_contract_invalid")
|
|
293
326
|
if reserved.get("state") != "ready":
|
|
294
327
|
upload = reserved.get("upload", {})
|
|
295
|
-
if
|
|
328
|
+
if not isinstance(upload, dict):
|
|
329
|
+
raise GridlineError("Invalid attachment upload grant", code="remote_contract_invalid")
|
|
330
|
+
uncertain_tus_upload = None
|
|
331
|
+
if upload.get("method") == "TUS":
|
|
332
|
+
try:
|
|
333
|
+
self._upload_tus(path, size, upload)
|
|
334
|
+
except GridlineError as error:
|
|
335
|
+
if not _tus_retryable(error):
|
|
336
|
+
raise
|
|
337
|
+
# Final PATCH may have committed while its reply and HEAD were lost.
|
|
338
|
+
uncertain_tus_upload = error
|
|
339
|
+
if _bounded_file_identity(path, size) != (size, digest):
|
|
340
|
+
raise GridlineError("Input changed during upload", code="input_changed")
|
|
341
|
+
elif upload.get("method") == "PUT":
|
|
342
|
+
if not isinstance(upload.get("headers"), dict) or not isinstance(upload["headers"].get("Authorization"), str):
|
|
343
|
+
raise GridlineError("Invalid attachment upload grant", code="remote_contract_invalid")
|
|
344
|
+
sent = 0
|
|
345
|
+
sent_hash = hashlib.sha256()
|
|
346
|
+
|
|
347
|
+
def chunks() -> Iterable[bytes]:
|
|
348
|
+
nonlocal sent
|
|
349
|
+
with path.open("rb") as handle:
|
|
350
|
+
while chunk := handle.read(65_536):
|
|
351
|
+
sent += len(chunk)
|
|
352
|
+
if sent > size:
|
|
353
|
+
raise GridlineError("Input changed during upload", code="input_changed")
|
|
354
|
+
sent_hash.update(chunk)
|
|
355
|
+
yield chunk
|
|
356
|
+
|
|
357
|
+
self._bytes(
|
|
358
|
+
"PUT", upload.get("url"), data=chunks(),
|
|
359
|
+
headers={"Authorization": upload["headers"]["Authorization"],
|
|
360
|
+
"Content-Length": str(size)}, maximum=16_384,
|
|
361
|
+
)
|
|
362
|
+
if sent != size or sent_hash.hexdigest() != digest:
|
|
363
|
+
raise GridlineError("Input changed during upload", code="input_changed")
|
|
364
|
+
else:
|
|
296
365
|
raise GridlineError("Invalid attachment upload grant", code="remote_contract_invalid")
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
366
|
+
try:
|
|
367
|
+
confirmed = self._json("POST", f"{_API}/attachments/{quote(artifact_id)}/confirm")
|
|
368
|
+
except GridlineError:
|
|
369
|
+
if uncertain_tus_upload is not None:
|
|
370
|
+
raise uncertain_tus_upload
|
|
371
|
+
raise
|
|
372
|
+
if (confirmed.get("state"), confirmed.get("artifactId"), confirmed.get("sha256"),
|
|
373
|
+
confirmed.get("byteLength")) != ("ready", artifact_id, digest, size):
|
|
374
|
+
if uncertain_tus_upload is not None:
|
|
375
|
+
raise uncertain_tus_upload
|
|
300
376
|
raise GridlineError("Attachment confirmation mismatch", code="remote_contract_invalid")
|
|
301
|
-
return {"logicalName": name, "artifactId": artifact_id, "sha256": digest, "bytes":
|
|
377
|
+
return {"logicalName": name, "artifactId": artifact_id, "sha256": digest, "bytes": size}
|
|
378
|
+
|
|
379
|
+
def _upload_tus(self, path: Path, size: int, upload: dict[str, Any]) -> None:
|
|
380
|
+
endpoint = _tus_endpoint(upload.get("url"))
|
|
381
|
+
headers = upload.get("headers")
|
|
382
|
+
metadata = upload.get("metadata")
|
|
383
|
+
if (not isinstance(headers, dict) or not isinstance(headers.get("x-signature"), str)
|
|
384
|
+
or not re.fullmatch(r"\S{1,8192}", headers["x-signature"]) or not isinstance(metadata, dict)
|
|
385
|
+
or not all(isinstance(metadata.get(key), str) and metadata[key]
|
|
386
|
+
for key in ("bucketName", "objectName", "contentType"))
|
|
387
|
+
or upload.get("chunkBytes") != _TUS_CHUNK or not _future(upload.get("expiresAt"))):
|
|
388
|
+
raise GridlineError("Invalid signed TUS grant", code="remote_contract_invalid")
|
|
389
|
+
signed = {"Tus-Resumable": "1.0.0", "x-signature": headers["x-signature"]}
|
|
390
|
+
encoded = ",".join(
|
|
391
|
+
f"{key} {base64.b64encode(metadata[key].encode('utf-8')).decode('ascii')}"
|
|
392
|
+
for key in ("bucketName", "objectName", "contentType")
|
|
393
|
+
)
|
|
394
|
+
creation = {**signed, "Upload-Length": str(size), "Upload-Metadata": encoded}
|
|
395
|
+
location = None
|
|
396
|
+
for attempt in range(3):
|
|
397
|
+
try:
|
|
398
|
+
status, response_headers = self._tus_request("POST", endpoint, creation)
|
|
399
|
+
if status != 201:
|
|
400
|
+
raise GridlineError("TUS creation returned unexpected status", code="attachment_upload_failed", status=status)
|
|
401
|
+
location = _tus_location(endpoint, response_headers.get("Location"))
|
|
402
|
+
break
|
|
403
|
+
except GridlineError as error:
|
|
404
|
+
if attempt == 2 or not _tus_retryable(error):
|
|
405
|
+
raise
|
|
406
|
+
time.sleep(0.25 * (2 ** attempt))
|
|
407
|
+
if location is None:
|
|
408
|
+
raise GridlineError("TUS creation failed", code="attachment_upload_failed")
|
|
409
|
+
|
|
410
|
+
offset = 0
|
|
411
|
+
failures = 0
|
|
412
|
+
with path.open("rb") as handle:
|
|
413
|
+
while offset < size:
|
|
414
|
+
handle.seek(offset)
|
|
415
|
+
chunk = handle.read(min(_TUS_CHUNK, size - offset))
|
|
416
|
+
if not chunk:
|
|
417
|
+
raise GridlineError("Input changed during upload", code="input_changed")
|
|
418
|
+
try:
|
|
419
|
+
status, response_headers = self._tus_request(
|
|
420
|
+
"PATCH", location,
|
|
421
|
+
{**signed, "Upload-Offset": str(offset),
|
|
422
|
+
"Content-Type": "application/offset+octet-stream",
|
|
423
|
+
"Content-Length": str(len(chunk))}, chunk,
|
|
424
|
+
)
|
|
425
|
+
if status != 204:
|
|
426
|
+
raise GridlineError("TUS chunk returned unexpected status", code="attachment_upload_failed", status=status)
|
|
427
|
+
reported = response_headers.get("Upload-Offset")
|
|
428
|
+
next_offset = _tus_offset(reported, size) if reported is not None else self._tus_head(location, signed, size)
|
|
429
|
+
if next_offset != offset + len(chunk):
|
|
430
|
+
raise GridlineError("TUS chunk offset mismatch", code="remote_contract_invalid")
|
|
431
|
+
offset = next_offset
|
|
432
|
+
failures = 0
|
|
433
|
+
except GridlineError as error:
|
|
434
|
+
if not _tus_retryable(error) or failures >= 4:
|
|
435
|
+
raise
|
|
436
|
+
failures += 1
|
|
437
|
+
time.sleep(0.25 * (2 ** (failures - 1)))
|
|
438
|
+
recovered = self._tus_head(location, signed, size)
|
|
439
|
+
if recovered < offset:
|
|
440
|
+
raise GridlineError("TUS offset moved backwards", code="remote_contract_invalid")
|
|
441
|
+
offset = recovered
|
|
442
|
+
|
|
443
|
+
def _tus_head(self, location: str, signed: dict[str, str], size: int) -> int:
|
|
444
|
+
for attempt in range(3):
|
|
445
|
+
try:
|
|
446
|
+
status, headers = self._tus_request("HEAD", location, signed)
|
|
447
|
+
if status != 200:
|
|
448
|
+
raise GridlineError("TUS status returned unexpected status", code="attachment_upload_failed", status=status)
|
|
449
|
+
reported_length = headers.get("Upload-Length")
|
|
450
|
+
if reported_length is not None and reported_length != str(size):
|
|
451
|
+
raise GridlineError("TUS length mismatch", code="remote_contract_invalid")
|
|
452
|
+
return _tus_offset(headers.get("Upload-Offset"), size)
|
|
453
|
+
except GridlineError as error:
|
|
454
|
+
if attempt == 2 or not _tus_retryable(error):
|
|
455
|
+
raise
|
|
456
|
+
time.sleep(0.25 * (2 ** attempt))
|
|
457
|
+
raise GridlineError("TUS status failed", code="attachment_upload_failed")
|
|
458
|
+
|
|
459
|
+
def _tus_request(self, method: str, url: str, headers: dict[str, str], data: bytes | None = None) -> tuple[int, Any]:
|
|
460
|
+
request = Request(url, data=data, headers={"User-Agent": _USER_AGENT, **headers}, method=method)
|
|
461
|
+
try:
|
|
462
|
+
with self._opener.open(request, timeout=max(self._timeout, 120)) as response:
|
|
463
|
+
if len(response.read(16_385)) > 16_384:
|
|
464
|
+
raise GridlineError("TUS response exceeded size limit", code="remote_contract_invalid")
|
|
465
|
+
return response.status, response.headers
|
|
466
|
+
except HTTPError as error:
|
|
467
|
+
raise GridlineError(f"Attachment upload failed: HTTP {error.code}",
|
|
468
|
+
code="attachment_upload_failed", status=error.code) from error
|
|
469
|
+
except URLError as error:
|
|
470
|
+
raise GridlineError("Attachment upload network failure", code="network_error") from error
|
|
471
|
+
except OSError as error:
|
|
472
|
+
raise GridlineError("Attachment upload network failure", code="network_error") from error
|
|
302
473
|
|
|
303
474
|
def _json(self, method: str, path: str, body: dict[str, Any] | None = None, *, key: str | None = None) -> dict[str, Any]:
|
|
304
475
|
headers = {"Authorization": f"Bearer {self._read_token()}", "Accept": "application/json"}
|
|
@@ -316,7 +487,7 @@ class GridlineClient:
|
|
|
316
487
|
raise GridlineError("Invalid response shape", code="remote_contract_invalid")
|
|
317
488
|
return value
|
|
318
489
|
|
|
319
|
-
def _bytes(self, method: str, url: str | None, *, data: bytes | None = None,
|
|
490
|
+
def _bytes(self, method: str, url: str | None, *, data: bytes | Iterable[bytes] | None = None,
|
|
320
491
|
headers: dict[str, str] | None = None, maximum: int) -> bytes:
|
|
321
492
|
if not isinstance(url, str) or urlparse(url).scheme != "https":
|
|
322
493
|
raise GridlineError("Grant URL must use HTTPS", code="remote_contract_invalid")
|
|
@@ -382,7 +553,7 @@ def _local_inputs(document: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
|
|
382
553
|
inputs = document.get("inputs", {})
|
|
383
554
|
if not isinstance(inputs, dict):
|
|
384
555
|
raise GridlineError("Invalid YAML inputs", code="invalid_yaml")
|
|
385
|
-
return [(name, {"path": item["file"], "format": item
|
|
556
|
+
return [(name, {"path": item["file"], "format": item.get("format") or Path(item["file"]).suffix[1:].lower(),
|
|
386
557
|
"sha256": item.get("sha256"), "bytes": item.get("bytes")})
|
|
387
558
|
for name, item in inputs.items() if isinstance(item, dict) and "file" in item]
|
|
388
559
|
|
|
@@ -408,6 +579,25 @@ def _bounded_file(path: Path, maximum: int) -> bytes:
|
|
|
408
579
|
return data
|
|
409
580
|
|
|
410
581
|
|
|
582
|
+
def _bounded_file_identity(path: Path, maximum: int) -> tuple[int, str]:
|
|
583
|
+
if path.is_symlink() or not path.is_file():
|
|
584
|
+
raise GridlineError("File must be a readable regular file", code="invalid_file")
|
|
585
|
+
declared_size = path.stat().st_size
|
|
586
|
+
if declared_size < 1 or declared_size > maximum:
|
|
587
|
+
raise GridlineError("File exceeds allowed size", code="file_size")
|
|
588
|
+
size = 0
|
|
589
|
+
checksum = hashlib.sha256()
|
|
590
|
+
with path.open("rb") as handle:
|
|
591
|
+
while chunk := handle.read(65_536):
|
|
592
|
+
size += len(chunk)
|
|
593
|
+
if size > maximum:
|
|
594
|
+
raise GridlineError("File exceeds allowed size", code="file_size")
|
|
595
|
+
checksum.update(chunk)
|
|
596
|
+
if size != declared_size:
|
|
597
|
+
raise GridlineError("File changed while preparing upload", code="input_changed")
|
|
598
|
+
return size, checksum.hexdigest()
|
|
599
|
+
|
|
600
|
+
|
|
411
601
|
def _id(value: Any, label: str) -> str:
|
|
412
602
|
if not isinstance(value, str) or not _ID.fullmatch(value):
|
|
413
603
|
raise GridlineError(f"Invalid {label}", code="remote_contract_invalid")
|
|
@@ -426,3 +616,41 @@ def _future(value: Any) -> bool:
|
|
|
426
616
|
return moment.tzinfo is not None and moment > datetime.now(timezone.utc)
|
|
427
617
|
except ValueError:
|
|
428
618
|
return False
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _tus_endpoint(value: Any) -> str:
|
|
622
|
+
if not isinstance(value, str):
|
|
623
|
+
raise GridlineError("Invalid TUS endpoint", code="remote_contract_invalid")
|
|
624
|
+
parsed = urlparse(value)
|
|
625
|
+
if (parsed.scheme != "https" or not parsed.hostname
|
|
626
|
+
or not re.fullmatch(r"[a-z0-9-]+\.storage\.supabase\.co", parsed.hostname)
|
|
627
|
+
or parsed.netloc != parsed.hostname or parsed.path != _TUS_PATH
|
|
628
|
+
or parsed.query or parsed.fragment):
|
|
629
|
+
raise GridlineError("Invalid TUS endpoint", code="remote_contract_invalid")
|
|
630
|
+
return value
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _tus_location(endpoint: str, value: Any) -> str:
|
|
634
|
+
if not isinstance(value, str) or not value or len(value) > 2048 or re.search(r"[\x00-\x1f]", value):
|
|
635
|
+
raise GridlineError("TUS upload location missing", code="remote_contract_invalid")
|
|
636
|
+
location = urljoin(endpoint, value)
|
|
637
|
+
parsed, origin = urlparse(location), urlparse(endpoint)
|
|
638
|
+
if (parsed.scheme != "https" or parsed.netloc != origin.netloc
|
|
639
|
+
or parsed.username or parsed.password
|
|
640
|
+
or not re.fullmatch(r"/storage/v1/upload/resumable/sign/[A-Za-z0-9._~-]{8,2048}", parsed.path)
|
|
641
|
+
or parsed.query or parsed.fragment):
|
|
642
|
+
raise GridlineError("TUS upload location escaped storage host", code="remote_contract_invalid")
|
|
643
|
+
return location
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _tus_offset(value: Any, size: int) -> int:
|
|
647
|
+
if not isinstance(value, str) or not re.fullmatch(r"0|[1-9][0-9]*", value):
|
|
648
|
+
raise GridlineError("Invalid TUS offset", code="remote_contract_invalid")
|
|
649
|
+
offset = int(value)
|
|
650
|
+
if offset > size:
|
|
651
|
+
raise GridlineError("TUS offset exceeded input size", code="remote_contract_invalid")
|
|
652
|
+
return offset
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _tus_retryable(error: GridlineError) -> bool:
|
|
656
|
+
return error.code == "network_error" or error.status == 409 or bool(error.status and error.status >= 500)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gridlinegpu
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Python client for Gridline YAML workload routing
|
|
5
5
|
Author: Gridline GPU
|
|
6
6
|
License: Proprietary
|
|
@@ -23,7 +23,7 @@ the `gridline` command in the same Python environment on macOS and Linux.
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
python -m pip install 'gridlinegpu>=0.1.
|
|
26
|
+
python -m pip install 'gridlinegpu>=0.1.4,<0.2'
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
Run browser setup once. It creates a 30-day scoped token in a private file, attempts to
|
|
@@ -36,7 +36,9 @@ gridline setup --apply
|
|
|
36
36
|
|
|
37
37
|
Use a YAML file beside its referenced inputs. Task, model, engine, providers, output,
|
|
38
38
|
budget, and deadline belong in YAML rather than Python arguments.
|
|
39
|
-
|
|
39
|
+
Server-advertised artifact limits govern each submission. Newer Gridline deployments
|
|
40
|
+
use direct, signed resumable Storage uploads for large local inputs; older deployments
|
|
41
|
+
may still cap inputs at 40 MiB and outputs at 8 MiB.
|
|
40
42
|
|
|
41
43
|
```python
|
|
42
44
|
from gridlinegpu import GridlineClient
|
|
@@ -86,3 +88,22 @@ new key. `outputs()` verifies byte count and SHA-256 before creating private fil
|
|
|
86
88
|
Cancellation requests stop; cleanup and final provider billing can remain pending.
|
|
87
89
|
For finite jobs, `wait()` returns when outputs are complete, even if cleanup and billing
|
|
88
90
|
leave `outcome` pending. Check `status()` later for final settlement.
|
|
91
|
+
|
|
92
|
+
When worker progress is enabled on the hosted API/control service and supported by the
|
|
93
|
+
deployed worker image, `status()` also returns optional `progress`:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
status = client.status(job_id)
|
|
97
|
+
progress = status.get("progress")
|
|
98
|
+
if progress:
|
|
99
|
+
for event in progress["events"]:
|
|
100
|
+
print(event["stage"], event["observedAt"])
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Events are bounded, ordered worker reports for `progress["attemptId"]` and its fence.
|
|
104
|
+
Stages include input staging, model preparation/loading, inference start/completion,
|
|
105
|
+
and output upload. `observedAt` is the server's receipt time. Poll no faster than every
|
|
106
|
+
five seconds; deduplicate by attempt/fence/stage. Missing progress or skipped stages
|
|
107
|
+
mean unavailable evidence, not failure or an inferred stage. Inference completion
|
|
108
|
+
does not imply stored outputs, job success, cleanup, or settled billing. `wait()` keeps
|
|
109
|
+
its existing completion behavior and does not stream progress callbacks.
|
|
@@ -57,6 +57,15 @@ class CliTest(unittest.TestCase):
|
|
|
57
57
|
self.assertEqual(client_type.return_value.launch.call_args.kwargs["idempotency_key"], "one")
|
|
58
58
|
self.assertIn('"jobId": "job_1"', self.output.getvalue())
|
|
59
59
|
|
|
60
|
+
@patch("gridlinegpu.cli.GridlineClient")
|
|
61
|
+
def test_plan_warns_when_task_duration_is_customer_declared(self, client_type):
|
|
62
|
+
planned = MagicMock()
|
|
63
|
+
planned.review = {"result": {"status": "planned", "quote": {"selected": {
|
|
64
|
+
"expectedDurationMs": {"source": "customer_declared", "value": 600000}}}}}
|
|
65
|
+
client_type.return_value.plan.return_value = planned
|
|
66
|
+
self.assertEqual(self.invoke("workload", "plan", "--file", "workload.yaml"), 0)
|
|
67
|
+
self.assertIn("not measured GPU-specific performance", self.errors.getvalue())
|
|
68
|
+
|
|
60
69
|
@patch("gridlinegpu.cli.GridlineClient")
|
|
61
70
|
def test_router_abstention_cannot_launch_even_with_yes(self, client_type):
|
|
62
71
|
planned = MagicMock()
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.error import URLError
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
|
|
12
|
+
from gridlinegpu import GridlineClient, GridlineError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TOKEN = "gla_" + "a" * 43
|
|
16
|
+
YAML = """name: speech
|
|
17
|
+
task:
|
|
18
|
+
operation: asr_transcription
|
|
19
|
+
model: Systran/faster-whisper-large-v3
|
|
20
|
+
inputs:
|
|
21
|
+
audio: { file: audio.wav, format: wav }
|
|
22
|
+
limits: { maxCostUSD: 5, deadline: 1h }
|
|
23
|
+
"""
|
|
24
|
+
CUSTOM_YAML = """apiVersion: gridline.workload/v1
|
|
25
|
+
kind: Workload
|
|
26
|
+
metadata: { name: pytorch-probe, labels: {} }
|
|
27
|
+
task:
|
|
28
|
+
kind: custom
|
|
29
|
+
workloadClass: inference
|
|
30
|
+
image: pytorch/pytorch@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
|
31
|
+
command: [/opt/conda/bin/python, run.py]
|
|
32
|
+
runtime: { architecture: amd64, internetAccess: required }
|
|
33
|
+
execution: { mode: finite, deadlineSeconds: 3600 }
|
|
34
|
+
inputs:
|
|
35
|
+
- { name: beta, format: bin, source: { type: package_file, path: beta.bin } }
|
|
36
|
+
- { name: alpha, format: bin, source: { type: package_file, path: alpha.bin } }
|
|
37
|
+
outputs:
|
|
38
|
+
- { type: artifact, name: result, format: json, path: result.json, required: true,
|
|
39
|
+
destination: { type: gridline_artifact } }
|
|
40
|
+
resources:
|
|
41
|
+
nodeCount: 1
|
|
42
|
+
accelerators: { minimumCount: 1, maximumCount: 1, minimumVramBytesPerGpu: 1000,
|
|
43
|
+
vendors: [nvidia], topology: [single] }
|
|
44
|
+
minimumCpuCores: 2
|
|
45
|
+
minimumMemoryBytes: 1000
|
|
46
|
+
minimumDiskBytes: 1000
|
|
47
|
+
estimatedDurationSeconds: 60
|
|
48
|
+
network: {}
|
|
49
|
+
budget: { maximumTotalMicros: 10000000, currency: USD, scope: all_in_cost }
|
|
50
|
+
providers: { mode: include, providers: [runpod] }
|
|
51
|
+
geography: { allowedCountries: [], blockedCountries: [], allowedRegions: [] }
|
|
52
|
+
retry: { maximumAttempts: 1, interruption: forbid, spot: forbid }
|
|
53
|
+
profiling: { allowed: false }
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Response(io.BytesIO):
|
|
58
|
+
def __init__(self, body=b"", *, status=200, headers=None):
|
|
59
|
+
super().__init__(body)
|
|
60
|
+
self.status = status
|
|
61
|
+
self.headers = headers or {}
|
|
62
|
+
|
|
63
|
+
def __enter__(self):
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, *_):
|
|
67
|
+
self.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FakeOpener:
|
|
71
|
+
def __init__(self):
|
|
72
|
+
self.calls = []
|
|
73
|
+
self.changed = False
|
|
74
|
+
self.output_body = b"{}"
|
|
75
|
+
self.upload_required = False
|
|
76
|
+
self.uploaded = b""
|
|
77
|
+
self.upload_mode = "put"
|
|
78
|
+
self.tus_location = "https://fixture.storage.supabase.co/storage/v1/upload/resumable/sign/session-1"
|
|
79
|
+
self.tus_location_header = None
|
|
80
|
+
self.tus_fail_patch_once = False
|
|
81
|
+
self.tus_fail_final_reply_and_head = False
|
|
82
|
+
self.tus_patches = []
|
|
83
|
+
self.maximum_attachment_bytes = 1024
|
|
84
|
+
self.required_attachments = ["audio"]
|
|
85
|
+
self.job = {"jobId": "job_1", "outcome": "succeeded"}
|
|
86
|
+
self.expiry = (datetime.now(timezone.utc) + timedelta(minutes=2)).isoformat()
|
|
87
|
+
|
|
88
|
+
def open(self, request, timeout):
|
|
89
|
+
path = request.full_url.removeprefix("https://gridlinegpu.com")
|
|
90
|
+
self.calls.append((request.get_method(), path, request.headers, request.data))
|
|
91
|
+
if path == "/api/workloads/yaml/validate":
|
|
92
|
+
result = {"schemaVersion": 1, "requiredAttachments": self.required_attachments}
|
|
93
|
+
elif path == "/api/workloads/yaml/availability":
|
|
94
|
+
result = {"schemaVersion": 1, "planningAvailable": True, "executionEnabled": True,
|
|
95
|
+
"artifactStagingAvailable": True,
|
|
96
|
+
"maximumAttachmentBytes": self.maximum_attachment_bytes}
|
|
97
|
+
elif path == "/api/workloads/yaml/attachments":
|
|
98
|
+
requested = json.loads(request.data)
|
|
99
|
+
upload = ({"method": "TUS",
|
|
100
|
+
"url": "https://fixture.storage.supabase.co/storage/v1/upload/resumable/sign",
|
|
101
|
+
"headers": {"x-signature": "signed-fixture-token"},
|
|
102
|
+
"metadata": {"bucketName": "private-input", "objectName": "runs/input_1",
|
|
103
|
+
"contentType": "application/octet-stream"},
|
|
104
|
+
"chunkBytes": 6 * 1024 * 1024, "expiresAt": self.expiry}
|
|
105
|
+
if self.upload_mode == "tus" else
|
|
106
|
+
{"method": "PUT", "url": "https://storage.example/input",
|
|
107
|
+
"headers": {"Authorization": "fixture-upload-grant"}})
|
|
108
|
+
result = {"artifactId": "input_" + requested["sha256"][:12],
|
|
109
|
+
"sha256": requested["sha256"], "byteLength": requested["byteLength"],
|
|
110
|
+
"format": requested["format"],
|
|
111
|
+
"state": "reserved" if self.upload_required else "ready",
|
|
112
|
+
"upload": upload}
|
|
113
|
+
elif path == "https://storage.example/input":
|
|
114
|
+
self.uploaded = b"".join(request.data)
|
|
115
|
+
return Response(b"")
|
|
116
|
+
elif path == "https://fixture.storage.supabase.co/storage/v1/upload/resumable/sign":
|
|
117
|
+
return Response(status=201, headers={"Location": self.tus_location_header or self.tus_location})
|
|
118
|
+
elif path == self.tus_location:
|
|
119
|
+
if request.get_method() == "HEAD":
|
|
120
|
+
if self.tus_fail_final_reply_and_head and len(self.uploaded) == self.tus_length:
|
|
121
|
+
raise URLError("status unavailable after committed final chunk")
|
|
122
|
+
return Response(headers={"Upload-Offset": str(len(self.uploaded)),
|
|
123
|
+
"Upload-Length": str(self.tus_length)})
|
|
124
|
+
if request.get_method() != "PATCH":
|
|
125
|
+
raise AssertionError(f"Unexpected TUS method: {request.get_method()}")
|
|
126
|
+
offset = next(value for key, value in request.headers.items()
|
|
127
|
+
if key.lower() == "upload-offset")
|
|
128
|
+
if int(offset) != len(self.uploaded):
|
|
129
|
+
raise AssertionError("Wrong TUS offset")
|
|
130
|
+
self.uploaded += request.data
|
|
131
|
+
self.tus_patches.append(len(request.data))
|
|
132
|
+
if self.tus_fail_patch_once:
|
|
133
|
+
self.tus_fail_patch_once = False
|
|
134
|
+
raise URLError("lost response after storage accepted chunk")
|
|
135
|
+
if self.tus_fail_final_reply_and_head and len(self.uploaded) == self.tus_length:
|
|
136
|
+
raise URLError("final reply lost after commit")
|
|
137
|
+
return Response(status=204, headers={"Upload-Offset": str(len(self.uploaded))})
|
|
138
|
+
elif path.startswith("/api/workloads/yaml/attachments/") and path.endswith("/confirm"):
|
|
139
|
+
result = {"state": "ready", "artifactId": path.split("/")[-2],
|
|
140
|
+
"sha256": hashlib.sha256(self.uploaded).hexdigest(),
|
|
141
|
+
"byteLength": len(self.uploaded)}
|
|
142
|
+
elif path == "/api/workloads/yaml/submit":
|
|
143
|
+
result = {"workloadId": "yaml_1", "workloadHash": "b" * 64}
|
|
144
|
+
elif path == "/api/workloads/yaml/yaml_1/plan":
|
|
145
|
+
result = {"workloadId": "yaml_1", "workloadHash": "b" * 64,
|
|
146
|
+
"result": {"status": "planned", "planHash": "x" * 64 if self.changed else "c" * 64,
|
|
147
|
+
"quote": {"quoteId": "quote_1", "quoteHash": "d" * 64,
|
|
148
|
+
"expiresAt": self.expiry, "maximumTotalMicros": 5_000_000,
|
|
149
|
+
"fallbackConsent": {"mode": "none"}}}}
|
|
150
|
+
elif path == "/api/workloads/yaml/yaml_1/launch":
|
|
151
|
+
result = {"workloadId": "yaml_1", "planHash": "c" * 64, "jobId": "job_1"}
|
|
152
|
+
elif path == "/api/workloads/yaml/jobs/job_1":
|
|
153
|
+
result = {"job": self.job}
|
|
154
|
+
elif path == "/api/workloads/yaml/jobs/job_1/output":
|
|
155
|
+
result = {"jobId": "job_1", "state": "available", "manifest": {"outputs": [{
|
|
156
|
+
"name": "result.json", "artifactId": "output_1", "sha256": self.output_digest,
|
|
157
|
+
"bytes": getattr(self, "declared_output_bytes", len(self.output_body)),
|
|
158
|
+
"completed": True}], "missingRequiredOutputs": []}}
|
|
159
|
+
elif path == "/api/workloads/yaml/jobs/job_1/output/output_1/grant":
|
|
160
|
+
result = {"jobId": "job_1", "artifactId": "output_1", "sha256": self.output_digest,
|
|
161
|
+
"byteLength": getattr(self, "declared_output_bytes", len(self.output_body)),
|
|
162
|
+
"download": {"url": "https://storage.example/result",
|
|
163
|
+
"expiresAt": self.expiry}}
|
|
164
|
+
elif path == "https://storage.example/result":
|
|
165
|
+
return Response(self.output_body)
|
|
166
|
+
else:
|
|
167
|
+
raise AssertionError(f"Unexpected request: {path}")
|
|
168
|
+
return Response(json.dumps(result).encode())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ClientTest(unittest.TestCase):
|
|
172
|
+
def setUp(self):
|
|
173
|
+
self.folder = tempfile.TemporaryDirectory()
|
|
174
|
+
self.addCleanup(self.folder.cleanup)
|
|
175
|
+
root = Path(self.folder.name)
|
|
176
|
+
(root / "workload.yaml").write_text(YAML)
|
|
177
|
+
(root / "audio.wav").write_bytes(b"RIFF")
|
|
178
|
+
self.path = root / "workload.yaml"
|
|
179
|
+
self.opener = FakeOpener()
|
|
180
|
+
self.opener.output_digest = hashlib.sha256(b"{}").hexdigest()
|
|
181
|
+
self.client = GridlineClient(token=TOKEN, opener=self.opener)
|
|
182
|
+
|
|
183
|
+
def test_plan_launch_and_verified_download(self):
|
|
184
|
+
plan = self.client.plan(self.path)
|
|
185
|
+
self.assertEqual(plan.workload_id, "yaml_1")
|
|
186
|
+
self.assertFalse(any(path.endswith("/launch") for _, path, _, _ in self.opener.calls))
|
|
187
|
+
launched = self.client.launch(plan, max_cost_usd="5", idempotency_key="test-job-1")
|
|
188
|
+
self.assertEqual(launched["jobId"], "job_1")
|
|
189
|
+
self.assertEqual(self.client.wait("job_1", timeout=1)["job"]["outcome"], "succeeded")
|
|
190
|
+
saved = self.client.outputs("job_1", Path(self.folder.name) / "results")
|
|
191
|
+
self.assertEqual(Path(saved["downloaded"][0]["path"]).read_bytes(), b"{}")
|
|
192
|
+
self.assertRaises(FileExistsError, self.client.outputs, "job_1", Path(self.folder.name) / "results")
|
|
193
|
+
|
|
194
|
+
def test_minimal_file_extension_supplies_omitted_upload_format(self):
|
|
195
|
+
(Path(self.folder.name) / "talk.OGG").write_bytes(b"OggS")
|
|
196
|
+
self.path.write_text("""task:
|
|
197
|
+
operation: asr_transcription
|
|
198
|
+
model: Systran/faster-whisper-large-v3
|
|
199
|
+
inputs:
|
|
200
|
+
audio: { file: talk.OGG }
|
|
201
|
+
limits: { maxCostUSD: 5 }
|
|
202
|
+
""")
|
|
203
|
+
self.client.plan(self.path)
|
|
204
|
+
grants = [json.loads(body) for method, path, _, body in self.opener.calls
|
|
205
|
+
if method == "POST" and path.endswith("/attachments")]
|
|
206
|
+
self.assertEqual(grants[0]["format"], "ogg")
|
|
207
|
+
self.assertFalse(any(path.endswith("/launch") for _, path, _, _ in self.opener.calls))
|
|
208
|
+
|
|
209
|
+
def test_minimal_explicit_format_overrides_filename_extension(self):
|
|
210
|
+
(Path(self.folder.name) / "audio.data").write_bytes(b"RIFF")
|
|
211
|
+
self.path.write_text(YAML.replace("audio.wav", "audio.data"))
|
|
212
|
+
self.client.plan(self.path)
|
|
213
|
+
grant = next(json.loads(body) for method, path, _, body in self.opener.calls
|
|
214
|
+
if method == "POST" and path.endswith("/attachments"))
|
|
215
|
+
self.assertEqual(grant["format"], "wav")
|
|
216
|
+
|
|
217
|
+
def test_output_download_rejects_oversize_without_partial_file(self):
|
|
218
|
+
self.opener.output_body = b"unexpected"
|
|
219
|
+
self.opener.declared_output_bytes = 2
|
|
220
|
+
directory = Path(self.folder.name) / "results"
|
|
221
|
+
with self.assertRaises(GridlineError) as raised:
|
|
222
|
+
self.client.outputs("job_1", directory)
|
|
223
|
+
self.assertEqual(raised.exception.code, "output_hash_mismatch")
|
|
224
|
+
self.assertEqual(list(directory.iterdir()), [])
|
|
225
|
+
|
|
226
|
+
def test_output_download_rejects_same_length_digest_mismatch(self):
|
|
227
|
+
self.opener.output_body = b"[]"
|
|
228
|
+
directory = Path(self.folder.name) / "results"
|
|
229
|
+
with self.assertRaises(GridlineError) as raised:
|
|
230
|
+
self.client.outputs("job_1", directory)
|
|
231
|
+
self.assertEqual(raised.exception.code, "output_hash_mismatch")
|
|
232
|
+
self.assertEqual(list(directory.iterdir()), [])
|
|
233
|
+
|
|
234
|
+
def test_output_download_streams_beyond_legacy_eight_mib(self):
|
|
235
|
+
self.opener.output_body = b"x" * (8 * 1024 * 1024 + 1)
|
|
236
|
+
self.opener.output_digest = hashlib.sha256(self.opener.output_body).hexdigest()
|
|
237
|
+
directory = Path(self.folder.name) / "results"
|
|
238
|
+
saved = self.client.outputs("job_1", directory)
|
|
239
|
+
self.assertEqual(Path(saved["downloaded"][0]["path"]).stat().st_size,
|
|
240
|
+
len(self.opener.output_body))
|
|
241
|
+
|
|
242
|
+
def test_input_upload_streams_and_confirms_exact_file(self):
|
|
243
|
+
self.opener.upload_required = True
|
|
244
|
+
planned = self.client.plan(self.path)
|
|
245
|
+
self.assertIsNotNone(planned.quote)
|
|
246
|
+
self.assertEqual(self.opener.uploaded, b"RIFF")
|
|
247
|
+
upload = next(call for call in self.opener.calls if call[1] == "https://storage.example/input")
|
|
248
|
+
self.assertEqual(next(value for key, value in upload[2].items()
|
|
249
|
+
if key.lower() == "content-length"), "4")
|
|
250
|
+
|
|
251
|
+
def test_input_limit_follows_server_advertisement(self):
|
|
252
|
+
self.opener.maximum_attachment_bytes = 64 * 1024 * 1024
|
|
253
|
+
digest = hashlib.sha256(b"RIFF").hexdigest()
|
|
254
|
+
with patch("gridlinegpu.client._bounded_file_identity", return_value=(4, digest)) as identity:
|
|
255
|
+
self.client.plan(self.path)
|
|
256
|
+
identity.assert_called_once_with(Path(self.folder.name) / "audio.wav",
|
|
257
|
+
self.opener.maximum_attachment_bytes)
|
|
258
|
+
|
|
259
|
+
def test_signed_tus_upload_streams_fixed_chunks_and_recovers_lost_patch_reply(self):
|
|
260
|
+
payload = b"x" * (6 * 1024 * 1024) + b"tail"
|
|
261
|
+
(Path(self.folder.name) / "audio.wav").write_bytes(payload)
|
|
262
|
+
self.opener.maximum_attachment_bytes = 8 * 1024 * 1024
|
|
263
|
+
self.opener.upload_required = True
|
|
264
|
+
self.opener.upload_mode = "tus"
|
|
265
|
+
self.opener.tus_length = len(payload)
|
|
266
|
+
self.opener.tus_fail_patch_once = True
|
|
267
|
+
self.opener.tus_location_header = "sign/session-1"
|
|
268
|
+
|
|
269
|
+
plan = self.client.plan(self.path)
|
|
270
|
+
|
|
271
|
+
self.assertIsNotNone(plan.quote)
|
|
272
|
+
self.assertEqual(self.opener.uploaded, payload)
|
|
273
|
+
self.assertEqual(self.opener.tus_patches, [6 * 1024 * 1024, 4])
|
|
274
|
+
tus_calls = [call for call in self.opener.calls if "upload/resumable" in call[1]]
|
|
275
|
+
self.assertEqual([call[0] for call in tus_calls], ["POST", "PATCH", "HEAD", "PATCH"])
|
|
276
|
+
for _, _, headers, _ in tus_calls:
|
|
277
|
+
self.assertTrue(any(key.lower() == "x-signature" for key in headers))
|
|
278
|
+
self.assertFalse(any(key.lower() == "authorization" for key in headers))
|
|
279
|
+
|
|
280
|
+
def test_signed_tus_confirms_committed_object_after_final_reply_and_status_are_lost(self):
|
|
281
|
+
payload = b"x" * 1024
|
|
282
|
+
(Path(self.folder.name) / "audio.wav").write_bytes(payload)
|
|
283
|
+
self.opener.maximum_attachment_bytes = 8 * 1024 * 1024
|
|
284
|
+
self.opener.upload_required = True
|
|
285
|
+
self.opener.upload_mode = "tus"
|
|
286
|
+
self.opener.tus_length = len(payload)
|
|
287
|
+
self.opener.tus_fail_final_reply_and_head = True
|
|
288
|
+
|
|
289
|
+
plan = self.client.plan(self.path)
|
|
290
|
+
|
|
291
|
+
self.assertIsNotNone(plan.quote)
|
|
292
|
+
self.assertEqual(self.opener.uploaded, payload)
|
|
293
|
+
self.assertEqual(len([call for call in self.opener.calls if call[1].endswith("/confirm")]), 1)
|
|
294
|
+
|
|
295
|
+
def test_signed_tus_rejects_escaped_upload_locations(self):
|
|
296
|
+
self.opener.upload_required = True
|
|
297
|
+
self.opener.upload_mode = "tus"
|
|
298
|
+
for location in (
|
|
299
|
+
"https://attacker.example/storage/v1/upload/resumable/session-1",
|
|
300
|
+
"https://user@fixture.storage.supabase.co/storage/v1/upload/resumable/sign/session-1",
|
|
301
|
+
"https://fixture.storage.supabase.co/storage/v1/upload/resumable/session-1",
|
|
302
|
+
"/storage/v1/upload/resumable/session-1?token=leak",
|
|
303
|
+
"/storage/v1/upload/resumable/session-1#fragment",
|
|
304
|
+
"/storage/v1/upload/resumable/short",
|
|
305
|
+
"/storage/v1/upload/resumable/session-1/extra",
|
|
306
|
+
):
|
|
307
|
+
self.opener.tus_location = location
|
|
308
|
+
with self.assertRaises(GridlineError) as raised:
|
|
309
|
+
self.client.plan(self.path)
|
|
310
|
+
self.assertEqual(raised.exception.code, "remote_contract_invalid")
|
|
311
|
+
self.assertFalse(any(call[1] == location for call in self.opener.calls))
|
|
312
|
+
|
|
313
|
+
def test_invalid_advertised_attachment_limit_fails_before_reservation(self):
|
|
314
|
+
for value in (True, 512 * 1024 * 1024 + 1):
|
|
315
|
+
self.opener.maximum_attachment_bytes = value
|
|
316
|
+
with self.assertRaises(GridlineError) as raised:
|
|
317
|
+
self.client.plan(self.path)
|
|
318
|
+
self.assertEqual(raised.exception.code, "remote_contract_invalid")
|
|
319
|
+
self.assertFalse(any(path.endswith("/attachments") for _, path, _, _ in self.opener.calls))
|
|
320
|
+
|
|
321
|
+
def test_plan_stages_out_of_order_package_files_by_logical_name(self):
|
|
322
|
+
self.path.write_text(CUSTOM_YAML)
|
|
323
|
+
root = Path(self.folder.name)
|
|
324
|
+
(root / "beta.bin").write_bytes(b"BBBB")
|
|
325
|
+
(root / "alpha.bin").write_bytes(b"AAAA")
|
|
326
|
+
self.opener.required_attachments = ["alpha", "beta"]
|
|
327
|
+
|
|
328
|
+
self.client.plan(self.path)
|
|
329
|
+
|
|
330
|
+
submitted = next(json.loads(body) for _, path, _, body in self.opener.calls
|
|
331
|
+
if path.endswith("/submit"))
|
|
332
|
+
bindings = {item["logicalName"]: item for item in submitted["attachmentBindings"]}
|
|
333
|
+
self.assertEqual(set(bindings), {"alpha", "beta"})
|
|
334
|
+
for name, contents in (("alpha", b"AAAA"), ("beta", b"BBBB")):
|
|
335
|
+
self.assertEqual(bindings[name]["sha256"], hashlib.sha256(contents).hexdigest())
|
|
336
|
+
self.assertEqual(bindings[name]["bytes"], len(contents))
|
|
337
|
+
self.assertEqual(bindings[name]["artifactId"],
|
|
338
|
+
"input_" + hashlib.sha256(contents).hexdigest()[:12])
|
|
339
|
+
|
|
340
|
+
def test_plan_rejects_duplicate_server_attachment_names_before_staging(self):
|
|
341
|
+
self.opener.required_attachments = ["audio", "audio"]
|
|
342
|
+
with self.assertRaisesRegex(GridlineError, "Input names differ"):
|
|
343
|
+
self.client.plan(self.path)
|
|
344
|
+
self.assertFalse(any(path.endswith("/attachments") for _, path, _, _ in self.opener.calls))
|
|
345
|
+
|
|
346
|
+
def test_changed_plan_cannot_launch(self):
|
|
347
|
+
plan = self.client.plan(self.path)
|
|
348
|
+
self.opener.changed = True
|
|
349
|
+
with self.assertRaisesRegex(GridlineError, "changed"):
|
|
350
|
+
self.client.launch(plan, max_cost_usd="5", idempotency_key="same-key")
|
|
351
|
+
self.assertFalse(any(path.endswith("/launch") for _, path, _, _ in self.opener.calls))
|
|
352
|
+
|
|
353
|
+
def test_identical_yaml_creates_fresh_submission_unless_retry_key_is_explicit(self):
|
|
354
|
+
for _ in range(2):
|
|
355
|
+
self.client.plan(self.path)
|
|
356
|
+
keys = [next(value for name, value in headers.items() if name.lower() == "idempotency-key")
|
|
357
|
+
for _, path, headers, _ in self.opener.calls
|
|
358
|
+
if path.endswith("/submit")]
|
|
359
|
+
self.assertEqual(len(keys), 2)
|
|
360
|
+
self.assertNotEqual(keys[0], keys[1])
|
|
361
|
+
self.client.plan(self.path, submission_key="retry-this-submit")
|
|
362
|
+
self.client.plan(self.path, submission_key="retry-this-submit")
|
|
363
|
+
keys = [next(value for name, value in headers.items() if name.lower() == "idempotency-key")
|
|
364
|
+
for _, path, headers, _ in self.opener.calls
|
|
365
|
+
if path.endswith("/submit")]
|
|
366
|
+
self.assertEqual(keys[-2:], ["retry-this-submit", "retry-this-submit"])
|
|
367
|
+
|
|
368
|
+
def test_cost_ceiling_and_yaml_change_cannot_launch(self):
|
|
369
|
+
plan = self.client.plan(self.path)
|
|
370
|
+
with self.assertRaisesRegex(GridlineError, "ceiling"):
|
|
371
|
+
self.client.launch(plan, max_cost_usd="4.99", idempotency_key="same-key")
|
|
372
|
+
self.path.write_text(YAML + "# changed\n")
|
|
373
|
+
with self.assertRaisesRegex(GridlineError, "changed"):
|
|
374
|
+
self.client.launch(plan, max_cost_usd="5", idempotency_key="same-key")
|
|
375
|
+
|
|
376
|
+
def test_requests_identify_sdk_to_production_edge(self):
|
|
377
|
+
self.client.availability()
|
|
378
|
+
headers = self.opener.calls[-1][2]
|
|
379
|
+
self.assertEqual(next(value for name, value in headers.items() if name.lower() == "user-agent"),
|
|
380
|
+
"GridlinePythonSDK/1")
|
|
381
|
+
|
|
382
|
+
def test_uses_private_token_created_by_pip_installed_cli(self):
|
|
383
|
+
token_file = Path(self.folder.name) / ".config" / "gridline" / "python-token"
|
|
384
|
+
token_file.parent.mkdir(parents=True)
|
|
385
|
+
token_file.write_text(TOKEN + "\n")
|
|
386
|
+
token_file.chmod(0o600)
|
|
387
|
+
with patch.dict(os.environ, {"GRIDLINE_API_TOKEN_FILE": "", "GRIDLINE_API_TOKEN": ""}):
|
|
388
|
+
with patch.object(Path, "home", return_value=Path(self.folder.name)):
|
|
389
|
+
client = GridlineClient(opener=self.opener)
|
|
390
|
+
self.assertEqual(client._read_token(), TOKEN)
|
|
391
|
+
|
|
392
|
+
def test_wait_returns_when_finite_outputs_are_complete_before_billing(self):
|
|
393
|
+
self.opener.job = {"jobId": "job_1", "outcome": "pending", "execution": {"mode": "finite"},
|
|
394
|
+
"outputs": "complete", "cleanup": "pending", "billing": "pending"}
|
|
395
|
+
result = self.client.wait("job_1", timeout=1)
|
|
396
|
+
self.assertEqual(result["job"]["outcome"], "pending")
|
|
397
|
+
self.assertEqual(result["job"]["outputs"], "complete")
|
|
398
|
+
self.assertEqual(self.client.outputs("job_1", Path(self.folder.name) / "results")["state"],
|
|
399
|
+
"available")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
if __name__ == "__main__":
|
|
403
|
+
unittest.main()
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import io
|
|
2
|
-
import json
|
|
3
|
-
import os
|
|
4
|
-
import tempfile
|
|
5
|
-
import unittest
|
|
6
|
-
from datetime import datetime, timedelta, timezone
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from unittest.mock import patch
|
|
9
|
-
|
|
10
|
-
from gridlinegpu import GridlineClient, GridlineError
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
TOKEN = "gla_" + "a" * 43
|
|
14
|
-
YAML = """name: speech
|
|
15
|
-
task:
|
|
16
|
-
operation: asr_transcription
|
|
17
|
-
model: Systran/faster-whisper-large-v3
|
|
18
|
-
inputs:
|
|
19
|
-
audio: { file: audio.wav, format: wav }
|
|
20
|
-
limits: { maxCostUSD: 5, deadline: 1h }
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class Response(io.BytesIO):
|
|
25
|
-
def __enter__(self):
|
|
26
|
-
return self
|
|
27
|
-
|
|
28
|
-
def __exit__(self, *_):
|
|
29
|
-
self.close()
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class FakeOpener:
|
|
33
|
-
def __init__(self):
|
|
34
|
-
self.calls = []
|
|
35
|
-
self.changed = False
|
|
36
|
-
self.job = {"jobId": "job_1", "outcome": "succeeded"}
|
|
37
|
-
self.expiry = (datetime.now(timezone.utc) + timedelta(minutes=2)).isoformat()
|
|
38
|
-
|
|
39
|
-
def open(self, request, timeout):
|
|
40
|
-
path = request.full_url.removeprefix("https://gridlinegpu.com")
|
|
41
|
-
self.calls.append((request.get_method(), path, request.headers, request.data))
|
|
42
|
-
if path == "/api/workloads/yaml/validate":
|
|
43
|
-
result = {"schemaVersion": 1, "requiredAttachments": ["audio"]}
|
|
44
|
-
elif path == "/api/workloads/yaml/availability":
|
|
45
|
-
result = {"schemaVersion": 1, "planningAvailable": True, "executionEnabled": True,
|
|
46
|
-
"artifactStagingAvailable": True, "maximumAttachmentBytes": 1024}
|
|
47
|
-
elif path == "/api/workloads/yaml/attachments":
|
|
48
|
-
result = {"artifactId": "input_1", "sha256": self.digest, "byteLength": 4,
|
|
49
|
-
"format": "wav", "state": "ready"}
|
|
50
|
-
elif path == "/api/workloads/yaml/submit":
|
|
51
|
-
result = {"workloadId": "yaml_1", "workloadHash": "b" * 64}
|
|
52
|
-
elif path == "/api/workloads/yaml/yaml_1/plan":
|
|
53
|
-
result = {"workloadId": "yaml_1", "workloadHash": "b" * 64,
|
|
54
|
-
"result": {"status": "planned", "planHash": "x" * 64 if self.changed else "c" * 64,
|
|
55
|
-
"quote": {"quoteId": "quote_1", "quoteHash": "d" * 64,
|
|
56
|
-
"expiresAt": self.expiry, "maximumTotalMicros": 5_000_000,
|
|
57
|
-
"fallbackConsent": {"mode": "none"}}}}
|
|
58
|
-
elif path == "/api/workloads/yaml/yaml_1/launch":
|
|
59
|
-
result = {"workloadId": "yaml_1", "planHash": "c" * 64, "jobId": "job_1"}
|
|
60
|
-
elif path == "/api/workloads/yaml/jobs/job_1":
|
|
61
|
-
result = {"job": self.job}
|
|
62
|
-
elif path == "/api/workloads/yaml/jobs/job_1/output":
|
|
63
|
-
result = {"jobId": "job_1", "state": "available", "manifest": {"outputs": [{
|
|
64
|
-
"name": "result.json", "artifactId": "output_1", "sha256": self.output_digest,
|
|
65
|
-
"bytes": 2, "completed": True}], "missingRequiredOutputs": []}}
|
|
66
|
-
elif path == "/api/workloads/yaml/jobs/job_1/output/output_1/grant":
|
|
67
|
-
result = {"jobId": "job_1", "artifactId": "output_1", "sha256": self.output_digest,
|
|
68
|
-
"byteLength": 2, "download": {"url": "https://storage.example/result",
|
|
69
|
-
"expiresAt": self.expiry}}
|
|
70
|
-
elif path == "https://storage.example/result":
|
|
71
|
-
return Response(b"{}")
|
|
72
|
-
else:
|
|
73
|
-
raise AssertionError(f"Unexpected request: {path}")
|
|
74
|
-
return Response(json.dumps(result).encode())
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
class ClientTest(unittest.TestCase):
|
|
78
|
-
def setUp(self):
|
|
79
|
-
import hashlib
|
|
80
|
-
self.folder = tempfile.TemporaryDirectory()
|
|
81
|
-
self.addCleanup(self.folder.cleanup)
|
|
82
|
-
root = Path(self.folder.name)
|
|
83
|
-
(root / "workload.yaml").write_text(YAML)
|
|
84
|
-
(root / "audio.wav").write_bytes(b"RIFF")
|
|
85
|
-
self.path = root / "workload.yaml"
|
|
86
|
-
self.opener = FakeOpener()
|
|
87
|
-
self.opener.digest = hashlib.sha256(b"RIFF").hexdigest()
|
|
88
|
-
self.opener.output_digest = hashlib.sha256(b"{}").hexdigest()
|
|
89
|
-
self.client = GridlineClient(token=TOKEN, opener=self.opener)
|
|
90
|
-
|
|
91
|
-
def test_plan_launch_and_verified_download(self):
|
|
92
|
-
plan = self.client.plan(self.path)
|
|
93
|
-
self.assertEqual(plan.workload_id, "yaml_1")
|
|
94
|
-
self.assertFalse(any(path.endswith("/launch") for _, path, _, _ in self.opener.calls))
|
|
95
|
-
launched = self.client.launch(plan, max_cost_usd="5", idempotency_key="test-job-1")
|
|
96
|
-
self.assertEqual(launched["jobId"], "job_1")
|
|
97
|
-
self.assertEqual(self.client.wait("job_1", timeout=1)["job"]["outcome"], "succeeded")
|
|
98
|
-
saved = self.client.outputs("job_1", Path(self.folder.name) / "results")
|
|
99
|
-
self.assertEqual(Path(saved["downloaded"][0]["path"]).read_bytes(), b"{}")
|
|
100
|
-
self.assertRaises(FileExistsError, self.client.outputs, "job_1", Path(self.folder.name) / "results")
|
|
101
|
-
|
|
102
|
-
def test_changed_plan_cannot_launch(self):
|
|
103
|
-
plan = self.client.plan(self.path)
|
|
104
|
-
self.opener.changed = True
|
|
105
|
-
with self.assertRaisesRegex(GridlineError, "changed"):
|
|
106
|
-
self.client.launch(plan, max_cost_usd="5", idempotency_key="same-key")
|
|
107
|
-
self.assertFalse(any(path.endswith("/launch") for _, path, _, _ in self.opener.calls))
|
|
108
|
-
|
|
109
|
-
def test_identical_yaml_creates_fresh_submission_unless_retry_key_is_explicit(self):
|
|
110
|
-
for _ in range(2):
|
|
111
|
-
self.client.plan(self.path)
|
|
112
|
-
keys = [next(value for name, value in headers.items() if name.lower() == "idempotency-key")
|
|
113
|
-
for _, path, headers, _ in self.opener.calls
|
|
114
|
-
if path.endswith("/submit")]
|
|
115
|
-
self.assertEqual(len(keys), 2)
|
|
116
|
-
self.assertNotEqual(keys[0], keys[1])
|
|
117
|
-
self.client.plan(self.path, submission_key="retry-this-submit")
|
|
118
|
-
self.client.plan(self.path, submission_key="retry-this-submit")
|
|
119
|
-
keys = [next(value for name, value in headers.items() if name.lower() == "idempotency-key")
|
|
120
|
-
for _, path, headers, _ in self.opener.calls
|
|
121
|
-
if path.endswith("/submit")]
|
|
122
|
-
self.assertEqual(keys[-2:], ["retry-this-submit", "retry-this-submit"])
|
|
123
|
-
|
|
124
|
-
def test_cost_ceiling_and_yaml_change_cannot_launch(self):
|
|
125
|
-
plan = self.client.plan(self.path)
|
|
126
|
-
with self.assertRaisesRegex(GridlineError, "ceiling"):
|
|
127
|
-
self.client.launch(plan, max_cost_usd="4.99", idempotency_key="same-key")
|
|
128
|
-
self.path.write_text(YAML + "# changed\n")
|
|
129
|
-
with self.assertRaisesRegex(GridlineError, "changed"):
|
|
130
|
-
self.client.launch(plan, max_cost_usd="5", idempotency_key="same-key")
|
|
131
|
-
|
|
132
|
-
def test_requests_identify_sdk_to_production_edge(self):
|
|
133
|
-
self.client.availability()
|
|
134
|
-
headers = self.opener.calls[-1][2]
|
|
135
|
-
self.assertEqual(next(value for name, value in headers.items() if name.lower() == "user-agent"),
|
|
136
|
-
"GridlinePythonSDK/1")
|
|
137
|
-
|
|
138
|
-
def test_uses_private_token_created_by_pip_installed_cli(self):
|
|
139
|
-
token_file = Path(self.folder.name) / ".config" / "gridline" / "python-token"
|
|
140
|
-
token_file.parent.mkdir(parents=True)
|
|
141
|
-
token_file.write_text(TOKEN + "\n")
|
|
142
|
-
token_file.chmod(0o600)
|
|
143
|
-
with patch.dict(os.environ, {"GRIDLINE_API_TOKEN_FILE": "", "GRIDLINE_API_TOKEN": ""}):
|
|
144
|
-
with patch.object(Path, "home", return_value=Path(self.folder.name)):
|
|
145
|
-
client = GridlineClient(opener=self.opener)
|
|
146
|
-
self.assertEqual(client._read_token(), TOKEN)
|
|
147
|
-
|
|
148
|
-
def test_wait_returns_when_finite_outputs_are_complete_before_billing(self):
|
|
149
|
-
self.opener.job = {"jobId": "job_1", "outcome": "pending", "execution": {"mode": "finite"},
|
|
150
|
-
"outputs": "complete", "cleanup": "pending", "billing": "pending"}
|
|
151
|
-
result = self.client.wait("job_1", timeout=1)
|
|
152
|
-
self.assertEqual(result["job"]["outcome"], "pending")
|
|
153
|
-
self.assertEqual(result["job"]["outputs"], "complete")
|
|
154
|
-
self.assertEqual(self.client.outputs("job_1", Path(self.folder.name) / "results")["state"],
|
|
155
|
-
"available")
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if __name__ == "__main__":
|
|
159
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|