gridlinegpu 0.1.0__tar.gz → 0.1.2__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.0
3
+ Version: 0.1.2
4
4
  Summary: Python client for Gridline YAML workload routing
5
5
  Author: Gridline GPU
6
6
  License: Proprietary
@@ -22,7 +22,7 @@ fresh plan when you supply a cost ceiling and idempotency key.
22
22
  ## Install
23
23
 
24
24
  ```sh
25
- python -m pip install gridlinegpu
25
+ python -m pip install 'gridlinegpu>=0.1.2,<0.2'
26
26
  ```
27
27
 
28
28
  Create a scoped token using the Gridline CLI after browser sign-in. The token stays in a
@@ -48,7 +48,8 @@ print(plan.review) # Check selected GPU, exclusions, estimate, and maximum.
48
48
  # Launch only after reviewing the fresh plan and accepting its maximum spend.
49
49
  launch = client.launch(plan, max_cost_usd="5.00", idempotency_key="my-job-2026-09-27")
50
50
  job_id = launch["jobId"]
51
- print(client.wait(job_id, timeout=7200)["job"]["outcome"])
51
+ ready = client.wait(job_id, timeout=7200)
52
+ print(ready["job"]["outputs"], ready["job"]["outcome"])
52
53
  print(client.outputs(job_id, "results"))
53
54
  ```
54
55
 
@@ -57,3 +58,5 @@ changes a plan or retries an ambiguous paid request. If launch outcome is unknow
57
58
  inspect `LaunchOutcomeUnknown.workload_id` with the same idempotency key; do not use a
58
59
  new key. `outputs()` verifies byte count and SHA-256 before creating private files.
59
60
  Cancellation requests stop; cleanup and final provider billing can remain pending.
61
+ For finite jobs, `wait()` returns when outputs are complete, even if cleanup and billing
62
+ leave `outcome` pending. Check `status()` later for final settlement.
@@ -7,7 +7,7 @@ fresh plan when you supply a cost ceiling and idempotency key.
7
7
  ## Install
8
8
 
9
9
  ```sh
10
- python -m pip install gridlinegpu
10
+ python -m pip install 'gridlinegpu>=0.1.2,<0.2'
11
11
  ```
12
12
 
13
13
  Create a scoped token using the Gridline CLI after browser sign-in. The token stays in a
@@ -33,7 +33,8 @@ print(plan.review) # Check selected GPU, exclusions, estimate, and maximum.
33
33
  # Launch only after reviewing the fresh plan and accepting its maximum spend.
34
34
  launch = client.launch(plan, max_cost_usd="5.00", idempotency_key="my-job-2026-09-27")
35
35
  job_id = launch["jobId"]
36
- print(client.wait(job_id, timeout=7200)["job"]["outcome"])
36
+ ready = client.wait(job_id, timeout=7200)
37
+ print(ready["job"]["outputs"], ready["job"]["outcome"])
37
38
  print(client.outputs(job_id, "results"))
38
39
  ```
39
40
 
@@ -42,3 +43,5 @@ changes a plan or retries an ambiguous paid request. If launch outcome is unknow
42
43
  inspect `LaunchOutcomeUnknown.workload_id` with the same idempotency key; do not use a
43
44
  new key. `outputs()` verifies byte count and SHA-256 before creating private files.
44
45
  Cancellation requests stop; cleanup and final provider billing can remain pending.
46
+ For finite jobs, `wait()` returns when outputs are complete, even if cleanup and billing
47
+ leave `outcome` pending. Check `status()` later for final settlement.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gridlinegpu"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Python client for Gridline YAML workload routing"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -10,6 +10,7 @@ import json
10
10
  import os
11
11
  import re
12
12
  import time
13
+ import uuid
13
14
  from dataclasses import dataclass
14
15
  from datetime import datetime, timezone
15
16
  from decimal import Decimal
@@ -29,6 +30,7 @@ _KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
29
30
  _SHA = re.compile(r"^[0-9a-f]{64}$")
30
31
  _MAX_YAML = 65_536
31
32
  _MAX_DOWNLOAD = 8 * 1024 * 1024
33
+ _USER_AGENT = "GridlinePythonSDK/1"
32
34
 
33
35
 
34
36
  class _NoRedirect(HTTPRedirectHandler):
@@ -97,7 +99,10 @@ class GridlineClient:
97
99
  "POST", f"{_API}/validate", {"schemaVersion": 1, "yaml": source, "attachmentBindings": []}
98
100
  )
99
101
 
