ls-algorithm-plugin-sdk 0.2.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- algorithm_plugin_sdk/__init__.py +52 -0
- algorithm_plugin_sdk/algorithm.py +33 -0
- algorithm_plugin_sdk/cli.py +542 -0
- algorithm_plugin_sdk/context.py +307 -0
- algorithm_plugin_sdk/deployment.py +317 -0
- algorithm_plugin_sdk/errors.py +27 -0
- algorithm_plugin_sdk/examples/__init__.py +1 -0
- algorithm_plugin_sdk/examples/example_algorithm.py +119 -0
- algorithm_plugin_sdk/examples/simulated_algorithm.py +92 -0
- algorithm_plugin_sdk/loader.py +71 -0
- algorithm_plugin_sdk/models.py +322 -0
- algorithm_plugin_sdk/registration.py +282 -0
- algorithm_plugin_sdk/release.py +171 -0
- algorithm_plugin_sdk/runner.py +114 -0
- algorithm_plugin_sdk/service.py +664 -0
- algorithm_plugin_sdk/webui/__init__.py +1 -0
- algorithm_plugin_sdk/webui/app.css +49 -0
- algorithm_plugin_sdk/webui/app.js +197 -0
- algorithm_plugin_sdk/webui/index.html +62 -0
- algorithm_plugin_sdk/webui_app.py +43 -0
- ls_algorithm_plugin_sdk-0.2.5.dist-info/METADATA +87 -0
- ls_algorithm_plugin_sdk-0.2.5.dist-info/RECORD +25 -0
- ls_algorithm_plugin_sdk-0.2.5.dist-info/WHEEL +5 -0
- ls_algorithm_plugin_sdk-0.2.5.dist-info/entry_points.txt +2 -0
- ls_algorithm_plugin_sdk-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import ipaddress
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.error import HTTPError
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
from urllib.request import Request, urlopen
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
ManifestProvider = Callable[[], dict[str, Any]]
|
|
20
|
+
HeartbeatProvider = Callable[[], dict[str, Any]]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _env_disabled() -> bool:
|
|
24
|
+
return (os.getenv("LDP_PLUGIN_REGISTRATION_DISABLED") or "").strip().lower() in {
|
|
25
|
+
"1",
|
|
26
|
+
"true",
|
|
27
|
+
"yes",
|
|
28
|
+
"on",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _absolute_directory(name: str, value: str, *, writable: bool) -> Path:
|
|
33
|
+
path = Path(value).expanduser()
|
|
34
|
+
if not path.is_absolute():
|
|
35
|
+
raise ValueError(f"{name} must be an absolute path")
|
|
36
|
+
resolved = path.resolve(strict=True)
|
|
37
|
+
if not resolved.is_dir():
|
|
38
|
+
raise ValueError(f"{name} must be a directory: {resolved}")
|
|
39
|
+
if writable and not os.access(resolved, os.W_OK):
|
|
40
|
+
raise ValueError(f"{name} must be writable: {resolved}")
|
|
41
|
+
return resolved
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _validate_public_url(value: str) -> str:
|
|
45
|
+
endpoint = value.strip().rstrip("/")
|
|
46
|
+
parsed = urlparse(endpoint)
|
|
47
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
48
|
+
raise ValueError("LDP_PLUGIN_PUBLIC_URL must be an http or https URL")
|
|
49
|
+
hostname = parsed.hostname.lower()
|
|
50
|
+
if hostname in {"0.0.0.0", "localhost"}:
|
|
51
|
+
raise ValueError("LDP_PLUGIN_PUBLIC_URL must be reachable by the scheduler")
|
|
52
|
+
try:
|
|
53
|
+
address = ipaddress.ip_address(hostname)
|
|
54
|
+
except ValueError:
|
|
55
|
+
address = None
|
|
56
|
+
if address is not None and (address.is_unspecified or address.is_loopback):
|
|
57
|
+
raise ValueError("LDP_PLUGIN_PUBLIC_URL must be reachable by the scheduler")
|
|
58
|
+
return endpoint
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _machine_identity() -> str:
|
|
62
|
+
try:
|
|
63
|
+
value = Path("/etc/machine-id").read_text(encoding="utf-8").strip()
|
|
64
|
+
except OSError:
|
|
65
|
+
value = ""
|
|
66
|
+
return value or socket.gethostname()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def stable_instance_key(algorithm_type: str) -> str:
|
|
70
|
+
identity = f"{_machine_identity()}\0{algorithm_type}".encode()
|
|
71
|
+
return f"{algorithm_type}-{hashlib.sha256(identity).hexdigest()[:24]}"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _private_address() -> str:
|
|
75
|
+
try:
|
|
76
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
77
|
+
sock.connect(("192.0.2.1", 9))
|
|
78
|
+
return str(sock.getsockname()[0])
|
|
79
|
+
except OSError:
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True, slots=True)
|
|
84
|
+
class RegistrationSettings:
|
|
85
|
+
compute_url: str
|
|
86
|
+
api_key: str
|
|
87
|
+
public_url: str
|
|
88
|
+
input_root: Path
|
|
89
|
+
workspace_root: Path
|
|
90
|
+
cluster: str
|
|
91
|
+
namespace: str
|
|
92
|
+
node_name: str
|
|
93
|
+
instance_key: str | None = None
|
|
94
|
+
heartbeat_seconds: float = 5.0
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def from_env(cls) -> "RegistrationSettings":
|
|
98
|
+
required = {
|
|
99
|
+
"LDP_COMPUTE_URL": os.getenv("LDP_COMPUTE_URL"),
|
|
100
|
+
"LDP_INTERNAL_API_KEY": os.getenv("LDP_INTERNAL_API_KEY"),
|
|
101
|
+
"LDP_PLUGIN_PUBLIC_URL": os.getenv("LDP_PLUGIN_PUBLIC_URL"),
|
|
102
|
+
"LDP_JOB_INPUT_ROOT": os.getenv("LDP_JOB_INPUT_ROOT"),
|
|
103
|
+
"LDP_JOB_WORKSPACE_ROOT": os.getenv("LDP_JOB_WORKSPACE_ROOT"),
|
|
104
|
+
"LDP_CLUSTER": os.getenv("LDP_CLUSTER"),
|
|
105
|
+
"POD_NAMESPACE": os.getenv("POD_NAMESPACE"),
|
|
106
|
+
"NODE_NAME": os.getenv("NODE_NAME"),
|
|
107
|
+
}
|
|
108
|
+
missing = [name for name, value in required.items() if not str(value or "").strip()]
|
|
109
|
+
if missing:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
"missing plugin registration settings: " + ", ".join(missing)
|
|
112
|
+
)
|
|
113
|
+
compute_url = str(required["LDP_COMPUTE_URL"]).strip().rstrip("/")
|
|
114
|
+
parsed_compute = urlparse(compute_url)
|
|
115
|
+
if parsed_compute.scheme not in {"http", "https"} or not parsed_compute.hostname:
|
|
116
|
+
raise ValueError("LDP_COMPUTE_URL must be an http or https URL")
|
|
117
|
+
return cls(
|
|
118
|
+
compute_url=compute_url,
|
|
119
|
+
api_key=str(required["LDP_INTERNAL_API_KEY"]).strip(),
|
|
120
|
+
public_url=_validate_public_url(str(required["LDP_PLUGIN_PUBLIC_URL"])),
|
|
121
|
+
input_root=_absolute_directory(
|
|
122
|
+
"LDP_JOB_INPUT_ROOT", str(required["LDP_JOB_INPUT_ROOT"]), writable=False
|
|
123
|
+
),
|
|
124
|
+
workspace_root=_absolute_directory(
|
|
125
|
+
"LDP_JOB_WORKSPACE_ROOT",
|
|
126
|
+
str(required["LDP_JOB_WORKSPACE_ROOT"]),
|
|
127
|
+
writable=True,
|
|
128
|
+
),
|
|
129
|
+
cluster=str(required["LDP_CLUSTER"]).strip(),
|
|
130
|
+
namespace=str(required["POD_NAMESPACE"]).strip(),
|
|
131
|
+
node_name=str(required["NODE_NAME"]).strip(),
|
|
132
|
+
instance_key=(os.getenv("LDP_PLUGIN_INSTANCE_KEY") or "").strip() or None,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class JsonClient:
|
|
137
|
+
def __init__(self, base_url: str, api_key: str, *, timeout: float = 10.0):
|
|
138
|
+
self.base_url = base_url
|
|
139
|
+
self.api_key = api_key
|
|
140
|
+
self.timeout = timeout
|
|
141
|
+
|
|
142
|
+
def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
143
|
+
request = Request(
|
|
144
|
+
self.base_url + path,
|
|
145
|
+
data=json.dumps(payload, separators=(",", ":")).encode(),
|
|
146
|
+
method="POST",
|
|
147
|
+
headers={
|
|
148
|
+
"Content-Type": "application/json",
|
|
149
|
+
"X-API-Key": self.api_key,
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
153
|
+
body = response.read()
|
|
154
|
+
if not body:
|
|
155
|
+
return {}
|
|
156
|
+
decoded = json.loads(body)
|
|
157
|
+
if not isinstance(decoded, dict):
|
|
158
|
+
raise RuntimeError("compute API returned a non-object response")
|
|
159
|
+
return decoded
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class PluginRegistrationAgent:
|
|
163
|
+
"""Register one bare-metal plugin instance and maintain its heartbeat."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
settings: RegistrationSettings,
|
|
168
|
+
manifest_provider: ManifestProvider,
|
|
169
|
+
heartbeat_provider: HeartbeatProvider,
|
|
170
|
+
*,
|
|
171
|
+
client: JsonClient | None = None,
|
|
172
|
+
):
|
|
173
|
+
self.settings = settings
|
|
174
|
+
self.manifest_provider = manifest_provider
|
|
175
|
+
self.heartbeat_provider = heartbeat_provider
|
|
176
|
+
self.client = client or JsonClient(settings.compute_url, settings.api_key)
|
|
177
|
+
self.instance_id = ""
|
|
178
|
+
self.stop_event = threading.Event()
|
|
179
|
+
self.thread: threading.Thread | None = None
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def from_env(
|
|
183
|
+
cls,
|
|
184
|
+
manifest_provider: ManifestProvider,
|
|
185
|
+
heartbeat_provider: HeartbeatProvider,
|
|
186
|
+
) -> "PluginRegistrationAgent | None":
|
|
187
|
+
if _env_disabled():
|
|
188
|
+
return None
|
|
189
|
+
names = (
|
|
190
|
+
"LDP_COMPUTE_URL",
|
|
191
|
+
"LDP_INTERNAL_API_KEY",
|
|
192
|
+
"LDP_PLUGIN_PUBLIC_URL",
|
|
193
|
+
"LDP_JOB_INPUT_ROOT",
|
|
194
|
+
"LDP_JOB_WORKSPACE_ROOT",
|
|
195
|
+
)
|
|
196
|
+
if not any(str(os.getenv(name) or "").strip() for name in names):
|
|
197
|
+
return None
|
|
198
|
+
return cls(RegistrationSettings.from_env(), manifest_provider, heartbeat_provider)
|
|
199
|
+
|
|
200
|
+
def start(self) -> None:
|
|
201
|
+
if self.thread is not None:
|
|
202
|
+
return
|
|
203
|
+
self.stop_event.clear()
|
|
204
|
+
self.thread = threading.Thread(
|
|
205
|
+
target=self._run,
|
|
206
|
+
name="plugin-registration-agent",
|
|
207
|
+
daemon=True,
|
|
208
|
+
)
|
|
209
|
+
self.thread.start()
|
|
210
|
+
|
|
211
|
+
def stop(self) -> None:
|
|
212
|
+
self.stop_event.set()
|
|
213
|
+
if self.thread is not None:
|
|
214
|
+
self.thread.join(timeout=self.settings.heartbeat_seconds + 2)
|
|
215
|
+
if self.instance_id:
|
|
216
|
+
try:
|
|
217
|
+
self._heartbeat("offline")
|
|
218
|
+
except Exception:
|
|
219
|
+
logger.warning("failed to mark plugin instance offline", exc_info=True)
|
|
220
|
+
|
|
221
|
+
def _run(self) -> None:
|
|
222
|
+
while not self.stop_event.is_set():
|
|
223
|
+
try:
|
|
224
|
+
if not self.instance_id:
|
|
225
|
+
self._register()
|
|
226
|
+
self._heartbeat()
|
|
227
|
+
except HTTPError as exc:
|
|
228
|
+
if exc.code == 404:
|
|
229
|
+
self.instance_id = ""
|
|
230
|
+
logger.warning("plugin registration request failed: %s", exc)
|
|
231
|
+
except Exception:
|
|
232
|
+
logger.exception("plugin registration iteration failed")
|
|
233
|
+
self.stop_event.wait(self.settings.heartbeat_seconds)
|
|
234
|
+
|
|
235
|
+
def _register(self) -> None:
|
|
236
|
+
manifest = self.manifest_provider()
|
|
237
|
+
algorithm_type = str(manifest.get("algorithmType") or "")
|
|
238
|
+
implementation_digest = str(manifest.get("implementationDigest") or "")
|
|
239
|
+
if not algorithm_type or not implementation_digest:
|
|
240
|
+
raise RuntimeError(
|
|
241
|
+
"plugin manifest requires algorithmType and implementationDigest"
|
|
242
|
+
)
|
|
243
|
+
registration_manifest = dict(manifest)
|
|
244
|
+
registration_manifest["imageDigest"] = implementation_digest
|
|
245
|
+
registration_manifest.pop("implementationDigest", None)
|
|
246
|
+
registration_manifest.pop("metadata", None)
|
|
247
|
+
response = self.client.post(
|
|
248
|
+
"/api/internal/compute-schedule/plugin-instances/register",
|
|
249
|
+
{
|
|
250
|
+
"instanceKey": self.settings.instance_key
|
|
251
|
+
or stable_instance_key(algorithm_type),
|
|
252
|
+
"endpoint": self.settings.public_url,
|
|
253
|
+
"inputRoot": str(self.settings.input_root),
|
|
254
|
+
"workspaceRoot": str(self.settings.workspace_root),
|
|
255
|
+
"cluster": self.settings.cluster,
|
|
256
|
+
"namespace": self.settings.namespace,
|
|
257
|
+
"nodeName": self.settings.node_name,
|
|
258
|
+
"manifest": registration_manifest,
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
self.instance_id = str(response.get("instanceId") or "")
|
|
262
|
+
if not self.instance_id:
|
|
263
|
+
raise RuntimeError("compute API registration response has no instanceId")
|
|
264
|
+
logger.info(
|
|
265
|
+
"plugin instance registered instance_id=%s implementation_id=%s",
|
|
266
|
+
self.instance_id,
|
|
267
|
+
response.get("implementationId", ""),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def _heartbeat(self, status: str | None = None) -> None:
|
|
271
|
+
if not self.instance_id:
|
|
272
|
+
return
|
|
273
|
+
heartbeat = self.heartbeat_provider()
|
|
274
|
+
self.client.post(
|
|
275
|
+
f"/api/internal/compute-schedule/plugin-instances/{self.instance_id}/heartbeat",
|
|
276
|
+
{
|
|
277
|
+
"status": status or heartbeat["status"],
|
|
278
|
+
"activeExecutions": int(heartbeat.get("activeExecutions", 0)),
|
|
279
|
+
"maxConcurrency": int(heartbeat["maxConcurrency"]),
|
|
280
|
+
"resources": dict(heartbeat.get("resources", {})),
|
|
281
|
+
},
|
|
282
|
+
)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
_SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class ReleaseManifest:
|
|
15
|
+
"""Immutable identity and protocol metadata for a plugin release."""
|
|
16
|
+
|
|
17
|
+
algorithm_type: str
|
|
18
|
+
name: str
|
|
19
|
+
version: str
|
|
20
|
+
implementation_digest: str
|
|
21
|
+
build_revision: str
|
|
22
|
+
protocol_version: str
|
|
23
|
+
input_schema: str
|
|
24
|
+
output_schema: str
|
|
25
|
+
model_components: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def discover(
|
|
29
|
+
cls,
|
|
30
|
+
algorithm: Any,
|
|
31
|
+
*,
|
|
32
|
+
start: str | Path | None = None,
|
|
33
|
+
required: bool = True,
|
|
34
|
+
) -> "ReleaseManifest | None":
|
|
35
|
+
"""Locate release-manifest.json from the checkout or Algorithm module."""
|
|
36
|
+
roots: list[Path] = []
|
|
37
|
+
if start is not None:
|
|
38
|
+
roots.append(Path(start).expanduser().resolve())
|
|
39
|
+
try:
|
|
40
|
+
module = importlib.import_module(type(algorithm).__module__)
|
|
41
|
+
module_file = getattr(module, "__file__", None)
|
|
42
|
+
if module_file:
|
|
43
|
+
roots.append(Path(module_file).resolve().parent)
|
|
44
|
+
except (ImportError, OSError):
|
|
45
|
+
pass
|
|
46
|
+
roots.append(Path.cwd().resolve())
|
|
47
|
+
|
|
48
|
+
seen: set[Path] = set()
|
|
49
|
+
for root in roots:
|
|
50
|
+
if root.is_file():
|
|
51
|
+
root = root.parent
|
|
52
|
+
for candidate in (root, *root.parents):
|
|
53
|
+
if candidate in seen:
|
|
54
|
+
continue
|
|
55
|
+
seen.add(candidate)
|
|
56
|
+
path = candidate / "release-manifest.json"
|
|
57
|
+
if path.is_file():
|
|
58
|
+
return cls.load(
|
|
59
|
+
path,
|
|
60
|
+
expected_algorithm_type=algorithm.metadata().name.replace("-", "_"),
|
|
61
|
+
)
|
|
62
|
+
if required:
|
|
63
|
+
raise RuntimeError("release-manifest.json was not found from the current repository")
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def load(
|
|
68
|
+
cls,
|
|
69
|
+
path: str | Path,
|
|
70
|
+
*,
|
|
71
|
+
expected_algorithm_type: str | None = None,
|
|
72
|
+
) -> "ReleaseManifest":
|
|
73
|
+
manifest_path = Path(path)
|
|
74
|
+
try:
|
|
75
|
+
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
76
|
+
except FileNotFoundError as exc:
|
|
77
|
+
raise RuntimeError(f"release manifest is missing: {manifest_path}") from exc
|
|
78
|
+
except json.JSONDecodeError as exc:
|
|
79
|
+
raise RuntimeError(
|
|
80
|
+
f"release manifest is not valid JSON: {manifest_path}: {exc}"
|
|
81
|
+
) from exc
|
|
82
|
+
if not isinstance(payload, dict):
|
|
83
|
+
raise RuntimeError("release manifest must contain a JSON object")
|
|
84
|
+
|
|
85
|
+
required = (
|
|
86
|
+
"algorithmType",
|
|
87
|
+
"name",
|
|
88
|
+
"version",
|
|
89
|
+
"implementationDigest",
|
|
90
|
+
"buildRevision",
|
|
91
|
+
"protocolVersion",
|
|
92
|
+
"inputSchema",
|
|
93
|
+
"outputSchema",
|
|
94
|
+
)
|
|
95
|
+
missing = [name for name in required if not str(payload.get(name) or "").strip()]
|
|
96
|
+
if missing:
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
"release manifest is missing required fields: " + ", ".join(missing)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
algorithm_type = str(payload["algorithmType"]).strip()
|
|
102
|
+
if expected_algorithm_type and algorithm_type != expected_algorithm_type:
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
"release manifest algorithmType must be "
|
|
105
|
+
f"{expected_algorithm_type}, got {algorithm_type}"
|
|
106
|
+
)
|
|
107
|
+
digest = str(payload["implementationDigest"]).strip().lower()
|
|
108
|
+
if not _SHA256.fullmatch(digest):
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
"release manifest implementationDigest must be sha256 plus 64 hex digits"
|
|
111
|
+
)
|
|
112
|
+
components = payload.get("modelComponents", [])
|
|
113
|
+
if not isinstance(components, list) or not all(
|
|
114
|
+
isinstance(component, dict) for component in components
|
|
115
|
+
):
|
|
116
|
+
raise RuntimeError("release manifest modelComponents must be a list of objects")
|
|
117
|
+
for component in components:
|
|
118
|
+
component_digest = str(component.get("digest") or "").lower()
|
|
119
|
+
if not component.get("name") or not _SHA256.fullmatch(component_digest):
|
|
120
|
+
raise RuntimeError(
|
|
121
|
+
"each release manifest model component requires name and sha256 digest"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return cls(
|
|
125
|
+
algorithm_type=algorithm_type,
|
|
126
|
+
name=str(payload["name"]).strip(),
|
|
127
|
+
version=str(payload["version"]).strip(),
|
|
128
|
+
implementation_digest=digest,
|
|
129
|
+
build_revision=str(payload["buildRevision"]).strip(),
|
|
130
|
+
protocol_version=str(payload["protocolVersion"]).strip(),
|
|
131
|
+
input_schema=str(payload["inputSchema"]).strip(),
|
|
132
|
+
output_schema=str(payload["outputSchema"]).strip(),
|
|
133
|
+
model_components=tuple(dict(component) for component in components),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def to_dict(self) -> dict[str, Any]:
|
|
137
|
+
return {
|
|
138
|
+
"algorithmType": self.algorithm_type,
|
|
139
|
+
"name": self.name,
|
|
140
|
+
"version": self.version,
|
|
141
|
+
"implementationDigest": self.implementation_digest,
|
|
142
|
+
"buildRevision": self.build_revision,
|
|
143
|
+
"protocolVersion": self.protocol_version,
|
|
144
|
+
"inputSchema": self.input_schema,
|
|
145
|
+
"outputSchema": self.output_schema,
|
|
146
|
+
"modelComponents": [dict(component) for component in self.model_components],
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def plugin_manifest(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
max_concurrency: int,
|
|
153
|
+
capabilities: dict[str, Any],
|
|
154
|
+
resources: dict[str, Any] | None = None,
|
|
155
|
+
) -> dict[str, Any]:
|
|
156
|
+
if max_concurrency < 1:
|
|
157
|
+
raise ValueError("max_concurrency must be positive")
|
|
158
|
+
return {
|
|
159
|
+
"apiVersion": self.protocol_version,
|
|
160
|
+
"algorithmType": self.algorithm_type,
|
|
161
|
+
"name": self.name,
|
|
162
|
+
"version": self.version,
|
|
163
|
+
"implementationDigest": self.implementation_digest,
|
|
164
|
+
"buildRevision": self.build_revision,
|
|
165
|
+
"inputSchema": self.input_schema,
|
|
166
|
+
"outputSchema": self.output_schema,
|
|
167
|
+
"maxConcurrency": max_concurrency,
|
|
168
|
+
"capabilities": dict(capabilities),
|
|
169
|
+
"resources": dict(resources or {}),
|
|
170
|
+
"modelComponents": [dict(component) for component in self.model_components],
|
|
171
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
from .algorithm import Algorithm
|
|
7
|
+
from .context import ExecutionContext, ProgressCallback
|
|
8
|
+
from .errors import ExecutionCancelled, InvalidAlgorithmResult
|
|
9
|
+
from .models import AlgorithmRequest, AlgorithmResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AlgorithmRunner:
|
|
13
|
+
"""Runs one algorithm implementation without owning its scheduling policy."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, algorithm: Algorithm) -> None:
|
|
16
|
+
if not isinstance(algorithm, Algorithm):
|
|
17
|
+
raise TypeError("algorithm must be a Algorithm instance")
|
|
18
|
+
self.algorithm = algorithm
|
|
19
|
+
self._started = False
|
|
20
|
+
self._closed = False
|
|
21
|
+
self._lifecycle_lock = threading.Lock()
|
|
22
|
+
|
|
23
|
+
def start(self) -> None:
|
|
24
|
+
with self._lifecycle_lock:
|
|
25
|
+
if self._closed:
|
|
26
|
+
raise RuntimeError("runner is closed")
|
|
27
|
+
if self._started:
|
|
28
|
+
return
|
|
29
|
+
self.algorithm.startup()
|
|
30
|
+
self._started = True
|
|
31
|
+
|
|
32
|
+
def close(self) -> None:
|
|
33
|
+
with self._lifecycle_lock:
|
|
34
|
+
if self._closed:
|
|
35
|
+
return
|
|
36
|
+
if self._started:
|
|
37
|
+
self.algorithm.shutdown()
|
|
38
|
+
self._closed = True
|
|
39
|
+
|
|
40
|
+
def run(
|
|
41
|
+
self,
|
|
42
|
+
request: AlgorithmRequest,
|
|
43
|
+
*,
|
|
44
|
+
context: ExecutionContext | None = None,
|
|
45
|
+
execution_id: str | None = None,
|
|
46
|
+
scratch_dir: str | None = None,
|
|
47
|
+
progress_callback: ProgressCallback | None = None,
|
|
48
|
+
mark_unfinished_on_error: bool = True,
|
|
49
|
+
) -> AlgorithmResult:
|
|
50
|
+
if not isinstance(request, AlgorithmRequest):
|
|
51
|
+
raise TypeError("request must be an AlgorithmRequest")
|
|
52
|
+
self.start()
|
|
53
|
+
datasets = [item.dataset for item in request.inputs]
|
|
54
|
+
selected_context = context or ExecutionContext(
|
|
55
|
+
execution_id or f"exec-{uuid.uuid4().hex}",
|
|
56
|
+
datasets,
|
|
57
|
+
scratch_dir=(
|
|
58
|
+
scratch_dir
|
|
59
|
+
or (
|
|
60
|
+
request.workspace["scratchRoot"]
|
|
61
|
+
if request.workspace is not None
|
|
62
|
+
else None
|
|
63
|
+
)
|
|
64
|
+
),
|
|
65
|
+
progress_callback=progress_callback,
|
|
66
|
+
)
|
|
67
|
+
selected_context.raise_if_cancelled()
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
result = self.algorithm.execute(request, selected_context)
|
|
71
|
+
selected_context.raise_if_cancelled()
|
|
72
|
+
self._validate_result(request, result)
|
|
73
|
+
for dataset_result in result.datasets:
|
|
74
|
+
selected_context.mark_dataset_result(dataset_result)
|
|
75
|
+
except ExecutionCancelled as exc:
|
|
76
|
+
if mark_unfinished_on_error:
|
|
77
|
+
selected_context.mark_unfinished("cancelled", str(exc))
|
|
78
|
+
raise
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
if mark_unfinished_on_error:
|
|
81
|
+
selected_context.mark_unfinished(
|
|
82
|
+
"failed", f"{type(exc).__name__}: {exc}"
|
|
83
|
+
)
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _validate_result(
|
|
90
|
+
request: AlgorithmRequest,
|
|
91
|
+
result: AlgorithmResult,
|
|
92
|
+
) -> None:
|
|
93
|
+
if not isinstance(result, AlgorithmResult):
|
|
94
|
+
raise InvalidAlgorithmResult("execute() must return AlgorithmResult")
|
|
95
|
+
result.validate()
|
|
96
|
+
actual = [dataset_result.dataset for dataset_result in result.datasets]
|
|
97
|
+
if len(actual) != len(set(actual)):
|
|
98
|
+
raise InvalidAlgorithmResult(
|
|
99
|
+
"algorithm returned duplicate dataset results"
|
|
100
|
+
)
|
|
101
|
+
expected = [item.dataset for item in request.inputs]
|
|
102
|
+
if set(actual) != set(expected):
|
|
103
|
+
missing = [dataset for dataset in expected if dataset not in actual]
|
|
104
|
+
unknown = [dataset for dataset in actual if dataset not in expected]
|
|
105
|
+
raise InvalidAlgorithmResult(
|
|
106
|
+
f"algorithm result does not match request; missing={missing}, unknown={unknown}"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def __enter__(self) -> "AlgorithmRunner":
|
|
110
|
+
self.start()
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(self, *_: object) -> None:
|
|
114
|
+
self.close()
|