gridlinegpu 0.1.2__tar.gz → 0.1.3__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.2
3
+ Version: 0.1.3
4
4
  Summary: Python client for Gridline YAML workload routing
5
5
  Author: Gridline GPU
6
6
  License: Proprietary
@@ -17,21 +17,21 @@ Requires-Dist: PyYAML<7,>=6.0.2
17
17
 
18
18
  Submit one YAML workload through Gridline's authenticated API. The Python client stages
19
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.
20
+ fresh plan when you supply a cost ceiling and idempotency key. The package also installs
21
+ the `gridline` command in the same Python environment on macOS and Linux.
21
22
 
22
23
  ## Install
23
24
 
24
25
  ```sh
25
- python -m pip install 'gridlinegpu>=0.1.2,<0.2'
26
+ python -m pip install 'gridlinegpu>=0.1.3,<0.2'
26
27
  ```
27
28
 
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.
29
+ Run browser setup once. It creates a 30-day scoped token in a private file, attempts to
30
+ revoke the temporary browser session, and never prints the token. Keep the token file out of
31
+ source control and AI chats.
30
32
 
31
33
  ```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"
34
+ gridline setup --apply
35
35
  ```
36
36
 
37
37
  Use a YAML file beside its referenced inputs. Task, model, engine, providers, output,
@@ -53,6 +53,32 @@ print(ready["job"]["outputs"], ready["job"]["outcome"])
53
53
  print(client.outputs(job_id, "results"))
