gridweave-sdk 0.5.5__py3-none-any.whl

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.
gridweave/__init__.py ADDED
@@ -0,0 +1,36 @@
1
+ """gridweave — client SDK for submitting jobs to the compute cluster."""
2
+ import logging as _logging
3
+ from pathlib import Path as _Path
4
+
5
+ _logging.getLogger("gridweave").addHandler(_logging.NullHandler())
6
+
7
+ _version_file = _Path(__file__).resolve().parent.parent / "VERSION_SDK"
8
+ if _version_file.exists():
9
+ __version__ = _version_file.read_text().strip()
10
+ else:
11
+ try:
12
+ from importlib.metadata import version as _pkg_version
13
+ __version__ = _pkg_version("gridweave-sdk")
14
+ except Exception:
15
+ __version__ = "0.0.0"
16
+
17
+ from .auth import auth
18
+ from .remote import remote
19
+ from .train import train
20
+ from .execution import run, submit, get, gather, logs, status, cancel
21
+ from .images import build_env, run_image, submit_image
22
+ from .storage import upload, download, list_files
23
+ from .resources import resources
24
+ from .serve import serve, endpoint, endpoints, stop, start, delete
25
+ from .chain import chain, chain_rpc
26
+ from .registry import registry_login, registry_status, registry_logout, push_image, delete_image
27
+
28
+ __all__ = [
29
+ "auth", "remote", "train", "run", "submit", "get", "gather", "logs", "status",
30
+ "cancel", "run_image", "submit_image", "build_env",
31
+ "upload", "download", "list_files", "resources",
32
+ "serve", "endpoint", "endpoints", "stop", "start", "delete",
33
+ "chain", "chain_rpc",
34
+ "registry_login", "registry_status", "registry_logout", "push_image",
35
+ "delete_image",
36
+ ]
gridweave/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """python -m client_sdk — quick version/health check."""
2
+
3
+ from . import __version__
4
+ from .auth import get_platform_url
5
+
6
+ if __name__ == "__main__":
7
+ print(f"gridweave client v{__version__}")
8
+ print(f"platform: {get_platform_url()}")
gridweave/_http.py ADDED
@@ -0,0 +1,31 @@
1
+ """Thin wrappers over the shared pooled client (auth.client()).
2
+
3
+ SDK modules import this as `http` — one connection pool for every call,
4
+ with connect-level retries, instead of a fresh TCP+TLS handshake per
5
+ request."""
6
+
7
+ from .auth import client
8
+
9
+
10
+ def get(url, **kwargs):
11
+ return client().get(url, **kwargs)
12
+
13
+
14
+ def post(url, **kwargs):
15
+ return client().post(url, **kwargs)
16
+
17
+
18
+ def put(url, **kwargs):
19
+ return client().put(url, **kwargs)
20
+
21
+
22
+ def delete(url, **kwargs):
23
+ return client().delete(url, **kwargs)
24
+
25
+
26
+ def head(url, **kwargs):
27
+ return client().head(url, **kwargs)
28
+
29
+
30
+ def stream(method, url, **kwargs):
31
+ return client().stream(method, url, **kwargs)
@@ -0,0 +1,270 @@
1
+ """Client-side input validation — fail fast with actionable messages
2
+ instead of late, opaque server-side errors."""
3
+
4
+ import os
5
+ import re
6
+
7
+ MAX_PAYLOAD_BYTES = int(os.environ.get("GRIDWEAVE_MAX_PAYLOAD_BYTES",
8
+ str(10 * 1024 * 1024)))
9
+
10
+ _VRAM_RE = re.compile(r"^\d+(\.\d+)?\s*(GB|MB)?$", re.IGNORECASE)
11
+ _NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
12
+ _MODEL_RE = re.compile(r"^[\w.\-]+/[\w.\-]+$")
13
+ VALID_VENDORS = {"", "auto", "nvidia", "amd"}
14
+ MAX_VRAM_GB = 2048
15
+ MAX_GPUS = 32
16
+ MAX_CPUS = 1024
17
+ MAX_RAM_GB = 4096
18
+ # Package specs for per-job environments: torch==2.4, pkg[extra]>=1,
19
+ # conda-forge::ffmpeg. No shell metacharacters (;|&$`…) by construction —
20
+ # these specs end up on an installer command line.
21
+ _PKG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-\[\],=<>!~ :]*$")
22
+ MAX_ENV_PKGS = 100
23
+ MAX_PKG_LEN = 200
24
+
25
+
26
+ def validate_vram(vram: str) -> str:
27
+ """Accepts '24GB', '500MB', or a bare number of GB. Returns it stripped."""
28
+ if not vram:
29
+ return ""
30
+ v = str(vram).strip()
31
+ if not _VRAM_RE.match(v):
32
+ raise ValueError(
33
+ f"Invalid vram {vram!r}: expected a number with GB/MB suffix, e.g. '24GB'")
34
+ digits = re.sub(r"(?i)\s*(GB|MB)$", "", v)
35
+ gb = float(digits) / 1024 if v.upper().endswith("MB") else float(digits)
36
+ if not 0 < gb <= MAX_VRAM_GB:
37
+ raise ValueError(f"Invalid vram {vram!r}: must be between 0 and {MAX_VRAM_GB}GB")
38
+ return v
39
+
40
+
41
+ def validate_gpus(gpus: int) -> int:
42
+ if not isinstance(gpus, int) or isinstance(gpus, bool) or not 1 <= gpus <= MAX_GPUS:
43
+ raise ValueError(f"Invalid gpus {gpus!r}: must be an integer between 1 and {MAX_GPUS}")
44
+ return gpus
45
+
46
+
47
+ def validate_cpu(cpu: int) -> int:
48
+ """Host CPUs to book for a no-vram (CPU-lane) job."""
49
+ if not isinstance(cpu, int) or isinstance(cpu, bool) or not 1 <= cpu <= MAX_CPUS:
50
+ raise ValueError(f"Invalid cpu {cpu!r}: must be an integer between 1 and {MAX_CPUS}")
51
+ return cpu
52
+
53
+
54
+ def validate_pkg_list(pkgs, kind: str = "pip") -> list:
55
+ """Package list for a per-job environment (pip= / conda=)."""
56
+ if not pkgs:
57
+ return []
58
+ if not isinstance(pkgs, (list, tuple)):
59
+ raise ValueError(
60
+ f"Invalid {kind} {pkgs!r}: expected a list of package specs, "
61
+ f"e.g. {kind}=[\"pandas>=2\", \"pyarrow\"]")
62
+ if len(pkgs) > MAX_ENV_PKGS:
63
+ raise ValueError(
64
+ f"Invalid {kind}: {len(pkgs)} packages exceeds the maximum of "
65
+ f"{MAX_ENV_PKGS} — bake large environments into an image instead")
66
+ for p in pkgs:
67
+ if not isinstance(p, str) or not p.strip():
68
+ raise ValueError(
69
+ f"Invalid {kind} entry {p!r}: expected a non-empty string")
70
+ if len(p) > MAX_PKG_LEN or not _PKG_RE.match(p):
71
+ raise ValueError(
72
+ f"Invalid {kind} entry {p!r}: not a valid package spec "
73
+ f"(letters, digits, ._-[],=<>!~: only)")
74
+ return list(pkgs)
75
+
76
+
77
+ def validate_ram(ram: str) -> str:
78
+ """Host RAM to book for a no-vram (CPU-lane) job.
79
+ Accepts '8GB', '512MB', or a bare number of GB. Returns it stripped."""
80
+ if not ram:
81
+ return ""
82
+ v = str(ram).strip()
83
+ if not _VRAM_RE.match(v):
84
+ raise ValueError(
85
+ f"Invalid ram {ram!r}: expected a number with GB/MB suffix, e.g. '8GB'")
86
+ digits = re.sub(r"(?i)\s*(GB|MB)$", "", v)
87
+ gb = float(digits) / 1024 if v.upper().endswith("MB") else float(digits)
88
+ if not 0 < gb <= MAX_RAM_GB:
89
+ raise ValueError(f"Invalid ram {ram!r}: must be between 0 and {MAX_RAM_GB}GB")
90
+ return v
91
+
92
+
93
+ def normalize_vendor(vendor: str) -> str:
94
+ v = (vendor or "").strip().lower()
95
+ if v not in VALID_VENDORS:
96
+ raise ValueError(
97
+ f"Invalid vendor {vendor!r}: must be one of 'nvidia', 'amd', 'auto', or ''")
98
+ return "" if v == "auto" else v
99
+
100
+
101
+ def validate_endpoint_name(name: str) -> str:
102
+ """Endpoint names become k8s labels: ≤63 chars of [a-z0-9-], starting
103
+ and ending alphanumeric."""
104
+ if not name or len(name) > 63 or not _NAME_RE.match(name):
105
+ raise ValueError(
106
+ f"Invalid endpoint name {name!r}: up to 63 lowercase letters, "
107
+ f"digits, or hyphens, starting and ending with a letter or digit")
108
+ return name
109
+
110
+
111
+ def validate_model(model: str) -> str:
112
+ """A vLLM model source: either a HuggingFace 'owner/repo' name or an
113
+ s3:// URI to model weights the gateway downloads at deploy time."""
114
+ m = model or ""
115
+ if m.startswith("s3://"):
116
+ return validate_s3_uri(m)
117
+ if not _MODEL_RE.match(m):
118
+ raise ValueError(
119
+ f"Invalid model {model!r}: expected HuggingFace 'owner/repo' format "
120
+ f"or an s3:// model URI")
121
+ return model
122
+
123
+
124
+ def check_payload_size(num_bytes: int, what: str = "payload") -> None:
125
+ if num_bytes > MAX_PAYLOAD_BYTES:
126
+ raise ValueError(
127
+ f"{what} is {num_bytes / 1024 / 1024:.1f}MB — exceeds the "
128
+ f"{MAX_PAYLOAD_BYTES / 1024 / 1024:.0f}MB limit. Upload large data "
129
+ f"with gridweave.storage.upload() and load it inside the function "
130
+ f"(set GRIDWEAVE_MAX_PAYLOAD_BYTES to override).")
131
+
132
+
133
+ def validate_job_id(job_id: str) -> str:
134
+ if not job_id or not str(job_id).strip():
135
+ raise ValueError("Empty job_id — the handle is not a valid job reference")
136
+ return job_id
137
+
138
+
139
+ def validate_s3_uri(uri: str) -> str:
140
+ if not (uri or "").startswith("s3://"):
141
+ raise ValueError(f"Invalid storage uri {uri!r}: must start with 's3://'")
142
+ return uri
143
+
144
+
145
+ # Characters that would let a spec.image string break out into the shell or
146
+ # carry whitespace a container reference can never contain. A valid image ref
147
+ # is just registry/name:tag@digest — none of these belong in it.
148
+ _IMAGE_BAD_CHARS = ";|&$`<>()\\'\" \t\n\r"
149
+
150
+
151
+ def validate_image(image: str) -> str:
152
+ """Container image reference for a custom spec, e.g. 'org/name:tag'.
153
+ Rejected fast so a typo or injection never reaches the deploy (where it
154
+ would otherwise surface late as an ImagePullBackOff)."""
155
+ if not image or not str(image).strip():
156
+ raise ValueError(
157
+ "Invalid spec.image: a container image is required, e.g. 'org/name:tag'")
158
+ img = str(image).strip()
159
+ bad = "".join(sorted({c for c in img if c in _IMAGE_BAD_CHARS}))
160
+ if bad:
161
+ raise ValueError(
162
+ f"Invalid spec.image {image!r}: contains disallowed character(s) {bad!r}. "
163
+ f"Use a plain image reference like 'org/name:tag' "
164
+ f"(no spaces or shell metacharacters)")
165
+ return img
166
+
167
+
168
+ def validate_port(port: int) -> int:
169
+ """TCP port the user's container listens on (HTTP-mode spec)."""
170
+ if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
171
+ raise ValueError(
172
+ f"Invalid spec.port {port!r}: must be an integer between 1 and 65535")
173
+ return port
174
+
175
+
176
+ def validate_spec(spec: dict) -> dict:
177
+ """Mirror the server's custom-image deploy contract so an unrunnable spec
178
+ fails here — instantly, with the reason — instead of as a late pod or
179
+ request-time error. Server requires: 'image', plus either 'port' (HTTP
180
+ mode) or 'actions' (command mode); command-mode actions need a 'command'."""
181
+ if not isinstance(spec, dict):
182
+ raise ValueError(
183
+ f"Invalid spec: expected a dict (or path to a JSON file), "
184
+ f"got {type(spec).__name__}")
185
+
186
+ validate_image(spec.get("image", ""))
187
+
188
+ has_port = "port" in spec
189
+ has_actions = bool(spec.get("actions"))
190
+ if not has_port and not has_actions:
191
+ raise ValueError(
192
+ "Invalid spec: must include either 'port' (your container's HTTP "
193
+ "port) or 'actions' (command-mode handlers)")
194
+ if has_port:
195
+ validate_port(spec["port"])
196
+ if "gpus" in spec:
197
+ validate_gpus(spec["gpus"])
198
+ if "ram" in spec:
199
+ validate_ram(spec["ram"]) # becomes the pod's memory limit
200
+
201
+ actions = spec.get("actions") or {}
202
+ if actions and not isinstance(actions, dict):
203
+ raise ValueError(
204
+ f"Invalid spec.actions: expected a dict of name -> action, "
205
+ f"got {type(actions).__name__}")
206
+ for aname, action in actions.items():
207
+ if not isinstance(action, dict):
208
+ raise ValueError(
209
+ f"Invalid spec.actions[{aname!r}]: expected a dict, "
210
+ f"got {type(action).__name__}")
211
+ # In command mode (no port) every action runs a shell command; an
212
+ # empty command would deploy fine then fail at call time with a 500.
213
+ if not has_port and not str(action.get("command", "")).strip():
214
+ raise ValueError(
215
+ f"Invalid spec.actions[{aname!r}]: command-mode actions require "
216
+ f"a non-empty 'command', e.g. 'python /app/run.py'")
217
+ return spec
218
+
219
+
220
+ def validate_action(action: str) -> str:
221
+ """Named action on a custom endpoint — must be non-empty, else the call
222
+ hits /actions/ and 404s late."""
223
+ if not action or not str(action).strip():
224
+ raise ValueError("Empty action name — pass the action to call, e.g. ep.call('fold', ...)")
225
+ return action
226
+
227
+
228
+ def validate_local_file(local_path: str) -> str:
229
+ """A readable local file must exist before we try to upload it — fail
230
+ here with a clear message instead of a late open() error mid-call."""
231
+ if not local_path or not str(local_path).strip():
232
+ raise ValueError("upload() requires a local file path")
233
+ if not os.path.isfile(local_path):
234
+ raise ValueError(f"No such file to upload: {local_path!r}")
235
+ return local_path
236
+
237
+
238
+ def _check_price(label: str, value) -> None:
239
+ if value is None:
240
+ return
241
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
242
+ raise ValueError(f"Invalid {label} {value!r}: must be a number >= 0")
243
+
244
+
245
+ def validate_pricing(paid: bool, model: str,
246
+ price_per_call=None,
247
+ price_per_1m_input=None,
248
+ price_per_1m_output=None) -> None:
249
+ """Validate deployer-set endpoint call pricing for serve(). Pricing applies
250
+ only to paid endpoints; per-call and per-token are mutually exclusive; and
251
+ per-token pricing is vLLM-only (custom images have no token concept)."""
252
+ per_call = price_per_call is not None
253
+ per_token = price_per_1m_input is not None or price_per_1m_output is not None
254
+ if not per_call and not per_token:
255
+ return
256
+ if not paid:
257
+ raise ValueError(
258
+ "Endpoint pricing applies only to paid endpoints — pass paid=True "
259
+ "(free/org endpoints are auto-priced)")
260
+ if per_call and per_token:
261
+ raise ValueError(
262
+ "Set either price_per_call or per-token pricing "
263
+ "(price_per_1m_input / price_per_1m_output), not both")
264
+ if per_token and not model:
265
+ raise ValueError(
266
+ "Per-token pricing (price_per_1m_*) is vLLM-only — set model=..., "
267
+ "or use price_per_call for a custom-image endpoint")
268
+ _check_price("price_per_call", price_per_call)
269
+ _check_price("price_per_1m_input", price_per_1m_input)
270
+ _check_price("price_per_1m_output", price_per_1m_output)
gridweave/auth.py ADDED
@@ -0,0 +1,80 @@
1
+ """Authentication — stores token for all subsequent API calls."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ import httpx
7
+
8
+ _token: str | None = None
9
+ _platform_url: str = os.environ.get("PLATFORM_URL", "http://localhost:8100")
10
+ _user_id: str | None = None
11
+ _client: httpx.Client | None = None
12
+
13
+
14
+ def auth(token: str, platform_url: str | None = None):
15
+ """Set the auth token. Optionally override platform URL."""
16
+ global _token, _platform_url, _user_id
17
+ if not token or not str(token).strip():
18
+ raise ValueError("auth() requires a non-empty token")
19
+ _token = str(token).strip()
20
+ _user_id = None
21
+ if platform_url:
22
+ _platform_url = platform_url
23
+
24
+
25
+ def client() -> httpx.Client:
26
+ """Shared pooled HTTP client — one connection pool for all SDK calls
27
+ (each call used to open a fresh TCP+TLS connection), with transparent
28
+ retries on connect failures."""
29
+ global _client
30
+ if _client is None:
31
+ _client = httpx.Client(
32
+ timeout=30,
33
+ transport=httpx.HTTPTransport(retries=3),
34
+ limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
35
+ )
36
+ return _client
37
+
38
+
39
+ def get_token() -> str:
40
+ if not _token:
41
+ raise RuntimeError("Not authenticated. Call gridweave.auth('your-token') first.")
42
+ return _token
43
+
44
+
45
+ def get_platform_url() -> str:
46
+ return _platform_url
47
+
48
+
49
+ def _headers() -> dict:
50
+ try:
51
+ from . import __version__
52
+ except ImportError:
53
+ __version__ = "0.0.0"
54
+ return {
55
+ "Authorization": f"Bearer {get_token()}",
56
+ "X-SDK-Version": __version__,
57
+ # Pickled functions carry CPython bytecode (valid within one minor
58
+ # version only) — the server rejects submits from a mismatched
59
+ # Python with an actionable error instead of a worker segfault.
60
+ "X-Python-Version": f"{sys.version_info[0]}.{sys.version_info[1]}",
61
+ }
62
+
63
+
64
+ def _raise_for_status(resp: httpx.Response) -> None:
65
+ """Like httpx's built-in, but surfaces the server's error detail in the
66
+ exception message so notebook users see e.g. 'Insufficient credits' instead
67
+ of 'Client error 400 Bad Request'."""
68
+ if resp.is_success:
69
+ return
70
+ try:
71
+ body = resp.json()
72
+ detail = body.get("detail", body) if isinstance(body, dict) else body
73
+ except Exception:
74
+ detail = resp.text or "(no body)"
75
+ if resp.status_code == 426:
76
+ raise RuntimeError(f"\n\n{'='*60}\n SDK UPDATE REQUIRED\n{'='*60}\n {detail}\n{'='*60}\n")
77
+ raise httpx.HTTPStatusError(
78
+ f"{resp.status_code} {resp.reason_phrase}: {detail}",
79
+ request=resp.request, response=resp,
80
+ )
gridweave/chain.py ADDED
@@ -0,0 +1,29 @@
1
+ """Chain — read the on-chain audit log and run raw read-only chain queries.
2
+
3
+ The cluster anchors every credit/job/endpoint event on a private chain
4
+ (tamper-evident audit trail). These helpers let you read it — and verify it
5
+ yourself with arbitrary read-only RPC queries.
6
+ """
7
+
8
+ from .auth import get_platform_url, _headers, _raise_for_status
9
+ from . import _http as http
10
+
11
+
12
+ def chain(limit: int = 50) -> list[dict]:
13
+ """Recent on-chain audit events, newest first."""
14
+ resp = http.get(f"{get_platform_url()}/v1/chain/events",
15
+ params={"limit": limit}, headers=_headers(), timeout=15)
16
+ _raise_for_status(resp)
17
+ return resp.json()
18
+
19
+
20
+ def chain_rpc(method: str, params: dict | None = None) -> dict:
21
+ """Run a READ-ONLY chain (CometBFT) RPC query and return the raw result —
22
+ e.g. `chain_rpc("status")`, `chain_rpc("tx_search", {"query": "tx.height >= 1"})`,
23
+ `chain_rpc("abci_query", {"data": "<key>"})`. Writes are not allowed; this is
24
+ for inspecting and independently verifying the audit log."""
25
+ resp = http.post(f"{get_platform_url()}/v1/chain/rpc",
26
+ json={"method": method, "params": params or {}},
27
+ headers=_headers(), timeout=15)
28
+ _raise_for_status(resp)
29
+ return resp.json()