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,119 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from concurrent.futures import ThreadPoolExecutor, as_completed
5
+ from pathlib import Path
6
+
7
+ from algorithm_plugin_sdk import (
8
+ Algorithm,
9
+ AlgorithmInput,
10
+ AlgorithmMetadata,
11
+ AlgorithmRequest,
12
+ AlgorithmResult,
13
+ DatasetResult,
14
+ ExecutionContext
15
+ )
16
+
17
+
18
+ class ExampleAlgorithm(Algorithm):
19
+ """A packaged example that demonstrates algorithm-owned scheduling."""
20
+
21
+ @classmethod
22
+ def metadata(cls) -> AlgorithmMetadata:
23
+ return AlgorithmMetadata(
24
+ name="example-dataset-algorithm",
25
+ version="0.1.1",
26
+ description="Demonstrates algorithm-owned scheduling and merging",
27
+ supports_merge=True,
28
+ )
29
+
30
+ def execute(
31
+ self,
32
+ request: AlgorithmRequest,
33
+ context: ExecutionContext,
34
+ ) -> AlgorithmResult:
35
+ results: list[DatasetResult] = []
36
+ worker_count = max(1, len(request.gpu_ids))
37
+ with ThreadPoolExecutor(max_workers=worker_count) as executor:
38
+ futures = {
39
+ executor.submit(
40
+ self._process_dataset,
41
+ algorithm_input,
42
+ request,
43
+ context,
44
+ request.gpu_ids[index % len(request.gpu_ids)]
45
+ if request.gpu_ids
46
+ else None,
47
+ ): algorithm_input.dataset
48
+ for index, algorithm_input in enumerate(request.inputs)
49
+ }
50
+ for future in as_completed(futures):
51
+ results.append(future.result())
52
+ return AlgorithmResult(datasets=results)
53
+
54
+ def _process_dataset(
55
+ self,
56
+ algorithm_input: AlgorithmInput,
57
+ request: AlgorithmRequest,
58
+ context: ExecutionContext,
59
+ gpu_id: int | None,
60
+ ) -> DatasetResult:
61
+ dataset = algorithm_input.dataset
62
+ context.raise_if_cancelled()
63
+ context.report_progress(
64
+ dataset=dataset,
65
+ stage="inference",
66
+ completed=0,
67
+ total=1,
68
+ metrics={"gpuId": gpu_id},
69
+ )
70
+ output_dir = Path(algorithm_input.output)
71
+ output_dir.mkdir(parents=True, exist_ok=True)
72
+ artifact_path = output_dir / "prediction.json"
73
+ artifact_path.write_text(
74
+ json.dumps(
75
+ {
76
+ "dataset": dataset,
77
+ "inputs": algorithm_input.to_dict(),
78
+ "gpuId": gpu_id,
79
+ "parameters": request.parameters,
80
+ },
81
+ indent=2,
82
+ ),
83
+ encoding="utf-8",
84
+ )
85
+
86
+ merge_status = "not_requested"
87
+ merge_message = None
88
+ if request.merge:
89
+ try:
90
+ self._merge(dataset, artifact_path)
91
+ merge_status = "succeeded"
92
+ except Exception as exc:
93
+ merge_status = "skipped"
94
+ merge_message = f"{type(exc).__name__}: {exc}"
95
+
96
+ context.report_progress(
97
+ dataset=dataset,
98
+ stage="completed",
99
+ completed=1,
100
+ total=1,
101
+ metrics={"gpuId": gpu_id, "mergeStatus": merge_status},
102
+ )
103
+ return DatasetResult(
104
+ dataset=dataset,
105
+ status="succeeded",
106
+ artifacts={"prediction": str(artifact_path)},
107
+ merge_status=merge_status,
108
+ merge_message=merge_message,
109
+ )
110
+
111
+ @staticmethod
112
+ def _merge(dataset: str, artifact_path: Path) -> None:
113
+ dataset_path = Path(dataset)
114
+ if not dataset_path.is_dir():
115
+ raise FileNotFoundError(dataset)
116
+ (dataset_path / "example_prediction.json").write_text(
117
+ artifact_path.read_text(encoding="utf-8"),
118
+ encoding="utf-8",
119
+ )
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import time
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+
7
+ from algorithm_plugin_sdk import (
8
+ Algorithm,
9
+ AlgorithmMetadata,
10
+ AlgorithmRequest,
11
+ AlgorithmResult,
12
+ DatasetResult,
13
+ ExecutionContext
14
+ )
15
+
16
+
17
+ class SimulatedAlgorithm(Algorithm):
18
+ """A filesystem-free example for observing multi-dataset progress."""
19
+
20
+ @classmethod
21
+ def metadata(cls) -> AlgorithmMetadata:
22
+ return AlgorithmMetadata(
23
+ name="simulated-dataset-algorithm",
24
+ version="0.1.3",
25
+ description="Simulates concurrent dataset work without filesystem access",
26
+ )
27
+
28
+ def execute(
29
+ self,
30
+ request: AlgorithmRequest,
31
+ context: ExecutionContext,
32
+ ) -> AlgorithmResult:
33
+ datasets = [item.dataset for item in request.inputs]
34
+ worker_count = max(1, len(datasets))
35
+ with ThreadPoolExecutor(max_workers=worker_count) as executor:
36
+ futures = [
37
+ executor.submit(self._process_dataset, dataset, context)
38
+ for dataset in datasets
39
+ ]
40
+ results = [future.result() for future in as_completed(futures)]
41
+ return AlgorithmResult(datasets=results)
42
+
43
+ @staticmethod
44
+ def _process_dataset(
45
+ dataset: str,
46
+ context: ExecutionContext,
47
+ ) -> DatasetResult:
48
+ wait_seconds = random.uniform(2, 5)
49
+ run_seconds = random.uniform(3, 10)
50
+ context.report_progress(
51
+ dataset=dataset,
52
+ stage="waiting",
53
+ completed=0,
54
+ total=run_seconds,
55
+ message=f"waiting {wait_seconds:.1f}s before execution",
56
+ metrics={"waitSeconds": round(wait_seconds, 2), "runSeconds": round(run_seconds, 2)},
57
+ )
58
+ SimulatedAlgorithm._wait(wait_seconds, context)
59
+
60
+ started = time.monotonic()
61
+ while True:
62
+ context.raise_if_cancelled()
63
+ elapsed = min(time.monotonic() - started, run_seconds)
64
+ context.report_progress(
65
+ dataset=dataset,
66
+ stage="running",
67
+ completed=elapsed,
68
+ total=run_seconds,
69
+ message=f"simulated work for {run_seconds:.1f}s",
70
+ )
71
+ if elapsed >= run_seconds:
72
+ break
73
+ time.sleep(0.1)
74
+
75
+ return DatasetResult(
76
+ dataset=dataset,
77
+ status="succeeded",
78
+ metrics={
79
+ "waitSeconds": round(wait_seconds, 2),
80
+ "runSeconds": round(run_seconds, 2),
81
+ },
82
+ )
83
+
84
+ @staticmethod
85
+ def _wait(seconds: float, context: ExecutionContext) -> None:
86
+ deadline = time.monotonic() + seconds
87
+ while True:
88
+ context.raise_if_cancelled()
89
+ remaining = deadline - time.monotonic()
90
+ if remaining <= 0:
91
+ return
92
+ time.sleep(min(remaining, 0.1))
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib
4
+ from importlib import metadata
5
+
6
+ from .algorithm import Algorithm
7
+ from .errors import AlgorithmLoadError
8
+
9
+ ALGORITHM_ENTRY_POINT_GROUP = "algorithm_plugin_sdk.algorithms"
10
+
11
+
12
+ def load_algorithm(reference: str) -> Algorithm:
13
+ """Load an algorithm by entry-point name or ``package.module:Class``."""
14
+ if ":" not in reference:
15
+ return _load_registered_algorithm(reference)
16
+ module_name, separator, class_name = reference.partition(":")
17
+ if not separator or not module_name or not class_name:
18
+ raise AlgorithmLoadError(
19
+ "algorithm reference must use package.module:AlgorithmClass"
20
+ )
21
+ try:
22
+ module = importlib.import_module(module_name)
23
+ except Exception as exc:
24
+ raise AlgorithmLoadError(f"cannot import algorithm module {module_name}: {exc}") from exc
25
+ try:
26
+ algorithm_class = getattr(module, class_name)
27
+ except AttributeError as exc:
28
+ raise AlgorithmLoadError(
29
+ f"algorithm class does not exist: {reference}"
30
+ ) from exc
31
+ return _instantiate_algorithm(algorithm_class, reference)
32
+
33
+
34
+ def _load_registered_algorithm(name: str) -> Algorithm:
35
+ if not name:
36
+ raise AlgorithmLoadError("algorithm name cannot be empty")
37
+ candidates = list(
38
+ metadata.entry_points(group=ALGORITHM_ENTRY_POINT_GROUP, name=name)
39
+ )
40
+ if not candidates:
41
+ available = sorted(
42
+ entry.name
43
+ for entry in metadata.entry_points(group=ALGORITHM_ENTRY_POINT_GROUP)
44
+ )
45
+ suffix = f"; available: {', '.join(available)}" if available else ""
46
+ raise AlgorithmLoadError(f"registered algorithm does not exist: {name}{suffix}")
47
+ if len(candidates) != 1:
48
+ raise AlgorithmLoadError(f"registered algorithm name is ambiguous: {name}")
49
+ try:
50
+ algorithm_class = candidates[0].load()
51
+ except Exception as exc:
52
+ raise AlgorithmLoadError(
53
+ f"cannot load registered algorithm {name}: {exc}"
54
+ ) from exc
55
+ return _instantiate_algorithm(algorithm_class, name)
56
+
57
+
58
+ def _instantiate_algorithm(
59
+ algorithm_class: object,
60
+ reference: str,
61
+ ) -> Algorithm:
62
+ if not isinstance(algorithm_class, type) or not issubclass(
63
+ algorithm_class, Algorithm
64
+ ):
65
+ raise AlgorithmLoadError(
66
+ f"algorithm class must inherit Algorithm: {reference}"
67
+ )
68
+ try:
69
+ return algorithm_class()
70
+ except Exception as exc:
71
+ raise AlgorithmLoadError(f"cannot instantiate algorithm {reference}: {exc}") from exc
@@ -0,0 +1,322 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Iterator
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal, Mapping
7
+
8
+ from .errors import InvalidAlgorithmResult, InvalidRequest
9
+
10
+ DatasetStatus = Literal["succeeded", "failed", "skipped"]
11
+ MergeStatus = Literal["not_requested", "succeeded", "failed", "skipped"]
12
+ ResultStatus = Literal["succeeded", "partial", "failed"]
13
+
14
+
15
+ def _json_dict(value: Mapping[str, Any], name: str, error_type: type[ValueError]) -> dict[str, Any]:
16
+ if not isinstance(value, Mapping):
17
+ raise error_type(f"{name} must be a mapping")
18
+ result = dict(value)
19
+ if any(not isinstance(key, str) for key in result):
20
+ raise error_type(f"{name} keys must be strings")
21
+ try:
22
+ json.dumps(result)
23
+ except (TypeError, ValueError) as exc:
24
+ raise error_type(f"{name} must be JSON serializable") from exc
25
+ return result
26
+
27
+
28
+ def _validate_workspace(workspace: dict[str, str]):
29
+ if not isinstance(workspace, Mapping):
30
+ raise InvalidRequest("workspace must be a mapping or None")
31
+ unknown = set(workspace) - {
32
+ "inputRoot",
33
+ "outputRoot",
34
+ "scratchRoot",
35
+ }
36
+ if unknown:
37
+ raise InvalidRequest(
38
+ f"unknown workspace fields: {sorted(unknown)}"
39
+ )
40
+ workspace = {
41
+ name: workspace.get(name)
42
+ for name in ("inputRoot", "outputRoot", "scratchRoot")
43
+ }
44
+ for name, value in workspace.items():
45
+ if not isinstance(value, str) or not value.strip():
46
+ raise InvalidRequest(
47
+ f"workspace.{name} must be a non-empty string"
48
+ )
49
+ return workspace
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class AlgorithmMetadata:
54
+ name: str
55
+ version: str
56
+ description: str = ""
57
+ supports_merge: bool = False
58
+ parameters: dict[str, Any] = field(default_factory=dict)
59
+ input_paths: dict[str, Any] = field(
60
+ default_factory=lambda: {
61
+ "dataset": {
62
+ "type": "directory",
63
+ "required": True,
64
+ "description": "Input dataset directory",
65
+ }
66
+ }
67
+ )
68
+
69
+ def __post_init__(self) -> None:
70
+ if not isinstance(self.name, str) or not self.name.strip():
71
+ raise ValueError("metadata.name must be a non-empty string")
72
+ if not isinstance(self.version, str) or not self.version.strip():
73
+ raise ValueError("metadata.version must be a non-empty string")
74
+ object.__setattr__(
75
+ self,
76
+ "parameters",
77
+ _json_dict(self.parameters, "metadata.parameters", ValueError),
78
+ )
79
+ object.__setattr__(
80
+ self,
81
+ "input_paths",
82
+ _json_dict(self.input_paths, "metadata.input_paths", ValueError),
83
+ )
84
+
85
+ def to_dict(self) -> dict[str, Any]:
86
+ return {
87
+ "name": self.name,
88
+ "version": self.version,
89
+ "description": self.description,
90
+ "supportsMerge": self.supports_merge,
91
+ "parameters": self.parameters,
92
+ "inputPaths": self.input_paths,
93
+ }
94
+
95
+
96
+ @dataclass(frozen=True, slots=True)
97
+ class AlgorithmInput(Mapping[str, str]):
98
+ paths: dict[str, str]
99
+ output: str
100
+
101
+ def __post_init__(self) -> None:
102
+ if not isinstance(self.paths, Mapping):
103
+ raise InvalidRequest("input must be a mapping")
104
+ paths = dict(self.paths)
105
+ if any(not isinstance(name, str) or not name.strip() for name in paths):
106
+ raise InvalidRequest("input path names must be non-empty strings")
107
+ if any(not isinstance(path, str) or not path.strip() for path in paths.values()):
108
+ raise InvalidRequest("input paths must be non-empty strings")
109
+ if "dataset" not in paths:
110
+ raise InvalidRequest("input must contain a dataset path")
111
+ if not isinstance(self.output, str) or not self.output.strip():
112
+ raise InvalidRequest("input output must be a non-empty string")
113
+ object.__setattr__(self, "paths", paths)
114
+
115
+ @property
116
+ def dataset(self) -> str:
117
+ return self.paths["dataset"]
118
+
119
+ def __getitem__(self, name: str) -> str:
120
+ return self.paths[name]
121
+
122
+ def __iter__(self) -> Iterator[str]:
123
+ return iter(self.paths)
124
+
125
+ def __len__(self) -> int:
126
+ return len(self.paths)
127
+
128
+ def to_dict(self) -> dict[str, str]:
129
+ return dict(self.paths)
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class AlgorithmRequest:
134
+ inputs: list[AlgorithmInput]
135
+ merge: bool = False
136
+ gpu_ids: list[int] = field(default_factory=list)
137
+ parameters: dict[str, Any] = field(default_factory=dict)
138
+ workspace: dict[str, str] | None = None
139
+
140
+ def __post_init__(self) -> None:
141
+ inputs = list(self.inputs)
142
+ gpu_ids = list(self.gpu_ids)
143
+ parameters = _json_dict(self.parameters, "parameters", InvalidRequest)
144
+ if not inputs:
145
+ raise InvalidRequest("inputs cannot be empty")
146
+ if any(not isinstance(item, AlgorithmInput) for item in inputs):
147
+ raise InvalidRequest("inputs must contain AlgorithmInput objects")
148
+ datasets = [item.dataset for item in inputs]
149
+ outputs = [item.output for item in inputs]
150
+ if len(datasets) != len(set(datasets)):
151
+ raise InvalidRequest("inputs cannot contain duplicate dataset paths")
152
+ if len(outputs) != len(set(outputs)):
153
+ raise InvalidRequest("inputs cannot contain duplicate output paths")
154
+ if not isinstance(self.merge, bool):
155
+ raise InvalidRequest("merge must be a boolean")
156
+ if any(isinstance(gpu_id, bool) or not isinstance(gpu_id, int) for gpu_id in gpu_ids):
157
+ raise InvalidRequest("gpu_ids must contain integers")
158
+ if any(gpu_id < 0 for gpu_id in gpu_ids):
159
+ raise InvalidRequest("gpu_ids cannot contain negative values")
160
+ if len(gpu_ids) != len(set(gpu_ids)):
161
+ raise InvalidRequest("gpu_ids cannot contain duplicate values")
162
+ workspace = None
163
+ if self.workspace is not None:
164
+ workspace = _validate_workspace(self.workspace)
165
+ object.__setattr__(self, "inputs", inputs)
166
+ object.__setattr__(self, "gpu_ids", gpu_ids)
167
+ object.__setattr__(self, "parameters", parameters)
168
+ object.__setattr__(self, "workspace", workspace)
169
+
170
+ @classmethod
171
+ def from_dict(cls, value: Mapping[str, Any]) -> "AlgorithmRequest":
172
+ if not isinstance(value, Mapping):
173
+ raise InvalidRequest("request body must be a mapping")
174
+ known = {"input", "workspace", "merge", "parameters"}
175
+ unknown = set(value) - known
176
+ if unknown:
177
+ raise InvalidRequest(f"unknown request fields: {sorted(unknown)}")
178
+ raw_inputs = value.get("input", [])
179
+ if not isinstance(raw_inputs, list) or not raw_inputs:
180
+ raise InvalidRequest("input must be a non-empty list")
181
+ parsed_inputs = []
182
+ for index, item in enumerate(raw_inputs):
183
+ if not isinstance(item, Mapping):
184
+ raise InvalidRequest(f"input[{index}] must be a mapping")
185
+ item_unknown = set(item) - {"input_dataset", "output"}
186
+ if item_unknown:
187
+ raise InvalidRequest(
188
+ f"unknown input[{index}] fields: {sorted(item_unknown)}"
189
+ )
190
+ parsed_inputs.append(
191
+ AlgorithmInput(
192
+ {"dataset": item.get("input_dataset")},
193
+ output=item.get("output"),
194
+ )
195
+ )
196
+ return cls(
197
+ inputs=parsed_inputs,
198
+ merge=value.get("merge", False),
199
+ parameters=value.get("parameters", {}),
200
+ workspace=(
201
+ dict(value["workspace"])
202
+ if isinstance(value.get("workspace"), Mapping)
203
+ else value.get("workspace")
204
+ ),
205
+ )
206
+
207
+ def to_dict(self) -> dict[str, Any]:
208
+ payload = {
209
+ "input": [
210
+ {"input_dataset": item.dataset, "output": item.output}
211
+ for item in self.inputs
212
+ ],
213
+ "merge": self.merge,
214
+ "parameters": dict(self.parameters),
215
+ }
216
+ if self.workspace is not None:
217
+ payload["workspace"] = dict(self.workspace)
218
+ return payload
219
+
220
+
221
+ @dataclass(slots=True)
222
+ class DatasetResult:
223
+ dataset: str
224
+ status: DatasetStatus
225
+ artifacts: dict[str, str] = field(default_factory=dict)
226
+ metrics: dict[str, Any] = field(default_factory=dict)
227
+ error: str | None = None
228
+ merge_status: MergeStatus = "not_requested"
229
+ merge_message: str | None = None
230
+
231
+ def validate(self) -> None:
232
+ if not isinstance(self.dataset, str) or not self.dataset.strip():
233
+ raise InvalidAlgorithmResult("result.dataset must be a non-empty string")
234
+ if self.status not in {"succeeded", "failed", "skipped"}:
235
+ raise InvalidAlgorithmResult(f"invalid dataset status: {self.status}")
236
+ if self.merge_status not in {"not_requested", "succeeded", "failed", "skipped"}:
237
+ raise InvalidAlgorithmResult(f"invalid merge status: {self.merge_status}")
238
+ if not isinstance(self.artifacts, dict):
239
+ raise InvalidAlgorithmResult("result.artifacts must be a dict")
240
+ for name, path in self.artifacts.items():
241
+ if not isinstance(name, str) or not name.strip():
242
+ raise InvalidAlgorithmResult("artifact names must be non-empty strings")
243
+ if not isinstance(path, str) or not path.strip():
244
+ raise InvalidAlgorithmResult("artifact paths must be non-empty strings")
245
+ self.metrics = _json_dict(
246
+ self.metrics,
247
+ "result.metrics",
248
+ InvalidAlgorithmResult,
249
+ )
250
+ if self.error is not None and not isinstance(self.error, str):
251
+ raise InvalidAlgorithmResult("result.error must be a string or None")
252
+ if self.merge_message is not None and not isinstance(self.merge_message, str):
253
+ raise InvalidAlgorithmResult("result.merge_message must be a string or None")
254
+
255
+ def to_dict(self) -> dict[str, Any]:
256
+ return {
257
+ "dataset": self.dataset,
258
+ "status": self.status,
259
+ "artifacts": dict(self.artifacts),
260
+ "metrics": dict(self.metrics),
261
+ "error": self.error,
262
+ "mergeStatus": self.merge_status,
263
+ "mergeMessage": self.merge_message,
264
+ }
265
+
266
+
267
+ @dataclass(slots=True)
268
+ class AlgorithmResult:
269
+ datasets: list[DatasetResult]
270
+ metrics: dict[str, Any] = field(default_factory=dict)
271
+ warnings: list[str] = field(default_factory=list)
272
+
273
+ @property
274
+ def status(self) -> ResultStatus:
275
+ successful = sum(
276
+ result.status == "succeeded" and result.merge_status != "failed"
277
+ for result in self.datasets
278
+ )
279
+ if successful == len(self.datasets):
280
+ return "succeeded"
281
+ if successful == 0:
282
+ return "failed"
283
+ return "partial"
284
+
285
+ @classmethod
286
+ def from_datasets(cls, datasets: list[DatasetResult]) -> "AlgorithmResult":
287
+ return cls(datasets=list(datasets))
288
+
289
+ def validate(self) -> None:
290
+ if not isinstance(self.datasets, list):
291
+ raise InvalidAlgorithmResult("result.datasets must be a list")
292
+ for result in self.datasets:
293
+ if not isinstance(result, DatasetResult):
294
+ raise InvalidAlgorithmResult(
295
+ "result.datasets must contain DatasetResult objects"
296
+ )
297
+ result.validate()
298
+ self.metrics = _json_dict(
299
+ self.metrics,
300
+ "result.metrics",
301
+ InvalidAlgorithmResult,
302
+ )
303
+ if not isinstance(self.warnings, list) or any(
304
+ not isinstance(warning, str) for warning in self.warnings
305
+ ):
306
+ raise InvalidAlgorithmResult("result.warnings must be a list of strings")
307
+
308
+ def to_dict(self) -> dict[str, Any]:
309
+ successful = sum(
310
+ result.status == "succeeded" and result.merge_status != "failed"
311
+ for result in self.datasets
312
+ )
313
+ return {
314
+ "status": self.status,
315
+ "datasets": [result.to_dict() for result in self.datasets],
316
+ "metrics": {
317
+ "totalDatasets": len(self.datasets),
318
+ "successfulDatasets": successful,
319
+ **self.metrics,
320
+ },
321
+ "warnings": list(self.warnings),
322
+ }