54
54
  ```
55
55
 
56
+ The same installed package supports a terminal flow:
57
+
58
+ ```sh
59
+ gridline workload validate --file workload.yaml
60
+ gridline workload plan --file workload.yaml
61
+ gridline workload run --file workload.yaml --apply --max-cost-usd 5 \
62
+ --idempotency-key my-job-2026-09-27
63
+ gridline workload status JOB_ID
64
+ gridline workload outputs JOB_ID --directory results
65
+ ```
66
+
67
+ The paid `run` command plans a fresh quote, prints it, and asks for explicit confirmation.
68
+ For an approved noninteractive run, add `--yes` and explicit `--profile production`
69
+ alongside `--apply`, a cost ceiling, and an idempotency key. Cancellation similarly
70
+ requires `--apply` and confirmation. A custom
71
+ token path may be passed as global `--token-file` or through `GRIDLINE_API_TOKEN_FILE`.
72
+ Workload commands also honor `GRIDLINE_API_TOKEN` when no token file is selected.
73
+ The existing standalone Bun CLI remains available during transition; check which
74
+ `gridline` executable your shell selects when both are installed. Run
75
+ `python -m gridlinegpu` to choose this package's command explicitly.
76
+ Use `gridline auth token list` to find a token ID and
77
+ `gridline auth token revoke TOKEN_ID --apply` to revoke it. Both require fresh browser
78
+ approval; the Python CLI never stores a privileged refresh token.
79
+ After revoking a token, remove its local file before running setup again; setup never
80
+ overwrites an existing file.
81
+
56
82
  `plan()` stages input bytes but does not allocate a GPU. `launch()` never silently
57
83
  changes a plan or retries an ambiguous paid request. If launch outcome is unknown,
58
84
  inspect `LaunchOutcomeUnknown.workload_id` with the same idempotency key; do not use a
@@ -0,0 +1,73 @@
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. The package also installs
6
+ the `gridline` command in the same Python environment on macOS and Linux.
7
+
8
+ ## Install
9
+
10
+ ```sh
11
+ python -m pip install 'gridlinegpu>=0.1.3,<0.2'
12
+ ```
13
+
14
+ Run browser setup once. It creates a 30-day scoped token in a private file, attempts to
15
+ revoke the temporary browser session, and never prints the token. Keep the token file out of
16
+ source control and AI chats.
17
+
18
+ ```sh
19
+ gridline setup --apply
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
+ ready = client.wait(job_id, timeout=7200)
37
+ print(ready["job"]["outputs"], ready["job"]["outcome"])
38
+ print(client.outputs(job_id, "results"))
39
+ ```
40
+
41
+ The same installed package supports a terminal flow:
42
+
43
+ ```sh
44
+ gridline workload validate --file workload.yaml
45
+ gridline workload plan --file workload.yaml
46
+ gridline workload run --file workload.yaml --apply --max-cost-usd 5 \
47
+ --idempotency-key my-job-2026-09-27
48
+ gridline workload status JOB_ID
49
+ gridline workload outputs JOB_ID --directory results
50
+ ```
51
+
52
+ The paid `run` command plans a fresh quote, prints it, and asks for explicit confirmation.
53
+ For an approved noninteractive run, add `--yes` and explicit `--profile production`
54
+ alongside `--apply`, a cost ceiling, and an idempotency key. Cancellation similarly
55
+ requires `--apply` and confirmation. A custom
56
+ token path may be passed as global `--token-file` or through `GRIDLINE_API_TOKEN_FILE`.
57
+ Workload commands also honor `GRIDLINE_API_TOKEN` when no token file is selected.
58
+ The existing standalone Bun CLI remains available during transition; check which
59
+ `gridline` executable your shell selects when both are installed. Run
60
+ `python -m gridlinegpu` to choose this package's command explicitly.
61
+ Use `gridline auth token list` to find a token ID and
62
+ `gridline auth token revoke TOKEN_ID --apply` to revoke it. Both require fresh browser
63
+ approval; the Python CLI never stores a privileged refresh token.
64
+ After revoking a token, remove its local file before running setup again; setup never
65
+ overwrites an existing file.
66
+
67
+ `plan()` stages input bytes but does not allocate a GPU. `launch()` never silently
68
+ changes a plan or retries an ambiguous paid request. If launch outcome is unknown,
69
+ inspect `LaunchOutcomeUnknown.workload_id` with the same idempotency key; do not use a
70
+ new key. `outputs()` verifies byte count and SHA-256 before creating private files.
71
+ Cancellation requests stop; cleanup and final provider billing can remain pending.
72
+ For finite jobs, `wait()` returns when outputs are complete, even if cleanup and billing
73
+ leave `outcome` pending. Check `status()` later for final settlement.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gridlinegpu"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Python client for Gridline YAML workload routing"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -17,6 +17,9 @@ classifiers = [
17
17
  "Operating System :: OS Independent",
18
18
  ]
19
19
 
20
+ [project.scripts]
21
+ gridline = "gridlinegpu.cli:main"
22
+
20
23
  [project.urls]
21
24
  Documentation = "https://gridlinegpu.com/cli"
22
25
  Repository = "https://github.com/GridlineGPU/gridline-gpu"
@@ -0,0 +1,6 @@
1
+ """Run the packaged customer CLI with ``python -m gridlinegpu``."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,187 @@
1
+ """Customer workload command installed by the gridlinegpu Python package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import sys
9
+ from decimal import Decimal, InvalidOperation
10
+ from importlib.metadata import PackageNotFoundError, version
11
+ from pathlib import Path
12
+ from typing import Any, Sequence, TextIO
13
+
14
+ from .cli_auth import DeviceAuth, default_token_file
15
+ from .client import GridlineClient
16
+ from .errors import GridlineError, LaunchOutcomeUnknown
17
+
18
+ _KEY = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\Z")
19
+
20
+
21
+ def _version() -> str:
22
+ try:
23
+ return version("gridlinegpu")
24
+ except PackageNotFoundError:
25
+ return "development"
26
+
27
+
28
+ def _parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="gridline", description="Gridline Python SDK and workload CLI")
31
+ parser.add_argument("--version", action="version", version=f"gridline Python CLI {_version()}")
32
+ parser.add_argument("--profile", choices=["production"],
33
+ help="Explicit production target, required with --yes")
34
+ parser.add_argument("--token-file", type=Path,
35
+ help="Private scoped token file (default: GRIDLINE_API_TOKEN_FILE or ~/.config/gridline/python-token)")
36
+ commands = parser.add_subparsers(dest="command", required=True)
37
+
38
+ setup = commands.add_parser("setup", help="Approve browser login and create a private workload token")
39
+ setup.add_argument("--output-file", type=Path, help="New token file; never overwrites")
40
+ setup.add_argument("--name", default="python-cli", help="Revocable token name")
41
+ setup.add_argument("--apply", action="store_true", help="Create scoped token after browser approval")
42
+
43
+ auth = commands.add_parser("auth", help="List or revoke scoped workload tokens")
44
+ auth_actions = auth.add_subparsers(dest="auth_action", required=True)
45
+ tokens = auth_actions.add_parser("token", help="Manage revocable scoped tokens")
46
+ token_actions = tokens.add_subparsers(dest="token_action", required=True)
47
+ token_actions.add_parser("list", help="List tokens after browser approval")
48
+ revoke = token_actions.add_parser("revoke", help="Revoke token after browser approval")
49
+ revoke.add_argument("token_id")
50
+ revoke.add_argument("--apply", action="store_true")
51
+
52
+ workload = commands.add_parser("workload", help="Validate, plan, launch, and inspect YAML jobs")
53
+ actions = workload.add_subparsers(dest="action", required=True)
54
+ for name, help_text in [
55
+ ("validate", "Validate YAML without allocating a GPU"),
56
+ ("plan", "Stage inputs and review a route without allocating a GPU"),
57
+ ("run", "Plan and explicitly launch the same fresh quote"),
58
+ ]:
59
+ action = actions.add_parser(name, help=help_text)
60
+ action.add_argument("--file", type=Path, required=True)
61
+ if name == "run":
62
+ action.add_argument("--max-cost-usd", required=True)
63
+ action.add_argument("--idempotency-key", required=True)
64
+ action.add_argument("--apply", action="store_true")
65
+ action.add_argument("--yes", action="store_true",
66
+ help="Confirm reviewed plan in a noninteractive terminal")
67
+ for name, help_text in [
68
+ ("status", "Read job state"),
69
+ ("wait", "Wait for job output or terminal outcome"),
70
+ ("outputs", "Download verified output files"),
71
+ ("cancel", "Request job cancellation"),
72
+ ]:
73
+ action = actions.add_parser(name, help=help_text)
74
+ action.add_argument("job_id")
75
+ if name == "outputs":
76
+ action.add_argument("--directory", type=Path, required=True)
77
+ if name == "cancel":
78
+ action.add_argument("--apply", action="store_true")
79
+ action.add_argument("--yes", action="store_true")
80
+ actions.add_parser("availability", help="Show account planning and execution gates")
81
+ return parser
82
+
83
+
84
+ def _emit(value: Any, output: TextIO) -> None:
85
+ print(json.dumps(value, indent=2, sort_keys=True, default=str), file=output)
86
+
87
+
88
+ def _confirm(message: str, *, yes: bool, input_stream: TextIO,
89
+ output: TextIO) -> None:
90
+ if yes:
91
+ return
92
+ if not input_stream.isatty():
93
+ raise GridlineError("Interactive confirmation required; use --yes only after reviewing the plan",
94
+ code="confirmation_required")
95
+ print(f"{message} Type yes to continue: ", end="", flush=True, file=output)
96
+ if input_stream.readline().strip().lower() != "yes":
97
+ raise GridlineError("Operation was not approved", code="not_approved")
98
+
99
+
100
+ def _cost(value: str) -> Decimal:
101
+ try:
102
+ amount = Decimal(value)
103
+ except InvalidOperation as error:
104
+ raise ValueError("--max-cost-usd must be a positive amount") from error
105
+ if not amount.is_finite() or amount <= 0:
106
+ raise ValueError("--max-cost-usd must be a positive amount")
107
+ return amount
108
+
109
+
110
+ def main(argv: Sequence[str] | None = None, *, input_stream: TextIO = sys.stdin,
111
+ output: TextIO = sys.stdout, errors: TextIO = sys.stderr) -> int:
112
+ args = _parser().parse_args(argv)
113
+ token_file = args.token_file or default_token_file()
114
+ try:
115
+ if args.command == "setup":
116
+ if not args.apply:
117
+ raise GridlineError("setup creates a scoped token; pass --apply to continue",
118
+ code="apply_required")
119
+ result = DeviceAuth().setup(args.output_file or token_file,
120
+ name=args.name, announce=lambda line: print(line, file=output))
121
+ _emit(result, output)
122
+ return 0
123
+ if args.command == "auth":
124
+ auth = DeviceAuth()
125
+ announce = lambda line: print(line, file=output)
126
+ if args.token_action == "list":
127
+ result = auth.tokens(announce=announce)
128
+ else:
129
+ if not args.apply:
130
+ raise GridlineError("Token revocation requires --apply", code="apply_required")
131
+ result = auth.revoke(args.token_id, announce=announce)
132
+ _emit(result, output)
133
+ return 0
134
+
135
+ client = GridlineClient(token_file=args.token_file) if args.token_file else GridlineClient()
136
+ action = args.action
137
+ if action == "availability":
138
+ result = client.availability()
139
+ elif action == "validate":
140
+ result = client.validate(args.file)
141
+ elif action == "plan":
142
+ result = client.plan(args.file).review
143
+ elif action == "run":
144
+ if not args.apply:
145
+ raise GridlineError("Paid launch requires --apply", code="apply_required")
146
+ if args.yes and args.profile != "production":
147
+ raise GridlineError("Noninteractive launch requires --profile production",
148
+ code="target_confirmation_required")
149
+ amount = _cost(args.max_cost_usd)
150
+ if not _KEY.fullmatch(args.idempotency_key):
151
+ raise ValueError("--idempotency-key must contain 1–128 safe characters")
152
+ planned = client.plan(args.file)
153
+ _emit(planned.review, output)
154
+ if planned.quote is None:
155
+ raise GridlineError("Router abstained; inspect plan coverage", code="plan_abstained")
156
+ _confirm(f"Launch with approved ceiling ${amount}?", yes=args.yes,
157
+ input_stream=input_stream, output=output)
158
+ result = client.launch(planned, max_cost_usd=amount,
159
+ idempotency_key=args.idempotency_key)
160
+ elif action == "status":
161
+ result = client.status(args.job_id)
162
+ elif action == "wait":
163
+ result = client.wait(args.job_id)
164
+ elif action == "outputs":
165
+ result = client.outputs(args.job_id, args.directory)
166
+ elif action == "cancel":
167
+ if not args.apply:
168
+ raise GridlineError("Cancellation requires --apply", code="apply_required")
169
+ if args.yes and args.profile != "production":
170
+ raise GridlineError("Noninteractive cancellation requires --profile production",
171
+ code="target_confirmation_required")
172
+ _confirm(f"Cancel job {args.job_id}?", yes=args.yes,
173
+ input_stream=input_stream, output=output)
174
+ result = client.cancel(args.job_id)
175
+ else:
176
+ raise ValueError("Unknown workload action")
177
+ _emit(result, output)
178
+ return 0
179
+ except LaunchOutcomeUnknown as error:
180
+ print(f"{error}. Check workload status before any retry.", file=errors)
181
+ except (GridlineError, ValueError, OSError) as error:
182
+ print(f"Gridline: {error}", file=errors)
183
+ return 1
184
+
185
+
186
+ if __name__ == "__main__":
187
+ raise SystemExit(main())
@@ -0,0 +1,259 @@
1
+ """Short-lived browser authorization for the pip-installed customer CLI.
2
+
3
+ The device session stays in memory only. It creates one scoped automation token
4
+ in a private file, then revokes the more privileged device session.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import stat
13
+ import time
14
+ import webbrowser
15
+ from pathlib import Path
16
+ from typing import Any, Callable
17
+ from urllib.error import HTTPError, URLError
18
+ from urllib.parse import quote, urlparse
19
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
20
+
21
+ from .errors import GridlineError
22
+
23
+ _TOKEN = re.compile(r"gla_[A-Za-z0-9_-]{43}\Z")
24
+ _AUTH = "/api/cli-auth"
25
+ _MAX_RESPONSE = 65_536
26
+
27
+
28
+ class _NoRedirect(HTTPRedirectHandler):
29
+ def redirect_request(self, request: Request, fp: Any, code: int,
30
+ msg: str, headers: Any, url: str) -> None:
31
+ return None
32
+
33
+
34
+ class DeviceAuth:
35
+ """Exchange a browser device approval for a private routing token file."""
36
+
37
+ def __init__(self, *, base_url: str = "https://gridlinegpu.com", opener: Any = None,
38
+ sleep: Callable[[float], None] = time.sleep,
39
+ now: Callable[[], float] = time.monotonic,
40
+ open_browser: Callable[[str], Any] = webbrowser.open) -> None:
41
+ parsed = urlparse(base_url)
42
+ if (parsed.scheme != "https" or not parsed.netloc or
43
+ parsed.path not in ("", "/") or parsed.username or parsed.password or
44
+ parsed.query or parsed.fragment):
45
+ raise ValueError("Authentication URL must be an HTTPS origin")
46
+ self.base_url = base_url.rstrip("/")
47
+ self.opener = opener or build_opener(_NoRedirect())
48
+ self.sleep = sleep
49
+ self.now = now
50
+ self.open_browser = open_browser
51
+
52
+ def setup(self, output_file: str | Path, *, name: str = "python-cli",
53
+ announce: Callable[[str], None] = print) -> dict[str, Any]:
54
+ target = _private_target(output_file)
55
+ if target.exists() or target.is_symlink():
56
+ raise GridlineError("Token file already exists; refusing to overwrite it",
57
+ code="token_file_exists")
58
+ if not name or len(name) > 64 or any(ord(char) < 32 for char in name):
59
+ raise ValueError("Token name must contain 1–64 printable characters")
60
+
61
+ access_token = self._authorize(announce)
62
+ result: dict[str, Any] | None = None
63
+ revoked_session = False
64
+ try:
65
+ created = self._request("POST", f"{_AUTH}/automation-tokens", {
66
+ "name": name, "scopes": ["read", "routing:write"], "expires_in_days": 30,
67
+ }, access_token=access_token)
68
+ token = created.get("token")
69
+ record = created.get("token_record")
70
+ if (not isinstance(token, str) or not _TOKEN.fullmatch(token) or
71
+ not isinstance(record, dict) or not isinstance(record.get("id"), str) or
72
+ not isinstance(record.get("expires_at"), str) or
73
+ record.get("scopes") != ["read", "routing:write"]):
74
+ if isinstance(record, dict) and isinstance(record.get("id"), str):
75
+ if not self._revoke_token(record["id"], access_token):
76
+ announce("Warning: unrecognized token may remain active; review token list.")
77
+ raise GridlineError("Invalid automation token response",
78
+ code="remote_contract_invalid")
79
+ try:
80
+ _write_private_token(target, token)
81
+ except Exception:
82
+ if not self._revoke_token(record["id"], access_token):
83
+ announce("Warning: unwritten token may remain active; review token list.")
84
+ raise
85
+ result = {
86
+ "tokenFile": str(target), "tokenId": record["id"],
87
+ "scopes": ["read", "routing:write"], "expiresAt": record["expires_at"],
88
+ }
89
+ finally:
90
+ revoked_session = self._logout(access_token, announce)
91
+ if result is None:
92
+ raise GridlineError("Automation token was not created", code="remote_contract_invalid")
93
+ result["temporarySessionRevoked"] = revoked_session
94
+ return result
95
+
96
+ def tokens(self, *, announce: Callable[[str], None] = print) -> dict[str, Any]:
97
+ access_token = self._authorize(announce)
98
+ try:
99
+ response = self._request("GET", f"{_AUTH}/automation-tokens",
100
+ access_token=access_token)
101
+ if not isinstance(response.get("tokens"), list):
102
+ raise GridlineError("Invalid token list response", code="remote_contract_invalid")
103
+ return response
104
+ finally:
105
+ self._logout(access_token, announce)
106
+
107
+ def revoke(self, token_id: str, *, announce: Callable[[str], None] = print) -> dict[str, Any]:
108
+ if not re.fullmatch(r"[0-9a-fA-F-]{36}", token_id):
109
+ raise ValueError("Token ID must be a UUID")
110
+ access_token = self._authorize(announce)
111
+ try:
112
+ self._request("DELETE", f"{_AUTH}/automation-tokens/{quote(token_id, safe='')}",
113
+ access_token=access_token)
114
+ return {"revoked": True, "tokenId": token_id}
115
+ finally:
116
+ self._logout(access_token, announce)
117
+
118
+ def _authorize(self, announce: Callable[[str], None]) -> str:
119
+ challenge = self._request("POST", f"{_AUTH}/device", {
120
+ "client_name": "Gridline Python CLI", "scopes": ["read", "auth:write"],
121
+ })
122
+ device_code = challenge.get("device_code")
123
+ verification_url = challenge.get("verification_uri_complete")
124
+ expires_in = challenge.get("expires_in")
125
+ interval = challenge.get("interval")
126
+ if (not isinstance(device_code, str) or not device_code.startswith("gld_") or
127
+ not isinstance(verification_url, str) or
128
+ type(expires_in) is not int or expires_in < 1 or
129
+ type(interval) is not int or interval < 1):
130
+ raise GridlineError("Invalid device authorization response",
131
+ code="remote_contract_invalid")
132
+ url = urlparse(verification_url)
133
+ origin = urlparse(self.base_url)
134
+ if ((url.scheme, url.netloc) != (origin.scheme, origin.netloc) or
135
+ url.username or url.password):
136
+ raise GridlineError("Unsafe device approval URL", code="unsafe_verification_url")
137
+
138
+ announce(f"Approve Gridline access in your browser: {verification_url}")
139
+ try:
140
+ self.open_browser(verification_url)
141
+ except Exception:
142
+ pass # Printed URL still supports headless terminals.
143
+ deadline = self.now() + min(expires_in, 600)
144
+ access_token: str | None = None
145
+ while self.now() < deadline:
146
+ self.sleep(min(max(interval, 1), 10))
147
+ try:
148
+ response = self._request("POST", f"{_AUTH}/token", {
149
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
150
+ "device_code": device_code,
151
+ })
152
+ except GridlineError as error:
153
+ if error.code == "authorization_pending":
154
+ continue
155
+ if error.code in ("access_denied", "expired_token"):
156
+ raise GridlineError("Browser authorization was denied or expired",
157
+ code=error.code) from error
158
+ raise
159
+ access_token = response.get("access_token")
160
+ scopes = response.get("scope", "")
161
+ if (not isinstance(access_token, str) or not access_token.startswith("glm_") or
162
+ response.get("token_type") != "Bearer" or
163
+ not isinstance(scopes, str) or "auth:write" not in scopes.split()):
164
+ if isinstance(access_token, str) and access_token.startswith("glm_"):
165
+ self._logout(access_token, announce)
166
+ raise GridlineError("Invalid device token response",
167
+ code="remote_contract_invalid")
168
+ break
169
+ if access_token is None:
170
+ raise GridlineError("Browser authorization expired", code="authorization_expired")
171
+ return access_token
172
+
173
+ def _logout(self, access_token: str, announce: Callable[[str], None]) -> bool:
174
+ try:
175
+ self._request("POST", f"{_AUTH}/logout", access_token=access_token)
176
+ return True
177
+ except GridlineError:
178
+ announce("Warning: temporary browser session could not be revoked; it will expire.")
179
+ return False
180
+
181
+ def _revoke_token(self, token_id: str, access_token: str) -> bool:
182
+ try:
183
+ self._request("DELETE", f"{_AUTH}/automation-tokens/{quote(token_id, safe='')}",
184
+ access_token=access_token)
185
+ return True
186
+ except GridlineError:
187
+ return False
188
+
189
+ def _request(self, method: str, path: str, body: dict[str, Any] | None = None,
190
+ *, access_token: str | None = None) -> dict[str, Any]:
191
+ headers = {"Accept": "application/json", "User-Agent": "GridlinePythonCLI/1"}
192
+ if access_token:
193
+ headers["Authorization"] = f"Bearer {access_token}"
194
+ data = None if body is None else json.dumps(body, separators=(",", ":")).encode()
195
+ if data is not None:
196
+ headers["Content-Type"] = "application/json"
197
+ request = Request(self.base_url + path, data=data, headers=headers, method=method)
198
+ try:
199
+ with self.opener.open(request, timeout=30) as response:
200
+ raw = response.read(_MAX_RESPONSE + 1)
201
+ except HTTPError as error:
202
+ try:
203
+ value = json.loads(error.read(16_384))
204
+ except (ValueError, UnicodeDecodeError):
205
+ value = {}
206
+ code = value.get("error") if isinstance(value, dict) else None
207
+ raise GridlineError("Gridline authentication request failed",
208
+ code=code if isinstance(code, str) else "auth_http_error",
209
+ status=error.code) from error
210
+ except URLError as error:
211
+ raise GridlineError("Authentication network request failed",
212
+ code="network_error") from error
213
+ if len(raw) > _MAX_RESPONSE:
214
+ raise GridlineError("Authentication response too large", code="response_too_large")
215
+ if not raw:
216
+ return {}
217
+ try:
218
+ value = json.loads(raw)
219
+ except (ValueError, UnicodeDecodeError) as error:
220
+ raise GridlineError("Invalid authentication response", code="remote_contract_invalid") from error
221
+ if not isinstance(value, dict):
222
+ raise GridlineError("Invalid authentication response", code="remote_contract_invalid")
223
+ return value
224
+
225
+
226
+ def default_token_file() -> Path:
227
+ return Path(os.environ.get("GRIDLINE_API_TOKEN_FILE") or
228
+ Path.home() / ".config" / "gridline" / "python-token").expanduser()
229
+
230
+
231
+ def _private_target(value: str | Path) -> Path:
232
+ if os.name != "posix":
233
+ raise GridlineError("Browser setup requires POSIX private-file permissions",
234
+ code="unsupported_platform")
235
+ target = Path(value).expanduser().absolute()
236
+ parent = target.parent
237
+ parent.mkdir(mode=0o700, parents=True, exist_ok=True)
238
+ info = parent.lstat()
239
+ if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or
240
+ info.st_mode & 0o077):
241
+ raise GridlineError("Token directory must be owned by you and mode 0700",
242
+ code="token_directory_invalid")
243
+ return target
244
+
245
+
246
+ def _write_private_token(target: Path, token: str) -> None:
247
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
248
+ descriptor = os.open(target, flags, 0o600)
249
+ try:
250
+ os.fchmod(descriptor, 0o600)
251
+ with os.fdopen(descriptor, "w", encoding="utf-8", closefd=False) as handle:
252
+ handle.write(token + "\n")
253
+ handle.flush()
254
+ os.fsync(descriptor)
255
+ except Exception:
256
+ target.unlink(missing_ok=True)
257
+ raise
258
+ finally:
259
+ os.close(descriptor)
@@ -59,8 +59,8 @@ class PlannedWorkload:
59
59
  class GridlineClient:
60
60
  """Use a scoped Gridline automation token; never put it in YAML or source code.
61
61
 
62
- Create a token with ``gridline auth token create`` and pass its 0600 file path.
63
- The SDK also reads ``GRIDLINE_API_TOKEN_FILE`` or ``GRIDLINE_API_TOKEN``.
62
+ Create a token with ``gridline setup --apply``. The SDK reads its private
63
+ default file, ``GRIDLINE_API_TOKEN_FILE``, or ``GRIDLINE_API_TOKEN``.
64
64
  """
65
65
 
66
66
  def __init__(
@@ -77,6 +77,10 @@ class GridlineClient:
77
77
  if not token and not token_file:
78
78
  token_file = os.getenv("GRIDLINE_API_TOKEN_FILE")
79
79
  token = os.getenv("GRIDLINE_API_TOKEN") if not token_file else None
80
+ if not token and not token_file:
81
+ installed = Path.home() / ".config" / "gridline" / "python-token"
82
+ if installed.exists():
83
+ token_file = installed
80
84
  if not token and not token_file:
81
85
  raise ValueError("Set GRIDLINE_API_TOKEN_FILE or pass token_file")
82
86
  parsed = urlparse(base_url)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridlinegpu
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Summary: Python client for Gridline YAML workload routing
5
5
  Author: Gridline GPU
6
6
  License: Proprietary
@@ -17,21 +17,21 @@ Requires-Dist: PyYAML<7,>=6.0.2
17
17
 
18
18
  Submit one YAML workload through Gridline's authenticated API. The Python client stages
19
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.
20
+ fresh plan when you supply a cost ceiling and idempotency key. The package also installs
21
+ the `gridline` command in the same Python environment on macOS and Linux.
21
22
 
22
23
  ## Install
23
24
 
24
25
  ```sh
