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/worker.py ADDED
@@ -0,0 +1,526 @@
1
+ """Worker process for executing one registered SPL object.
2
+
3
+ The daemon itself should not import and execute user objects in-process. This
4
+ worker is launched as a subprocess with the Python executable registered for an
5
+ object. That gives the MVP its most important boundary: each object runs with
6
+ the packages and interpreter of its own environment.
7
+
8
+ The worker receives file paths instead of a network connection:
9
+
10
+ * ``input.json`` contains call arguments and optional pipeline output selector;
11
+ * ``result.json`` is written on success;
12
+ * ``artifacts/`` receives files declared by the object result;
13
+ * stdout/stderr are captured by the daemon for diagnostics.
14
+
15
+ For the first version, arguments and return values are JSON-like. This keeps
16
+ the protocol transparent and avoids silently pickling arbitrary objects. Large
17
+ or non-JSON outputs should be returned as artifacts.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import sys
24
+
25
+
26
+ def _prefer_runtime_env_over_pythonpath_site_packages() -> None:
27
+ version = f"python{sys.version_info.major}.{sys.version_info.minor}"
28
+ protected_candidates = [
29
+ os.path.normcase(os.path.abspath(os.path.join(sys.base_prefix, "Lib"))),
30
+ os.path.normcase(
31
+ os.path.abspath(os.path.join(sys.prefix, "Lib", "site-packages"))
32
+ ),
33
+ os.path.normcase(
34
+ os.path.abspath(os.path.join(sys.base_prefix, "lib", version))
35
+ ),
36
+ os.path.normcase(
37
+ os.path.abspath(os.path.join(sys.prefix, "lib", version, "site-packages"))
38
+ ),
39
+ os.path.normcase(
40
+ os.path.abspath(os.path.join(sys.prefix, "lib64", version, "site-packages"))
41
+ ),
42
+ ]
43
+ protected_indexes = [
44
+ index
45
+ for index, item in enumerate(sys.path)
46
+ if os.path.normcase(os.path.abspath(item)) in protected_candidates
47
+ ]
48
+ if not protected_indexes:
49
+ return
50
+
51
+ first_protected_index = min(protected_indexes)
52
+ early_external_site_packages = [
53
+ item
54
+ for index, item in enumerate(sys.path)
55
+ if index < first_protected_index
56
+ and "site-packages" in os.path.normcase(os.path.abspath(item))
57
+ and os.path.normcase(os.path.abspath(item)) not in protected_candidates
58
+ ]
59
+ if not early_external_site_packages:
60
+ return
61
+
62
+ sys.path[:] = [
63
+ item for item in sys.path if item not in early_external_site_packages
64
+ ]
65
+ last_protected_index = max(
66
+ index
67
+ for index, item in enumerate(sys.path)
68
+ if os.path.normcase(os.path.abspath(item)) in protected_candidates
69
+ )
70
+ for item in reversed(early_external_site_packages):
71
+ sys.path.insert(last_protected_index + 1, item)
72
+
73
+
74
+ _prefer_runtime_env_over_pythonpath_site_packages()
75
+
76
+ import argparse
77
+ import importlib.metadata
78
+ import json
79
+ import shutil
80
+ from collections.abc import Mapping, Sequence
81
+ from importlib.metadata import PackageNotFoundError
82
+ from pathlib import Path
83
+ from typing import Any
84
+ from urllib.error import HTTPError, URLError
85
+ from urllib.request import Request, urlopen
86
+
87
+ from spl.daemon.store import validate_name
88
+
89
+
90
+ ARTIFACTS_KEY = "__spl_artifacts__"
91
+ RESULT_KEY = "__spl_result__"
92
+
93
+
94
+ def read_json(path: Path) -> Any:
95
+ """Read a UTF-8 JSON file."""
96
+
97
+ return json.loads(path.read_text(encoding="utf-8"))
98
+
99
+
100
+ def write_json(path: Path, value: Any) -> None:
101
+ """Write a UTF-8 JSON file with stable formatting."""
102
+
103
+ path.parent.mkdir(parents=True, exist_ok=True)
104
+ path.write_text(
105
+ json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True),
106
+ encoding="utf-8",
107
+ )
108
+
109
+
110
+ class RemoteNodeClient:
111
+ """Small worker-side bridge back to the local daemon for NodeRemote runs."""
112
+
113
+ def __init__(
114
+ self,
115
+ daemon_url: str,
116
+ *,
117
+ timeout_seconds: float | None = None,
118
+ ):
119
+ self.daemon_url = daemon_url.rstrip("/")
120
+ self.timeout_seconds = timeout_seconds
121
+
122
+ def run_node(self, node: Any, kwargs: dict[str, Any]) -> Any:
123
+ payload = {
124
+ "node": {
125
+ "uuid": str(node.uuid),
126
+ "url": node.url,
127
+ "name": node.name,
128
+ "version": node.version,
129
+ },
130
+ "kwargs": kwargs,
131
+ "timeout_seconds": self.timeout_seconds,
132
+ }
133
+ target_machine = getattr(node, "target_machine", None)
134
+ if target_machine is not None:
135
+ payload["node"]["target_machine"] = target_machine
136
+ owner_id = getattr(node, "owner_id", None)
137
+ if owner_id is not None:
138
+ payload["node"]["owner_id"] = owner_id
139
+ library = getattr(node, "library", None)
140
+ if library is not None:
141
+ payload["node"]["library"] = library
142
+ request = Request(
143
+ f"{self.daemon_url}/remote-nodes/run",
144
+ data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
145
+ headers={
146
+ "Accept": "application/json",
147
+ "Content-Type": "application/json; charset=utf-8",
148
+ },
149
+ method="POST",
150
+ )
151
+ try:
152
+ with urlopen(request) as response: # noqa: S310 - local daemon URL.
153
+ raw = response.read().decode("utf-8")
154
+ except HTTPError as exc:
155
+ raw = exc.read().decode("utf-8")
156
+ try:
157
+ message = json.loads(raw).get("error", raw)
158
+ except json.JSONDecodeError:
159
+ message = raw
160
+ raise RuntimeError(f"remote node call failed: {message}") from exc
161
+ except URLError as exc:
162
+ raise RuntimeError(
163
+ f"local daemon is not reachable for remote node call: {exc.reason}"
164
+ ) from exc
165
+ return json.loads(raw).get("value")
166
+
167
+
168
+ def validate_environment(distributions: list[dict[str, str]]) -> None:
169
+ """Fail fast when the worker interpreter does not match SPL metadata.
170
+
171
+ The daemon selects a registered Python executable, but the SPL object itself
172
+ describes package versions through ``DDistribution`` records. Checking them
173
+ inside the worker makes the run exact for the interpreter that will actually
174
+ execute user code.
175
+ """
176
+
177
+ mismatches = []
178
+ for distribution in distributions:
179
+ package = distribution["package"]
180
+ expected = distribution["version"]
181
+ try:
182
+ actual = importlib.metadata.version(package)
183
+ except PackageNotFoundError:
184
+ mismatches.append(f"{package}=={expected} is not installed")
185
+ continue
186
+ if actual != expected:
187
+ mismatches.append(
188
+ f"{package}=={expected} is required, actual version is {actual}"
189
+ )
190
+
191
+ if mismatches:
192
+ raise RuntimeError(
193
+ "worker environment does not match SPL metadata: "
194
+ + "; ".join(mismatches)
195
+ )
196
+
197
+
198
+ def to_jsonable(value: Any) -> Any:
199
+ """Convert common Python containers into JSON-compatible values.
200
+
201
+ The function is intentionally strict for unknown objects. A daemon that
202
+ silently converts everything with ``repr`` would be hard to use correctly:
203
+ the caller might think it received a reusable result while actually getting
204
+ a display string.
205
+ """
206
+
207
+ if value is None or isinstance(value, str | int | float | bool):
208
+ return value
209
+ if isinstance(value, Path):
210
+ return str(value)
211
+ if isinstance(value, Mapping):
212
+ return {str(key): to_jsonable(item) for key, item in value.items()}
213
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
214
+ return [to_jsonable(item) for item in value]
215
+ if isinstance(value, set):
216
+ return [to_jsonable(item) for item in sorted(value, key=repr)]
217
+ raise TypeError(
218
+ "result is not JSON serializable; return JSON-like data or declare artifacts"
219
+ )
220
+
221
+
222
+ def safe_artifact_name(name: str) -> str:
223
+ """Validate an artifact name before writing under the artifacts directory."""
224
+
225
+ return validate_name(name)
226
+
227
+
228
+ def copy_artifact(source: Path, target: Path) -> None:
229
+ """Copy one artifact file or directory into the run artifact directory."""
230
+
231
+ if not source.exists():
232
+ raise ValueError(f"artifact source is not found: {source}")
233
+ if source.is_dir():
234
+ shutil.copytree(source, target, dirs_exist_ok=True)
235
+ else:
236
+ target.parent.mkdir(parents=True, exist_ok=True)
237
+ shutil.copy2(source, target)
238
+
239
+
240
+ def collect_artifacts(value: Any, artifacts_dir: Path) -> tuple[Any, dict[str, str]]:
241
+ """Extract and copy artifacts declared by the function result.
242
+
243
+ Convention for MVP::
244
+
245
+ {
246
+ "__spl_result__": {"score": 0.91},
247
+ "__spl_artifacts__": {"model.pkl": "relative/or/absolute/path.pkl"}
248
+ }
249
+
250
+ If ``__spl_result__`` is omitted, the result is the original dictionary
251
+ without the two reserved SPL keys.
252
+ """
253
+
254
+ if not isinstance(value, Mapping) or ARTIFACTS_KEY not in value:
255
+ return value, {}
256
+
257
+ artifact_spec = value[ARTIFACTS_KEY]
258
+ if RESULT_KEY in value:
259
+ result = value[RESULT_KEY]
260
+ else:
261
+ result = {
262
+ key: item
263
+ for key, item in value.items()
264
+ if key not in {ARTIFACTS_KEY, RESULT_KEY}
265
+ }
266
+
267
+ if isinstance(artifact_spec, Mapping):
268
+ items = artifact_spec.items()
269
+ elif isinstance(artifact_spec, Sequence) and not isinstance(artifact_spec, str):
270
+ items = ((Path(str(path)).name, path) for path in artifact_spec)
271
+ else:
272
+ raise TypeError("__spl_artifacts__ must be a mapping or a list of paths")
273
+
274
+ copied: dict[str, str] = {}
275
+ artifacts_dir.mkdir(parents=True, exist_ok=True)
276
+ for name, source in items:
277
+ artifact_name = safe_artifact_name(str(name))
278
+ source_path = Path(str(source)).expanduser().absolute()
279
+ target_path = artifacts_dir / artifact_name
280
+ copy_artifact(source_path, target_path)
281
+ copied[artifact_name] = str(target_path)
282
+
283
+ return result, copied
284
+
285
+
286
+ def run_pipeline(
287
+ pipeline: Any,
288
+ kwargs: dict[str, Any],
289
+ output: str | None,
290
+ *,
291
+ daemon_url: str,
292
+ timeout_seconds: float | None,
293
+ ) -> Any:
294
+ """Run a ``spl.core`` pipeline without changing the existing core files.
295
+
296
+ The current core exposes ``Deployment`` and ``Run`` but does not provide a
297
+ direct "give me the final output" helper. This adapter supplies the minimal
298
+ selection rules the daemon needs:
299
+
300
+ * if ``output`` is given, it must be a pipeline alias;
301
+ * otherwise aliases are returned as a dictionary;
302
+ * a single-node pipeline can be returned without an alias;
303
+ * multi-node pipelines should define aliases for daemon use.
304
+ """
305
+
306
+ from spl.core.common import Deployment
307
+
308
+ client = RemoteNodeClient(daemon_url, timeout_seconds=timeout_seconds)
309
+ try:
310
+ deployment = Deployment(client, pipeline)
311
+ except TypeError:
312
+ # Older framework builds did not require a client for local-only
313
+ # pipelines. Keep the worker tolerant while NodeRemote is still moving.
314
+ deployment = Deployment(pipeline)
315
+ run = deployment.run(**kwargs)
316
+
317
+ if output is not None:
318
+ return run[pipeline.get_node_by_alias(output)]
319
+
320
+ if pipeline.aliases:
321
+ return {
322
+ alias: run[node]
323
+ for alias, node in sorted(pipeline.aliases.items(), key=lambda item: item[0])
324
+ }
325
+
326
+ if len(pipeline.nodes) == 1:
327
+ [node] = list(pipeline.nodes)
328
+ return run[node]
329
+
330
+ raise ValueError(
331
+ "pipeline has multiple nodes and no aliases; pass output or register aliases"
332
+ )
333
+
334
+
335
+ def load_entrypoint(
336
+ object_yaml: Path,
337
+ entrypoint: str,
338
+ *,
339
+ remote_signatures_path: Path | None = None,
340
+ ) -> Any:
341
+ """Import a serialized SPL file and return the requested object."""
342
+
343
+ from spl.core.ir.utils import spl_import_from_file
344
+
345
+ remote_ports = _read_remote_ports(remote_signatures_path)
346
+ _install_node_remote_hydration(remote_ports)
347
+
348
+ namespace: dict[str, Any] = {}
349
+ _seed_node_remote_namespace(namespace)
350
+ spl_import_from_file(object_yaml, globals=namespace)
351
+ try:
352
+ return namespace[entrypoint]
353
+ except KeyError as exc:
354
+ raise KeyError(f"entrypoint is not found in SPL file: {entrypoint}") from exc
355
+
356
+
357
+ def _read_remote_ports(path: Path | None) -> dict[str, dict[str, Any]]:
358
+ if path is None or not path.exists():
359
+ return {}
360
+ payload = read_json(path)
361
+ return {
362
+ str(item["id"]): {
363
+ "inputs": item.get("inputs") or [],
364
+ "outputs": item.get("outputs") or [],
365
+ "remote": item.get("remote") or {},
366
+ }
367
+ for item in payload.get("nodes", [])
368
+ if item.get("kind") == "remote" and item.get("id")
369
+ }
370
+
371
+
372
+ def _seed_node_remote_namespace(namespace: dict[str, Any]) -> None:
373
+ """Provide names missing from the current framework's DNodeRemote unparse."""
374
+
375
+ from uuid import UUID
376
+
377
+ from spl.core.entities.node_remote import NodeRemote
378
+
379
+ namespace.update(
380
+ {
381
+ "NodeRemote": NodeRemote,
382
+ "UUID": UUID,
383
+ }
384
+ )
385
+
386
+
387
+ def _install_node_remote_hydration(remote_ports: dict[str, dict[str, Any]]) -> None:
388
+ """Patch NodeRemote construction in this worker process with sidecar ports."""
389
+
390
+ if not remote_ports:
391
+ return
392
+
393
+ from spl.core.entities.node import InputPort, OutputPort
394
+ from spl.core.entities.node_remote import NodeRemote
395
+
396
+ if getattr(NodeRemote, "__spl_daemon_hydrated__", False):
397
+ setattr(NodeRemote, "__spl_daemon_remote_ports__", remote_ports)
398
+ return
399
+
400
+ original_init = NodeRemote.__init__
401
+
402
+ def hydrated_init(
403
+ self: Any,
404
+ url: str | None = None,
405
+ name: str | None = None,
406
+ version: str = "latest",
407
+ inputs: list[Any] | None = None,
408
+ outputs: list[Any] | None = None,
409
+ uuid: Any = None,
410
+ **kwargs: Any,
411
+ ) -> None:
412
+ current_ports = getattr(NodeRemote, "__spl_daemon_remote_ports__", {})
413
+ metadata = current_ports.get(str(uuid))
414
+ if metadata is not None and not inputs and not outputs:
415
+ inputs = [
416
+ InputPort(
417
+ name=str(item.get("name") or "default"),
418
+ typ_=item.get("type"),
419
+ default=item.get("default"),
420
+ )
421
+ for item in metadata.get("inputs") or []
422
+ ]
423
+ outputs = [
424
+ OutputPort(
425
+ name=str(item.get("name") or "default"),
426
+ typ_=item.get("type"),
427
+ )
428
+ for item in metadata.get("outputs") or []
429
+ ]
430
+ original_init(self, url, name, version, inputs, outputs, uuid=uuid, **kwargs)
431
+ if metadata is not None:
432
+ remote = metadata.get("remote") or {}
433
+ for attr in ("owner_id", "library", "target_machine"):
434
+ if remote.get(attr) is not None:
435
+ object.__setattr__(self, attr, remote[attr])
436
+
437
+ setattr(NodeRemote, "__spl_daemon_hydrated__", True)
438
+ setattr(NodeRemote, "__spl_daemon_remote_ports__", remote_ports)
439
+ NodeRemote.__init__ = hydrated_init
440
+
441
+
442
+ def execute(
443
+ *,
444
+ object_yaml: Path,
445
+ entrypoint: str,
446
+ input_path: Path,
447
+ result_path: Path,
448
+ artifacts_dir: Path,
449
+ env_spec_path: Path | None = None,
450
+ remote_signatures_path: Path | None = None,
451
+ daemon_url: str = "http://127.0.0.1:8765",
452
+ ) -> dict[str, Any]:
453
+ """Load, call, and persist one function or pipeline result."""
454
+
455
+ payload = read_json(input_path)
456
+ args = payload.get("args", [])
457
+ kwargs = payload.get("kwargs", {})
458
+ output = payload.get("output")
459
+
460
+ if env_spec_path is not None:
461
+ validate_environment(read_json(env_spec_path))
462
+
463
+ target = load_entrypoint(
464
+ object_yaml,
465
+ entrypoint,
466
+ remote_signatures_path=remote_signatures_path,
467
+ )
468
+
469
+ from spl.core.entities.pipeline import Pipeline
470
+
471
+ if isinstance(target, Pipeline):
472
+ raw_result = run_pipeline(
473
+ target,
474
+ kwargs,
475
+ output,
476
+ daemon_url=daemon_url,
477
+ timeout_seconds=payload.get("timeout_seconds"),
478
+ )
479
+ elif callable(target):
480
+ raw_result = target(*args, **kwargs)
481
+ else:
482
+ raise TypeError(f"entrypoint is not callable or Pipeline: {entrypoint}")
483
+
484
+ result_without_artifacts, artifacts = collect_artifacts(raw_result, artifacts_dir)
485
+ result_payload = {
486
+ "result": to_jsonable(result_without_artifacts),
487
+ "artifacts": artifacts,
488
+ }
489
+ write_json(result_path, result_payload)
490
+ return result_payload
491
+
492
+
493
+ def build_parser() -> argparse.ArgumentParser:
494
+ """Create the worker argument parser."""
495
+
496
+ parser = argparse.ArgumentParser(description="Execute one SPL object")
497
+ parser.add_argument("--object-yaml", required=True, type=Path)
498
+ parser.add_argument("--entrypoint", required=True)
499
+ parser.add_argument("--input", required=True, type=Path)
500
+ parser.add_argument("--result", required=True, type=Path)
501
+ parser.add_argument("--artifacts-dir", required=True, type=Path)
502
+ parser.add_argument("--env-spec", default=None, type=Path)
503
+ parser.add_argument("--remote-signatures", default=None, type=Path)
504
+ parser.add_argument("--daemon-url", default="http://127.0.0.1:8765")
505
+ return parser
506
+
507
+
508
+ def main(argv: list[str] | None = None) -> int:
509
+ """Run the worker from the command line."""
510
+
511
+ args = build_parser().parse_args(argv)
512
+ execute(
513
+ object_yaml=args.object_yaml,
514
+ entrypoint=args.entrypoint,
515
+ input_path=args.input,
516
+ result_path=args.result,
517
+ artifacts_dir=args.artifacts_dir,
518
+ env_spec_path=args.env_spec,
519
+ remote_signatures_path=args.remote_signatures,
520
+ daemon_url=args.daemon_url,
521
+ )
522
+ return 0
523
+
524
+
525
+ if __name__ == "__main__":
526
+ raise SystemExit(main())