100
- def plan(self, yaml_path: str | Path) -> PlannedWorkload:
102
+ def plan(self, yaml_path: str | Path, *, submission_key: str | None = None) -> PlannedWorkload:
103
+ """Create a fresh submission; reuse submission_key only to retry an uncertain submit."""
104
+ if submission_key is not None and not _KEY.fullmatch(submission_key):
105
+ raise ValueError("submission_key must contain 1–128 safe characters")
101
106
  path, source, document = _read_yaml(yaml_path)
102
107
  validated = self.validate(path)
103
108
  available = self.availability()
@@ -113,8 +118,9 @@ class GridlineClient:
113
118
  raise GridlineError("Invalid server attachment limit", code="remote_contract_invalid")
114
119
  bindings = [self._stage(path.parent, name, item, ceiling) for name, item in inputs]
115
120
  body = {"schemaVersion": 1, "yaml": source, "attachmentBindings": bindings}
116
- stable = _sha(json.dumps(body, sort_keys=True, separators=(",", ":")).encode())
117
- submitted = self._json("POST", f"{_API}/submit", body, key=f"sdk-submit-{stable}")
121
+ submitted = self._json(
122
+ "POST", f"{_API}/submit", body, key=submission_key or f"sdk-submit-{uuid.uuid4()}"
123
+ )
118
124
  workload_id = _id(submitted.get("workloadId"), "workloadId")
119
125
  result = self._json("POST", f"{_API}/{quote(workload_id)}/plan")
120
126
  if result.get("workloadId") != workload_id or result.get("workloadHash") != submitted.get("workloadHash"):
@@ -205,13 +211,16 @@ class GridlineClient:
205
211
  return result
206
212
 
207
213
  def wait(self, job_id: str, *, timeout: float = 3600, interval: float = 5) -> dict[str, Any]:
214
+ """Wait for finite-job outputs or a terminal outcome; billing may finish later."""
208
215
  if timeout <= 0 or interval <= 0:
209
216
  raise ValueError("timeout and interval must be positive")
210
217
  deadline = time.monotonic() + timeout
211
218
  while True:
212
219
  result = self.status(job_id)
213
- state = result["job"].get("outcome")
214
- if state in ("succeeded", "failed", "cancelled", "timed_out"):
220
+ job = result["job"]
221
+ if job.get("outcome") in ("succeeded", "failed", "cancelled", "timed_out") or (
222
+ job.get("execution", {}).get("mode") == "finite" and job.get("outputs") == "complete"
223
+ ):
215
224
  return result
216
225
  if time.monotonic() >= deadline:
217
226
  raise TimeoutError(f"Job {job_id} did not finish before timeout")
@@ -307,7 +316,7 @@ class GridlineClient:
307
316
  headers: dict[str, str] | None = None, maximum: int) -> bytes:
308
317
  if not isinstance(url, str) or urlparse(url).scheme != "https":
309
318
  raise GridlineError("Grant URL must use HTTPS", code="remote_contract_invalid")
310
- request = Request(url, data=data, headers=headers or {}, method=method)
319
+ request = Request(url, data=data, headers={"User-Agent": _USER_AGENT, **(headers or {})}, method=method)
311
320
  try:
312
321
  with self._opener.open(request, timeout=self._timeout) as response:
