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,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."""