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.
Files changed (74) hide show
  1. spl/__init__.py +14 -0
  2. spl/client.py +1364 -0
  3. spl/core/__init__.py +23 -0
  4. spl/core/common.py +350 -0
  5. spl/core/entities/__init__.py +0 -0
  6. spl/core/entities/adapter.py +210 -0
  7. spl/core/entities/artifact.py +141 -0
  8. spl/core/entities/control.py +45 -0
  9. spl/core/entities/distribution.py +65 -0
  10. spl/core/entities/function.py +254 -0
  11. spl/core/entities/local_function.py +286 -0
  12. spl/core/entities/misc.py +14 -0
  13. spl/core/entities/module.py +88 -0
  14. spl/core/entities/node.py +286 -0
  15. spl/core/entities/node_function.py +79 -0
  16. spl/core/entities/node_remote.py +295 -0
  17. spl/core/entities/pipeline.py +436 -0
  18. spl/core/entities/scalar.py +55 -0
  19. spl/core/ir/__init__.py +0 -0
  20. spl/core/ir/common.py +34 -0
  21. spl/core/ir/parse.py +79 -0
  22. spl/core/ir/unparse.py +29 -0
  23. spl/core/ir/utils.py +163 -0
  24. spl/daemon/__init__.py +23 -0
  25. spl/daemon/__main__.py +11 -0
  26. spl/daemon/cli.py +582 -0
  27. spl/daemon/client.py +43 -0
  28. spl/daemon/docker_environment.py +329 -0
  29. spl/daemon/docker_pool.py +516 -0
  30. spl/daemon/environment.py +228 -0
  31. spl/daemon/environment_base.py +479 -0
  32. spl/daemon/heartbeat_service.py +119 -0
  33. spl/daemon/metadata.py +427 -0
  34. spl/daemon/remote_client.py +457 -0
  35. spl/daemon/repositories/__init__.py +17 -0
  36. spl/daemon/repositories/env.py +323 -0
  37. spl/daemon/repositories/library.py +181 -0
  38. spl/daemon/repositories/object.py +997 -0
  39. spl/daemon/repositories/run.py +279 -0
  40. spl/daemon/repositories/server_connection.py +657 -0
  41. spl/daemon/repositories/sync_event.py +129 -0
  42. spl/daemon/routes/__init__.py +1 -0
  43. spl/daemon/routes/_helpers.py +147 -0
  44. spl/daemon/routes/artifacts.py +77 -0
  45. spl/daemon/routes/diagnostics.py +114 -0
  46. spl/daemon/routes/envs.py +82 -0
  47. spl/daemon/routes/libraries.py +129 -0
  48. spl/daemon/routes/objects.py +174 -0
  49. spl/daemon/routes/remote.py +56 -0
  50. spl/daemon/routes/runs.py +96 -0
  51. spl/daemon/routes/server_connections.py +86 -0
  52. spl/daemon/runtime_backend.py +368 -0
  53. spl/daemon/runtime_config.py +133 -0
  54. spl/daemon/runtime_dependencies.py +459 -0
  55. spl/daemon/secret_store.py +187 -0
  56. spl/daemon/server.py +2224 -0
  57. spl/daemon/server_connection.py +267 -0
  58. spl/daemon/services/__init__.py +1 -0
  59. spl/daemon/services/sync.py +76 -0
  60. spl/daemon/signature.py +376 -0
  61. spl/daemon/storage_base.py +542 -0
  62. spl/daemon/store.py +436 -0
  63. spl/daemon/worker.py +526 -0
  64. spl/daemon_client.py +945 -0
  65. spl/pipeline_widget.py +1452 -0
  66. spl/py.typed +0 -0
  67. spl/server_client.py +787 -0
  68. splime-0.1.2.dist-info/METADATA +189 -0
  69. splime-0.1.2.dist-info/RECORD +74 -0
  70. splime-0.1.2.dist-info/WHEEL +5 -0
  71. splime-0.1.2.dist-info/entry_points.txt +2 -0
  72. splime-0.1.2.dist-info/licenses/LICENSE +201 -0
  73. splime-0.1.2.dist-info/licenses/NOTICE +8 -0
  74. 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