gridlinegpu 0.1.4__tar.gz → 0.1.5__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.4 → gridlinegpu-0.1.5}/PKG-INFO +21 -1
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/README.md +20 -0
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/pyproject.toml +1 -1
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/src/gridlinegpu/cli.py +9 -2
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/src/gridlinegpu/client.py +73 -2
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/src/gridlinegpu.egg-info/PKG-INFO +21 -1
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/tests/test_cli.py +30 -0
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/tests/test_client.py +213 -3
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/setup.cfg +0 -0
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/src/gridlinegpu/__init__.py +0 -0
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/src/gridlinegpu/__main__.py +0 -0
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/src/gridlinegpu/cli_auth.py +0 -0
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/src/gridlinegpu/errors.py +0 -0
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/src/gridlinegpu.egg-info/SOURCES.txt +0 -0
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/src/gridlinegpu.egg-info/dependency_links.txt +0 -0
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/src/gridlinegpu.egg-info/entry_points.txt +0 -0
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/src/gridlinegpu.egg-info/requires.txt +0 -0
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/src/gridlinegpu.egg-info/top_level.txt +0 -0
- {gridlinegpu-0.1.4 → gridlinegpu-0.1.5}/tests/test_cli_auth.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gridlinegpu
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Python client for Gridline YAML workload routing
|
|
5
5
|
Author: Gridline GPU
|
|
6
6
|
License: Proprietary
|
|
@@ -44,6 +44,8 @@ may still cap inputs at 40 MiB and outputs at 8 MiB.
|
|
|
44
44
|
from gridlinegpu import GridlineClient
|
|
45
45
|
|
|
46
46
|
client = GridlineClient()
|
|
47
|
+
search = client.search("workload.yaml")
|
|
48
|
+
print(search["coverage"], search["offers"]) # Catalog facts may be unknown; coverage may be partial.
|
|
47
49
|
plan = client.plan("workload.yaml")
|
|
48
50
|
print(plan.review) # Check selected GPU, exclusions, estimate, and maximum.
|
|
49
51
|
|
|
@@ -58,6 +60,7 @@ print(client.outputs(job_id, "results"))
|
|
|
58
60
|
The same installed package supports a terminal flow:
|
|
59
61
|
|
|
60
62
|
```sh
|
|
63
|
+
gridline workload search workload.yaml
|
|
61
64
|
gridline workload validate --file workload.yaml
|
|
62
65
|
gridline workload plan --file workload.yaml
|
|
63
66
|
gridline workload run --file workload.yaml --apply --max-cost-usd 5 \
|
|
@@ -81,6 +84,13 @@ approval; the Python CLI never stores a privileged refresh token.
|
|
|
81
84
|
After revoking a token, remove its local file before running setup again; setup never
|
|
82
85
|
overwrites an existing file.
|
|
83
86
|
|
|
87
|
+
`plan()` validates YAML and checks advertised task readiness before reserving or
|
|
88
|
+
uploading inputs. An explicitly unavailable managed ASR/LLM runtime, minimal managed
|
|
89
|
+
model resolver, or custom finite path raises `GridlineError` with code
|
|
90
|
+
`planning_unavailable`. Qualify the custom container and enable its launch path, or
|
|
91
|
+
use available managed runtime/full YAML settings as indicated by the error. Missing
|
|
92
|
+
task readiness fields on older servers preserve existing behavior. Custom endpoint
|
|
93
|
+
support remains a server planning decision; finite readiness does not describe it.
|
|
84
94
|
`plan()` stages input bytes but does not allocate a GPU. `launch()` never silently
|
|
85
95
|
changes a plan or retries an ambiguous paid request. If launch outcome is unknown,
|
|
86
96
|
inspect `LaunchOutcomeUnknown.workload_id` with the same idempotency key; do not use a
|
|
@@ -89,6 +99,16 @@ Cancellation requests stop; cleanup and final provider billing can remain pendin
|
|
|
89
99
|
For finite jobs, `wait()` returns when outputs are complete, even if cleanup and billing
|
|
90
100
|
leave `outcome` pending. Check `status()` later for final settlement.
|
|
91
101
|
|
|
102
|
+
`search()` reads only YAML and connected-provider catalogs. It does not inspect or
|
|
103
|
+
upload referenced inputs, resolve a model or image, create a workload, or allocate a
|
|
104
|
+
GPU. `availability()["searchAvailable"]` is separate from planning and execution
|
|
105
|
+
readiness. Missing price, VRAM, capacity, or topology facts remain unknown. Partial
|
|
106
|
+
catalog coverage and unsupported execution remain visible; a search result is not a
|
|
107
|
+
launch quote. `scopeStatus: "empty_scope"` with `catalogCompleteness: "not_applicable"`
|
|
108
|
+
means no accounts were searched. Servers with catalog search disabled return a
|
|
109
|
+
search availability error. The CLI prints the same JSON response and also accepts
|
|
110
|
+
`gridline workload search --file workload.yaml`.
|
|
111
|
+
|
|
92
112
|
When worker progress is enabled on the hosted API/control service and supported by the
|
|
93
113
|
deployed worker image, `status()` also returns optional `progress`:
|
|
94
114
|
|
|
@@ -29,6 +29,8 @@ may still cap inputs at 40 MiB and outputs at 8 MiB.
|
|
|
29
29
|
from gridlinegpu import GridlineClient
|
|
30
30
|
|
|
31
31
|
client = GridlineClient()
|
|
32
|
+
search = client.search("workload.yaml")
|
|
33
|
+
print(search["coverage"], search["offers"]) # Catalog facts may be unknown; coverage may be partial.
|
|
32
34
|
plan = client.plan("workload.yaml")
|
|
33
35
|
print(plan.review) # Check selected GPU, exclusions, estimate, and maximum.
|
|
34
36
|
|
|
@@ -43,6 +45,7 @@ print(client.outputs(job_id, "results"))
|
|
|
43
45
|
The same installed package supports a terminal flow:
|
|
44
46
|
|
|
45
47
|
```sh
|
|
48
|
+
gridline workload search workload.yaml
|
|
46
49
|
gridline workload validate --file workload.yaml
|
|
47
50
|
gridline workload plan --file workload.yaml
|
|
48
51
|
gridline workload run --file workload.yaml --apply --max-cost-usd 5 \
|
|
@@ -66,6 +69,13 @@ approval; the Python CLI never stores a privileged refresh token.
|
|
|
66
69
|
After revoking a token, remove its local file before running setup again; setup never
|
|
67
70
|
overwrites an existing file.
|
|
68
71
|
|
|
72
|
+
`plan()` validates YAML and checks advertised task readiness before reserving or
|
|
73
|
+
uploading inputs. An explicitly unavailable managed ASR/LLM runtime, minimal managed
|
|
74
|
+
model resolver, or custom finite path raises `GridlineError` with code
|
|
75
|
+
`planning_unavailable`. Qualify the custom container and enable its launch path, or
|
|
76
|
+
use available managed runtime/full YAML settings as indicated by the error. Missing
|
|
77
|
+
task readiness fields on older servers preserve existing behavior. Custom endpoint
|
|
78
|
+
support remains a server planning decision; finite readiness does not describe it.
|
|
69
79
|
`plan()` stages input bytes but does not allocate a GPU. `launch()` never silently
|
|
70
80
|
changes a plan or retries an ambiguous paid request. If launch outcome is unknown,
|
|
71
81
|
inspect `LaunchOutcomeUnknown.workload_id` with the same idempotency key; do not use a
|
|
@@ -74,6 +84,16 @@ Cancellation requests stop; cleanup and final provider billing can remain pendin
|
|
|
74
84
|
For finite jobs, `wait()` returns when outputs are complete, even if cleanup and billing
|
|
75
85
|
leave `outcome` pending. Check `status()` later for final settlement.
|
|
76
86
|
|
|
87
|
+
`search()` reads only YAML and connected-provider catalogs. It does not inspect or
|
|
88
|
+
upload referenced inputs, resolve a model or image, create a workload, or allocate a
|
|
89
|
+
GPU. `availability()["searchAvailable"]` is separate from planning and execution
|
|
90
|
+
readiness. Missing price, VRAM, capacity, or topology facts remain unknown. Partial
|
|
91
|
+
catalog coverage and unsupported execution remain visible; a search result is not a
|
|
92
|
+
launch quote. `scopeStatus: "empty_scope"` with `catalogCompleteness: "not_applicable"`
|
|
93
|
+
means no accounts were searched. Servers with catalog search disabled return a
|
|
94
|
+
search availability error. The CLI prints the same JSON response and also accepts
|
|
95
|
+
`gridline workload search --file workload.yaml`.
|
|
96
|
+
|
|
77
97
|
When worker progress is enabled on the hosted API/control service and supported by the
|
|
78
98
|
deployed worker image, `status()` also returns optional `progress`:
|
|
79
99
|
|
|
@@ -49,8 +49,11 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
49
49
|
revoke.add_argument("token_id")
|
|
50
50
|
revoke.add_argument("--apply", action="store_true")
|
|
51
51
|
|
|
52
|
-
workload = commands.add_parser("workload", help="
|
|
52
|
+
workload = commands.add_parser("workload", help="Search catalogs, plan, launch, and inspect YAML jobs")
|
|
53
53
|
actions = workload.add_subparsers(dest="action", required=True)
|
|
54
|
+
search = actions.add_parser("search", help="Search connected-provider catalogs without staging inputs")
|
|
55
|
+
search.add_argument("yaml_path", type=Path, nargs="?")
|
|
56
|
+
search.add_argument("--file", type=Path)
|
|
54
57
|
for name, help_text in [
|
|
55
58
|
("validate", "Validate YAML without allocating a GPU"),
|
|
56
59
|
("plan", "Stage inputs and review a route without allocating a GPU"),
|
|
@@ -77,7 +80,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
77
80
|
if name == "cancel":
|
|
78
81
|
action.add_argument("--apply", action="store_true")
|
|
79
82
|
action.add_argument("--yes", action="store_true")
|
|
80
|
-
actions.add_parser("availability", help="Show account planning and execution gates")
|
|
83
|
+
actions.add_parser("availability", help="Show account search, planning, and execution gates")
|
|
81
84
|
return parser
|
|
82
85
|
|
|
83
86
|
|
|
@@ -145,6 +148,10 @@ def main(argv: Sequence[str] | None = None, *, input_stream: TextIO = sys.stdin,
|
|
|
145
148
|
action = args.action
|
|
146
149
|
if action == "availability":
|
|
147
150
|
result = client.availability()
|
|
151
|
+
elif action == "search":
|
|
152
|
+
if (args.yaml_path is None) == (args.file is None):
|
|
153
|
+
raise ValueError("workload search requires YAML or --file YAML, exactly once")
|
|
154
|
+
result = client.search(args.yaml_path or args.file)
|
|
148
155
|
elif action == "validate":
|
|
149
156
|
result = client.validate(args.file)
|
|
150
157
|
elif action == "plan":
|
|
@@ -100,21 +100,43 @@ class GridlineClient:
|
|
|
100
100
|
def availability(self) -> dict[str, Any]:
|
|
101
101
|
return self._json("GET", f"{_API}/availability")
|
|
102
102
|
|
|
103
|
+
def search(self, yaml_path: str | Path) -> dict[str, Any]:
|
|
104
|
+
"""Read connected-provider catalogs without staging inputs or creating a workload.
|
|
105
|
+
|
|
106
|
+
Nullable facts and partial coverage remain visible in the returned search.
|
|
107
|
+
Search availability is independent of planning and execution readiness.
|
|
108
|
+
"""
|
|
109
|
+
_, source, _ = _read_yaml(yaml_path)
|
|
110
|
+
result = self._json("POST", f"{_API}/search", {"schemaVersion": 1, "yaml": source})
|
|
111
|
+
if (result.get("schemaVersion") != 1
|
|
112
|
+
or result.get("scopeStatus") not in ("empty_scope", "queried")
|
|
113
|
+
or result.get("catalogCompleteness") not in ("complete", "partial", "not_applicable")
|
|
114
|
+
or not isinstance(result.get("observedAt"), str)
|
|
115
|
+
or not isinstance(result.get("intent"), dict)
|
|
116
|
+
or not isinstance(result.get("coverage"), list)
|
|
117
|
+
or not isinstance(result.get("offers"), list)):
|
|
118
|
+
raise GridlineError("Invalid catalog search response", code="remote_contract_invalid")
|
|
119
|
+
return result
|
|
120
|
+
|
|
103
121
|
def validate(self, yaml_path: str | Path) -> dict[str, Any]:
|
|
104
122
|
_, source, _ = _read_yaml(yaml_path)
|
|
105
123
|
return self._json(
|
|
106
124
|
"POST", f"{_API}/validate", {"schemaVersion": 1, "yaml": source, "attachmentBindings": []}
|
|
107
125
|
)
|
|
108
126
|
|
|
109
|
-
def plan(self, yaml_path: str | Path, *, submission_key: str | None = None
|
|
127
|
+
def plan(self, yaml_path: str | Path, *, submission_key: str | None = None,
|
|
128
|
+
qualification_timeout: float = 3600) -> PlannedWorkload:
|
|
110
129
|
"""Create a fresh submission; reuse submission_key only to retry an uncertain submit."""
|
|
111
130
|
if submission_key is not None and not _KEY.fullmatch(submission_key):
|
|
112
131
|
raise ValueError("submission_key must contain 1–128 safe characters")
|
|
132
|
+
if not isinstance(qualification_timeout, (int, float)) or isinstance(qualification_timeout, bool) or not 0 <= qualification_timeout <= 3600:
|
|
133
|
+
raise ValueError("qualification_timeout must be between 0 and 3600 seconds")
|
|
113
134
|
path, source, document = _read_yaml(yaml_path)
|
|
114
135
|
validated = self.validate(path)
|
|
115
136
|
available = self.availability()
|
|
116
137
|
if not available.get("planningAvailable"):
|
|
117
138
|
raise GridlineError("YAML planning is unavailable for this account", code="planning_unavailable")
|
|
139
|
+
_require_task_planning(document, available)
|
|
118
140
|
inputs = _local_inputs(document)
|
|
119
141
|
required = validated.get("requiredAttachments")
|
|
120
142
|
names = [name for name, _ in inputs]
|
|
@@ -135,7 +157,21 @@ class GridlineClient:
|
|
|
135
157
|
"POST", f"{_API}/submit", body, key=submission_key or f"sdk-submit-{uuid.uuid4()}"
|
|
136
158
|
)
|
|
137
159
|
workload_id = _id(submitted.get("workloadId"), "workloadId")
|
|
138
|
-
|
|
160
|
+
deadline = time.monotonic() + qualification_timeout
|
|
161
|
+
while True:
|
|
162
|
+
try:
|
|
163
|
+
result = self._json("POST", f"{_API}/{quote(workload_id)}/plan")
|
|
164
|
+
break
|
|
165
|
+
except GridlineError as error:
|
|
166
|
+
if error.code != "image_qualification_pending" or error.status != 409:
|
|
167
|
+
raise
|
|
168
|
+
remaining = deadline - time.monotonic()
|
|
169
|
+
if remaining <= 0:
|
|
170
|
+
raise GridlineError(
|
|
171
|
+
f"Image qualification still pending for workload {workload_id}; retry planning later.",
|
|
172
|
+
code="image_qualification_pending", status=409,
|
|
173
|
+
) from error
|
|
174
|
+
time.sleep(min(15, remaining))
|
|
139
175
|
if result.get("workloadId") != workload_id or result.get("workloadHash") != submitted.get("workloadHash"):
|
|
140
176
|
raise GridlineError("Plan identity differs from submitted workload", code="remote_contract_invalid")
|
|
141
177
|
return PlannedWorkload(path, _sha(source.encode()), workload_id, result)
|
|
@@ -536,6 +572,41 @@ def _read_yaml(value: str | Path) -> tuple[Path, str, dict[str, Any]]:
|
|
|
536
572
|
return path, source, document
|
|
537
573
|
|
|
538
574
|
|
|
575
|
+
def _require_task_planning(document: dict[str, Any], available: dict[str, Any]) -> None:
|
|
576
|
+
"""Honor explicit readiness limits after server syntax validation, before staging."""
|
|
577
|
+
task = document.get("task")
|
|
578
|
+
if not isinstance(task, dict):
|
|
579
|
+
return
|
|
580
|
+
minimal = "apiVersion" not in document
|
|
581
|
+
kind = task.get("kind") if not minimal else ("custom" if "class" in task else "managed")
|
|
582
|
+
if kind == "managed":
|
|
583
|
+
flag = {
|
|
584
|
+
"asr_transcription": "managedAsrPlanningAvailable",
|
|
585
|
+
"llm_inference": "managedLlmPlanningAvailable",
|
|
586
|
+
}.get(task.get("operation"))
|
|
587
|
+
if flag and available.get(flag) is False:
|
|
588
|
+
raise GridlineError(
|
|
589
|
+
f"Planning for {task['operation']} is unavailable for this account; "
|
|
590
|
+
"enable a supported managed runtime before staging inputs",
|
|
591
|
+
code="planning_unavailable",
|
|
592
|
+
)
|
|
593
|
+
if minimal and available.get("minimalManagedResolutionAvailable") is False:
|
|
594
|
+
raise GridlineError(
|
|
595
|
+
"Minimal managed YAML resolution is unavailable; use full workload YAML "
|
|
596
|
+
"with explicit runtime settings or enable managed model resolution",
|
|
597
|
+
code="planning_unavailable",
|
|
598
|
+
)
|
|
599
|
+
elif kind == "custom":
|
|
600
|
+
execution = document.get("execution", {"mode": "finite"} if minimal else {})
|
|
601
|
+
if (isinstance(execution, dict) and execution.get("mode", "finite" if minimal else None) == "finite"
|
|
602
|
+
and available.get("customFinitePlanningAvailable") is False):
|
|
603
|
+
raise GridlineError(
|
|
604
|
+
"Custom finite workload planning is unavailable for this account; "
|
|
605
|
+
"complete container qualification and enable custom launch planning before staging inputs",
|
|
606
|
+
code="planning_unavailable",
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
539
610
|
def _local_inputs(document: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
|
540
611
|
if "apiVersion" in document:
|
|
541
612
|
inputs = document.get("inputs", [])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gridlinegpu
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Python client for Gridline YAML workload routing
|
|
5
5
|
Author: Gridline GPU
|
|
6
6
|
License: Proprietary
|
|
@@ -44,6 +44,8 @@ may still cap inputs at 40 MiB and outputs at 8 MiB.
|
|
|
44
44
|
from gridlinegpu import GridlineClient
|
|
45
45
|
|
|
46
46
|
client = GridlineClient()
|
|
47
|
+
search = client.search("workload.yaml")
|
|
48
|
+
print(search["coverage"], search["offers"]) # Catalog facts may be unknown; coverage may be partial.
|
|
47
49
|
plan = client.plan("workload.yaml")
|
|
48
50
|
print(plan.review) # Check selected GPU, exclusions, estimate, and maximum.
|
|
49
51
|
|
|
@@ -58,6 +60,7 @@ print(client.outputs(job_id, "results"))
|
|
|
58
60
|
The same installed package supports a terminal flow:
|
|
59
61
|
|
|
60
62
|
```sh
|
|
63
|
+
gridline workload search workload.yaml
|
|
61
64
|
gridline workload validate --file workload.yaml
|
|
62
65
|
gridline workload plan --file workload.yaml
|
|
63
66
|
gridline workload run --file workload.yaml --apply --max-cost-usd 5 \
|
|
@@ -81,6 +84,13 @@ approval; the Python CLI never stores a privileged refresh token.
|
|
|
81
84
|
After revoking a token, remove its local file before running setup again; setup never
|
|
82
85
|
overwrites an existing file.
|
|
83
86
|
|
|
87
|
+
`plan()` validates YAML and checks advertised task readiness before reserving or
|
|
88
|
+
uploading inputs. An explicitly unavailable managed ASR/LLM runtime, minimal managed
|
|
89
|
+
model resolver, or custom finite path raises `GridlineError` with code
|
|
90
|
+
`planning_unavailable`. Qualify the custom container and enable its launch path, or
|
|
91
|
+
use available managed runtime/full YAML settings as indicated by the error. Missing
|
|
92
|
+
task readiness fields on older servers preserve existing behavior. Custom endpoint
|
|
93
|
+
support remains a server planning decision; finite readiness does not describe it.
|
|
84
94
|
`plan()` stages input bytes but does not allocate a GPU. `launch()` never silently
|
|
85
95
|
changes a plan or retries an ambiguous paid request. If launch outcome is unknown,
|
|
86
96
|
inspect `LaunchOutcomeUnknown.workload_id` with the same idempotency key; do not use a
|
|
@@ -89,6 +99,16 @@ Cancellation requests stop; cleanup and final provider billing can remain pendin
|
|
|
89
99
|
For finite jobs, `wait()` returns when outputs are complete, even if cleanup and billing
|
|
90
100
|
leave `outcome` pending. Check `status()` later for final settlement.
|
|
91
101
|
|
|
102
|
+
`search()` reads only YAML and connected-provider catalogs. It does not inspect or
|
|
103
|
+
upload referenced inputs, resolve a model or image, create a workload, or allocate a
|
|
104
|
+
GPU. `availability()["searchAvailable"]` is separate from planning and execution
|
|
105
|
+
readiness. Missing price, VRAM, capacity, or topology facts remain unknown. Partial
|
|
106
|
+
catalog coverage and unsupported execution remain visible; a search result is not a
|
|
107
|
+
launch quote. `scopeStatus: "empty_scope"` with `catalogCompleteness: "not_applicable"`
|
|
108
|
+
means no accounts were searched. Servers with catalog search disabled return a
|
|
109
|
+
search availability error. The CLI prints the same JSON response and also accepts
|
|
110
|
+
`gridline workload search --file workload.yaml`.
|
|
111
|
+
|
|
92
112
|
When worker progress is enabled on the hosted API/control service and supported by the
|
|
93
113
|
deployed worker image, `status()` also returns optional `progress`:
|
|
94
114
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import io
|
|
2
|
+
import json
|
|
2
3
|
import unittest
|
|
3
4
|
from unittest.mock import MagicMock, patch
|
|
4
5
|
|
|
@@ -14,6 +15,35 @@ class CliTest(unittest.TestCase):
|
|
|
14
15
|
def invoke(self, *arguments):
|
|
15
16
|
return main(arguments, input_stream=self.input, output=self.output, errors=self.errors)
|
|
16
17
|
|
|
18
|
+
@patch("gridlinegpu.cli.GridlineClient")
|
|
19
|
+
def test_search_accepts_positional_yaml_and_prints_coverage_and_unknown_facts(self, client_type):
|
|
20
|
+
result = {"scopeStatus": "queried", "catalogCompleteness": "partial",
|
|
21
|
+
"coverage": [{"provider": "lambda", "status": "failed"}],
|
|
22
|
+
"offers": [{"provider": "runpod", "pricePerHr": None,
|
|
23
|
+
"unknownFacts": ["pricePerHr"]}]}
|
|
24
|
+
client_type.return_value.search.return_value = result
|
|
25
|
+
self.assertEqual(self.invoke("workload", "search", "workload.yaml"), 0)
|
|
26
|
+
self.assertEqual(json.loads(self.output.getvalue()), result)
|
|
27
|
+
self.assertEqual(str(client_type.return_value.search.call_args.args[0]), "workload.yaml")
|
|
28
|
+
client_type.return_value.plan.assert_not_called()
|
|
29
|
+
client_type.return_value.validate.assert_not_called()
|
|
30
|
+
client_type.return_value.availability.assert_not_called()
|
|
31
|
+
client_type.return_value.launch.assert_not_called()
|
|
32
|
+
|
|
33
|
+
@patch("gridlinegpu.cli.GridlineClient")
|
|
34
|
+
def test_search_accepts_file_option_without_mutation_confirmation(self, client_type):
|
|
35
|
+
client_type.return_value.search.return_value = {"scopeStatus": "empty_scope", "offers": []}
|
|
36
|
+
self.assertEqual(self.invoke("workload", "search", "--file", "workload.yaml"), 0)
|
|
37
|
+
client_type.return_value.search.assert_called_once()
|
|
38
|
+
self.assertEqual(self.errors.getvalue(), "")
|
|
39
|
+
|
|
40
|
+
@patch("gridlinegpu.cli.GridlineClient")
|
|
41
|
+
def test_search_rejects_missing_or_duplicate_source(self, client_type):
|
|
42
|
+
for arguments in (("workload", "search"),
|
|
43
|
+
("workload", "search", "one.yaml", "--file", "two.yaml")):
|
|
44
|
+
self.assertEqual(self.invoke(*arguments), 1)
|
|
45
|
+
client_type.return_value.search.assert_not_called()
|
|
46
|
+
|
|
17
47
|
@patch("gridlinegpu.cli.GridlineClient")
|
|
18
48
|
def test_paid_run_requires_apply_and_does_not_plan(self, client_type):
|
|
19
49
|
code = self.invoke("workload", "run", "--file", "workload.yaml",
|
|
@@ -13,6 +13,22 @@ from gridlinegpu import GridlineClient, GridlineError
|
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
TOKEN = "gla_" + "a" * 43
|
|
16
|
+
SEARCH = {
|
|
17
|
+
"schemaVersion": 1,
|
|
18
|
+
"intent": {"providers": {"mode": "all_connected"}, "gpuCount": {"minimum": 1, "maximum": 128},
|
|
19
|
+
"minimumVramGb": None, "minimumDiskGb": None, "blockedCountries": [],
|
|
20
|
+
"allowedCountries": [], "allowedRegions": []},
|
|
21
|
+
"scopeStatus": "queried",
|
|
22
|
+
"catalogCompleteness": "partial",
|
|
23
|
+
"observedAt": "2026-10-01T00:00:00.000Z",
|
|
24
|
+
"coverage": [{"provider": "lambda", "accountRef": "account_opaque", "status": "partial",
|
|
25
|
+
"complete": False, "reason": "offer_limit", "observedOffers": 1}],
|
|
26
|
+
"offers": [{"provider": "lambda", "accountRef": "account_opaque", "offerId": "offer_opaque",
|
|
27
|
+
"gpuModel": "future-gpu", "gpuCount": 2, "vramGb": None, "vcpu": None,
|
|
28
|
+
"ramGb": None, "diskGb": None, "pricePerHr": None, "priceBasis": "unknown",
|
|
29
|
+
"region": None, "countryCode": None, "availability": "unknown",
|
|
30
|
+
"executionEligibility": "unknown", "unknownFacts": ["vramGb", "pricePerHr"]}],
|
|
31
|
+
}
|
|
16
32
|
YAML = """name: speech
|
|
17
33
|
task:
|
|
18
34
|
operation: asr_transcription
|
|
@@ -52,6 +68,32 @@ geography: { allowedCountries: [], blockedCountries: [], allowedRegions: [] }
|
|
|
52
68
|
retry: { maximumAttempts: 1, interruption: forbid, spot: forbid }
|
|
53
69
|
profiling: { allowed: false }
|
|
54
70
|
"""
|
|
71
|
+
FULL_MANAGED_YAML = CUSTOM_YAML[:CUSTOM_YAML.index("task:\n")] + """task:
|
|
72
|
+
kind: managed
|
|
73
|
+
operation: asr_transcription
|
|
74
|
+
model: { id: example/model }
|
|
75
|
+
engine: { selection: fixed, name: fixture-engine }
|
|
76
|
+
allowedPrecisions: [float16]
|
|
77
|
+
allowedQuantizations: [native]
|
|
78
|
+
settings: {}
|
|
79
|
+
""" + CUSTOM_YAML[CUSTOM_YAML.index("execution:"):]
|
|
80
|
+
MINIMAL_CUSTOM_YAML = """task:
|
|
81
|
+
class: batch
|
|
82
|
+
image: example/worker@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
|
83
|
+
command: [python, run.py]
|
|
84
|
+
runtime: { architecture: amd64, internet: required }
|
|
85
|
+
inputs:
|
|
86
|
+
audio: { file: audio.wav, format: wav }
|
|
87
|
+
outputs:
|
|
88
|
+
result: { path: result.json, format: json }
|
|
89
|
+
limits: { maxCostUSD: 5, deadline: 1h }
|
|
90
|
+
resources:
|
|
91
|
+
gpu: { count: { min: 1, max: 1 }, vramMin: 24GB, topology: [single], vendors: [nvidia] }
|
|
92
|
+
cpuMin: 2
|
|
93
|
+
memoryMin: 4GB
|
|
94
|
+
diskMin: 20GB
|
|
95
|
+
estimatedDuration: 1m
|
|
96
|
+
"""
|
|
55
97
|
|
|
56
98
|
|
|
57
99
|
class Response(io.BytesIO):
|
|
@@ -81,6 +123,8 @@ class FakeOpener:
|
|
|
81
123
|
self.tus_fail_final_reply_and_head = False
|
|
82
124
|
self.tus_patches = []
|
|
83
125
|
self.maximum_attachment_bytes = 1024
|
|
126
|
+
self.planning_readiness = {}
|
|
127
|
+
self.search_result = SEARCH
|
|
84
128
|
self.required_attachments = ["audio"]
|
|
85
129
|
self.job = {"jobId": "job_1", "outcome": "succeeded"}
|
|
86
130
|
self.expiry = (datetime.now(timezone.utc) + timedelta(minutes=2)).isoformat()
|
|
@@ -88,12 +132,15 @@ class FakeOpener:
|
|
|
88
132
|
def open(self, request, timeout):
|
|
89
133
|
path = request.full_url.removeprefix("https://gridlinegpu.com")
|
|
90
134
|
self.calls.append((request.get_method(), path, request.headers, request.data))
|
|
91
|
-
if path == "/api/workloads/yaml/
|
|
135
|
+
if path == "/api/workloads/yaml/search":
|
|
136
|
+
result = self.search_result
|
|
137
|
+
elif path == "/api/workloads/yaml/validate":
|
|
92
138
|
result = {"schemaVersion": 1, "requiredAttachments": self.required_attachments}
|
|
93
139
|
elif path == "/api/workloads/yaml/availability":
|
|
94
140
|
result = {"schemaVersion": 1, "planningAvailable": True, "executionEnabled": True,
|
|
95
141
|
"artifactStagingAvailable": True,
|
|
96
|
-
"maximumAttachmentBytes": self.maximum_attachment_bytes
|
|
142
|
+
"maximumAttachmentBytes": self.maximum_attachment_bytes,
|
|
143
|
+
**self.planning_readiness}
|
|
97
144
|
elif path == "/api/workloads/yaml/attachments":
|
|
98
145
|
requested = json.loads(request.data)
|
|
99
146
|
upload = ({"method": "TUS",
|
|
@@ -180,6 +227,44 @@ class ClientTest(unittest.TestCase):
|
|
|
180
227
|
self.opener.output_digest = hashlib.sha256(b"{}").hexdigest()
|
|
181
228
|
self.client = GridlineClient(token=TOKEN, opener=self.opener)
|
|
182
229
|
|
|
230
|
+
def test_search_reads_only_yaml_with_unpinned_runtime_and_missing_input(self):
|
|
231
|
+
for source in (YAML, MINIMAL_CUSTOM_YAML.replace(
|
|
232
|
+
"example/worker@sha256:" + "a" * 64, "example/worker:latest")):
|
|
233
|
+
with self.subTest(source=source.splitlines()[0]):
|
|
234
|
+
self.path.write_text(source)
|
|
235
|
+
self.opener.calls.clear()
|
|
236
|
+
(self.path.parent / "audio.wav").unlink(missing_ok=True)
|
|
237
|
+
self.opener.planning_readiness = {"planningAvailable": False,
|
|
238
|
+
"executionEnabled": False,
|
|
239
|
+
"artifactStagingAvailable": False}
|
|
240
|
+
with patch("gridlinegpu.client._local_inputs") as inputs, patch(
|
|
241
|
+
"gridlinegpu.client._bounded_file_identity") as identity:
|
|
242
|
+
result = self.client.search(self.path)
|
|
243
|
+
inputs.assert_not_called()
|
|
244
|
+
identity.assert_not_called()
|
|
245
|
+
self.assertEqual(result, SEARCH)
|
|
246
|
+
self.assertIsNone(result["offers"][0]["pricePerHr"])
|
|
247
|
+
self.assertEqual(len(self.opener.calls), 1)
|
|
248
|
+
method, path, headers, body = self.opener.calls[0]
|
|
249
|
+
self.assertEqual((method, path), ("POST", "/api/workloads/yaml/search"))
|
|
250
|
+
self.assertEqual(json.loads(body), {"schemaVersion": 1, "yaml": source})
|
|
251
|
+
self.assertNotIn("Idempotency-key", headers)
|
|
252
|
+
self.assertEqual(self.opener.uploaded, b"")
|
|
253
|
+
|
|
254
|
+
def test_search_empty_scope_is_not_catalog_success(self):
|
|
255
|
+
self.opener.search_result = {**SEARCH, "scopeStatus": "empty_scope",
|
|
256
|
+
"catalogCompleteness": "not_applicable", "coverage": [], "offers": []}
|
|
257
|
+
result = self.client.search(self.path)
|
|
258
|
+
self.assertEqual(result["scopeStatus"], "empty_scope")
|
|
259
|
+
self.assertEqual(result["catalogCompleteness"], "not_applicable")
|
|
260
|
+
self.assertEqual(result["offers"], [])
|
|
261
|
+
|
|
262
|
+
def test_search_rejects_unknown_response_version(self):
|
|
263
|
+
self.opener.search_result = {**SEARCH, "schemaVersion": 2}
|
|
264
|
+
with self.assertRaises(GridlineError) as raised:
|
|
265
|
+
self.client.search(self.path)
|
|
266
|
+
self.assertEqual(raised.exception.code, "remote_contract_invalid")
|
|
267
|
+
|
|
183
268
|
def test_plan_launch_and_verified_download(self):
|
|
184
269
|
plan = self.client.plan(self.path)
|
|
185
270
|
self.assertEqual(plan.workload_id, "yaml_1")
|
|
@@ -191,6 +276,131 @@ class ClientTest(unittest.TestCase):
|
|
|
191
276
|
self.assertEqual(Path(saved["downloaded"][0]["path"]).read_bytes(), b"{}")
|
|
192
277
|
self.assertRaises(FileExistsError, self.client.outputs, "job_1", Path(self.folder.name) / "results")
|
|
193
278
|
|
|
279
|
+
def test_unavailable_custom_finite_planning_stops_before_inputs_and_submission(self):
|
|
280
|
+
for source in (CUSTOM_YAML, MINIMAL_CUSTOM_YAML,
|
|
281
|
+
MINIMAL_CUSTOM_YAML + "execution: { mode: finite }\n"):
|
|
282
|
+
with self.subTest(source=source.splitlines()[0]):
|
|
283
|
+
self.path.write_text(source)
|
|
284
|
+
self.opener.calls.clear()
|
|
285
|
+
self.opener.upload_required = True
|
|
286
|
+
self.opener.planning_readiness = {"customFinitePlanningAvailable": False}
|
|
287
|
+
with patch("gridlinegpu.client._bounded_file_identity") as identity:
|
|
288
|
+
with self.assertRaisesRegex(GridlineError, "container qualification") as raised:
|
|
289
|
+
self.client.plan(self.path)
|
|
290
|
+
self.assertEqual(raised.exception.code, "planning_unavailable")
|
|
291
|
+
identity.assert_not_called()
|
|
292
|
+
self.assertEqual([path for _, path, _, _ in self.opener.calls],
|
|
293
|
+
["/api/workloads/yaml/validate", "/api/workloads/yaml/availability"])
|
|
294
|
+
self.assertEqual(self.opener.uploaded, b"")
|
|
295
|
+
|
|
296
|
+
def test_unavailable_managed_planning_stops_both_yaml_shapes_before_staging(self):
|
|
297
|
+
for operation, flag in (("asr_transcription", "managedAsrPlanningAvailable"),
|
|
298
|
+
("llm_inference", "managedLlmPlanningAvailable")):
|
|
299
|
+
full = FULL_MANAGED_YAML.replace("asr_transcription", operation)
|
|
300
|
+
for source in (YAML.replace("asr_transcription", operation), full):
|
|
301
|
+
with self.subTest(operation=operation, full=source.startswith("apiVersion")):
|
|
302
|
+
self.path.write_text(source)
|
|
303
|
+
self.opener.calls.clear()
|
|
304
|
+
self.opener.planning_readiness = {flag: False}
|
|
305
|
+
self.opener.upload_required = True
|
|
306
|
+
with self.assertRaisesRegex(GridlineError, operation) as raised:
|
|
307
|
+
self.client.plan(self.path)
|
|
308
|
+
self.assertEqual(raised.exception.code, "planning_unavailable")
|
|
309
|
+
self.assertEqual([path for _, path, _, _ in self.opener.calls],
|
|
310
|
+
["/api/workloads/yaml/validate", "/api/workloads/yaml/availability"])
|
|
311
|
+
self.assertEqual(self.opener.uploaded, b"")
|
|
312
|
+
|
|
313
|
+
def test_unavailable_minimal_managed_resolution_stops_before_staging(self):
|
|
314
|
+
self.opener.planning_readiness = {"managedAsrPlanningAvailable": True,
|
|
315
|
+
"minimalManagedResolutionAvailable": False}
|
|
316
|
+
with self.assertRaisesRegex(GridlineError, "full workload YAML") as raised:
|
|
317
|
+
self.client.plan(self.path)
|
|
318
|
+
self.assertEqual(raised.exception.code, "planning_unavailable")
|
|
319
|
+
self.assertEqual([path for _, path, _, _ in self.opener.calls],
|
|
320
|
+
["/api/workloads/yaml/validate", "/api/workloads/yaml/availability"])
|
|
321
|
+
|
|
322
|
+
def test_ready_tasks_stage_and_plan_without_using_unrelated_readiness_flags(self):
|
|
323
|
+
for source, readiness in (
|
|
324
|
+
(YAML, {"managedAsrPlanningAvailable": True, "managedLlmPlanningAvailable": False,
|
|
325
|
+
"minimalManagedResolutionAvailable": True, "customFinitePlanningAvailable": False}),
|
|
326
|
+
(YAML.replace("asr_transcription", "llm_inference"),
|
|
327
|
+
{"managedAsrPlanningAvailable": False, "managedLlmPlanningAvailable": True,
|
|
328
|
+
"minimalManagedResolutionAvailable": True, "customFinitePlanningAvailable": False}),
|
|
329
|
+
(MINIMAL_CUSTOM_YAML, {"managedAsrPlanningAvailable": False,
|
|
330
|
+
"managedLlmPlanningAvailable": False,
|
|
331
|
+
"minimalManagedResolutionAvailable": False,
|
|
332
|
+
"customFinitePlanningAvailable": True}),
|
|
333
|
+
(CUSTOM_YAML, {"customFinitePlanningAvailable": True}),
|
|
334
|
+
(FULL_MANAGED_YAML, {"managedAsrPlanningAvailable": True,
|
|
335
|
+
"minimalManagedResolutionAvailable": False}),
|
|
336
|
+
(FULL_MANAGED_YAML.replace("asr_transcription", "llm_inference"),
|
|
337
|
+
{"managedLlmPlanningAvailable": True, "minimalManagedResolutionAvailable": False}),
|
|
338
|
+
):
|
|
339
|
+
with self.subTest(readiness=readiness):
|
|
340
|
+
self.path.write_text(source)
|
|
341
|
+
self.opener.calls.clear()
|
|
342
|
+
self.opener.planning_readiness = readiness
|
|
343
|
+
self.opener.upload_required = True
|
|
344
|
+
full = source.startswith("apiVersion")
|
|
345
|
+
self.opener.required_attachments = ["alpha", "beta"] if full else ["audio"]
|
|
346
|
+
for name in ("alpha", "beta"):
|
|
347
|
+
(Path(self.folder.name) / f"{name}.bin").write_bytes(b"RIFF")
|
|
348
|
+
planned = self.client.plan(self.path)
|
|
349
|
+
self.assertIsNotNone(planned.quote)
|
|
350
|
+
self.assertEqual(self.opener.uploaded, b"RIFF")
|
|
351
|
+
self.assertTrue(any(path.endswith("/submit") for _, path, _, _ in self.opener.calls))
|
|
352
|
+
|
|
353
|
+
def test_custom_endpoint_does_not_use_finite_readiness(self):
|
|
354
|
+
self.path.write_text(MINIMAL_CUSTOM_YAML.replace(
|
|
355
|
+
" result: { path: result.json, format: json }",
|
|
356
|
+
" api: { endpoint: http, port: 8080, healthPath: /health }",
|
|
357
|
+
) + """execution:
|
|
358
|
+
mode: endpoint
|
|
359
|
+
maxLifetime: 1h
|
|
360
|
+
maxRequests: 10
|
|
361
|
+
maxConcurrency: 1
|
|
362
|
+
startupDeadline: 5m
|
|
363
|
+
idleTimeout: 10m
|
|
364
|
+
""")
|
|
365
|
+
self.opener.planning_readiness = {"customFinitePlanningAvailable": False,
|
|
366
|
+
"minimalManagedResolutionAvailable": False}
|
|
367
|
+
self.assertIsNotNone(self.client.plan(self.path).quote)
|
|
368
|
+
|
|
369
|
+
def test_legacy_availability_without_task_fields_still_stages_and_plans(self):
|
|
370
|
+
self.path.write_text(MINIMAL_CUSTOM_YAML)
|
|
371
|
+
self.opener.upload_required = True
|
|
372
|
+
self.assertIsNotNone(self.client.plan(self.path).quote)
|
|
373
|
+
self.assertEqual(self.opener.uploaded, b"RIFF")
|
|
374
|
+
|
|
375
|
+
def test_server_validation_error_is_preserved_before_readiness_or_staging(self):
|
|
376
|
+
self.opener.planning_readiness = {"customFinitePlanningAvailable": False}
|
|
377
|
+
error = GridlineError("Invalid workload", code="invalid_workload")
|
|
378
|
+
with patch.object(self.client, "validate", side_effect=error):
|
|
379
|
+
with self.assertRaises(GridlineError) as raised:
|
|
380
|
+
self.client.plan(self.path)
|
|
381
|
+
self.assertIs(raised.exception, error)
|
|
382
|
+
self.assertEqual(self.opener.calls, [])
|
|
383
|
+
def test_qualification_wait_reuses_submission_and_only_retries_pending_plan(self):
|
|
384
|
+
original = self.client._json
|
|
385
|
+
calls = []
|
|
386
|
+
def pending_once(method, path, *args, **kwargs):
|
|
387
|
+
if path.endswith("/plan"):
|
|
388
|
+
calls.append(path)
|
|
389
|
+
if len(calls) == 1:
|
|
390
|
+
raise GridlineError("pending", code="image_qualification_pending", status=409)
|
|
391
|
+
return original(method, path, *args, **kwargs)
|
|
392
|
+
with patch.object(self.client, "_json", side_effect=pending_once), patch("gridlinegpu.client.time.sleep") as wait:
|
|
393
|
+
self.assertEqual(self.client.plan(self.path).workload_id, "yaml_1")
|
|
394
|
+
self.assertEqual(calls, ["/api/workloads/yaml/yaml_1/plan"] * 2)
|
|
395
|
+
wait.assert_called_once_with(15)
|
|
396
|
+
self.assertEqual(sum(path.endswith("/submit") for _, path, _, _ in self.opener.calls), 1)
|
|
397
|
+
for code in ("image_qualification_pending", "image_qualification_failed"):
|
|
398
|
+
with patch.object(self.client, "_json", side_effect=lambda method, path, *args, **kwargs:
|
|
399
|
+
(_ for _ in ()).throw(GridlineError("pending", code=code, status=409)) if path.endswith("/plan") else original(method, path, *args, **kwargs)), patch("gridlinegpu.client.time.sleep") as wait:
|
|
400
|
+
with self.assertRaises(GridlineError):
|
|
401
|
+
self.client.plan(self.path, qualification_timeout=0)
|
|
402
|
+
wait.assert_not_called()
|
|
403
|
+
|
|
194
404
|
def test_minimal_file_extension_supplies_omitted_upload_format(self):
|
|
195
405
|
(Path(self.folder.name) / "talk.OGG").write_bytes(b"OggS")
|
|
196
406
|
self.path.write_text("""task:
|
|
@@ -253,7 +463,7 @@ limits: { maxCostUSD: 5 }
|
|
|
253
463
|
digest = hashlib.sha256(b"RIFF").hexdigest()
|
|
254
464
|
with patch("gridlinegpu.client._bounded_file_identity", return_value=(4, digest)) as identity:
|
|
255
465
|
self.client.plan(self.path)
|
|
256
|
-
identity.assert_called_once_with(Path(self.folder.name) / "audio.wav",
|
|
466
|
+
identity.assert_called_once_with((Path(self.folder.name) / "audio.wav").resolve(),
|
|
257
467
|
self.opener.maximum_attachment_bytes)
|
|
258
468
|
|
|
259
469
|
def test_signed_tus_upload_streams_fixed_chunks_and_recovers_lost_patch_reply(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|