gridlinegpu 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """Gridline YAML workload client."""
2
+
3
+ from .client import GridlineClient, PlannedWorkload
4
+ from .errors import GridlineError, LaunchOutcomeUnknown
5
+
6
+ __all__ = ["GridlineClient", "GridlineError", "LaunchOutcomeUnknown", "PlannedWorkload"]
gridlinegpu/client.py ADDED
@@ -0,0 +1,415 @@
1
+ """Small, synchronous client for Gridline's YAML-only workload API.
2
+
3
+ All task settings live in YAML. Methods never select a GPU or retry a paid launch.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import re
12
+ import time
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ from decimal import Decimal
16
+ from pathlib import Path
17
+ from typing import Any
18
+ from urllib.error import HTTPError, URLError
19
+ from urllib.parse import quote, urlparse
20
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
21
+
22
+ import yaml
23
+
24
+ from .errors import GridlineError, LaunchOutcomeUnknown
25
+
26
+ _API = "/api/workloads/yaml"
27
+ _ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$")
28
+ _KEY = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
29
+ _SHA = re.compile(r"^[0-9a-f]{64}$")
30
+ _MAX_YAML = 65_536
31
+ _MAX_DOWNLOAD = 8 * 1024 * 1024
32
+
33
+
34
+ class _NoRedirect(HTTPRedirectHandler):
35
+ def redirect_request(self, request: Request, fp: Any, code: int, msg: str, headers: Any, url: str) -> None:
36
+ return None
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class PlannedWorkload:
41
+ path: Path
42
+ yaml_sha256: str
43
+ workload_id: str
44
+ response: dict[str, Any]
45
+
46
+ @property
47
+ def quote(self) -> dict[str, Any] | None:
48
+ result = self.response.get("result", {})
49
+ return result.get("quote") if result.get("status") == "planned" else None
50
+
51
+ @property
52
+ def review(self) -> dict[str, Any]:
53
+ """Return server plan, including coverage, chosen GPU and price estimates."""
54
+ return self.response
55
+
56
+
57
+ class GridlineClient:
58
+ """Use a scoped Gridline automation token; never put it in YAML or source code.
59
+
60
+ Create a token with ``gridline auth token create`` and pass its 0600 file path.
61
+ The SDK also reads ``GRIDLINE_API_TOKEN_FILE`` or ``GRIDLINE_API_TOKEN``.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ token: str | None = None,
68
+ token_file: str | Path | None = None,
69
+ base_url: str = "https://gridlinegpu.com",
70
+ timeout: float = 30,
71
+ opener: Any = None,
72
+ ) -> None:
73
+ if token and token_file:
74
+ raise ValueError("Choose token or token_file")
75
+ if not token and not token_file:
76
+ token_file = os.getenv("GRIDLINE_API_TOKEN_FILE")
77
+ token = os.getenv("GRIDLINE_API_TOKEN") if not token_file else None
78
+ if not token and not token_file:
79
+ raise ValueError("Set GRIDLINE_API_TOKEN_FILE or pass token_file")
80
+ parsed = urlparse(base_url)
81
+ if parsed.scheme != "https" or not parsed.netloc or parsed.path not in ("", "/"):
82
+ raise ValueError("base_url must be an HTTPS origin")
83
+ if timeout <= 0 or timeout > 120:
84
+ raise ValueError("timeout must be between 0 and 120 seconds")
85
+ self._token = token
86
+ self._token_file = Path(token_file).expanduser() if token_file else None
87
+ self._base_url = base_url.rstrip("/")
88
+ self._timeout = timeout
89
+ self._opener = opener or build_opener(_NoRedirect())
90
+
91
+ def availability(self) -> dict[str, Any]:
92
+ return self._json("GET", f"{_API}/availability")
93
+
94
+ def validate(self, yaml_path: str | Path) -> dict[str, Any]:
95
+ _, source, _ = _read_yaml(yaml_path)
96
+ return self._json(
97
+ "POST", f"{_API}/validate", {"schemaVersion": 1, "yaml": source, "attachmentBindings": []}
98
+ )
99
+
100
+ def plan(self, yaml_path: str | Path) -> PlannedWorkload:
101
+ path, source, document = _read_yaml(yaml_path)
102
+ validated = self.validate(path)
103
+ available = self.availability()
104
+ if not available.get("planningAvailable"):
105
+ raise GridlineError("YAML planning is unavailable for this account", code="planning_unavailable")
106
+ inputs = _local_inputs(document)
107
+ if validated.get("requiredAttachments") != [name for name, _ in inputs]:
108
+ raise GridlineError("Input names differ from server validation", code="remote_contract_invalid")
109
+ if inputs and not available.get("artifactStagingAvailable"):
110
+ raise GridlineError("Input staging is unavailable for this account", code="staging_unavailable")
111
+ ceiling = available.get("maximumAttachmentBytes")
112
+ if not isinstance(ceiling, int) or ceiling < 1:
113
+ raise GridlineError("Invalid server attachment limit", code="remote_contract_invalid")
114
+ bindings = [self._stage(path.parent, name, item, ceiling) for name, item in inputs]
115
+ 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}")
118
+ workload_id = _id(submitted.get("workloadId"), "workloadId")
119
+ result = self._json("POST", f"{_API}/{quote(workload_id)}/plan")
120
+ if result.get("workloadId") != workload_id or result.get("workloadHash") != submitted.get("workloadHash"):
121
+ raise GridlineError("Plan identity differs from submitted workload", code="remote_contract_invalid")
122
+ return PlannedWorkload(path, _sha(source.encode()), workload_id, result)
123
+
124
+ def launch(
125
+ self,
126
+ planned: PlannedWorkload,
127
+ *,
128
+ max_cost_usd: Decimal | str | int | float,
129
+ idempotency_key: str,
130
+ ) -> dict[str, Any]:
131
+ """Launch only same fresh plan after caller has reviewed it and set a ceiling."""
132
+ if not _KEY.fullmatch(idempotency_key):
133
+ raise ValueError("idempotency_key must contain 1–128 safe characters")
134
+ _, source, _ = _read_yaml(planned.path)
135
+ if _sha(source.encode()) != planned.yaml_sha256:
136
+ raise GridlineError("YAML changed since plan review", code="plan_changed")
137
+ earlier = planned.quote
138
+ if earlier is None:
139
+ raise GridlineError("Router abstained; inspect plan coverage", code="plan_abstained")
140
+ if not self.availability().get("executionEnabled"):
141
+ raise GridlineError("YAML execution is disabled for this account", code="execution_disabled")
142
+ micros = earlier.get("maximumTotalMicros")
143
+ if not isinstance(micros, int) or micros <= 0:
144
+ raise GridlineError("Plan has no valid maximum", code="remote_contract_invalid")
145
+ try:
146
+ accepted = Decimal(str(max_cost_usd))
147
+ except Exception as error:
148
+ raise ValueError("max_cost_usd must be a number") from error
149
+ if not accepted.is_finite() or accepted < Decimal(micros) / 1_000_000:
150
+ raise GridlineError("Approved cost ceiling is below YAML plan maximum", code="cost_limit")
151
+ current = self._json("POST", f"{_API}/{quote(planned.workload_id)}/plan")
152
+ fresh = current.get("result", {})
153
+ new_quote = fresh.get("quote", {})
154
+ if (
155
+ current.get("workloadId") != planned.workload_id
156
+ or fresh.get("status") != "planned"
157
+ or fresh.get("planHash") != planned.response.get("result", {}).get("planHash")
158
+ or new_quote.get("quoteHash") != earlier.get("quoteHash")
159
+ or new_quote.get("quoteId") != earlier.get("quoteId")
160
+ or not _future(new_quote.get("expiresAt"))
161
+ ):
162
+ raise GridlineError("Plan changed or expired; review a new plan", code="plan_changed")
163
+ consent = new_quote.get("fallbackConsent", {})
164
+ fallback_ids = consent.get("acceptedChoiceIds", []) if consent.get("mode") == "accepted_ordered" else []
165
+ launch = {
166
+ "schemaVersion": 1,
167
+ "quoteId": new_quote["quoteId"],
168
+ "quoteHash": new_quote["quoteHash"],
169
+ "planHash": fresh["planHash"],
170
+ "acceptedFallbackChoiceIds": fallback_ids,
171
+ }
172
+ try:
173
+ result = self._json(
174
+ "POST", f"{_API}/{quote(planned.workload_id)}/launch", launch, key=idempotency_key
175
+ )
176
+ except (GridlineError, OSError) as error:
177
+ if isinstance(error, GridlineError) and error.status is not None and error.status < 500:
178
+ raise
179
+ try:
180
+ recovered = self.lookup_launch(planned.workload_id, idempotency_key)
181
+ except (GridlineError, OSError):
182
+ recovered = None
183
+ if recovered and recovered.get("planHash") == fresh["planHash"]:
184
+ return recovered
185
+ raise LaunchOutcomeUnknown(planned.workload_id, idempotency_key) from error
186
+ if result.get("workloadId") != planned.workload_id or result.get("planHash") != fresh["planHash"]:
187
+ raise GridlineError("Launch identity mismatch", code="remote_contract_invalid")
188
+ return result
189
+
190
+ def lookup_launch(self, workload_id: str, idempotency_key: str) -> dict[str, Any] | None:
191
+ try:
192
+ return self._json(
193
+ "POST", f"{_API}/launches/lookup",
194
+ {"schemaVersion": 1, "workloadId": _id(workload_id, "workloadId"), "idempotencyKey": idempotency_key},
195
+ )
196
+ except GridlineError as error:
197
+ if error.status == 404:
198
+ return None
199
+ raise
200
+
201
+ def status(self, job_id: str) -> dict[str, Any]:
202
+ result = self._json("GET", f"{_API}/jobs/{quote(_id(job_id, 'jobId'))}")
203
+ if result.get("job", {}).get("jobId") != job_id:
204
+ raise GridlineError("Job identity mismatch", code="remote_contract_invalid")
205
+ return result
206
+
207
+ def wait(self, job_id: str, *, timeout: float = 3600, interval: float = 5) -> dict[str, Any]:
208
+ if timeout <= 0 or interval <= 0:
209
+ raise ValueError("timeout and interval must be positive")
210
+ deadline = time.monotonic() + timeout
211
+ while True:
212
+ result = self.status(job_id)
213
+ state = result["job"].get("outcome")
214
+ if state in ("succeeded", "failed", "cancelled", "timed_out"):
215
+ return result
216
+ if time.monotonic() >= deadline:
217
+ raise TimeoutError(f"Job {job_id} did not finish before timeout")
218
+ time.sleep(min(interval, max(0, deadline - time.monotonic())))
219
+
220
+ def outputs(self, job_id: str, directory: str | Path) -> dict[str, Any]:
221
+ job_id = _id(job_id, "jobId")
222
+ result = self._json("GET", f"{_API}/jobs/{quote(job_id)}/output")
223
+ if result.get("jobId") != job_id:
224
+ raise GridlineError("Output identity mismatch", code="remote_contract_invalid")
225
+ if result.get("state") == "pending":
226
+ return {"jobId": job_id, "state": "pending", "downloaded": []}
227
+ manifest = result.get("manifest", {})
228
+ target = Path(directory)
229
+ target.mkdir(parents=True, exist_ok=True)
230
+ downloaded = []
231
+ for item in manifest.get("outputs", []):
232
+ if not item.get("completed"):
233
+ continue
234
+ name = item.get("name")
235
+ artifact_id = _id(item.get("artifactId"), "artifactId")
236
+ if not isinstance(name, str) or name in ("", ".", "..") or Path(name).name != name:
237
+ raise GridlineError("Unsafe output name", code="remote_contract_invalid")
238
+ digest, size = item.get("sha256"), item.get("bytes")
239
+ if not isinstance(digest, str) or not _SHA.fullmatch(digest) or not isinstance(size, int) or size > _MAX_DOWNLOAD:
240
+ raise GridlineError("Invalid output digest or size", code="remote_contract_invalid")
241
+ grant = self._json("GET", f"{_API}/jobs/{quote(job_id)}/output/{quote(artifact_id)}/grant")
242
+ if (grant.get("jobId"), grant.get("artifactId"), grant.get("sha256"), grant.get("byteLength")) != (job_id, artifact_id, digest, size):
243
+ raise GridlineError("Output grant identity mismatch", code="remote_contract_invalid")
244
+ access = grant.get("download", {})
245
+ if not _future(access.get("expiresAt")):
246
+ raise GridlineError("Output grant expired", code="grant_expired")
247
+ data = self._bytes("GET", access.get("url"), maximum=_MAX_DOWNLOAD)
248
+ if len(data) != size or _sha(data) != digest:
249
+ raise GridlineError("Output checksum mismatch", code="output_hash_mismatch")
250
+ destination = target / name
251
+ with destination.open("xb") as handle:
252
+ os.chmod(destination, 0o600)
253
+ handle.write(data)
254
+ downloaded.append({"name": name, "path": str(destination), "sha256": digest})
255
+ return {"jobId": job_id, "state": result.get("state"), "downloaded": downloaded,
256
+ "missingRequiredOutputs": manifest.get("missingRequiredOutputs", [])}
257
+
258
+ def cancel(self, job_id: str) -> dict[str, Any]:
259
+ job_id = _id(job_id, "jobId")
260
+ result = self._json("POST", f"{_API}/jobs/{quote(job_id)}/cancel")
261
+ if result.get("jobId") != job_id:
262
+ raise GridlineError("Cancellation identity mismatch", code="remote_contract_invalid")
263
+ return result
264
+
265
+ def _stage(self, base: Path, name: str, item: dict[str, Any], ceiling: int) -> dict[str, Any]:
266
+ path = _local_path(base, item["path"])
267
+ data = _bounded_file(path, min(ceiling, 40 * 1024 * 1024))
268
+ digest = _sha(data)
269
+ if item.get("sha256") not in (None, digest) or item.get("bytes") not in (None, len(data)):
270
+ raise GridlineError(f"Input {name} differs from YAML digest or size", code="input_changed")
271
+ format_name = item["format"]
272
+ reserved = self._json(
273
+ "POST", f"{_API}/attachments",
274
+ {"schemaVersion": 1, "format": format_name, "sha256": digest, "byteLength": len(data)},
275
+ key=f"sdk-attachment-{_sha((format_name + digest).encode())}",
276
+ )
277
+ artifact_id = _id(reserved.get("artifactId"), "artifactId")
278
+ if (reserved.get("sha256"), reserved.get("byteLength"), reserved.get("format")) != (digest, len(data), format_name):
279
+ raise GridlineError("Attachment reservation mismatch", code="remote_contract_invalid")
280
+ if reserved.get("state") != "ready":
281
+ upload = reserved.get("upload", {})
282
+ if upload.get("method") != "PUT" or not isinstance(upload.get("headers", {}).get("Authorization"), str):
283
+ raise GridlineError("Invalid attachment upload grant", code="remote_contract_invalid")
284
+ self._bytes("PUT", upload.get("url"), data=data, headers={"Authorization": upload["headers"]["Authorization"]}, maximum=16_384)
285
+ confirmed = self._json("POST", f"{_API}/attachments/{quote(artifact_id)}/confirm")
286
+ if (confirmed.get("state"), confirmed.get("artifactId"), confirmed.get("sha256")) != ("ready", artifact_id, digest):
287
+ raise GridlineError("Attachment confirmation mismatch", code="remote_contract_invalid")
288
+ return {"logicalName": name, "artifactId": artifact_id, "sha256": digest, "bytes": len(data)}
289
+
290
+ def _json(self, method: str, path: str, body: dict[str, Any] | None = None, *, key: str | None = None) -> dict[str, Any]:
291
+ headers = {"Authorization": f"Bearer {self._read_token()}", "Accept": "application/json"}
292
+ if key:
293
+ headers["Idempotency-Key"] = key
294
+ data = None if body is None else json.dumps(body, separators=(",", ":")).encode()
295
+ if data is not None:
296
+ headers["Content-Type"] = "application/json"
297
+ raw = self._bytes(method, self._base_url + path, data=data, headers=headers, maximum=2 * 1024 * 1024)
298
+ try:
299
+ value = json.loads(raw)
300
+ except (ValueError, UnicodeDecodeError) as error:
301
+ raise GridlineError("Invalid JSON response", code="remote_contract_invalid") from error
302
+ if not isinstance(value, dict):
303
+ raise GridlineError("Invalid response shape", code="remote_contract_invalid")
304
+ return value
305
+
306
+ def _bytes(self, method: str, url: str | None, *, data: bytes | None = None,
307
+ headers: dict[str, str] | None = None, maximum: int) -> bytes:
308
+ if not isinstance(url, str) or urlparse(url).scheme != "https":
309
+ raise GridlineError("Grant URL must use HTTPS", code="remote_contract_invalid")
310
+ request = Request(url, data=data, headers=headers or {}, method=method)
311
+ try:
312
+ with self._opener.open(request, timeout=self._timeout) as response:
313
+ raw = response.read(maximum + 1)
314
+ except HTTPError as error:
315
+ detail = {}
316
+ try:
317
+ detail = json.loads(error.read(16_384))
318
+ except (ValueError, UnicodeDecodeError):
319
+ pass
320
+ info = detail.get("error", {}) if isinstance(detail, dict) else {}
321
+ code = info.get("code", "http_error") if isinstance(info, dict) else "http_error"
322
+ raise GridlineError(f"Gridline request failed: HTTP {error.code}", code=code, status=error.code) from error
323
+ except URLError as error:
324
+ raise GridlineError("Network request failed", code="network_error") from error
325
+ if len(raw) > maximum:
326
+ raise GridlineError("Response exceeded size limit", code="response_too_large")
327
+ return raw
328
+
329
+ def _read_token(self) -> str:
330
+ if self._token_file:
331
+ stat = self._token_file.stat()
332
+ if self._token_file.is_symlink() or not self._token_file.is_file() or stat.st_mode & 0o077 or stat.st_size > 256:
333
+ raise GridlineError("Token file must be a private regular file", code="token_file_invalid")
334
+ token = self._token_file.read_text(encoding="utf-8").strip()
335
+ else:
336
+ token = self._token or ""
337
+ if not re.fullmatch(r"gla_[A-Za-z0-9_-]{43}", token):
338
+ raise GridlineError("Invalid Gridline automation token", code="token_invalid")
339
+ return token
340
+
341
+
342
+ def _read_yaml(value: str | Path) -> tuple[Path, str, dict[str, Any]]:
343
+ path = Path(value).expanduser().absolute()
344
+ data = _bounded_file(path, _MAX_YAML)
345
+ try:
346
+ source = data.decode("utf-8")
347
+ document = yaml.safe_load(source)
348
+ except (UnicodeDecodeError, yaml.YAMLError) as error:
349
+ raise GridlineError("Invalid YAML encoding or syntax", code="invalid_yaml") from error
350
+ if not isinstance(document, dict):
351
+ raise GridlineError("YAML root must be a mapping", code="invalid_yaml")
352
+ return path, source, document
353
+
354
+
355
+ def _local_inputs(document: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
356
+ if "apiVersion" in document:
357
+ inputs = document.get("inputs", [])
358
+ if not isinstance(inputs, list):
359
+ raise GridlineError("Invalid YAML inputs", code="invalid_yaml")
360
+ found = []
361
+ for item in inputs:
362
+ if not isinstance(item, dict):
363
+ raise GridlineError("Invalid YAML input", code="invalid_yaml")
364
+ source = item.get("source", {})
365
+ if isinstance(source, dict) and source.get("type") == "package_file":
366
+ found.append((item["name"], {"path": source["path"], "format": item["format"],
367
+ "sha256": source.get("sha256"), "bytes": source.get("bytes")}))
368
+ return found
369
+ inputs = document.get("inputs", {})
370
+ if not isinstance(inputs, dict):
371
+ raise GridlineError("Invalid YAML inputs", code="invalid_yaml")
372
+ return [(name, {"path": item["file"], "format": item["format"],
373
+ "sha256": item.get("sha256"), "bytes": item.get("bytes")})
374
+ for name, item in inputs.items() if isinstance(item, dict) and "file" in item]
375
+
376
+
377
+ def _local_path(base: Path, value: str) -> Path:
378
+ if not isinstance(value, str) or not value or Path(value).is_absolute() or ".." in Path(value).parts:
379
+ raise GridlineError("Input path must stay beside YAML", code="invalid_input_path")
380
+ path = (base / value).resolve()
381
+ if not path.is_relative_to(base.resolve()):
382
+ raise GridlineError("Input path leaves YAML directory", code="invalid_input_path")
383
+ return path
384
+
385
+
386
+ def _bounded_file(path: Path, maximum: int) -> bytes:
387
+ if path.is_symlink() or not path.is_file():
388
+ raise GridlineError("File must be a readable regular file", code="invalid_file")
389
+ if path.stat().st_size < 1 or path.stat().st_size > maximum:
390
+ raise GridlineError("File exceeds allowed size", code="file_size")
391
+ with path.open("rb") as handle:
392
+ data = handle.read(maximum + 1)
393
+ if not data or len(data) > maximum:
394
+ raise GridlineError("File exceeds allowed size", code="file_size")
395
+ return data
396
+
397
+
398
+ def _id(value: Any, label: str) -> str:
399
+ if not isinstance(value, str) or not _ID.fullmatch(value):
400
+ raise GridlineError(f"Invalid {label}", code="remote_contract_invalid")
401
+ return value
402
+
403
+
404
+ def _sha(value: bytes) -> str:
405
+ return hashlib.sha256(value).hexdigest()
406
+
407
+
408
+ def _future(value: Any) -> bool:
409
+ if not isinstance(value, str):
410
+ return False
411
+ try:
412
+ moment = datetime.fromisoformat(value.replace("Z", "+00:00"))
413
+ return moment.tzinfo is not None and moment > datetime.now(timezone.utc)
414
+ except ValueError:
415
+ return False
gridlinegpu/errors.py ADDED
@@ -0,0 +1,20 @@
1
+ """Public, secret-free SDK errors."""
2
+
3
+
4
+ class GridlineError(Exception):
5
+ def __init__(self, message: str, *, code: str = "gridline_error", status: int | None = None):
6
+ super().__init__(message)
7
+ self.code = code
8
+ self.status = status
9
+
10
+
11
+ class LaunchOutcomeUnknown(GridlineError):
12
+ """Paid launch may have been accepted; reuse the same idempotency key."""
13
+
14
+ def __init__(self, workload_id: str, idempotency_key: str):
15
+ super().__init__(
16
+ "Launch outcome unknown. Check status or retry lookup with the same idempotency key.",
17
+ code="launch_outcome_unknown",
18
+ )
19
+ self.workload_id = workload_id
20
+ self.idempotency_key = idempotency_key
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: gridlinegpu
3
+ Version: 0.1.0
4
+ Summary: Python client for Gridline YAML workload routing
5
+ Author: Gridline GPU
6
+ License: Proprietary
7
+ Project-URL: Documentation, https://gridlinegpu.com/cli
8
+ Project-URL: Repository, https://github.com/GridlineGPU/gridline-gpu
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: PyYAML<7,>=6.0.2
15
+
16
+ # Gridline Python SDK
17
+
18
+ Submit one YAML workload through Gridline's authenticated API. The Python client stages
19
+ files referenced by YAML, returns a route for review, then launches only that exact
20
+ fresh plan when you supply a cost ceiling and idempotency key.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ python -m pip install gridlinegpu
26
+ ```
27
+
28
+ Create a scoped token using the Gridline CLI after browser sign-in. The token stays in a
29
+ private file; do not paste it into source code or an AI chat.
30
+
31
+ ```sh
32
+ gridline --profile production auth login --apply
33
+ gridline --profile production auth token create --name python-workloads --scopes read,routing:write --output-file "$HOME/.config/gridline/python-token" --apply
34
+ export GRIDLINE_API_TOKEN_FILE="$HOME/.config/gridline/python-token"
35
+ ```
36
+
37
+ Use a YAML file beside its referenced inputs. Task, model, engine, providers, output,
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.
40
+
41
+ ```python
42
+ from gridlinegpu import GridlineClient
43
+
44
+ client = GridlineClient()
45
+ plan = client.plan("workload.yaml")
46
+ print(plan.review) # Check selected GPU, exclusions, estimate, and maximum.
47
+
48
+ # Launch only after reviewing the fresh plan and accepting its maximum spend.
49
+ launch = client.launch(plan, max_cost_usd="5.00", idempotency_key="my-job-2026-09-27")
50
+ job_id = launch["jobId"]
51
+ print(client.wait(job_id, timeout=7200)["job"]["outcome"])
52
+ print(client.outputs(job_id, "results"))
53
+ ```
54
+
55
+ `plan()` stages input bytes but does not allocate a GPU. `launch()` never silently
56
+ changes a plan or retries an ambiguous paid request. If launch outcome is unknown,
57
+ inspect `LaunchOutcomeUnknown.workload_id` with the same idempotency key; do not use a
58
+ new key. `outputs()` verifies byte count and SHA-256 before creating private files.
59
+ Cancellation requests stop; cleanup and final provider billing can remain pending.
@@ -0,0 +1,7 @@
1
+ gridlinegpu/__init__.py,sha256=Ea9abPP1G1WAbCyL4K1nd8_mSkkA8lODYfw1uq9Fn9w,236
2
+ gridlinegpu/client.py,sha256=xogaAriVjprNt_l-5aHAtrfj7-YdzekgKfvFfAvI9DE,20983
3
+ gridlinegpu/errors.py,sha256=q-B4iE99FS0KTkoDUodHwu3Z7FH5qZqtbYPWHaVS0kI,710
4
+ gridlinegpu-0.1.0.dist-info/METADATA,sha256=Wmtef0mBd4mJzcLZPc7p7fbx_dyCZLoB_Jqpxf-pnYQ,2423
5
+ gridlinegpu-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ gridlinegpu-0.1.0.dist-info/top_level.txt,sha256=_XIF8788LsMlU5FbMKrxvaKtsnWhbxOz_5r3qa0iA2E,12
7
+ gridlinegpu-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ gridlinegpu