25
- python -m pip install 'gridlinegpu>=0.1.2,<0.2'
26
+ python -m pip install 'gridlinegpu>=0.1.3,<0.2'
26
27
  ```
27
28
 
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.
29
+ Run browser setup once. It creates a 30-day scoped token in a private file, attempts to
30
+ revoke the temporary browser session, and never prints the token. Keep the token file out of
31
+ source control and AI chats.
30
32
 
31
33
  ```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"
34
+ gridline setup --apply
35
35
  ```
36
36
 
37
37
  Use a YAML file beside its referenced inputs. Task, model, engine, providers, output,
@@ -53,6 +53,32 @@ print(ready["job"]["outputs"], ready["job"]["outcome"])
53
53
  print(client.outputs(job_id, "results"))
54
54
  ```
55
55
 
56
+ The same installed package supports a terminal flow:
57
+
58
+ ```sh
59
+ gridline workload validate --file workload.yaml
60
+ gridline workload plan --file workload.yaml
61
+ gridline workload run --file workload.yaml --apply --max-cost-usd 5 \
62
+ --idempotency-key my-job-2026-09-27
63
+ gridline workload status JOB_ID
64
+ gridline workload outputs JOB_ID --directory results
65
+ ```
66
+
67
+ The paid `run` command plans a fresh quote, prints it, and asks for explicit confirmation.
68
+ For an approved noninteractive run, add `--yes` and explicit `--profile production`
69
+ alongside `--apply`, a cost ceiling, and an idempotency key. Cancellation similarly
70
+ requires `--apply` and confirmation. A custom
71
+ token path may be passed as global `--token-file` or through `GRIDLINE_API_TOKEN_FILE`.
72
+ Workload commands also honor `GRIDLINE_API_TOKEN` when no token file is selected.
73
+ The existing standalone Bun CLI remains available during transition; check which
74
+ `gridline` executable your shell selects when both are installed. Run
75
+ `python -m gridlinegpu` to choose this package's command explicitly.
76
+ Use `gridline auth token list` to find a token ID and
77
+ `gridline auth token revoke TOKEN_ID --apply` to revoke it. Both require fresh browser
78
+ approval; the Python CLI never stores a privileged refresh token.
79
+ After revoking a token, remove its local file before running setup again; setup never
80
+ overwrites an existing file.
81
+
56
82
  `plan()` stages input bytes but does not allocate a GPU. `launch()` never silently
