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/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())
|