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.
- {gridlinegpu-0.1.2 → gridlinegpu-0.1.3}/PKG-INFO +34 -8
- gridlinegpu-0.1.3/README.md +73 -0
- {gridlinegpu-0.1.2 → gridlinegpu-0.1.3}/pyproject.toml +4 -1
- gridlinegpu-0.1.3/src/gridlinegpu/__main__.py +6 -0
- gridlinegpu-0.1.3/src/gridlinegpu/cli.py +187 -0
- gridlinegpu-0.1.3/src/gridlinegpu/cli_auth.py +259 -0
- {gridlinegpu-0.1.2 → gridlinegpu-0.1.3}/src/gridlinegpu/client.py +6 -2
- {gridlinegpu-0.1.2 → gridlinegpu-0.1.3}/src/gridlinegpu.egg-info/PKG-INFO +34 -8
- {gridlinegpu-0.1.2 → gridlinegpu-0.1.3}/src/gridlinegpu.egg-info/SOURCES.txt +6 -0
- gridlinegpu-0.1.3/src/gridlinegpu.egg-info/entry_points.txt +2 -0
- gridlinegpu-0.1.3/tests/test_cli.py +91 -0
- gridlinegpu-0.1.3/tests/test_cli_auth.py +132 -0
- {gridlinegpu-0.1.2 → gridlinegpu-0.1.3}/tests/test_client.py +12 -0
- gridlinegpu-0.1.2/README.md +0 -47
- {gridlinegpu-0.1.2 → gridlinegpu-0.1.3}/setup.cfg +0 -0
- {gridlinegpu-0.1.2 → gridlinegpu-0.1.3}/src/gridlinegpu/__init__.py +0 -0
- {gridlinegpu-0.1.2 → gridlinegpu-0.1.3}/src/gridlinegpu/errors.py +0 -0
- {gridlinegpu-0.1.2 → gridlinegpu-0.1.3}/src/gridlinegpu.egg-info/dependency_links.txt +0 -0
- {gridlinegpu-0.1.2 → gridlinegpu-0.1.3}/src/gridlinegpu.egg-info/requires.txt +0 -0
- {gridlinegpu-0.1.2 → gridlinegpu-0.1.3}/src/gridlinegpu.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gridlinegpu
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
26
|
+
python -m pip install 'gridlinegpu>=0.1.3,<0.2'
|
|
26
27
|
```
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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.
|
|
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,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
|
|
63
|
-
|
|
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.
|
|
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.
|
|
26
|
+
python -m pip install 'gridlinegpu>=0.1.3,<0.2'
|
|
26
27
|
```
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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,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"}
|
gridlinegpu-0.1.2/README.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|