57
83
  changes a plan or retries an ambiguous paid request. If launch outcome is unknown,
58
84
  inspect `LaunchOutcomeUnknown.workload_id` with the same idempotency key; do not use a
@@ -1,11 +1,17 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  src/gridlinegpu/__init__.py
4
+ src/gridlinegpu/__main__.py
5
+ src/gridlinegpu/cli.py
6
+ src/gridlinegpu/cli_auth.py
4
7
  src/gridlinegpu/client.py
5
8
  src/gridlinegpu/errors.py
6
9
  src/gridlinegpu.egg-info/PKG-INFO
7
10
  src/gridlinegpu.egg-info/SOURCES.txt
8
11
  src/gridlinegpu.egg-info/dependency_links.txt
12
+ src/gridlinegpu.egg-info/entry_points.txt
9
13
  src/gridlinegpu.egg-info/requires.txt
10
14
  src/gridlinegpu.egg-info/top_level.txt
15
+ tests/test_cli.py
16
+ tests/test_cli_auth.py
11
17
  tests/test_client.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gridline = gridlinegpu.cli:main
@@ -0,0 +1,91 @@
1
+ import io
2
+ import unittest
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from gridlinegpu.cli import main
6
+
7
+
8
+ class CliTest(unittest.TestCase):
9
+ def setUp(self):
10
+ self.output = io.StringIO()
11
+ self.errors = io.StringIO()
12
+ self.input = io.StringIO()
13
+
14
+ def invoke(self, *arguments):
15
+ return main(arguments, input_stream=self.input, output=self.output, errors=self.errors)
16
+
17
+ @patch("gridlinegpu.cli.GridlineClient")
18
+ def test_paid_run_requires_apply_and_does_not_plan(self, client_type):
19
+ code = self.invoke("workload", "run", "--file", "workload.yaml",
20
+ "--max-cost-usd", "5", "--idempotency-key", "one")
21
+ self.assertEqual(code, 1)
22
+ client_type.return_value.plan.assert_not_called()
23
+ self.assertIn("--apply", self.errors.getvalue())
24
+
25
+ @patch("gridlinegpu.cli.GridlineClient")
26
+ def test_noninteractive_run_refuses_launch_without_yes(self, client_type):
27
+ planned = MagicMock()
28
+ planned.review = {"result": {"status": "planned"}}
29
+ planned.quote = {"maximumTotalMicros": 5_000_000}
30
+ client_type.return_value.plan.return_value = planned
31
+ code = self.invoke("workload", "run", "--file", "workload.yaml", "--apply",
32
+ "--max-cost-usd", "5", "--idempotency-key", "one")
33
+ self.assertEqual(code, 1)
34
+ client_type.return_value.launch.assert_not_called()
35
+ self.assertIn("confirmation", self.errors.getvalue())
36
+
37
+ @patch("gridlinegpu.cli.GridlineClient")
38
+ def test_yes_requires_explicit_production_target(self, client_type):
39
+ code = self.invoke("workload", "run", "--file", "workload.yaml", "--apply",
40
+ "--yes", "--max-cost-usd", "5", "--idempotency-key", "one")
41
+ self.assertEqual(code, 1)
42
+ client_type.return_value.plan.assert_not_called()
43
+ self.assertIn("--profile production", self.errors.getvalue())
44
+
45
+ @patch("gridlinegpu.cli.GridlineClient")
46
+ def test_approved_run_passes_fresh_plan_and_cost_to_sdk(self, client_type):
47
+ planned = MagicMock()
48
+ planned.review = {"result": {"status": "planned"}}
49
+ planned.quote = {"maximumTotalMicros": 5_000_000}
50
+ client_type.return_value.plan.return_value = planned
51
+ client_type.return_value.launch.return_value = {"jobId": "job_1"}
52
+ code = self.invoke("--profile", "production", "workload", "run", "--file", "workload.yaml", "--apply",
53
+ "--yes", "--max-cost-usd", "5", "--idempotency-key", "one")
54
+ self.assertEqual(code, 0)
55
+ client_type.return_value.launch.assert_called_once()
56
+ self.assertIs(client_type.return_value.launch.call_args.args[0], planned)
57
+ self.assertEqual(client_type.return_value.launch.call_args.kwargs["idempotency_key"], "one")
58
+ self.assertIn('"jobId": "job_1"', self.output.getvalue())
59
+
60
+ @patch("gridlinegpu.cli.GridlineClient")
61
+ def test_router_abstention_cannot_launch_even_with_yes(self, client_type):
62
+ planned = MagicMock()
63
+ planned.review = {"result": {"status": "abstained"}}
64
+ planned.quote = None
65
+ client_type.return_value.plan.return_value = planned
66
+ code = self.invoke("--profile", "production", "workload", "run", "--file", "workload.yaml", "--apply",
67
+ "--yes", "--max-cost-usd", "5", "--idempotency-key", "one")
68
+ self.assertEqual(code, 1)
69
+ client_type.return_value.launch.assert_not_called()
70
+ self.assertIn("abstained", self.errors.getvalue())
71
+
72
+ @patch("gridlinegpu.cli.GridlineClient")
73
+ def test_cancel_requires_apply(self, client_type):
74
+ code = self.invoke("workload", "cancel", "job_1")
75
+ self.assertEqual(code, 1)
76
+ client_type.return_value.cancel.assert_not_called()
77
+
78
+ @patch("gridlinegpu.cli.GridlineClient")
79
+ def test_workload_uses_sdk_token_precedence(self, client_type):
80
+ self.assertEqual(self.invoke("workload", "availability"), 0)
81
+ client_type.assert_called_with()
82
+
83
+ @patch("gridlinegpu.cli.GridlineClient")
84
+ def test_explicit_token_file_overrides_sdk_environment(self, client_type):
85
+ self.assertEqual(self.invoke("--token-file", "/tmp/gridline-token",
86
+ "workload", "availability"), 0)
87
+ self.assertEqual(str(client_type.call_args.kwargs["token_file"]), "/tmp/gridline-token")
88
+
89
+
90
+ if __name__ == "__main__":
91
+ unittest.main()
@@ -0,0 +1,132 @@
1
+ import io
2
+ import json
3
+ import stat
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+ from urllib.error import HTTPError
9
+
10
+ from gridlinegpu.cli_auth import DeviceAuth
11
+ from gridlinegpu.errors import GridlineError
12
+
13
+
14
+ TOKEN = "gla_" + "a" * 43
15
+ TOKEN_ID = "12345678-1234-1234-1234-123456789abc"
16
+
17
+
18
+ class Response(io.BytesIO):
19
+ def __enter__(self):
20
+ return self
21
+
22
+ def __exit__(self, *_):
23
+ self.close()
24
+
25
+
26
+ class FakeOpener:
27
+ def __init__(self, *, approval_url="https://gridlinegpu.com/cli/auth?user_code=ABCD"):
28
+ self.calls = []
29
+ self.pending = True
30
+ self.approval_url = approval_url
31
+ self.created_scopes = ["read", "routing:write"]
32
+
33
+ def open(self, request, timeout):
34
+ path = request.full_url.removeprefix("https://gridlinegpu.com")
35
+ self.calls.append((request.get_method(), path, request.headers, request.data))
36
+ if path == "/api/cli-auth/device":
37
+ result = {"device_code": "gld_private", "verification_uri_complete": self.approval_url,
38
+ "expires_in": 600, "interval": 1}
39
+ elif path == "/api/cli-auth/token":
40
+ if self.pending:
41
+ self.pending = False
42
+ raise HTTPError(request.full_url, 400, "pending", {},
43
+ io.BytesIO(b'{"error":"authorization_pending"}'))
44
+ result = {"access_token": "glm_private", "token_type": "Bearer",
45
+ "scope": "read auth:write"}
46
+ elif path == "/api/cli-auth/automation-tokens" and request.get_method() == "POST":
47
+ result = {"token": TOKEN, "token_record": {"id": TOKEN_ID,
48
+ "expires_at": "2026-10-28T00:00:00Z",
49
+ "scopes": self.created_scopes}}
50
+ elif path == "/api/cli-auth/automation-tokens" and request.get_method() == "GET":
51
+ result = {"tokens": [{"id": TOKEN_ID, "name": "python-cli"}]}
52
+ elif path in ("/api/cli-auth/logout", f"/api/cli-auth/automation-tokens/{TOKEN_ID}"):
53
+ return Response(b"")
54
+ else:
55
+ raise AssertionError(f"Unexpected request: {path}")
56
+ return Response(json.dumps(result).encode())
57
+
58
+
59
+ class DeviceAuthTest(unittest.TestCase):
60
+ def setUp(self):
61
+ folder = tempfile.TemporaryDirectory()
62
+ self.addCleanup(folder.cleanup)
63
+ self.target = Path(folder.name) / "gridline" / "python-token"
64
+ self.opener = FakeOpener()
65
+ self.clock = 0
66
+ self.browser_urls = []
67
+ self.messages = []
68
+ self.auth = DeviceAuth(opener=self.opener, now=lambda: self.clock,
69
+ sleep=self.advance, open_browser=self.browser_urls.append)
70
+
71
+ def advance(self, seconds):
72
+ self.clock += seconds
73
+
74
+ def test_setup_creates_private_token_and_revokes_device_session(self):
75
+ result = self.auth.setup(self.target, announce=self.messages.append)
76
+ self.assertEqual(self.target.read_text().strip(), TOKEN)
77
+ self.assertEqual(stat.S_IMODE(self.target.stat().st_mode), 0o600)
78
+ self.assertEqual(stat.S_IMODE(self.target.parent.stat().st_mode), 0o700)
79
+ self.assertEqual(result["tokenId"], TOKEN_ID)
80
+ self.assertTrue(result["temporarySessionRevoked"])
81
+ self.assertEqual(len(self.browser_urls), 1)
82
+ self.assertFalse(any(TOKEN in message or "glm_private" in message for message in self.messages))
83
+ device_body = json.loads(self.opener.calls[0][3])
84
+ created_body = json.loads(next(data for method, path, _, data in self.opener.calls
85
+ if method == "POST" and path == "/api/cli-auth/automation-tokens"))
86
+ self.assertEqual(device_body["scopes"], ["read", "auth:write"])
87
+ self.assertEqual(created_body["scopes"], ["read", "routing:write"])
88
+ self.assertEqual(self.opener.calls[-1][:2], ("POST", "/api/cli-auth/logout"))
89
+
90
+ def test_existing_token_file_is_never_overwritten_or_networked(self):
91
+ self.target.parent.mkdir(mode=0o700)
92
+ self.target.write_text("existing")
93
+ with self.assertRaisesRegex(GridlineError, "already exists"):
94
+ self.auth.setup(self.target)
95
+ self.assertEqual(self.target.read_text(), "existing")
96
+ self.assertEqual(self.opener.calls, [])
97
+
98
+ def test_rejects_cross_origin_browser_approval(self):
99
+ self.opener.approval_url = "https://other.example/cli/auth?user_code=ABCD"
100
+ with self.assertRaisesRegex(GridlineError, "Unsafe"):
101
+ self.auth.setup(self.target)
102
+ self.assertEqual(self.browser_urls, [])
103
+ self.assertFalse(self.target.exists())
104
+
105
+ def test_failed_private_write_requests_token_revocation(self):
106
+ with patch("gridlinegpu.cli_auth._write_private_token", side_effect=OSError("disk full")):
107
+ with self.assertRaises(OSError):
108
+ self.auth.setup(self.target, announce=self.messages.append)
109
+ paths = [path for _, path, _, _ in self.opener.calls]
110
+ self.assertIn(f"/api/cli-auth/automation-tokens/{TOKEN_ID}", paths)
111
+ self.assertEqual(paths[-1], "/api/cli-auth/logout")
112
+ self.assertFalse(self.target.exists())
113
+
114
+ def test_unexpected_token_scopes_are_revoked_before_write(self):
115
+ self.opener.created_scopes = ["read", "routing:write", "auth:write"]
116
+ with self.assertRaisesRegex(GridlineError, "Invalid automation token response"):
117
+ self.auth.setup(self.target, announce=self.messages.append)
118
+ paths = [path for _, path, _, _ in self.opener.calls]
119
+ self.assertIn(f"/api/cli-auth/automation-tokens/{TOKEN_ID}", paths)
120
+ self.assertFalse(self.target.exists())
121
+
122
+ def test_token_can_be_listed_and_revoked_with_fresh_browser_approval(self):
123
+ self.assertEqual(self.auth.tokens(announce=self.messages.append)["tokens"][0]["id"], TOKEN_ID)
124
+ result = self.auth.revoke(TOKEN_ID, announce=self.messages.append)
125
+ self.assertTrue(result["revoked"])
126
+ self.assertIn(("DELETE", f"/api/cli-auth/automation-tokens/{TOKEN_ID}"),
127
+ [(method, path) for method, path, _, _ in self.opener.calls])
128
+ self.assertEqual(sum(path == "/api/cli-auth/logout" for _, path, _, _ in self.opener.calls), 2)
129
+
130
+
131
+ if __name__ == "__main__":
132
+ unittest.main()
@@ -1,9 +1,11 @@
1
1
  import io
