gridlinegpu 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gridlinegpu-0.1.0/PKG-INFO +59 -0
- gridlinegpu-0.1.0/README.md +44 -0
- gridlinegpu-0.1.0/pyproject.toml +25 -0
- gridlinegpu-0.1.0/setup.cfg +4 -0
- gridlinegpu-0.1.0/src/gridlinegpu/__init__.py +6 -0
- gridlinegpu-0.1.0/src/gridlinegpu/client.py +415 -0
- gridlinegpu-0.1.0/src/gridlinegpu/errors.py +20 -0
- gridlinegpu-0.1.0/src/gridlinegpu.egg-info/PKG-INFO +59 -0
- gridlinegpu-0.1.0/src/gridlinegpu.egg-info/SOURCES.txt +11 -0
- gridlinegpu-0.1.0/src/gridlinegpu.egg-info/dependency_links.txt +1 -0
- gridlinegpu-0.1.0/src/gridlinegpu.egg-info/requires.txt +1 -0
- gridlinegpu-0.1.0/src/gridlinegpu.egg-info/top_level.txt +1 -0
- gridlinegpu-0.1.0/tests/test_client.py +116 -0
|
@@ -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,44 @@
|
|
|
1
|
+
# Gridline Python SDK
|
|
2
|
+
|
|
3
|
+
Submit one YAML workload through Gridline's authenticated API. The Python client stages
|
|
4
|
+
files referenced by YAML, returns a route for review, then launches only that exact
|
|
5
|
+
fresh plan when you supply a cost ceiling and idempotency key.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
python -m pip install gridlinegpu
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Create a scoped token using the Gridline CLI after browser sign-in. The token stays in a
|
|
14
|
+
private file; do not paste it into source code or an AI chat.
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
gridline --profile production auth login --apply
|
|
18
|
+
gridline --profile production auth token create --name python-workloads --scopes read,routing:write --output-file "$HOME/.config/gridline/python-token" --apply
|
|
19
|
+
export GRIDLINE_API_TOKEN_FILE="$HOME/.config/gridline/python-token"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Use a YAML file beside its referenced inputs. Task, model, engine, providers, output,
|
|
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.
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from gridlinegpu import GridlineClient
|
|
28
|
+
|
|
29
|
+
client = GridlineClient()
|
|
30
|
+
plan = client.plan("workload.yaml")
|
|
31
|
+
print(plan.review) # Check selected GPU, exclusions, estimate, and maximum.
|
|
32
|
+
|
|
33
|
+
# Launch only after reviewing the fresh plan and accepting its maximum spend.
|
|
34
|
+
launch = client.launch(plan, max_cost_usd="5.00", idempotency_key="my-job-2026-09-27")
|
|
35
|
+
job_id = launch["jobId"]
|
|
36
|
+
print(client.wait(job_id, timeout=7200)["job"]["outcome"])
|
|
37
|
+
print(client.outputs(job_id, "results"))
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`plan()` stages input bytes but does not allocate a GPU. `launch()` never silently
|
|
41
|
+
changes a plan or retries an ambiguous paid request. If launch outcome is unknown,
|
|
42
|
+
inspect `LaunchOutcomeUnknown.workload_id` with the same idempotency key; do not use a
|
|
43
|
+
new key. `outputs()` verifies byte count and SHA-256 before creating private files.
|
|
44
|
+
Cancellation requests stop; cleanup and final provider billing can remain pending.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=75", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gridlinegpu"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python client for Gridline YAML workload routing"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [{ name = "Gridline GPU" }]
|
|
13
|
+
dependencies = ["PyYAML>=6.0.2,<7"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Documentation = "https://gridlinegpu.com/cli"
|
|
22
|
+
Repository = "https://github.com/GridlineGPU/gridline-gpu"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
@@ -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
|
|
@@ -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,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/gridlinegpu/__init__.py
|
|
4
|
+
src/gridlinegpu/client.py
|
|
5
|
+
src/gridlinegpu/errors.py
|
|
6
|
+
src/gridlinegpu.egg-info/PKG-INFO
|
|
7
|
+
src/gridlinegpu.egg-info/SOURCES.txt
|
|
8
|
+
src/gridlinegpu.egg-info/dependency_links.txt
|
|
9
|
+
src/gridlinegpu.egg-info/requires.txt
|
|
10
|
+
src/gridlinegpu.egg-info/top_level.txt
|
|
11
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
PyYAML<7,>=6.0.2
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gridlinegpu
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from datetime import datetime, timedelta, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from gridlinegpu import GridlineClient, GridlineError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
TOKEN = "gla_" + "a" * 43
|
|
12
|
+
YAML = """name: speech
|
|
13
|
+
task:
|
|
14
|
+
operation: asr_transcription
|
|
15
|
+
model: Systran/faster-whisper-large-v3
|
|
16
|
+
inputs:
|
|
17
|
+
audio: { file: audio.wav, format: wav }
|
|
18
|
+
limits: { maxCostUSD: 5, deadline: 1h }
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Response(io.BytesIO):
|
|
23
|
+
def __enter__(self):
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
def __exit__(self, *_):
|
|
27
|
+
self.close()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FakeOpener:
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.calls = []
|
|
33
|
+
self.changed = False
|
|
34
|
+
self.expiry = (datetime.now(timezone.utc) + timedelta(minutes=2)).isoformat()
|
|
35
|
+
|
|
36
|
+
def open(self, request, timeout):
|
|
37
|
+
path = request.full_url.removeprefix("https://gridlinegpu.com")
|
|
38
|
+
self.calls.append((request.get_method(), path, request.headers, request.data))
|
|
39
|
+
if path == "/api/workloads/yaml/validate":
|
|
40
|
+
result = {"schemaVersion": 1, "requiredAttachments": ["audio"]}
|
|
41
|
+
elif path == "/api/workloads/yaml/availability":
|
|
42
|
+
result = {"schemaVersion": 1, "planningAvailable": True, "executionEnabled": True,
|
|
43
|
+
"artifactStagingAvailable": True, "maximumAttachmentBytes": 1024}
|
|
44
|
+
elif path == "/api/workloads/yaml/attachments":
|
|
45
|
+
result = {"artifactId": "input_1", "sha256": self.digest, "byteLength": 4,
|
|
46
|
+
"format": "wav", "state": "ready"}
|
|
47
|
+
elif path == "/api/workloads/yaml/submit":
|
|
48
|
+
result = {"workloadId": "yaml_1", "workloadHash": "b" * 64}
|
|
49
|
+
elif path == "/api/workloads/yaml/yaml_1/plan":
|
|
50
|
+
result = {"workloadId": "yaml_1", "workloadHash": "b" * 64,
|
|
51
|
+
"result": {"status": "planned", "planHash": "x" * 64 if self.changed else "c" * 64,
|
|
52
|
+
"quote": {"quoteId": "quote_1", "quoteHash": "d" * 64,
|
|
53
|
+
"expiresAt": self.expiry, "maximumTotalMicros": 5_000_000,
|
|
54
|
+
"fallbackConsent": {"mode": "none"}}}}
|
|
55
|
+
elif path == "/api/workloads/yaml/yaml_1/launch":
|
|
56
|
+
result = {"workloadId": "yaml_1", "planHash": "c" * 64, "jobId": "job_1"}
|
|
57
|
+
elif path == "/api/workloads/yaml/jobs/job_1":
|
|
58
|
+
result = {"job": {"jobId": "job_1", "outcome": "succeeded"}}
|
|
59
|
+
elif path == "/api/workloads/yaml/jobs/job_1/output":
|
|
60
|
+
result = {"jobId": "job_1", "state": "available", "manifest": {"outputs": [{
|
|
61
|
+
"name": "result.json", "artifactId": "output_1", "sha256": self.output_digest,
|
|
62
|
+
"bytes": 2, "completed": True}], "missingRequiredOutputs": []}}
|
|
63
|
+
elif path == "/api/workloads/yaml/jobs/job_1/output/output_1/grant":
|
|
64
|
+
result = {"jobId": "job_1", "artifactId": "output_1", "sha256": self.output_digest,
|
|
65
|
+
"byteLength": 2, "download": {"url": "https://storage.example/result",
|
|
66
|
+
"expiresAt": self.expiry}}
|
|
67
|
+
elif path == "https://storage.example/result":
|
|
68
|
+
return Response(b"{}")
|
|
69
|
+
else:
|
|
70
|
+
raise AssertionError(f"Unexpected request: {path}")
|
|
71
|
+
return Response(json.dumps(result).encode())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ClientTest(unittest.TestCase):
|
|
75
|
+
def setUp(self):
|
|
76
|
+
import hashlib
|
|
77
|
+
self.folder = tempfile.TemporaryDirectory()
|
|
78
|
+
self.addCleanup(self.folder.cleanup)
|
|
79
|
+
root = Path(self.folder.name)
|
|
80
|
+
(root / "workload.yaml").write_text(YAML)
|
|
81
|
+
(root / "audio.wav").write_bytes(b"RIFF")
|
|
82
|
+
self.path = root / "workload.yaml"
|
|
83
|
+
self.opener = FakeOpener()
|
|
84
|
+
self.opener.digest = hashlib.sha256(b"RIFF").hexdigest()
|
|
85
|
+
self.opener.output_digest = hashlib.sha256(b"{}").hexdigest()
|
|
86
|
+
self.client = GridlineClient(token=TOKEN, opener=self.opener)
|
|
87
|
+
|
|
88
|
+
def test_plan_launch_and_verified_download(self):
|
|
89
|
+
plan = self.client.plan(self.path)
|
|
90
|
+
self.assertEqual(plan.workload_id, "yaml_1")
|
|
91
|
+
self.assertFalse(any(path.endswith("/launch") for _, path, _, _ in self.opener.calls))
|
|
92
|
+
launched = self.client.launch(plan, max_cost_usd="5", idempotency_key="test-job-1")
|
|
93
|
+
self.assertEqual(launched["jobId"], "job_1")
|
|
94
|
+
self.assertEqual(self.client.wait("job_1", timeout=1)["job"]["outcome"], "succeeded")
|
|
95
|
+
saved = self.client.outputs("job_1", Path(self.folder.name) / "results")
|
|
96
|
+
self.assertEqual(Path(saved["downloaded"][0]["path"]).read_bytes(), b"{}")
|
|
97
|
+
self.assertRaises(FileExistsError, self.client.outputs, "job_1", Path(self.folder.name) / "results")
|
|
98
|
+
|
|
99
|
+
def test_changed_plan_cannot_launch(self):
|
|
100
|
+
plan = self.client.plan(self.path)
|
|
101
|
+
self.opener.changed = True
|
|
102
|
+
with self.assertRaisesRegex(GridlineError, "changed"):
|
|
103
|
+
self.client.launch(plan, max_cost_usd="5", idempotency_key="same-key")
|
|
104
|
+
self.assertFalse(any(path.endswith("/launch") for _, path, _, _ in self.opener.calls))
|
|
105
|
+
|
|
106
|
+
def test_cost_ceiling_and_yaml_change_cannot_launch(self):
|
|
107
|
+
plan = self.client.plan(self.path)
|
|
108
|
+
with self.assertRaisesRegex(GridlineError, "ceiling"):
|
|
109
|
+
self.client.launch(plan, max_cost_usd="4.99", idempotency_key="same-key")
|
|
110
|
+
self.path.write_text(YAML + "# changed\n")
|
|
111
|
+
with self.assertRaisesRegex(GridlineError, "changed"):
|
|
112
|
+
self.client.launch(plan, max_cost_usd="5", idempotency_key="same-key")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
unittest.main()
|