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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridlinegpu
3
- Version: 0.1.4
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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gridlinegpu"
7
- version = "0.1.4"
7
+ version = "0.1.5"
8
8
  description = "Python client for Gridline YAML workload routing"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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="Validate, plan, launch, and inspect YAML jobs")
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) -> PlannedWorkload:
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
- result = self._json("POST", f"{_API}/{quote(workload_id)}/plan")
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.4
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/validate":
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