2
2
  import json
3
+ import os
3
4
  import tempfile
4
5
  import unittest
5
6
  from datetime import datetime, timedelta, timezone
6
7
  from pathlib import Path
8
+ from unittest.mock import patch
7
9
 
8
10
  from gridlinegpu import GridlineClient, GridlineError
9
11
 
@@ -133,6 +135,16 @@ class ClientTest(unittest.TestCase):
133
135
  self.assertEqual(next(value for name, value in headers.items() if name.lower() == "user-agent"),
134
136
  "GridlinePythonSDK/1")
135
137
 
138
+ def test_uses_private_token_created_by_pip_installed_cli(self):
139
+ token_file = Path(self.folder.name) / ".config" / "gridline" / "python-token"
140
+ token_file.parent.mkdir(parents=True)
141
+ token_file.write_text(TOKEN + "\n")
142
+ token_file.chmod(0o600)
143
+ with patch.dict(os.environ, {"GRIDLINE_API_TOKEN_FILE": "", "GRIDLINE_API_TOKEN": ""}):
144
+ with patch.object(Path, "home", return_value=Path(self.folder.name)):
145
+ client = GridlineClient(opener=self.opener)
146
+ self.assertEqual(client._read_token(), TOKEN)
147
+
136
148
  def test_wait_returns_when_finite_outputs_are_complete_before_billing(self):
137
149
  self.opener.job = {"jobId": "job_1", "outcome": "pending", "execution": {"mode": "finite"},
138
150
  "outputs": "complete", "cleanup": "pending", "billing": "pending"}
@@ -1,47 +0,0 @@
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>=0.1.2,<0.2'
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
- ready = client.wait(job_id, timeout=7200)
37
- print(ready["job"]["outputs"], ready["job"]["outcome"])
38
- print(client.outputs(job_id, "results"))
39
- ```
40
-
41
- `plan()` stages input bytes but does not allocate a GPU. `launch()` never silently
42
- changes a plan or retries an ambiguous paid request. If launch outcome is unknown,
43
- inspect `LaunchOutcomeUnknown.workload_id` with the same idempotency key; do not use a
44
- new key. `outputs()` verifies byte count and SHA-256 before creating private files.
45
- Cancellation requests stop; cleanup and final provider billing can remain pending.
46
- For finite jobs, `wait()` returns when outputs are complete, even if cleanup and billing
47
- leave `outcome` pending. Check `status()` later for final settlement.
File without changes