313
322
  raw = response.read(maximum + 1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridlinegpu
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Python client for Gridline YAML workload routing
5
5
  Author: Gridline GPU
6
6
  License: Proprietary
@@ -22,7 +22,7 @@ fresh plan when you supply a cost ceiling and idempotency key.
22
22
  ## Install
23
23
 
24
24
  ```sh
25
- python -m pip install gridlinegpu
25
+ python -m pip install 'gridlinegpu>=0.1.2,<0.2'
26
26
  ```
27
27
 
28
28
  Create a scoped token using the Gridline CLI after browser sign-in. The token stays in a
@@ -48,7 +48,8 @@ print(plan.review) # Check selected GPU, exclusions, estimate, and maximum.
48
48
  # Launch only after reviewing the fresh plan and accepting its maximum spend.
49
49
  launch = client.launch(plan, max_cost_usd="5.00", idempotency_key="my-job-2026-09-27")
50
50
  job_id = launch["jobId"]
51
- print(client.wait(job_id, timeout=7200)["job"]["outcome"])
51
+ ready = client.wait(job_id, timeout=7200)
52
+ print(ready["job"]["outputs"], ready["job"]["outcome"])
52
53
  print(client.outputs(job_id, "results"))
53
54
  ```
54
55
 
@@ -57,3 +58,5 @@ changes a plan or retries an ambiguous paid request. If launch outcome is unknow
57
58
  inspect `LaunchOutcomeUnknown.workload_id` with the same idempotency key; do not use a
58
59
  new key. `outputs()` verifies byte count and SHA-256 before creating private files.
59
60
  Cancellation requests stop; cleanup and final provider billing can remain pending.
61
+ For finite jobs, `wait()` returns when outputs are complete, even if cleanup and billing
62
+ leave `outcome` pending. Check `status()` later for final settlement.
@@ -31,6 +31,7 @@ class FakeOpener:
31
31
  def __init__(self):
32
32
  self.calls = []
33
33
  self.changed = False
34
+ self.job = {"jobId": "job_1", "outcome": "succeeded"}
34
35
  self.expiry = (datetime.now(timezone.utc) + timedelta(minutes=2)).isoformat()
35
36
 
36
37
  def open(self, request, timeout):
@@ -55,7 +56,7 @@ class FakeOpener:
55
56
  elif path == "/api/workloads/yaml/yaml_1/launch":
56
57
  result = {"workloadId": "yaml_1", "planHash": "c" * 64, "jobId": "job_1"}
57
58
  elif path == "/api/workloads/yaml/jobs/job_1":
58
- result = {"job": {"jobId": "job_1", "outcome": "succeeded"}}
59
+ result = {"job": self.job}
59
60
  elif path == "/api/workloads/yaml/jobs/job_1/output":
60
61
  result = {"jobId": "job_1", "state": "available", "manifest": {"outputs": [{
61
62
  "name": "result.json", "artifactId": "output_1", "sha256": self.output_digest,
@@ -103,6 +104,21 @@ class ClientTest(unittest.TestCase):
103
104
  self.client.launch(plan, max_cost_usd="5", idempotency_key="same-key")
104
105
  self.assertFalse(any(path.endswith("/launch") for _, path, _, _ in self.opener.calls))
105
106
 
107
+ def test_identical_yaml_creates_fresh_submission_unless_retry_key_is_explicit(self):
108
+ for _ in range(2):
109
+ self.client.plan(self.path)
110
+ keys = [next(value for name, value in headers.items() if name.lower() == "idempotency-key")
111
+ for _, path, headers, _ in self.opener.calls
112
+ if path.endswith("/submit")]
113
+ self.assertEqual(len(keys), 2)
114
+ self.assertNotEqual(keys[0], keys[1])
115
+ self.client.plan(self.path, submission_key="retry-this-submit")
116
+ self.client.plan(self.path, submission_key="retry-this-submit")
117
+ keys = [next(value for name, value in headers.items() if name.lower() == "idempotency-key")
118
+ for _, path, headers, _ in self.opener.calls
119
+ if path.endswith("/submit")]
120
+ self.assertEqual(keys[-2:], ["retry-this-submit", "retry-this-submit"])
121
+
106
122
  def test_cost_ceiling_and_yaml_change_cannot_launch(self):
107
123
  plan = self.client.plan(self.path)
108
124
  with self.assertRaisesRegex(GridlineError, "ceiling"):
@@ -111,6 +127,21 @@ class ClientTest(unittest.TestCase):
111
127
  with self.assertRaisesRegex(GridlineError, "changed"):
112
128
  self.client.launch(plan, max_cost_usd="5", idempotency_key="same-key")
113
129
 
130
+ def test_requests_identify_sdk_to_production_edge(self):
131
+ self.client.availability()
132
+ headers = self.opener.calls[-1][2]
133
+ self.assertEqual(next(value for name, value in headers.items() if name.lower() == "user-agent"),
134
+ "GridlinePythonSDK/1")
135
+
136
+ def test_wait_returns_when_finite_outputs_are_complete_before_billing(self):
137
+ self.opener.job = {"jobId": "job_1", "outcome": "pending", "execution": {"mode": "finite"},
138
+ "outputs": "complete", "cleanup": "pending", "billing": "pending"}
139
+ result = self.client.wait("job_1", timeout=1)
140
+ self.assertEqual(result["job"]["outcome"], "pending")
141
+ self.assertEqual(result["job"]["outputs"], "complete")
142
+ self.assertEqual(self.client.outputs("job_1", Path(self.folder.name) / "results")["state"],
143
+ "available")
144
+
114
145
 
115
146
  if __name__ == "__main__":
116
147
  unittest.main()
File without changes