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,307 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Callable, Literal, Mapping
|
|
9
|
+
|
|
10
|
+
from .errors import ExecutionCancelled
|
|
11
|
+
from .models import DatasetResult
|
|
12
|
+
|
|
13
|
+
ProgressStatus = Literal[
|
|
14
|
+
"pending",
|
|
15
|
+
"running",
|
|
16
|
+
"succeeded",
|
|
17
|
+
"failed",
|
|
18
|
+
"skipped",
|
|
19
|
+
"cancelled",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def utc_now() -> str:
|
|
24
|
+
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class EpisodeProgress:
|
|
29
|
+
episode: str
|
|
30
|
+
status: ProgressStatus = "pending"
|
|
31
|
+
stage: str = "pending"
|
|
32
|
+
completed: float | None = None
|
|
33
|
+
total: float | None = None
|
|
34
|
+
percent: float | None = None
|
|
35
|
+
message: str = ""
|
|
36
|
+
metrics: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
updated_at: str = field(default_factory=utc_now)
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict[str, Any]:
|
|
40
|
+
return {
|
|
41
|
+
"episode": self.episode,
|
|
42
|
+
"status": self.status,
|
|
43
|
+
"stage": self.stage,
|
|
44
|
+
"completed": self.completed,
|
|
45
|
+
"total": self.total,
|
|
46
|
+
"percent": self.percent,
|
|
47
|
+
"message": self.message,
|
|
48
|
+
"metrics": copy.deepcopy(self.metrics),
|
|
49
|
+
"updatedAt": self.updated_at,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class DatasetProgress:
|
|
55
|
+
dataset: str
|
|
56
|
+
status: ProgressStatus = "pending"
|
|
57
|
+
stage: str = "pending"
|
|
58
|
+
completed: float | None = None
|
|
59
|
+
total: float | None = None
|
|
60
|
+
percent: float | None = None
|
|
61
|
+
message: str = ""
|
|
62
|
+
metrics: dict[str, Any] = field(default_factory=dict)
|
|
63
|
+
episodes: dict[str, EpisodeProgress] = field(default_factory=dict)
|
|
64
|
+
updated_at: str = field(default_factory=utc_now)
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict[str, Any]:
|
|
67
|
+
return {
|
|
68
|
+
"dataset": self.dataset,
|
|
69
|
+
"status": self.status,
|
|
70
|
+
"stage": self.stage,
|
|
71
|
+
"completed": self.completed,
|
|
72
|
+
"total": self.total,
|
|
73
|
+
"percent": self.percent,
|
|
74
|
+
"message": self.message,
|
|
75
|
+
"metrics": copy.deepcopy(self.metrics),
|
|
76
|
+
"episodes": {
|
|
77
|
+
episode: progress.to_dict()
|
|
78
|
+
for episode, progress in self.episodes.items()
|
|
79
|
+
},
|
|
80
|
+
"updatedAt": self.updated_at,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class ProgressSnapshot:
|
|
86
|
+
execution_id: str
|
|
87
|
+
sequence: int
|
|
88
|
+
timestamp: str
|
|
89
|
+
datasets: dict[str, DatasetProgress]
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> dict[str, Any]:
|
|
92
|
+
return {
|
|
93
|
+
"executionId": self.execution_id,
|
|
94
|
+
"sequence": self.sequence,
|
|
95
|
+
"timestamp": self.timestamp,
|
|
96
|
+
"datasets": {
|
|
97
|
+
dataset: progress.to_dict()
|
|
98
|
+
for dataset, progress in self.datasets.items()
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
ProgressCallback = Callable[[ProgressSnapshot], None]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ExecutionContext:
|
|
107
|
+
"""Thread-safe execution controls and full-dataset progress aggregation."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
execution_id: str,
|
|
112
|
+
datasets: list[str],
|
|
113
|
+
*,
|
|
114
|
+
scratch_dir: str | None = None,
|
|
115
|
+
deadline: datetime | None = None,
|
|
116
|
+
logger: logging.Logger | None = None,
|
|
117
|
+
progress_callback: ProgressCallback | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
self.execution_id = execution_id
|
|
120
|
+
self.scratch_dir = scratch_dir
|
|
121
|
+
self.deadline = deadline
|
|
122
|
+
self.logger = logger or logging.getLogger(
|
|
123
|
+
f"algorithm_plugin_sdk.execution.{execution_id}"
|
|
124
|
+
)
|
|
125
|
+
self._progress = {
|
|
126
|
+
dataset: DatasetProgress(dataset=dataset)
|
|
127
|
+
for dataset in datasets
|
|
128
|
+
}
|
|
129
|
+
self._progress_callback = progress_callback
|
|
130
|
+
self._sequence = 0
|
|
131
|
+
self._lock = threading.RLock()
|
|
132
|
+
self._report_lock = threading.RLock()
|
|
133
|
+
self._cancel_event = threading.Event()
|
|
134
|
+
self._cancel_reason: str | None = None
|
|
135
|
+
|
|
136
|
+
def report_progress(
|
|
137
|
+
self,
|
|
138
|
+
*,
|
|
139
|
+
dataset: str,
|
|
140
|
+
episode: str | None = None,
|
|
141
|
+
stage: str,
|
|
142
|
+
completed: int | float | None = None,
|
|
143
|
+
total: int | float | None = None,
|
|
144
|
+
message: str = "",
|
|
145
|
+
metrics: Mapping[str, Any] | None = None,
|
|
146
|
+
status: ProgressStatus = "running",
|
|
147
|
+
) -> None:
|
|
148
|
+
if dataset not in self._progress:
|
|
149
|
+
raise ValueError(f"progress dataset is not in the request: {dataset}")
|
|
150
|
+
if not isinstance(stage, str) or not stage.strip():
|
|
151
|
+
raise ValueError("progress stage must be a non-empty string")
|
|
152
|
+
if episode is not None and (not isinstance(episode, str) or not episode.strip()):
|
|
153
|
+
raise ValueError("progress episode must be a non-empty string or None")
|
|
154
|
+
completed_value = self._number(completed, "completed")
|
|
155
|
+
total_value = self._number(total, "total")
|
|
156
|
+
if total_value is not None and total_value < 0:
|
|
157
|
+
raise ValueError("progress total cannot be negative")
|
|
158
|
+
if completed_value is not None and completed_value < 0:
|
|
159
|
+
raise ValueError("progress completed cannot be negative")
|
|
160
|
+
|
|
161
|
+
with self._report_lock:
|
|
162
|
+
with self._lock:
|
|
163
|
+
dataset_progress = self._progress[dataset]
|
|
164
|
+
if episode is None:
|
|
165
|
+
progress = dataset_progress
|
|
166
|
+
else:
|
|
167
|
+
progress = dataset_progress.episodes.setdefault(
|
|
168
|
+
episode,
|
|
169
|
+
EpisodeProgress(episode=episode),
|
|
170
|
+
)
|
|
171
|
+
if dataset_progress.status == "pending":
|
|
172
|
+
dataset_progress.status = "running"
|
|
173
|
+
dataset_progress.updated_at = utc_now()
|
|
174
|
+
self._update_progress(
|
|
175
|
+
progress,
|
|
176
|
+
status=status,
|
|
177
|
+
stage=stage,
|
|
178
|
+
completed=completed_value,
|
|
179
|
+
total=total_value,
|
|
180
|
+
message=message,
|
|
181
|
+
metrics=metrics,
|
|
182
|
+
)
|
|
183
|
+
snapshot = self._next_snapshot_locked()
|
|
184
|
+
self._emit(snapshot)
|
|
185
|
+
|
|
186
|
+
def mark_dataset_result(self, result: DatasetResult) -> None:
|
|
187
|
+
with self._report_lock:
|
|
188
|
+
with self._lock:
|
|
189
|
+
progress = self._progress[result.dataset]
|
|
190
|
+
progress.status = result.status
|
|
191
|
+
progress.stage = "completed"
|
|
192
|
+
if progress.total is not None and result.status == "succeeded":
|
|
193
|
+
progress.completed = progress.total
|
|
194
|
+
progress.percent = 100.0
|
|
195
|
+
progress.message = result.error or result.merge_message or ""
|
|
196
|
+
progress.metrics = {
|
|
197
|
+
**progress.metrics,
|
|
198
|
+
**result.metrics,
|
|
199
|
+
"mergeStatus": result.merge_status,
|
|
200
|
+
}
|
|
201
|
+
progress.updated_at = utc_now()
|
|
202
|
+
for episode in progress.episodes.values():
|
|
203
|
+
if episode.status in {"pending", "running"}:
|
|
204
|
+
episode.status = result.status
|
|
205
|
+
episode.stage = "completed"
|
|
206
|
+
if episode.total is not None and result.status == "succeeded":
|
|
207
|
+
episode.completed = episode.total
|
|
208
|
+
episode.percent = 100.0
|
|
209
|
+
episode.updated_at = utc_now()
|
|
210
|
+
snapshot = self._next_snapshot_locked()
|
|
211
|
+
self._emit(snapshot)
|
|
212
|
+
|
|
213
|
+
def mark_unfinished(self, status: ProgressStatus, message: str) -> None:
|
|
214
|
+
with self._report_lock:
|
|
215
|
+
with self._lock:
|
|
216
|
+
changed = False
|
|
217
|
+
for progress in self._progress.values():
|
|
218
|
+
if progress.status in {"pending", "running"}:
|
|
219
|
+
progress.status = status
|
|
220
|
+
progress.stage = "completed"
|
|
221
|
+
progress.message = message
|
|
222
|
+
progress.updated_at = utc_now()
|
|
223
|
+
for episode in progress.episodes.values():
|
|
224
|
+
if episode.status in {"pending", "running"}:
|
|
225
|
+
episode.status = status
|
|
226
|
+
episode.stage = "completed"
|
|
227
|
+
episode.message = message
|
|
228
|
+
episode.updated_at = utc_now()
|
|
229
|
+
changed = True
|
|
230
|
+
snapshot = self._next_snapshot_locked() if changed else None
|
|
231
|
+
if snapshot is not None:
|
|
232
|
+
self._emit(snapshot)
|
|
233
|
+
|
|
234
|
+
def snapshot(self) -> ProgressSnapshot:
|
|
235
|
+
with self._lock:
|
|
236
|
+
return self._snapshot_locked()
|
|
237
|
+
|
|
238
|
+
def cancel(self, reason: str = "cancelled") -> None:
|
|
239
|
+
with self._lock:
|
|
240
|
+
self._cancel_reason = reason
|
|
241
|
+
self._cancel_event.set()
|
|
242
|
+
|
|
243
|
+
def is_cancelled(self) -> bool:
|
|
244
|
+
if self._cancel_event.is_set():
|
|
245
|
+
return True
|
|
246
|
+
if self.deadline is not None and datetime.now(timezone.utc) >= self.deadline:
|
|
247
|
+
self.cancel("deadline exceeded")
|
|
248
|
+
return True
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
def raise_if_cancelled(self) -> None:
|
|
252
|
+
if self.is_cancelled():
|
|
253
|
+
raise ExecutionCancelled(self._cancel_reason or "execution cancelled")
|
|
254
|
+
|
|
255
|
+
@staticmethod
|
|
256
|
+
def _number(value: int | float | None, name: str) -> float | None:
|
|
257
|
+
if value is None:
|
|
258
|
+
return None
|
|
259
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
260
|
+
raise ValueError(f"progress {name} must be numeric or None")
|
|
261
|
+
return float(value)
|
|
262
|
+
|
|
263
|
+
@staticmethod
|
|
264
|
+
def _percent(completed: float | None, total: float | None) -> float | None:
|
|
265
|
+
if completed is None or total is None or total <= 0:
|
|
266
|
+
return None
|
|
267
|
+
return round(max(0.0, min(100.0, completed / total * 100.0)), 4)
|
|
268
|
+
|
|
269
|
+
def _update_progress(
|
|
270
|
+
self,
|
|
271
|
+
progress: DatasetProgress | EpisodeProgress,
|
|
272
|
+
*,
|
|
273
|
+
status: ProgressStatus,
|
|
274
|
+
stage: str,
|
|
275
|
+
completed: float | None,
|
|
276
|
+
total: float | None,
|
|
277
|
+
message: str,
|
|
278
|
+
metrics: Mapping[str, Any] | None,
|
|
279
|
+
) -> None:
|
|
280
|
+
progress.status = status
|
|
281
|
+
progress.stage = stage
|
|
282
|
+
progress.completed = completed
|
|
283
|
+
progress.total = total
|
|
284
|
+
progress.percent = self._percent(completed, total)
|
|
285
|
+
progress.message = message
|
|
286
|
+
progress.metrics = dict(metrics or {})
|
|
287
|
+
progress.updated_at = utc_now()
|
|
288
|
+
|
|
289
|
+
def _next_snapshot_locked(self) -> ProgressSnapshot:
|
|
290
|
+
self._sequence += 1
|
|
291
|
+
return self._snapshot_locked()
|
|
292
|
+
|
|
293
|
+
def _snapshot_locked(self) -> ProgressSnapshot:
|
|
294
|
+
return ProgressSnapshot(
|
|
295
|
+
execution_id=self.execution_id,
|
|
296
|
+
sequence=self._sequence,
|
|
297
|
+
timestamp=utc_now(),
|
|
298
|
+
datasets=copy.deepcopy(self._progress),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def _emit(self, snapshot: ProgressSnapshot) -> None:
|
|
302
|
+
if self._progress_callback is None:
|
|
303
|
+
return
|
|
304
|
+
try:
|
|
305
|
+
self._progress_callback(snapshot)
|
|
306
|
+
except Exception:
|
|
307
|
+
self.logger.exception("progress callback failed")
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from importlib import metadata
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from .loader import ALGORITHM_ENTRY_POINT_GROUP, load_algorithm
|
|
13
|
+
from .release import ReleaseManifest
|
|
14
|
+
|
|
15
|
+
CONFIG_SCHEMA = "algorithm-plugin-sdk.deployment/v1"
|
|
16
|
+
DEFAULT_CONFIG_NAME = "algorithm-plugin.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def repository_root(start: str | Path | None = None) -> Path:
|
|
20
|
+
current = Path(start or Path.cwd()).expanduser().resolve()
|
|
21
|
+
if current.is_file():
|
|
22
|
+
current = current.parent
|
|
23
|
+
for candidate in (current, *current.parents):
|
|
24
|
+
if any(
|
|
25
|
+
(candidate / marker).exists()
|
|
26
|
+
for marker in ("release-manifest.json", "pyproject.toml", "setup.cfg", ".git")
|
|
27
|
+
):
|
|
28
|
+
return candidate
|
|
29
|
+
raise RuntimeError(f"cannot find a repository from {current}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _setup_cfg_entry_points(root: Path) -> list[tuple[str, str]]:
|
|
33
|
+
path = root / "setup.cfg"
|
|
34
|
+
if not path.is_file():
|
|
35
|
+
return []
|
|
36
|
+
parser = configparser.ConfigParser(interpolation=None)
|
|
37
|
+
parser.read(path, encoding="utf-8")
|
|
38
|
+
if not parser.has_section("options.entry_points"):
|
|
39
|
+
return []
|
|
40
|
+
raw = parser.get("options.entry_points", ALGORITHM_ENTRY_POINT_GROUP, fallback="")
|
|
41
|
+
entries: list[tuple[str, str]] = []
|
|
42
|
+
for line in raw.splitlines():
|
|
43
|
+
name, separator, reference = line.partition("=")
|
|
44
|
+
if separator and name.strip() and reference.strip():
|
|
45
|
+
entries.append((name.strip(), reference.strip()))
|
|
46
|
+
return entries
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def discover_algorithm_reference(
|
|
50
|
+
root: str | Path,
|
|
51
|
+
requested: str | None = None,
|
|
52
|
+
) -> tuple[str, str | None]:
|
|
53
|
+
"""Find the repository's sole Algorithm entry point.
|
|
54
|
+
|
|
55
|
+
The class reference from setup.cfg is preferred so config generation also works
|
|
56
|
+
from an editable source checkout. Installed entry points are the fallback.
|
|
57
|
+
"""
|
|
58
|
+
repository = Path(root).resolve()
|
|
59
|
+
configured = _setup_cfg_entry_points(repository)
|
|
60
|
+
if requested:
|
|
61
|
+
for name, reference in configured:
|
|
62
|
+
if requested in {name, reference}:
|
|
63
|
+
return reference, name
|
|
64
|
+
return requested, None
|
|
65
|
+
if len(configured) == 1:
|
|
66
|
+
name, reference = configured[0]
|
|
67
|
+
return reference, name
|
|
68
|
+
if len(configured) > 1:
|
|
69
|
+
names = ", ".join(name for name, _ in configured)
|
|
70
|
+
raise RuntimeError(f"multiple algorithms found; select one explicitly: {names}")
|
|
71
|
+
|
|
72
|
+
installed = list(metadata.entry_points(group=ALGORITHM_ENTRY_POINT_GROUP))
|
|
73
|
+
if len(installed) == 1:
|
|
74
|
+
return installed[0].name, installed[0].name
|
|
75
|
+
if not installed:
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
"no Algorithm entry point found; declare algorithm_plugin_sdk.algorithms"
|
|
78
|
+
)
|
|
79
|
+
names = ", ".join(sorted(entry.name for entry in installed))
|
|
80
|
+
raise RuntimeError(f"multiple installed algorithms found; select one: {names}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _absolute(value: str | Path | None, root: Path) -> str | None:
|
|
84
|
+
if value is None or not str(value).strip():
|
|
85
|
+
return None
|
|
86
|
+
path = Path(value).expanduser()
|
|
87
|
+
if not path.is_absolute():
|
|
88
|
+
path = root / path
|
|
89
|
+
return str(path.resolve())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(slots=True)
|
|
93
|
+
class DeploymentConfig:
|
|
94
|
+
path: Path
|
|
95
|
+
payload: dict[str, Any]
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def generate(
|
|
99
|
+
cls,
|
|
100
|
+
*,
|
|
101
|
+
output: str | Path,
|
|
102
|
+
root: str | Path,
|
|
103
|
+
algorithm: str | None,
|
|
104
|
+
host: str,
|
|
105
|
+
port: int,
|
|
106
|
+
webui: bool,
|
|
107
|
+
max_concurrency: int,
|
|
108
|
+
scratch_dir: str | None,
|
|
109
|
+
token: str | None,
|
|
110
|
+
registration: dict[str, str] | None,
|
|
111
|
+
gpu_ids: list[int] | None = None,
|
|
112
|
+
environment: dict[str, str] | None = None,
|
|
113
|
+
) -> "DeploymentConfig":
|
|
114
|
+
repository = repository_root(root)
|
|
115
|
+
reference, entry_point = discover_algorithm_reference(repository, algorithm)
|
|
116
|
+
instance = load_algorithm(reference)
|
|
117
|
+
release = ReleaseManifest.discover(instance, start=repository, required=True)
|
|
118
|
+
assert release is not None
|
|
119
|
+
if port < 1 or port > 65535:
|
|
120
|
+
raise ValueError("service port must be in [1, 65535]")
|
|
121
|
+
if max_concurrency < 1:
|
|
122
|
+
raise ValueError("max concurrency must be positive")
|
|
123
|
+
configured_gpu_ids = list(gpu_ids or [])
|
|
124
|
+
if any(
|
|
125
|
+
isinstance(gpu_id, bool) or not isinstance(gpu_id, int) or gpu_id < 0
|
|
126
|
+
for gpu_id in configured_gpu_ids
|
|
127
|
+
) or len(configured_gpu_ids) != len(set(configured_gpu_ids)):
|
|
128
|
+
raise ValueError("GPU IDs must be unique non-negative integers")
|
|
129
|
+
|
|
130
|
+
output_path = Path(output).expanduser()
|
|
131
|
+
if not output_path.is_absolute():
|
|
132
|
+
output_path = repository / output_path
|
|
133
|
+
output_path = output_path.resolve()
|
|
134
|
+
state_dir = output_path.parent / ".algorithm-plugin"
|
|
135
|
+
payload: dict[str, Any] = {
|
|
136
|
+
"schemaVersion": CONFIG_SCHEMA,
|
|
137
|
+
"algorithm": {
|
|
138
|
+
"reference": reference,
|
|
139
|
+
"entryPoint": entry_point,
|
|
140
|
+
},
|
|
141
|
+
"repository": str(repository),
|
|
142
|
+
"releaseManifest": str(repository / "release-manifest.json"),
|
|
143
|
+
"service": {
|
|
144
|
+
"host": host,
|
|
145
|
+
"port": port,
|
|
146
|
+
"webui": webui,
|
|
147
|
+
"maxConcurrency": max_concurrency,
|
|
148
|
+
"scratchDir": _absolute(scratch_dir, repository),
|
|
149
|
+
"gpuIds": configured_gpu_ids,
|
|
150
|
+
"token": token or None,
|
|
151
|
+
},
|
|
152
|
+
"registration": registration,
|
|
153
|
+
"environment": dict(sorted((environment or {}).items())),
|
|
154
|
+
"runtime": {
|
|
155
|
+
"pidFile": str(state_dir / "service.pid.json"),
|
|
156
|
+
"logFile": str(state_dir / "service.log"),
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
config = cls(output_path, payload)
|
|
160
|
+
config.refresh()
|
|
161
|
+
return config
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def load(
|
|
165
|
+
cls,
|
|
166
|
+
path: str | Path,
|
|
167
|
+
*,
|
|
168
|
+
refresh: bool = True,
|
|
169
|
+
) -> "DeploymentConfig":
|
|
170
|
+
config_path = Path(path).expanduser().resolve()
|
|
171
|
+
try:
|
|
172
|
+
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
173
|
+
except FileNotFoundError as exc:
|
|
174
|
+
raise RuntimeError(f"deployment config is missing: {config_path}") from exc
|
|
175
|
+
except json.JSONDecodeError as exc:
|
|
176
|
+
raise RuntimeError(f"deployment config is invalid JSON: {exc}") from exc
|
|
177
|
+
if not isinstance(payload, dict) or payload.get("schemaVersion") != CONFIG_SCHEMA:
|
|
178
|
+
raise RuntimeError(f"deployment config schema must be {CONFIG_SCHEMA}")
|
|
179
|
+
config = cls(config_path, payload)
|
|
180
|
+
if refresh:
|
|
181
|
+
config.refresh()
|
|
182
|
+
return config
|
|
183
|
+
|
|
184
|
+
def refresh(self) -> None:
|
|
185
|
+
algorithm = self.payload.get("algorithm")
|
|
186
|
+
if not isinstance(algorithm, dict) or not algorithm.get("reference"):
|
|
187
|
+
raise RuntimeError("deployment config has no algorithm reference")
|
|
188
|
+
repository = Path(str(self.payload.get("repository") or "")).expanduser()
|
|
189
|
+
if not repository.is_absolute() or not repository.is_dir():
|
|
190
|
+
raise RuntimeError(f"configured repository is unavailable: {repository}")
|
|
191
|
+
instance = load_algorithm(str(algorithm["reference"]))
|
|
192
|
+
release_path = Path(str(self.payload.get("releaseManifest") or ""))
|
|
193
|
+
release = ReleaseManifest.load(
|
|
194
|
+
release_path,
|
|
195
|
+
expected_algorithm_type=instance.metadata().name.replace("-", "_"),
|
|
196
|
+
)
|
|
197
|
+
if release.version != instance.metadata().version:
|
|
198
|
+
raise RuntimeError(
|
|
199
|
+
"Algorithm metadata version does not match release-manifest.json: "
|
|
200
|
+
f"{instance.metadata().version} != {release.version}"
|
|
201
|
+
)
|
|
202
|
+
algorithm.update(
|
|
203
|
+
{
|
|
204
|
+
"type": release.algorithm_type,
|
|
205
|
+
"name": release.name,
|
|
206
|
+
"version": release.version,
|
|
207
|
+
"implementationDigest": release.implementation_digest,
|
|
208
|
+
"buildRevision": release.build_revision,
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
self._validate_service()
|
|
212
|
+
self._validate_registration()
|
|
213
|
+
environment = self.payload.get("environment", {})
|
|
214
|
+
if not isinstance(environment, dict) or not all(
|
|
215
|
+
isinstance(key, str) and isinstance(value, str)
|
|
216
|
+
for key, value in environment.items()
|
|
217
|
+
):
|
|
218
|
+
raise RuntimeError("deployment environment must be a string map")
|
|
219
|
+
|
|
220
|
+
def _validate_service(self) -> None:
|
|
221
|
+
service = self.payload.get("service")
|
|
222
|
+
if not isinstance(service, dict):
|
|
223
|
+
raise RuntimeError("deployment config has no service settings")
|
|
224
|
+
port = int(service.get("port", 0))
|
|
225
|
+
concurrency = int(service.get("maxConcurrency", 0))
|
|
226
|
+
if not 1 <= port <= 65535:
|
|
227
|
+
raise RuntimeError("service port must be in [1, 65535]")
|
|
228
|
+
if concurrency < 1:
|
|
229
|
+
raise RuntimeError("service maxConcurrency must be positive")
|
|
230
|
+
gpu_ids = service.get("gpuIds")
|
|
231
|
+
if not isinstance(gpu_ids, list) or any(
|
|
232
|
+
isinstance(gpu_id, bool) or not isinstance(gpu_id, int) or gpu_id < 0
|
|
233
|
+
for gpu_id in gpu_ids
|
|
234
|
+
) or len(gpu_ids) != len(set(gpu_ids)):
|
|
235
|
+
raise RuntimeError("service gpuIds must be unique non-negative integers")
|
|
236
|
+
|
|
237
|
+
def _validate_registration(self) -> None:
|
|
238
|
+
registration = self.payload.get("registration")
|
|
239
|
+
if registration is None:
|
|
240
|
+
return
|
|
241
|
+
required = (
|
|
242
|
+
"computeUrl", "apiKey", "publicUrl", "inputRoot", "workspaceRoot",
|
|
243
|
+
"cluster", "namespace", "nodeName",
|
|
244
|
+
)
|
|
245
|
+
if not isinstance(registration, dict):
|
|
246
|
+
raise RuntimeError("registration must be an object or null")
|
|
247
|
+
missing = [name for name in required if not str(registration.get(name) or "").strip()]
|
|
248
|
+
if missing:
|
|
249
|
+
raise RuntimeError("registration is missing: " + ", ".join(missing))
|
|
250
|
+
for name in ("computeUrl", "publicUrl"):
|
|
251
|
+
parsed = urlparse(str(registration[name]).strip())
|
|
252
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
253
|
+
raise RuntimeError(f"registration {name} must be an http or https URL")
|
|
254
|
+
for name in ("inputRoot", "workspaceRoot"):
|
|
255
|
+
path = Path(str(registration[name])).expanduser()
|
|
256
|
+
if not path.is_absolute() or not path.is_dir():
|
|
257
|
+
raise RuntimeError(
|
|
258
|
+
f"registration {name} must be an existing absolute directory"
|
|
259
|
+
)
|
|
260
|
+
workspace = Path(str(registration["workspaceRoot"])).expanduser()
|
|
261
|
+
if not os.access(workspace, os.W_OK):
|
|
262
|
+
raise RuntimeError("registration workspaceRoot must be writable")
|
|
263
|
+
|
|
264
|
+
def save(self) -> None:
|
|
265
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
temporary = self.path.with_name(f".{self.path.name}.tmp")
|
|
267
|
+
temporary.write_text(
|
|
268
|
+
json.dumps(self.payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
|
269
|
+
encoding="utf-8",
|
|
270
|
+
)
|
|
271
|
+
os.chmod(temporary, 0o600)
|
|
272
|
+
temporary.replace(self.path)
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def algorithm_reference(self) -> str:
|
|
276
|
+
return str(self.payload["algorithm"]["reference"])
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def repository(self) -> Path:
|
|
280
|
+
return Path(self.payload["repository"])
|
|
281
|
+
|
|
282
|
+
@property
|
|
283
|
+
def pid_file(self) -> Path:
|
|
284
|
+
return Path(self.payload["runtime"]["pidFile"])
|
|
285
|
+
|
|
286
|
+
@property
|
|
287
|
+
def log_file(self) -> Path:
|
|
288
|
+
return Path(self.payload["runtime"]["logFile"])
|
|
289
|
+
|
|
290
|
+
def process_environment(self) -> dict[str, str]:
|
|
291
|
+
values = os.environ.copy()
|
|
292
|
+
values.update(self.payload.get("environment", {}))
|
|
293
|
+
registration = self.payload.get("registration")
|
|
294
|
+
if registration is None:
|
|
295
|
+
values["LDP_PLUGIN_REGISTRATION_DISABLED"] = "1"
|
|
296
|
+
else:
|
|
297
|
+
values.pop("LDP_PLUGIN_REGISTRATION_DISABLED", None)
|
|
298
|
+
values.update(
|
|
299
|
+
{
|
|
300
|
+
"LDP_COMPUTE_URL": str(registration["computeUrl"]),
|
|
301
|
+
"LDP_INTERNAL_API_KEY": str(registration["apiKey"]),
|
|
302
|
+
"LDP_PLUGIN_PUBLIC_URL": str(registration["publicUrl"]),
|
|
303
|
+
"LDP_JOB_INPUT_ROOT": str(registration["inputRoot"]),
|
|
304
|
+
"LDP_JOB_WORKSPACE_ROOT": str(registration["workspaceRoot"]),
|
|
305
|
+
"LDP_CLUSTER": str(registration["cluster"]),
|
|
306
|
+
"POD_NAMESPACE": str(registration["namespace"]),
|
|
307
|
+
"NODE_NAME": str(registration["nodeName"]),
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
if registration.get("instanceKey"):
|
|
311
|
+
values["LDP_PLUGIN_INSTANCE_KEY"] = str(registration["instanceKey"])
|
|
312
|
+
token = self.payload["service"].get("token")
|
|
313
|
+
if token:
|
|
314
|
+
values["LDP_PLUGIN_TOKEN"] = str(token)
|
|
315
|
+
else:
|
|
316
|
+
values.pop("LDP_PLUGIN_TOKEN", None)
|
|
317
|
+
return values
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
class AlgorithmPluginError(RuntimeError):
|
|
2
|
+
"""Base error raised by the SDK."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class InvalidRequest(AlgorithmPluginError, ValueError):
|
|
6
|
+
"""The execution request is structurally invalid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvalidAlgorithmResult(AlgorithmPluginError, ValueError):
|
|
10
|
+
"""The algorithm returned a result that violates the SDK contract."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ExecutionCancelled(AlgorithmPluginError):
|
|
14
|
+
"""The execution was cancelled by its caller or deadline."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AlgorithmLoadError(AlgorithmPluginError, ImportError):
|
|
18
|
+
"""An algorithm class could not be imported or instantiated."""
|
|
19
|
+
|
|
20
|
+
class ExecutionNotFound(KeyError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
class ExecutionNotFinished(RuntimeError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
class IdempotencyConflict(RuntimeError):
|
|
27
|
+
pass
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Runnable example algorithms packaged with the SDK."""
|