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,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
|
+
}
|