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.
@@ -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()