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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridlinegpu
3
- Version: 0.1.3
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.3,<0.2'
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
- Current hosted artifact limits are 40 MiB per local input and 8 MiB per output.
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.3,<0.2'
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
- Current hosted artifact limits are 40 MiB per local input and 8 MiB per output.
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.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gridlinegpu"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "Python client for Gridline YAML workload routing"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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 = 8 * 1024 * 1024
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
- if validated.get("requiredAttachments") != [name for name, _ in inputs]:
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 not isinstance(ceiling, int) or ceiling < 1:
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) or not isinstance(size, int) or size > _MAX_DOWNLOAD:
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
- with destination.open("xb") as handle:
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
- data = _bounded_file(path, min(ceiling, 40 * 1024 * 1024))
281
- digest = _sha(data)
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": len(data)},
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, len(data), format_name):
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 upload.get("method") != "PUT" or not isinstance(upload.get("headers", {}).get("Authorization"), str):
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
- self._bytes("PUT", upload.get("url"), data=data, headers={"Authorization": upload["headers"]["Authorization"]}, maximum=16_384)
298
- confirmed = self._json("POST", f"{_API}/attachments/{quote(artifact_id)}/confirm")
299
- if (confirmed.get("state"), confirmed.get("artifactId"), confirmed.get("sha256")) != ("ready", artifact_id, digest):
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": len(data)}
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["format"],
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
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.3,<0.2'
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
- Current hosted artifact limits are 40 MiB per local input and 8 MiB per output.
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