splime 0.1.2__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.
- spl/__init__.py +14 -0
- spl/client.py +1364 -0
- spl/core/__init__.py +23 -0
- spl/core/common.py +350 -0
- spl/core/entities/__init__.py +0 -0
- spl/core/entities/adapter.py +210 -0
- spl/core/entities/artifact.py +141 -0
- spl/core/entities/control.py +45 -0
- spl/core/entities/distribution.py +65 -0
- spl/core/entities/function.py +254 -0
- spl/core/entities/local_function.py +286 -0
- spl/core/entities/misc.py +14 -0
- spl/core/entities/module.py +88 -0
- spl/core/entities/node.py +286 -0
- spl/core/entities/node_function.py +79 -0
- spl/core/entities/node_remote.py +295 -0
- spl/core/entities/pipeline.py +436 -0
- spl/core/entities/scalar.py +55 -0
- spl/core/ir/__init__.py +0 -0
- spl/core/ir/common.py +34 -0
- spl/core/ir/parse.py +79 -0
- spl/core/ir/unparse.py +29 -0
- spl/core/ir/utils.py +163 -0
- spl/daemon/__init__.py +23 -0
- spl/daemon/__main__.py +11 -0
- spl/daemon/cli.py +582 -0
- spl/daemon/client.py +43 -0
- spl/daemon/docker_environment.py +329 -0
- spl/daemon/docker_pool.py +516 -0
- spl/daemon/environment.py +228 -0
- spl/daemon/environment_base.py +479 -0
- spl/daemon/heartbeat_service.py +119 -0
- spl/daemon/metadata.py +427 -0
- spl/daemon/remote_client.py +457 -0
- spl/daemon/repositories/__init__.py +17 -0
- spl/daemon/repositories/env.py +323 -0
- spl/daemon/repositories/library.py +181 -0
- spl/daemon/repositories/object.py +997 -0
- spl/daemon/repositories/run.py +279 -0
- spl/daemon/repositories/server_connection.py +657 -0
- spl/daemon/repositories/sync_event.py +129 -0
- spl/daemon/routes/__init__.py +1 -0
- spl/daemon/routes/_helpers.py +147 -0
- spl/daemon/routes/artifacts.py +77 -0
- spl/daemon/routes/diagnostics.py +114 -0
- spl/daemon/routes/envs.py +82 -0
- spl/daemon/routes/libraries.py +129 -0
- spl/daemon/routes/objects.py +174 -0
- spl/daemon/routes/remote.py +56 -0
- spl/daemon/routes/runs.py +96 -0
- spl/daemon/routes/server_connections.py +86 -0
- spl/daemon/runtime_backend.py +368 -0
- spl/daemon/runtime_config.py +133 -0
- spl/daemon/runtime_dependencies.py +459 -0
- spl/daemon/secret_store.py +187 -0
- spl/daemon/server.py +2224 -0
- spl/daemon/server_connection.py +267 -0
- spl/daemon/services/__init__.py +1 -0
- spl/daemon/services/sync.py +76 -0
- spl/daemon/signature.py +376 -0
- spl/daemon/storage_base.py +542 -0
- spl/daemon/store.py +436 -0
- spl/daemon/worker.py +526 -0
- spl/daemon_client.py +945 -0
- spl/pipeline_widget.py +1452 -0
- spl/py.typed +0 -0
- spl/server_client.py +787 -0
- splime-0.1.2.dist-info/METADATA +189 -0
- splime-0.1.2.dist-info/RECORD +74 -0
- splime-0.1.2.dist-info/WHEEL +5 -0
- splime-0.1.2.dist-info/entry_points.txt +2 -0
- splime-0.1.2.dist-info/licenses/LICENSE +201 -0
- splime-0.1.2.dist-info/licenses/NOTICE +8 -0
- splime-0.1.2.dist-info/top_level.txt +1 -0
spl/daemon_client.py
ADDED
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
"""Small HTTP client for the local SPL daemon.
|
|
2
|
+
|
|
3
|
+
The client intentionally mirrors the daemon's minimal API. It uses only the
|
|
4
|
+
standard library so any local Python environment can talk to the daemon without
|
|
5
|
+
installing extra web client packages.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
from urllib.error import HTTPError, URLError
|
|
17
|
+
from urllib.parse import quote
|
|
18
|
+
from urllib.request import Request, urlopen
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEFAULT_DAEMON_HOST = "127.0.0.1"
|
|
22
|
+
DEFAULT_DAEMON_PORT = 8765
|
|
23
|
+
DEFAULT_URL = f"http://{DEFAULT_DAEMON_HOST}:{DEFAULT_DAEMON_PORT}"
|
|
24
|
+
DEFAULT_SERVER_URL = "https://splime.io/api"
|
|
25
|
+
DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 60.0
|
|
26
|
+
DAEMON_ENDPOINT_FILENAME = "daemon-endpoint.json"
|
|
27
|
+
DAEMON_API_TOKEN_ENV = "SPL_DAEMON_API_TOKEN"
|
|
28
|
+
|
|
29
|
+
OfflinePolicy = Literal["queue", "wait", "fail_fast"]
|
|
30
|
+
RunSource = Literal["auto", "local"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def default_daemon_home() -> Path:
|
|
34
|
+
"""Return the default local daemon data directory."""
|
|
35
|
+
|
|
36
|
+
return Path(os.environ.get("SPL_DAEMON_HOME", Path.home() / ".spl-daemon"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def daemon_url(
|
|
40
|
+
host: str = DEFAULT_DAEMON_HOST,
|
|
41
|
+
port: int = DEFAULT_DAEMON_PORT,
|
|
42
|
+
) -> str:
|
|
43
|
+
"""Build a local daemon HTTP URL from host and port."""
|
|
44
|
+
|
|
45
|
+
if port < 1 or port > 65535:
|
|
46
|
+
raise ValueError("daemon port must be between 1 and 65535")
|
|
47
|
+
host = host.strip()
|
|
48
|
+
if ":" in host and not host.startswith("["):
|
|
49
|
+
host = f"[{host}]"
|
|
50
|
+
return f"http://{host}:{port}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def daemon_endpoint_file(home: str | Path | None = None) -> Path:
|
|
54
|
+
"""Return the file where the running daemon publishes its current URL."""
|
|
55
|
+
|
|
56
|
+
if home is not None:
|
|
57
|
+
return Path(home).absolute() / DAEMON_ENDPOINT_FILENAME
|
|
58
|
+
return default_daemon_home() / DAEMON_ENDPOINT_FILENAME
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def read_daemon_endpoint(home: str | Path | None = None) -> dict[str, Any] | None:
|
|
62
|
+
"""Read the last endpoint published by a running local daemon."""
|
|
63
|
+
|
|
64
|
+
path = daemon_endpoint_file(home)
|
|
65
|
+
try:
|
|
66
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
67
|
+
except (OSError, json.JSONDecodeError):
|
|
68
|
+
return None
|
|
69
|
+
return value if isinstance(value, dict) else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _write_json_file(path: Path, value: dict[str, Any]) -> None:
|
|
73
|
+
"""Write a small JSON document atomically enough for local daemon metadata."""
|
|
74
|
+
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
tmp_path = path.with_name(f"{path.name}.tmp")
|
|
77
|
+
tmp_path.write_text(
|
|
78
|
+
json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True),
|
|
79
|
+
encoding="utf-8",
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
tmp_path.chmod(0o600)
|
|
83
|
+
except OSError:
|
|
84
|
+
pass
|
|
85
|
+
tmp_path.replace(path)
|
|
86
|
+
try:
|
|
87
|
+
path.chmod(0o600)
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def generate_daemon_api_token() -> str:
|
|
93
|
+
"""Generate the local daemon HTTP API bearer token."""
|
|
94
|
+
|
|
95
|
+
return secrets.token_urlsafe(32)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def write_daemon_endpoint(
|
|
99
|
+
home: str | Path | None,
|
|
100
|
+
*,
|
|
101
|
+
bind_host: str,
|
|
102
|
+
host: str,
|
|
103
|
+
port: int,
|
|
104
|
+
api_token: str | None = None,
|
|
105
|
+
updated_at: str | None = None,
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Publish the current local daemon URL for clients started later."""
|
|
108
|
+
|
|
109
|
+
base_url = daemon_url(host, port)
|
|
110
|
+
payload = {
|
|
111
|
+
"base_url": base_url,
|
|
112
|
+
"bind_host": bind_host,
|
|
113
|
+
"host": host,
|
|
114
|
+
"port": port,
|
|
115
|
+
}
|
|
116
|
+
if api_token is not None:
|
|
117
|
+
payload["api_token"] = api_token
|
|
118
|
+
if updated_at is not None:
|
|
119
|
+
payload["updated_at"] = updated_at
|
|
120
|
+
_write_json_file(daemon_endpoint_file(home), payload)
|
|
121
|
+
return payload
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve_api_token(
|
|
125
|
+
base_url: str | None = None,
|
|
126
|
+
*,
|
|
127
|
+
daemon_home: str | Path | None = None,
|
|
128
|
+
explicit_token: str | None = None,
|
|
129
|
+
) -> str | None:
|
|
130
|
+
"""Resolve the local daemon API bearer token for official clients."""
|
|
131
|
+
|
|
132
|
+
if explicit_token:
|
|
133
|
+
return explicit_token
|
|
134
|
+
env_token = os.environ.get(DAEMON_API_TOKEN_ENV)
|
|
135
|
+
if env_token:
|
|
136
|
+
return env_token
|
|
137
|
+
|
|
138
|
+
endpoint = read_daemon_endpoint(daemon_home)
|
|
139
|
+
if endpoint is None:
|
|
140
|
+
return None
|
|
141
|
+
endpoint_token = endpoint.get("api_token")
|
|
142
|
+
if not isinstance(endpoint_token, str) or not endpoint_token:
|
|
143
|
+
return None
|
|
144
|
+
if base_url is None:
|
|
145
|
+
return endpoint_token
|
|
146
|
+
endpoint_url = endpoint.get("base_url")
|
|
147
|
+
if isinstance(endpoint_url, str) and endpoint_url.rstrip("/") == base_url.rstrip("/"):
|
|
148
|
+
return endpoint_token
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def clear_daemon_endpoint(
|
|
153
|
+
home: str | Path | None,
|
|
154
|
+
*,
|
|
155
|
+
base_url: str | None = None,
|
|
156
|
+
) -> None:
|
|
157
|
+
"""Remove a daemon endpoint file when it belongs to the stopping daemon."""
|
|
158
|
+
|
|
159
|
+
path = daemon_endpoint_file(home)
|
|
160
|
+
if base_url is not None:
|
|
161
|
+
current = read_daemon_endpoint(home)
|
|
162
|
+
if current is None or current.get("base_url") != base_url.rstrip("/"):
|
|
163
|
+
return
|
|
164
|
+
try:
|
|
165
|
+
path.unlink()
|
|
166
|
+
except FileNotFoundError:
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def resolve_base_url(
|
|
171
|
+
base_url: str | None = None,
|
|
172
|
+
*,
|
|
173
|
+
daemon_host: str = DEFAULT_DAEMON_HOST,
|
|
174
|
+
daemon_port: int | None = None,
|
|
175
|
+
daemon_home: str | Path | None = None,
|
|
176
|
+
) -> str:
|
|
177
|
+
"""Resolve explicit URL, explicit port, saved daemon endpoint, or default."""
|
|
178
|
+
|
|
179
|
+
if base_url is not None:
|
|
180
|
+
if daemon_port is not None:
|
|
181
|
+
raise ValueError("pass either base_url or daemon_port, not both")
|
|
182
|
+
return base_url.rstrip("/")
|
|
183
|
+
if daemon_port is not None:
|
|
184
|
+
return daemon_url(daemon_host, daemon_port)
|
|
185
|
+
|
|
186
|
+
endpoint = read_daemon_endpoint(daemon_home)
|
|
187
|
+
if endpoint is not None:
|
|
188
|
+
saved_url = endpoint.get("base_url")
|
|
189
|
+
if isinstance(saved_url, str) and saved_url.strip():
|
|
190
|
+
return saved_url.rstrip("/")
|
|
191
|
+
host = endpoint.get("host")
|
|
192
|
+
port = endpoint.get("port")
|
|
193
|
+
if isinstance(host, str) and isinstance(port, int):
|
|
194
|
+
return daemon_url(host, port)
|
|
195
|
+
|
|
196
|
+
return DEFAULT_URL
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class ClientError(RuntimeError):
|
|
200
|
+
"""Raised when the daemon returns an error response."""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class Client:
|
|
204
|
+
"""Convenience wrapper around the local daemon HTTP API."""
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
base_url: str | None = None,
|
|
209
|
+
*,
|
|
210
|
+
daemon_host: str = DEFAULT_DAEMON_HOST,
|
|
211
|
+
daemon_port: int | None = None,
|
|
212
|
+
daemon_home: str | Path | None = None,
|
|
213
|
+
api_token: str | None = None,
|
|
214
|
+
):
|
|
215
|
+
self.base_url = resolve_base_url(
|
|
216
|
+
base_url,
|
|
217
|
+
daemon_host=daemon_host,
|
|
218
|
+
daemon_port=daemon_port,
|
|
219
|
+
daemon_home=daemon_home,
|
|
220
|
+
)
|
|
221
|
+
self.api_token = resolve_api_token(
|
|
222
|
+
self.base_url,
|
|
223
|
+
daemon_home=daemon_home,
|
|
224
|
+
explicit_token=api_token,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def _headers(self, *, accept_json: bool = True) -> dict[str, str]:
|
|
228
|
+
headers: dict[str, str] = {}
|
|
229
|
+
if accept_json:
|
|
230
|
+
headers["Accept"] = "application/json"
|
|
231
|
+
if self.api_token:
|
|
232
|
+
headers["Authorization"] = f"Bearer {self.api_token}"
|
|
233
|
+
return headers
|
|
234
|
+
|
|
235
|
+
def _local_daemon_unreachable(self, exc: URLError) -> ClientError:
|
|
236
|
+
"""Build a clear error for the common "daemon is not running" case."""
|
|
237
|
+
|
|
238
|
+
reason = getattr(exc, "reason", exc)
|
|
239
|
+
return ClientError(
|
|
240
|
+
f"local SPL daemon is not reachable at {self.base_url} ({reason}). "
|
|
241
|
+
"This URL points to the local daemon, not to the central daemon server. "
|
|
242
|
+
"Start it with "
|
|
243
|
+
"`python -m spl.daemon serve` "
|
|
244
|
+
"or pass the correct `base_url`/`daemon_port` to SPLClient."
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def _json_request(
|
|
248
|
+
self,
|
|
249
|
+
method: str,
|
|
250
|
+
path: str,
|
|
251
|
+
payload: dict[str, Any] | None = None,
|
|
252
|
+
) -> Any:
|
|
253
|
+
"""Send one JSON request and decode the JSON response."""
|
|
254
|
+
|
|
255
|
+
body = None
|
|
256
|
+
headers = self._headers()
|
|
257
|
+
if payload is not None:
|
|
258
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
259
|
+
headers["Content-Type"] = "application/json; charset=utf-8"
|
|
260
|
+
|
|
261
|
+
request = Request(
|
|
262
|
+
f"{self.base_url}{path}",
|
|
263
|
+
data=body,
|
|
264
|
+
headers=headers,
|
|
265
|
+
method=method,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
with urlopen(request) as response: # noqa: S310 - local daemon URL.
|
|
270
|
+
raw = response.read().decode("utf-8")
|
|
271
|
+
except HTTPError as exc:
|
|
272
|
+
raw = exc.read().decode("utf-8")
|
|
273
|
+
try:
|
|
274
|
+
message = json.loads(raw).get("error", raw)
|
|
275
|
+
except json.JSONDecodeError:
|
|
276
|
+
message = raw
|
|
277
|
+
raise ClientError(f"{exc.code}: {message}") from exc
|
|
278
|
+
except URLError as exc:
|
|
279
|
+
raise self._local_daemon_unreachable(exc) from exc
|
|
280
|
+
|
|
281
|
+
if not raw:
|
|
282
|
+
return None
|
|
283
|
+
return json.loads(raw)
|
|
284
|
+
|
|
285
|
+
def _bytes_request(self, path: str) -> bytes:
|
|
286
|
+
"""Download a binary response, used for artifact files."""
|
|
287
|
+
|
|
288
|
+
request = Request(
|
|
289
|
+
f"{self.base_url}{path}",
|
|
290
|
+
headers=self._headers(accept_json=False),
|
|
291
|
+
method="GET",
|
|
292
|
+
)
|
|
293
|
+
try:
|
|
294
|
+
with urlopen(request) as response: # noqa: S310 - local daemon URL.
|
|
295
|
+
return response.read()
|
|
296
|
+
except HTTPError as exc:
|
|
297
|
+
raw = exc.read().decode("utf-8")
|
|
298
|
+
try:
|
|
299
|
+
message = json.loads(raw).get("error", raw)
|
|
300
|
+
except json.JSONDecodeError:
|
|
301
|
+
message = raw
|
|
302
|
+
raise ClientError(f"{exc.code}: {message}") from exc
|
|
303
|
+
except URLError as exc:
|
|
304
|
+
raise self._local_daemon_unreachable(exc) from exc
|
|
305
|
+
|
|
306
|
+
def health(self) -> dict[str, Any]:
|
|
307
|
+
"""Check that the daemon is reachable."""
|
|
308
|
+
|
|
309
|
+
return self._json_request("GET", "/health")
|
|
310
|
+
|
|
311
|
+
def connect_server(
|
|
312
|
+
self,
|
|
313
|
+
*,
|
|
314
|
+
machine_token: str,
|
|
315
|
+
user_token: str,
|
|
316
|
+
server_url: str = DEFAULT_SERVER_URL,
|
|
317
|
+
machine_id: str | None = None,
|
|
318
|
+
display_name: str | None = None,
|
|
319
|
+
capabilities: dict[str, Any] | None = None,
|
|
320
|
+
heartbeat_interval_seconds: float | None = DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
|
|
321
|
+
) -> dict[str, Any]:
|
|
322
|
+
"""Ask the local daemon to connect to the central daemon server."""
|
|
323
|
+
|
|
324
|
+
payload: dict[str, Any] = {
|
|
325
|
+
"machine_token": machine_token,
|
|
326
|
+
"user_token": user_token,
|
|
327
|
+
"server_url": server_url,
|
|
328
|
+
"display_name": display_name,
|
|
329
|
+
"capabilities": capabilities or {},
|
|
330
|
+
}
|
|
331
|
+
if machine_id is not None:
|
|
332
|
+
payload["machine_id"] = machine_id
|
|
333
|
+
if heartbeat_interval_seconds is not None:
|
|
334
|
+
payload["heartbeat_interval_seconds"] = heartbeat_interval_seconds
|
|
335
|
+
return self._json_request("POST", "/server/connect", payload)
|
|
336
|
+
|
|
337
|
+
def disconnect_server(self) -> dict[str, Any]:
|
|
338
|
+
"""Ask the local daemon to gracefully disconnect from the central server."""
|
|
339
|
+
|
|
340
|
+
return self._json_request("POST", "/server/disconnect")
|
|
341
|
+
|
|
342
|
+
def server_connection(self) -> dict[str, Any]:
|
|
343
|
+
"""Return the local daemon's current central-server connection state."""
|
|
344
|
+
|
|
345
|
+
return self._json_request("GET", "/server/connection")
|
|
346
|
+
|
|
347
|
+
def server_connections(self) -> list[dict[str, Any]]:
|
|
348
|
+
"""Return stored central-server connection attempts."""
|
|
349
|
+
|
|
350
|
+
return self._json_request("GET", "/server/connections")
|
|
351
|
+
|
|
352
|
+
def server_machines(self) -> dict[str, Any]:
|
|
353
|
+
"""Return machines visible to the connected user."""
|
|
354
|
+
|
|
355
|
+
return self._json_request("GET", "/server/machines")
|
|
356
|
+
|
|
357
|
+
def server_objects(
|
|
358
|
+
self,
|
|
359
|
+
*,
|
|
360
|
+
owner_id: str | None = None,
|
|
361
|
+
library: str | None = None,
|
|
362
|
+
compact: bool = False,
|
|
363
|
+
) -> list[dict[str, Any]]:
|
|
364
|
+
"""Return objects visible on the connected central server."""
|
|
365
|
+
|
|
366
|
+
query = []
|
|
367
|
+
if owner_id is not None:
|
|
368
|
+
query.append(f"owner={quote(owner_id)}")
|
|
369
|
+
if library is not None:
|
|
370
|
+
query.append(f"library={quote(library)}")
|
|
371
|
+
if compact:
|
|
372
|
+
query.append("view=summary")
|
|
373
|
+
suffix = f"?{'&'.join(query)}" if query else ""
|
|
374
|
+
return self._json_request("GET", f"/server/objects{suffix}")
|
|
375
|
+
|
|
376
|
+
def server_libraries(
|
|
377
|
+
self,
|
|
378
|
+
*,
|
|
379
|
+
include_accessible: bool = True,
|
|
380
|
+
) -> list[dict[str, Any]]:
|
|
381
|
+
"""Return libraries visible to the connected central-server user."""
|
|
382
|
+
|
|
383
|
+
include = "1" if include_accessible else "0"
|
|
384
|
+
return self._json_request(
|
|
385
|
+
"GET",
|
|
386
|
+
f"/server/libraries?include_accessible={include}",
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def create_server_library(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
390
|
+
"""Create a library through the connected central server."""
|
|
391
|
+
|
|
392
|
+
return self._json_request("POST", "/server/libraries", payload)
|
|
393
|
+
|
|
394
|
+
def get_server_library(self, library_ref: str) -> dict[str, Any]:
|
|
395
|
+
"""Return one central-server library."""
|
|
396
|
+
|
|
397
|
+
return self._json_request("GET", f"/server/libraries/{quote(library_ref)}")
|
|
398
|
+
|
|
399
|
+
def update_server_library(
|
|
400
|
+
self,
|
|
401
|
+
library_ref: str,
|
|
402
|
+
payload: dict[str, Any],
|
|
403
|
+
) -> dict[str, Any]:
|
|
404
|
+
"""Update one central-server library."""
|
|
405
|
+
|
|
406
|
+
return self._json_request(
|
|
407
|
+
"PUT",
|
|
408
|
+
f"/server/libraries/{quote(library_ref)}",
|
|
409
|
+
payload,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
def delete_server_library(self, library_ref: str) -> dict[str, Any]:
|
|
413
|
+
"""Delete or archive one central-server library when supported upstream."""
|
|
414
|
+
|
|
415
|
+
return self._json_request("DELETE", f"/server/libraries/{quote(library_ref)}")
|
|
416
|
+
|
|
417
|
+
def server_library_grants(self, library_ref: str) -> list[dict[str, Any]]:
|
|
418
|
+
"""Return grants for one central-server library."""
|
|
419
|
+
|
|
420
|
+
return self._json_request(
|
|
421
|
+
"GET",
|
|
422
|
+
f"/server/libraries/{quote(library_ref)}/grants",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def grant_server_library(
|
|
426
|
+
self,
|
|
427
|
+
library_ref: str,
|
|
428
|
+
payload: dict[str, Any],
|
|
429
|
+
) -> dict[str, Any]:
|
|
430
|
+
"""Grant access to one central-server library."""
|
|
431
|
+
|
|
432
|
+
return self._json_request(
|
|
433
|
+
"POST",
|
|
434
|
+
f"/server/libraries/{quote(library_ref)}/grants",
|
|
435
|
+
payload,
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def revoke_server_library_grant(
|
|
439
|
+
self,
|
|
440
|
+
library_ref: str,
|
|
441
|
+
grantee: str,
|
|
442
|
+
) -> dict[str, Any]:
|
|
443
|
+
"""Revoke a grantee's access to one central-server library."""
|
|
444
|
+
|
|
445
|
+
return self._json_request(
|
|
446
|
+
"POST",
|
|
447
|
+
f"/server/libraries/{quote(library_ref)}/grants/{quote(grantee)}/revoke",
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def add_server_library_reference(
|
|
451
|
+
self,
|
|
452
|
+
library_ref: str,
|
|
453
|
+
payload: dict[str, Any],
|
|
454
|
+
) -> dict[str, Any]:
|
|
455
|
+
"""Add a live reference entry to one central-server library."""
|
|
456
|
+
|
|
457
|
+
return self._json_request(
|
|
458
|
+
"POST",
|
|
459
|
+
f"/server/libraries/{quote(library_ref)}/references",
|
|
460
|
+
payload,
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def copy_server_library_object(
|
|
464
|
+
self,
|
|
465
|
+
library_ref: str,
|
|
466
|
+
payload: dict[str, Any],
|
|
467
|
+
) -> dict[str, Any]:
|
|
468
|
+
"""Copy an object snapshot into one central-server library."""
|
|
469
|
+
|
|
470
|
+
return self._json_request(
|
|
471
|
+
"POST",
|
|
472
|
+
f"/server/libraries/{quote(library_ref)}/copies",
|
|
473
|
+
payload,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def remove_server_library_entry(
|
|
477
|
+
self,
|
|
478
|
+
library_ref: str,
|
|
479
|
+
name: str,
|
|
480
|
+
) -> dict[str, Any]:
|
|
481
|
+
"""Remove an owned object or reference entry from one library."""
|
|
482
|
+
|
|
483
|
+
return self._json_request(
|
|
484
|
+
"DELETE",
|
|
485
|
+
f"/server/libraries/{quote(library_ref)}/entries/{quote(name)}",
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def register_env(self, name: str, python: str) -> dict[str, Any]:
|
|
489
|
+
"""Register a Python executable as a daemon environment."""
|
|
490
|
+
|
|
491
|
+
return self._json_request(
|
|
492
|
+
"POST",
|
|
493
|
+
"/envs",
|
|
494
|
+
{"name": name, "python": python},
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def list_envs(self) -> dict[str, Any]:
|
|
498
|
+
"""List daemon environments."""
|
|
499
|
+
|
|
500
|
+
return self._json_request("GET", "/envs")
|
|
501
|
+
|
|
502
|
+
def list_environment_builds(self) -> list[dict[str, Any]]:
|
|
503
|
+
"""List cached virtual environment builds."""
|
|
504
|
+
|
|
505
|
+
return self._json_request("GET", "/environment-builds")
|
|
506
|
+
|
|
507
|
+
def get_environment_build(self, spec_hash: str) -> dict[str, Any]:
|
|
508
|
+
"""Return one cached virtual environment build."""
|
|
509
|
+
|
|
510
|
+
return self._json_request("GET", f"/environment-builds/{quote(spec_hash)}")
|
|
511
|
+
|
|
512
|
+
def rebuild_environment_build(
|
|
513
|
+
self,
|
|
514
|
+
spec_hash: str,
|
|
515
|
+
*,
|
|
516
|
+
wait: bool = False,
|
|
517
|
+
) -> dict[str, Any]:
|
|
518
|
+
"""Force a cached virtual environment to be rebuilt."""
|
|
519
|
+
|
|
520
|
+
return self._json_request(
|
|
521
|
+
"POST",
|
|
522
|
+
f"/environment-builds/{quote(spec_hash)}/rebuild",
|
|
523
|
+
{"wait": wait},
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
def prune_docker_images(
|
|
527
|
+
self,
|
|
528
|
+
*,
|
|
529
|
+
spec_hash: str | None = None,
|
|
530
|
+
) -> list[dict[str, Any]]:
|
|
531
|
+
"""Remove cached Docker runtime images and mark their records absent."""
|
|
532
|
+
|
|
533
|
+
payload: dict[str, Any] = {}
|
|
534
|
+
if spec_hash is not None:
|
|
535
|
+
payload["spec_hash"] = spec_hash
|
|
536
|
+
return self._json_request("POST", "/docker-images/prune", payload)
|
|
537
|
+
|
|
538
|
+
def list_remote_signatures(self) -> list[dict[str, Any]]:
|
|
539
|
+
"""List cached remote object signatures."""
|
|
540
|
+
|
|
541
|
+
return self._json_request("GET", "/remote-signatures")
|
|
542
|
+
|
|
543
|
+
def resolve_remote_signature(
|
|
544
|
+
self,
|
|
545
|
+
ref: dict[str, Any],
|
|
546
|
+
*,
|
|
547
|
+
force: bool = False,
|
|
548
|
+
) -> dict[str, Any]:
|
|
549
|
+
"""Resolve and cache a remote object signature through the daemon."""
|
|
550
|
+
|
|
551
|
+
return self._json_request(
|
|
552
|
+
"POST",
|
|
553
|
+
"/remote-signatures/resolve",
|
|
554
|
+
{"ref": ref, "force": force},
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def resolve_remote_decomposition(
|
|
558
|
+
self,
|
|
559
|
+
ref: dict[str, Any],
|
|
560
|
+
) -> dict[str, Any]:
|
|
561
|
+
"""Resolve a remote pipeline decomposition through the daemon."""
|
|
562
|
+
|
|
563
|
+
return self._json_request(
|
|
564
|
+
"POST",
|
|
565
|
+
"/remote-decompositions/resolve",
|
|
566
|
+
{"ref": ref},
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def run_remote_node(
|
|
570
|
+
self,
|
|
571
|
+
node: dict[str, Any],
|
|
572
|
+
*,
|
|
573
|
+
kwargs: dict[str, Any] | None = None,
|
|
574
|
+
timeout_seconds: float | None = None,
|
|
575
|
+
) -> dict[str, Any]:
|
|
576
|
+
"""Run one NodeRemote-like reference through the daemon."""
|
|
577
|
+
|
|
578
|
+
return self._json_request(
|
|
579
|
+
"POST",
|
|
580
|
+
"/remote-nodes/run",
|
|
581
|
+
{
|
|
582
|
+
"node": node,
|
|
583
|
+
"kwargs": kwargs or {},
|
|
584
|
+
"timeout_seconds": timeout_seconds,
|
|
585
|
+
},
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
def register_object(
|
|
589
|
+
self,
|
|
590
|
+
name: str,
|
|
591
|
+
*,
|
|
592
|
+
entrypoint: str,
|
|
593
|
+
env: str,
|
|
594
|
+
yaml_text: str | None = None,
|
|
595
|
+
yaml_path: str | Path | None = None,
|
|
596
|
+
workdir: str | None = None,
|
|
597
|
+
runtime_config: dict[str, Any] | None = None,
|
|
598
|
+
description: str | None = None,
|
|
599
|
+
version_label: str | None = None,
|
|
600
|
+
object_id: str | None = None,
|
|
601
|
+
library: str | None = None,
|
|
602
|
+
create_library: bool = False,
|
|
603
|
+
library_display_name: str | None = None,
|
|
604
|
+
local_only: bool = False,
|
|
605
|
+
) -> dict[str, Any]:
|
|
606
|
+
"""Send a serialized SPL object to the daemon registry.
|
|
607
|
+
|
|
608
|
+
By default the client reads ``yaml_path`` and sends its contents. That
|
|
609
|
+
means the daemon does not need access to the caller's working directory.
|
|
610
|
+
"""
|
|
611
|
+
|
|
612
|
+
if yaml_text is None:
|
|
613
|
+
if yaml_path is None:
|
|
614
|
+
raise ValueError("yaml_text or yaml_path is required")
|
|
615
|
+
yaml_text = Path(yaml_path).read_text(encoding="utf-8")
|
|
616
|
+
|
|
617
|
+
payload = {
|
|
618
|
+
"name": name,
|
|
619
|
+
"entrypoint": entrypoint,
|
|
620
|
+
"env": env,
|
|
621
|
+
"yaml": yaml_text,
|
|
622
|
+
"local_only": local_only,
|
|
623
|
+
}
|
|
624
|
+
if workdir is not None:
|
|
625
|
+
payload["workdir"] = workdir
|
|
626
|
+
if runtime_config is not None:
|
|
627
|
+
payload["runtime_config"] = runtime_config
|
|
628
|
+
if description is not None:
|
|
629
|
+
payload["description"] = description
|
|
630
|
+
if version_label is not None:
|
|
631
|
+
payload["version_label"] = version_label
|
|
632
|
+
if object_id is not None:
|
|
633
|
+
payload["object_id"] = object_id
|
|
634
|
+
if library is not None:
|
|
635
|
+
payload["library"] = library
|
|
636
|
+
if create_library:
|
|
637
|
+
payload["create_library"] = True
|
|
638
|
+
if library_display_name is not None:
|
|
639
|
+
payload["library_display_name"] = library_display_name
|
|
640
|
+
return self._json_request("POST", "/objects", payload)
|
|
641
|
+
|
|
642
|
+
def list_objects(
|
|
643
|
+
self,
|
|
644
|
+
query: str | None = None,
|
|
645
|
+
*,
|
|
646
|
+
compact: bool = False,
|
|
647
|
+
) -> dict[str, Any] | list[dict[str, Any]]:
|
|
648
|
+
"""List registered objects."""
|
|
649
|
+
|
|
650
|
+
view = "summary" if compact else None
|
|
651
|
+
if query is None:
|
|
652
|
+
suffix = "?view=summary" if view else ""
|
|
653
|
+
return self._json_request("GET", f"/objects{suffix}")
|
|
654
|
+
query_parts = [f"query={quote(query)}"]
|
|
655
|
+
if view:
|
|
656
|
+
query_parts.append("view=summary")
|
|
657
|
+
return self._json_request("GET", f"/objects?{'&'.join(query_parts)}")
|
|
658
|
+
|
|
659
|
+
def search_objects(self, query: str) -> list[dict[str, Any]]:
|
|
660
|
+
"""Search registered objects by name, description, and metadata."""
|
|
661
|
+
|
|
662
|
+
return self._json_request("GET", f"/objects/search?q={quote(query)}")
|
|
663
|
+
|
|
664
|
+
def get_object(
|
|
665
|
+
self,
|
|
666
|
+
name_or_id: str,
|
|
667
|
+
*,
|
|
668
|
+
version: int | None = None,
|
|
669
|
+
include_yaml: bool = False,
|
|
670
|
+
) -> dict[str, Any]:
|
|
671
|
+
"""Return one object by name or internal id."""
|
|
672
|
+
|
|
673
|
+
path = f"/objects/{quote(name_or_id)}"
|
|
674
|
+
query = []
|
|
675
|
+
if version is not None:
|
|
676
|
+
query.append(f"version={version}")
|
|
677
|
+
if include_yaml:
|
|
678
|
+
query.append("include_yaml=1")
|
|
679
|
+
if query:
|
|
680
|
+
path = f"{path}?{'&'.join(query)}"
|
|
681
|
+
return self._json_request("GET", path)
|
|
682
|
+
|
|
683
|
+
def signature(
|
|
684
|
+
self,
|
|
685
|
+
name_or_id: str,
|
|
686
|
+
*,
|
|
687
|
+
version: int | None = None,
|
|
688
|
+
owner_id: str | None = None,
|
|
689
|
+
library: str | None = None,
|
|
690
|
+
function: str | None = None,
|
|
691
|
+
) -> dict[str, Any]:
|
|
692
|
+
"""Return a compact call/read signature for one object."""
|
|
693
|
+
|
|
694
|
+
if owner_id is not None or library is not None:
|
|
695
|
+
ref: dict[str, Any] = {"object_name": name_or_id}
|
|
696
|
+
if owner_id is not None:
|
|
697
|
+
ref["owner_id"] = owner_id
|
|
698
|
+
if library is not None:
|
|
699
|
+
ref["library"] = library
|
|
700
|
+
if version is not None:
|
|
701
|
+
ref["version"] = version
|
|
702
|
+
if function is not None:
|
|
703
|
+
ref["function"] = function
|
|
704
|
+
return self.resolve_remote_signature(ref)["signature"]
|
|
705
|
+
|
|
706
|
+
path = f"/objects/{quote(name_or_id)}/signature"
|
|
707
|
+
query = []
|
|
708
|
+
if version is not None:
|
|
709
|
+
query.append(f"version={version}")
|
|
710
|
+
if function is not None:
|
|
711
|
+
query.append(f"function={quote(function)}")
|
|
712
|
+
if query:
|
|
713
|
+
path = f"{path}?{'&'.join(query)}"
|
|
714
|
+
return self._json_request("GET", path)
|
|
715
|
+
|
|
716
|
+
def inputs(
|
|
717
|
+
self,
|
|
718
|
+
name_or_id: str,
|
|
719
|
+
*,
|
|
720
|
+
version: int | None = None,
|
|
721
|
+
owner_id: str | None = None,
|
|
722
|
+
library: str | None = None,
|
|
723
|
+
function: str | None = None,
|
|
724
|
+
) -> list[dict[str, Any]]:
|
|
725
|
+
"""Return the required/optional inputs for one object."""
|
|
726
|
+
|
|
727
|
+
if owner_id is not None or library is not None:
|
|
728
|
+
return self.signature(
|
|
729
|
+
name_or_id,
|
|
730
|
+
version=version,
|
|
731
|
+
owner_id=owner_id,
|
|
732
|
+
library=library,
|
|
733
|
+
function=function,
|
|
734
|
+
)["inputs"]
|
|
735
|
+
|
|
736
|
+
path = f"/objects/{quote(name_or_id)}/inputs"
|
|
737
|
+
query = []
|
|
738
|
+
if version is not None:
|
|
739
|
+
query.append(f"version={version}")
|
|
740
|
+
if function is not None:
|
|
741
|
+
query.append(f"function={quote(function)}")
|
|
742
|
+
if query:
|
|
743
|
+
path = f"{path}?{'&'.join(query)}"
|
|
744
|
+
return self._json_request("GET", path)
|
|
745
|
+
|
|
746
|
+
def outputs(
|
|
747
|
+
self,
|
|
748
|
+
name_or_id: str,
|
|
749
|
+
*,
|
|
750
|
+
version: int | None = None,
|
|
751
|
+
owner_id: str | None = None,
|
|
752
|
+
library: str | None = None,
|
|
753
|
+
function: str | None = None,
|
|
754
|
+
) -> list[dict[str, Any]]:
|
|
755
|
+
"""Return supported output selectors and result accessors."""
|
|
756
|
+
|
|
757
|
+
if owner_id is not None or library is not None:
|
|
758
|
+
return self.signature(
|
|
759
|
+
name_or_id,
|
|
760
|
+
version=version,
|
|
761
|
+
owner_id=owner_id,
|
|
762
|
+
library=library,
|
|
763
|
+
function=function,
|
|
764
|
+
)["outputs"]
|
|
765
|
+
|
|
766
|
+
path = f"/objects/{quote(name_or_id)}/outputs"
|
|
767
|
+
query = []
|
|
768
|
+
if version is not None:
|
|
769
|
+
query.append(f"version={version}")
|
|
770
|
+
if function is not None:
|
|
771
|
+
query.append(f"function={quote(function)}")
|
|
772
|
+
if query:
|
|
773
|
+
path = f"{path}?{'&'.join(query)}"
|
|
774
|
+
return self._json_request("GET", path)
|
|
775
|
+
|
|
776
|
+
def decomposition(
|
|
777
|
+
self,
|
|
778
|
+
name_or_id: str,
|
|
779
|
+
*,
|
|
780
|
+
version: int | None = None,
|
|
781
|
+
) -> dict[str, Any]:
|
|
782
|
+
"""Return normalized functions, pipeline nodes, and links."""
|
|
783
|
+
|
|
784
|
+
path = f"/objects/{quote(name_or_id)}/decomposition"
|
|
785
|
+
if version is not None:
|
|
786
|
+
path = f"{path}?version={version}"
|
|
787
|
+
return self._json_request("GET", path)
|
|
788
|
+
|
|
789
|
+
def object_versions(self, name_or_id: str) -> list[dict[str, Any]]:
|
|
790
|
+
"""Return all versions of one object."""
|
|
791
|
+
|
|
792
|
+
return self._json_request("GET", f"/objects/{quote(name_or_id)}/versions")
|
|
793
|
+
|
|
794
|
+
def run(
|
|
795
|
+
self,
|
|
796
|
+
object_name: str,
|
|
797
|
+
*,
|
|
798
|
+
args: list[Any] | None = None,
|
|
799
|
+
kwargs: dict[str, Any] | None = None,
|
|
800
|
+
output: str | None = None,
|
|
801
|
+
timeout_seconds: float | None = None,
|
|
802
|
+
version: int | None = None,
|
|
803
|
+
version_id: str | None = None,
|
|
804
|
+
function: str | None = None,
|
|
805
|
+
target_machine: str | None = None,
|
|
806
|
+
object_owner_id: str | None = None,
|
|
807
|
+
library: str | None = None,
|
|
808
|
+
offline_policy: OfflinePolicy | None = None,
|
|
809
|
+
source: RunSource = "auto",
|
|
810
|
+
remote: bool | None = None,
|
|
811
|
+
) -> dict[str, Any]:
|
|
812
|
+
"""Start a daemon run and return its initial state."""
|
|
813
|
+
|
|
814
|
+
payload: dict[str, Any] = {"object": object_name, "source": source}
|
|
815
|
+
if args is not None:
|
|
816
|
+
payload["args"] = args
|
|
817
|
+
if kwargs is not None:
|
|
818
|
+
payload["kwargs"] = kwargs
|
|
819
|
+
if output is not None:
|
|
820
|
+
payload["output"] = output
|
|
821
|
+
if timeout_seconds is not None:
|
|
822
|
+
payload["timeout_seconds"] = timeout_seconds
|
|
823
|
+
if version is not None:
|
|
824
|
+
payload["version"] = version
|
|
825
|
+
if version_id is not None:
|
|
826
|
+
payload["version_id"] = version_id
|
|
827
|
+
if function is not None:
|
|
828
|
+
payload["function"] = function
|
|
829
|
+
if target_machine is not None:
|
|
830
|
+
payload["target_machine"] = target_machine
|
|
831
|
+
if object_owner_id is not None:
|
|
832
|
+
payload["object_owner_id"] = object_owner_id
|
|
833
|
+
if library is not None:
|
|
834
|
+
payload["library"] = library
|
|
835
|
+
if offline_policy is not None:
|
|
836
|
+
payload["offline_policy"] = offline_policy
|
|
837
|
+
if remote is not None:
|
|
838
|
+
payload["remote"] = remote
|
|
839
|
+
elif object_owner_id is not None or library is not None:
|
|
840
|
+
payload["remote"] = True
|
|
841
|
+
return self._json_request("POST", "/runs", payload)
|
|
842
|
+
|
|
843
|
+
def get_remote_run(self, run_id: str) -> dict[str, Any]:
|
|
844
|
+
"""Read one server-side remote run state through the local daemon."""
|
|
845
|
+
|
|
846
|
+
return self._json_request("GET", f"/remote-runs/{quote(run_id)}")
|
|
847
|
+
|
|
848
|
+
def list_remote_artifacts(self, run_id: str) -> list[str]:
|
|
849
|
+
"""List artifact names for a server-side remote run."""
|
|
850
|
+
|
|
851
|
+
return self._json_request("GET", f"/remote-runs/{quote(run_id)}/artifacts")
|
|
852
|
+
|
|
853
|
+
def download_remote_artifact(
|
|
854
|
+
self,
|
|
855
|
+
run_id: str,
|
|
856
|
+
artifact_name: str,
|
|
857
|
+
target: str | Path,
|
|
858
|
+
) -> Path:
|
|
859
|
+
"""Download one server-side artifact through the local daemon."""
|
|
860
|
+
|
|
861
|
+
target_path = Path(target)
|
|
862
|
+
if target_path.is_dir():
|
|
863
|
+
target_path = target_path / artifact_name
|
|
864
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
865
|
+
data = self._bytes_request(
|
|
866
|
+
f"/remote-runs/{quote(run_id)}/artifacts/{quote(artifact_name)}"
|
|
867
|
+
)
|
|
868
|
+
target_path.write_bytes(data)
|
|
869
|
+
return target_path
|
|
870
|
+
|
|
871
|
+
def wait_remote_run(
|
|
872
|
+
self,
|
|
873
|
+
run_id: str,
|
|
874
|
+
*,
|
|
875
|
+
poll_interval: float = 0.5,
|
|
876
|
+
timeout_seconds: float | None = None,
|
|
877
|
+
) -> dict[str, Any]:
|
|
878
|
+
"""Poll a server-side remote run until it reaches a terminal state."""
|
|
879
|
+
|
|
880
|
+
started = time.monotonic()
|
|
881
|
+
while True:
|
|
882
|
+
state = self.get_remote_run(run_id)
|
|
883
|
+
if state["status"] in {"succeeded", "failed", "cancelled", "stale"}:
|
|
884
|
+
return state
|
|
885
|
+
if timeout_seconds is not None and time.monotonic() - started > timeout_seconds:
|
|
886
|
+
raise TimeoutError(
|
|
887
|
+
f"remote run did not finish within {timeout_seconds} seconds"
|
|
888
|
+
)
|
|
889
|
+
time.sleep(poll_interval)
|
|
890
|
+
|
|
891
|
+
def get_run(self, run_id: str) -> dict[str, Any]:
|
|
892
|
+
"""Read one run state."""
|
|
893
|
+
|
|
894
|
+
return self._json_request("GET", f"/runs/{quote(run_id)}")
|
|
895
|
+
|
|
896
|
+
def list_runs(self) -> list[dict[str, Any]]:
|
|
897
|
+
"""List known runs."""
|
|
898
|
+
|
|
899
|
+
return self._json_request("GET", "/runs")
|
|
900
|
+
|
|
901
|
+
def wait_run(
|
|
902
|
+
self,
|
|
903
|
+
run_id: str,
|
|
904
|
+
*,
|
|
905
|
+
poll_interval: float = 0.25,
|
|
906
|
+
timeout_seconds: float | None = None,
|
|
907
|
+
) -> dict[str, Any]:
|
|
908
|
+
"""Poll a run until it reaches a terminal state."""
|
|
909
|
+
|
|
910
|
+
started = time.monotonic()
|
|
911
|
+
while True:
|
|
912
|
+
state = self.get_run(run_id)
|
|
913
|
+
if state["status"] in {"succeeded", "failed"}:
|
|
914
|
+
return state
|
|
915
|
+
if timeout_seconds is not None and time.monotonic() - started > timeout_seconds:
|
|
916
|
+
raise TimeoutError(f"run did not finish within {timeout_seconds} seconds")
|
|
917
|
+
time.sleep(poll_interval)
|
|
918
|
+
|
|
919
|
+
def result(self, run_id: str) -> dict[str, Any]:
|
|
920
|
+
"""Return a completed run's result payload."""
|
|
921
|
+
|
|
922
|
+
return self._json_request("GET", f"/runs/{quote(run_id)}/result")
|
|
923
|
+
|
|
924
|
+
def list_artifacts(self, run_id: str) -> list[str]:
|
|
925
|
+
"""List artifact names for a run."""
|
|
926
|
+
|
|
927
|
+
return self._json_request("GET", f"/runs/{quote(run_id)}/artifacts")
|
|
928
|
+
|
|
929
|
+
def download_artifact(
|
|
930
|
+
self,
|
|
931
|
+
run_id: str,
|
|
932
|
+
artifact_name: str,
|
|
933
|
+
target: str | Path,
|
|
934
|
+
) -> Path:
|
|
935
|
+
"""Download one artifact file into ``target`` and return its path."""
|
|
936
|
+
|
|
937
|
+
target_path = Path(target)
|
|
938
|
+
if target_path.is_dir():
|
|
939
|
+
target_path = target_path / artifact_name
|
|
940
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
941
|
+
data = self._bytes_request(
|
|
942
|
+
f"/runs/{quote(run_id)}/artifacts/{quote(artifact_name)}"
|
|
943
|
+
)
|
|
944
|
+
target_path.write_bytes(data)
|
|
945
|
+
return target_path
|