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 +36 -0
- gridweave/__main__.py +8 -0
- gridweave/_http.py +31 -0
- gridweave/_validation.py +270 -0
- gridweave/auth.py +80 -0
- gridweave/chain.py +29 -0
- gridweave/execution.py +230 -0
- gridweave/images.py +168 -0
- gridweave/registry.py +115 -0
- gridweave/remote.py +56 -0
- gridweave/resources.py +13 -0
- gridweave/serve.py +462 -0
- gridweave/storage.py +63 -0
- gridweave/train.py +25 -0
- gridweave_sdk-0.5.5.dist-info/METADATA +140 -0
- gridweave_sdk-0.5.5.dist-info/RECORD +18 -0
- gridweave_sdk-0.5.5.dist-info/WHEEL +5 -0
- gridweave_sdk-0.5.5.dist-info/top_level.txt +1 -0
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
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)
|
gridweave/_validation.py
ADDED
|
@@ -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()
|