ls-algorithm-plugin-sdk 0.3.1__py3-none-any.whl → 0.3.4__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/cli_impl/configure.py +44 -5
- algorithm_plugin_sdk/cli_impl/run.py +5 -0
- algorithm_plugin_sdk/cli_impl/serve.py +22 -2
- algorithm_plugin_sdk/deployment.py +19 -0
- algorithm_plugin_sdk/gpu_isolation.py +27 -0
- algorithm_plugin_sdk/log_manager.py +125 -0
- algorithm_plugin_sdk/service.py +158 -8
- {ls_algorithm_plugin_sdk-0.3.1.dist-info → ls_algorithm_plugin_sdk-0.3.4.dist-info}/METADATA +1 -1
- {ls_algorithm_plugin_sdk-0.3.1.dist-info → ls_algorithm_plugin_sdk-0.3.4.dist-info}/RECORD +12 -10
- {ls_algorithm_plugin_sdk-0.3.1.dist-info → ls_algorithm_plugin_sdk-0.3.4.dist-info}/WHEEL +0 -0
- {ls_algorithm_plugin_sdk-0.3.1.dist-info → ls_algorithm_plugin_sdk-0.3.4.dist-info}/entry_points.txt +0 -0
- {ls_algorithm_plugin_sdk-0.3.1.dist-info → ls_algorithm_plugin_sdk-0.3.4.dist-info}/top_level.txt +0 -0
|
@@ -16,7 +16,7 @@ from ..deployment import (
|
|
|
16
16
|
repository_root,
|
|
17
17
|
stable_instance_key,
|
|
18
18
|
)
|
|
19
|
-
from .parsing import environment, gpu_ids
|
|
19
|
+
from .parsing import environment, gpu_ids, json_object
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
SYSTEMD_UNIT_DIR = Path("/etc/systemd/system")
|
|
@@ -56,14 +56,34 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
|
|
|
56
56
|
action=argparse.BooleanOptionalAction,
|
|
57
57
|
default=True,
|
|
58
58
|
)
|
|
59
|
-
parser.add_argument(
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--max-concurrency",
|
|
61
|
+
type=int,
|
|
62
|
+
help="maximum concurrent executions (default: GPU count, or 1)",
|
|
63
|
+
)
|
|
60
64
|
parser.add_argument("--scratch-dir")
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--local-scratch",
|
|
67
|
+
action="store_true",
|
|
68
|
+
help="override request scratchRoot with <repository>/.scratch",
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--local-output",
|
|
72
|
+
action="store_true",
|
|
73
|
+
help="override request output paths with <repository>/.output/<job-id>",
|
|
74
|
+
)
|
|
61
75
|
parser.add_argument(
|
|
62
76
|
"--gpu-ids",
|
|
63
77
|
type=gpu_ids,
|
|
64
78
|
default=[],
|
|
65
79
|
help="GPU IDs injected into every service execution",
|
|
66
80
|
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"--default-parameters",
|
|
83
|
+
type=json_object,
|
|
84
|
+
default={},
|
|
85
|
+
help="JSON object used to fill missing request parameters",
|
|
86
|
+
)
|
|
67
87
|
parser.add_argument("--service-token")
|
|
68
88
|
parser.add_argument(
|
|
69
89
|
"--compute-url",
|
|
@@ -139,10 +159,18 @@ def render_systemd_unit(
|
|
|
139
159
|
repository: Path,
|
|
140
160
|
config_path: Path,
|
|
141
161
|
service_name: str,
|
|
162
|
+
gpu_ids: list[int] | None = None,
|
|
142
163
|
) -> str:
|
|
143
164
|
root = repository.resolve()
|
|
165
|
+
working_directory = root / "jobs"
|
|
144
166
|
launcher = root / ".venv" / "bin" / "algorithm-plugin"
|
|
145
167
|
config = config_path.resolve()
|
|
168
|
+
environment = ["Environment=PYTHONUNBUFFERED=1"]
|
|
169
|
+
if gpu_ids:
|
|
170
|
+
environment.append(
|
|
171
|
+
"Environment=CUDA_VISIBLE_DEVICES="
|
|
172
|
+
+ ",".join(str(gpu_id) for gpu_id in gpu_ids)
|
|
173
|
+
)
|
|
146
174
|
return "\n".join(
|
|
147
175
|
(
|
|
148
176
|
"[Unit]",
|
|
@@ -153,12 +181,13 @@ def render_systemd_unit(
|
|
|
153
181
|
"[Service]",
|
|
154
182
|
"Type=simple",
|
|
155
183
|
"User=root",
|
|
156
|
-
f"WorkingDirectory={_escape_systemd_path(
|
|
184
|
+
f"WorkingDirectory={_escape_systemd_path(working_directory)}",
|
|
157
185
|
(
|
|
158
186
|
f"ExecStart={_quote_systemd_path(launcher)} "
|
|
159
187
|
f"serve --config {_quote_systemd_path(config)}"
|
|
160
188
|
),
|
|
161
|
-
|
|
189
|
+
*environment,
|
|
190
|
+
"LimitNOFILE=524288",
|
|
162
191
|
"Restart=on-failure",
|
|
163
192
|
"RestartSec=5s",
|
|
164
193
|
"",
|
|
@@ -376,6 +405,11 @@ def generate_config(
|
|
|
376
405
|
args: argparse.Namespace,
|
|
377
406
|
repository: Path,
|
|
378
407
|
) -> DeploymentConfig:
|
|
408
|
+
max_concurrency = (
|
|
409
|
+
args.max_concurrency
|
|
410
|
+
if args.max_concurrency is not None
|
|
411
|
+
else max(len(args.gpu_ids), 1)
|
|
412
|
+
)
|
|
379
413
|
config = DeploymentConfig.generate(
|
|
380
414
|
output=args.config_output,
|
|
381
415
|
root=repository,
|
|
@@ -383,12 +417,15 @@ def generate_config(
|
|
|
383
417
|
host=args.host,
|
|
384
418
|
port=args.port,
|
|
385
419
|
webui=args.webui,
|
|
386
|
-
max_concurrency=
|
|
420
|
+
max_concurrency=max_concurrency,
|
|
387
421
|
scratch_dir=args.scratch_dir,
|
|
422
|
+
local_scratch=args.local_scratch,
|
|
423
|
+
local_output=args.local_output,
|
|
388
424
|
token=args.service_token,
|
|
389
425
|
registration=registration_from_args(args, repository),
|
|
390
426
|
gpu_ids=args.gpu_ids,
|
|
391
427
|
environment=dict(args.env),
|
|
428
|
+
default_parameters=args.default_parameters,
|
|
392
429
|
)
|
|
393
430
|
config.save()
|
|
394
431
|
return config
|
|
@@ -407,6 +444,7 @@ def execute(args: argparse.Namespace) -> int:
|
|
|
407
444
|
repository=repository,
|
|
408
445
|
config_path=config_path,
|
|
409
446
|
service_name=service_name,
|
|
447
|
+
gpu_ids=args.gpu_ids,
|
|
410
448
|
)
|
|
411
449
|
|
|
412
450
|
inspect_systemd_unit(
|
|
@@ -414,6 +452,7 @@ def execute(args: argparse.Namespace) -> int:
|
|
|
414
452
|
content=unit,
|
|
415
453
|
force=args.force,
|
|
416
454
|
)
|
|
455
|
+
(repository / "jobs").mkdir(exist_ok=True)
|
|
417
456
|
config = generate_config(args, repository)
|
|
418
457
|
unit_path, changed = write_systemd_unit(
|
|
419
458
|
service_name=service_name,
|
|
@@ -17,6 +17,7 @@ from typing import Any, Iterator
|
|
|
17
17
|
|
|
18
18
|
from ..context import ExecutionContext, ProgressSnapshot
|
|
19
19
|
from ..errors import ExecutionCancelled
|
|
20
|
+
from ..gpu_isolation import apply_gpu_isolation
|
|
20
21
|
from ..loader import load_algorithm
|
|
21
22
|
from ..models import AlgorithmRequest
|
|
22
23
|
from ..runner import AlgorithmRunner
|
|
@@ -125,6 +126,10 @@ def cancel_on_interrupt(
|
|
|
125
126
|
|
|
126
127
|
def execute(args: argparse.Namespace) -> int:
|
|
127
128
|
request = request_from_args(args)
|
|
129
|
+
request = replace(
|
|
130
|
+
request,
|
|
131
|
+
gpu_ids=apply_gpu_isolation(request.gpu_ids),
|
|
132
|
+
)
|
|
128
133
|
progress = TerminalProgress()
|
|
129
134
|
context = ExecutionContext(
|
|
130
135
|
f"cli-{id(args)}",
|
|
@@ -5,6 +5,7 @@ import os
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from ..deployment import DEFAULT_CONFIG_NAME, DeploymentConfig
|
|
8
|
+
from ..gpu_isolation import apply_gpu_isolation
|
|
8
9
|
from ..loader import load_algorithm
|
|
9
10
|
from ..registration import PluginRegistrationAgent
|
|
10
11
|
from ..release import ReleaseManifest
|
|
@@ -78,15 +79,22 @@ def configure_parser(parser: argparse.ArgumentParser) -> None:
|
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
def execute(args: argparse.Namespace) -> int:
|
|
82
|
+
config: DeploymentConfig | None = None
|
|
83
|
+
service: dict[str, object] | None = None
|
|
84
|
+
local_scratch = False
|
|
85
|
+
local_output = False
|
|
81
86
|
if args.config:
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
# Apply the process mask before refreshing/loading the algorithm. A
|
|
88
|
+
# repository module may import CUDA during module import.
|
|
89
|
+
config = DeploymentConfig.load(args.config, refresh=False)
|
|
84
90
|
service = config.payload["service"]
|
|
85
91
|
args.algorithm = config.algorithm_reference
|
|
86
92
|
args.host = str(service["host"])
|
|
87
93
|
args.port = int(service["port"])
|
|
88
94
|
args.max_concurrent_executions = int(service["maxConcurrency"])
|
|
89
95
|
args.scratch_dir = service.get("scratchDir")
|
|
96
|
+
local_scratch = bool(service.get("localScratch", False))
|
|
97
|
+
local_output = bool(service.get("localOutput", False))
|
|
90
98
|
args.gpu_ids = list(service.get("gpuIds", []))
|
|
91
99
|
args.webui = bool(service.get("webui"))
|
|
92
100
|
values = config.process_environment()
|
|
@@ -96,6 +104,11 @@ def execute(args: argparse.Namespace) -> int:
|
|
|
96
104
|
elif not args.algorithm:
|
|
97
105
|
raise ValueError("algorithm is required unless --config is provided")
|
|
98
106
|
|
|
107
|
+
args.gpu_ids = apply_gpu_isolation(args.gpu_ids)
|
|
108
|
+
if config is not None:
|
|
109
|
+
config.refresh()
|
|
110
|
+
config.save()
|
|
111
|
+
|
|
99
112
|
algorithm_reference = str(args.algorithm)
|
|
100
113
|
algorithm = load_algorithm(algorithm_reference)
|
|
101
114
|
|
|
@@ -105,14 +118,21 @@ def execute(args: argparse.Namespace) -> int:
|
|
|
105
118
|
release = ReleaseManifest.discover(algorithm, start=Path.cwd(), required=False)
|
|
106
119
|
manager = ExecutionManager(
|
|
107
120
|
AlgorithmRunner(algorithm),
|
|
121
|
+
default_parameters=(
|
|
122
|
+
service.get("defaultParameters", {})
|
|
123
|
+
if service is not None else None
|
|
124
|
+
),
|
|
108
125
|
max_concurrent_executions=args.max_concurrent_executions,
|
|
109
126
|
scratch_dir=args.scratch_dir,
|
|
127
|
+
local_scratch=local_scratch,
|
|
128
|
+
local_output=local_output,
|
|
110
129
|
gpu_ids=args.gpu_ids,
|
|
111
130
|
runner_factory=create_runner,
|
|
112
131
|
max_attempts=args.max_attempts,
|
|
113
132
|
retry_seconds=args.retry_seconds,
|
|
114
133
|
retry_jitter_ratio=args.retry_jitter_ratio,
|
|
115
134
|
release_manifest=release,
|
|
135
|
+
repository=(config.repository if config is not None else Path.cwd()),
|
|
116
136
|
)
|
|
117
137
|
registration = PluginRegistrationAgent.from_env(manager.manifest, manager.heartbeat)
|
|
118
138
|
if registration is not None and release is None:
|
|
@@ -145,6 +145,9 @@ class DeploymentConfig:
|
|
|
145
145
|
registration: dict[str, str] | None,
|
|
146
146
|
gpu_ids: list[int] | None = None,
|
|
147
147
|
environment: dict[str, str] | None = None,
|
|
148
|
+
default_parameters: dict[str, Any] | None = None,
|
|
149
|
+
local_scratch: bool = False,
|
|
150
|
+
local_output: bool = False,
|
|
148
151
|
) -> "DeploymentConfig":
|
|
149
152
|
repository = repository_root(root)
|
|
150
153
|
_make_repository_importable(repository)
|
|
@@ -188,8 +191,11 @@ class DeploymentConfig:
|
|
|
188
191
|
"webui": webui,
|
|
189
192
|
"maxConcurrency": max_concurrency,
|
|
190
193
|
"scratchDir": _absolute(scratch_dir, repository),
|
|
194
|
+
"localScratch": local_scratch,
|
|
195
|
+
"localOutput": local_output,
|
|
191
196
|
"gpuIds": configured_gpu_ids,
|
|
192
197
|
"token": token or None,
|
|
198
|
+
"defaultParameters": dict(default_parameters or {}),
|
|
193
199
|
},
|
|
194
200
|
"registration": registration,
|
|
195
201
|
"environment": dict(sorted((environment or {}).items())),
|
|
@@ -266,6 +272,9 @@ class DeploymentConfig:
|
|
|
266
272
|
raise RuntimeError("service port must be in [1, 65535]")
|
|
267
273
|
if concurrency < 1:
|
|
268
274
|
raise RuntimeError("service maxConcurrency must be positive")
|
|
275
|
+
for name in ("localScratch", "localOutput"):
|
|
276
|
+
if not isinstance(service.get(name, False), bool):
|
|
277
|
+
raise RuntimeError(f"service {name} must be a boolean")
|
|
269
278
|
gpu_ids = service.get("gpuIds")
|
|
270
279
|
if not isinstance(gpu_ids, list) or any(
|
|
271
280
|
isinstance(gpu_id, bool) or not isinstance(gpu_id, int) or gpu_id < 0
|
|
@@ -273,6 +282,16 @@ class DeploymentConfig:
|
|
|
273
282
|
) or len(gpu_ids) != len(set(gpu_ids)):
|
|
274
283
|
raise RuntimeError("service gpuIds must be unique non-negative integers")
|
|
275
284
|
|
|
285
|
+
default_parameters = service.get("defaultParameters", {})
|
|
286
|
+
if not isinstance(default_parameters, dict):
|
|
287
|
+
raise RuntimeError("defaultParameters must be an object")
|
|
288
|
+
if any(not isinstance(key, str) for key in default_parameters):
|
|
289
|
+
raise RuntimeError("defaultParameters keys must be strings")
|
|
290
|
+
try:
|
|
291
|
+
json.dumps(default_parameters)
|
|
292
|
+
except (TypeError, ValueError) as exc:
|
|
293
|
+
raise RuntimeError("defaultParameters must be JSON serializable") from exc
|
|
294
|
+
|
|
276
295
|
def _validate_registration(self) -> None:
|
|
277
296
|
registration = self.payload.get("registration")
|
|
278
297
|
if registration is None:
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def apply_gpu_isolation(gpu_ids: Sequence[int] | None) -> list[int]:
|
|
8
|
+
"""Mask physical GPUs for this process and return local GPU ordinals."""
|
|
9
|
+
if gpu_ids is None:
|
|
10
|
+
return []
|
|
11
|
+
|
|
12
|
+
physical_gpu_ids = list(gpu_ids)
|
|
13
|
+
if any(
|
|
14
|
+
isinstance(gpu_id, bool)
|
|
15
|
+
or not isinstance(gpu_id, int)
|
|
16
|
+
or gpu_id < 0
|
|
17
|
+
for gpu_id in physical_gpu_ids
|
|
18
|
+
) or len(physical_gpu_ids) != len(set(physical_gpu_ids)):
|
|
19
|
+
raise ValueError("gpu_ids must be unique non-negative integers")
|
|
20
|
+
|
|
21
|
+
if not physical_gpu_ids:
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(
|
|
25
|
+
str(gpu_id) for gpu_id in physical_gpu_ids
|
|
26
|
+
)
|
|
27
|
+
return list(range(len(physical_gpu_ids)))
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .context import utc_now
|
|
11
|
+
|
|
12
|
+
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
|
|
13
|
+
_TIMESTAMP_GLOB = "[0-9]" * 14
|
|
14
|
+
_FILE_LOCKS_GUARD = threading.Lock()
|
|
15
|
+
_FILE_LOCKS: dict[Path, threading.RLock] = {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _safe_name(value: str) -> str:
|
|
19
|
+
cleaned = _SAFE_NAME.sub("_", str(value).strip()).strip("._")
|
|
20
|
+
return cleaned or "job"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _file_lock(path: Path) -> threading.RLock:
|
|
24
|
+
with _FILE_LOCKS_GUARD:
|
|
25
|
+
return _FILE_LOCKS.setdefault(path, threading.RLock())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _timestamp_prefix() -> str:
|
|
29
|
+
return utc_now().replace("-", "").replace(":", "").replace("T", "")[:14]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _job_log_path(directory: Path, job_id: str) -> Path:
|
|
33
|
+
safe_job_id = _safe_name(job_id)
|
|
34
|
+
pattern = f"{_TIMESTAMP_GLOB}-{safe_job_id}.log"
|
|
35
|
+
with _FILE_LOCKS_GUARD:
|
|
36
|
+
existing = sorted(directory.glob(pattern))
|
|
37
|
+
if existing:
|
|
38
|
+
return existing[0]
|
|
39
|
+
timestamp = _timestamp_prefix()
|
|
40
|
+
path = directory / f"{timestamp}-{safe_job_id}.log"
|
|
41
|
+
path.touch(exist_ok=True)
|
|
42
|
+
return path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ExecutionLog:
|
|
46
|
+
"""Per-job, line-buffered log sink attached to context.logger."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
log_dir: str | Path,
|
|
52
|
+
job_id: str,
|
|
53
|
+
execution_id: str,
|
|
54
|
+
logger: logging.Logger,
|
|
55
|
+
task_id: str | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
resolved_task_id = task_id or execution_id
|
|
58
|
+
self._event_context = {
|
|
59
|
+
"jobId": job_id,
|
|
60
|
+
"taskId": resolved_task_id,
|
|
61
|
+
"executionId": execution_id,
|
|
62
|
+
"job_id": job_id,
|
|
63
|
+
"task_id": resolved_task_id,
|
|
64
|
+
"execution_id": execution_id,
|
|
65
|
+
}
|
|
66
|
+
directory = Path(log_dir).expanduser()
|
|
67
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
path = _job_log_path(directory, job_id)
|
|
69
|
+
self.path = path
|
|
70
|
+
self._stream = path.open("a", encoding="utf-8", buffering=1)
|
|
71
|
+
self._lock = _file_lock(path)
|
|
72
|
+
self._handler = _FlushFileHandler(self._stream, self._lock)
|
|
73
|
+
self._handler.setFormatter(
|
|
74
|
+
logging.Formatter(
|
|
75
|
+
f"%(asctime)s %(levelname)s [executionId={execution_id}] %(message)s"
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
self._handler.setLevel(logging.DEBUG)
|
|
79
|
+
self.logger = logger
|
|
80
|
+
self.logger.setLevel(logging.DEBUG)
|
|
81
|
+
self.logger.addHandler(self._handler)
|
|
82
|
+
self._closed = False
|
|
83
|
+
|
|
84
|
+
def write_event(self, event: str, **values: Any) -> None:
|
|
85
|
+
payload = {
|
|
86
|
+
"event": event,
|
|
87
|
+
"timestamp": utc_now(),
|
|
88
|
+
**self._event_context,
|
|
89
|
+
**values,
|
|
90
|
+
}
|
|
91
|
+
with self._lock:
|
|
92
|
+
if self._closed:
|
|
93
|
+
return
|
|
94
|
+
self._stream.write(json.dumps(payload, ensure_ascii=False, default=str) + "\n")
|
|
95
|
+
self._stream.flush()
|
|
96
|
+
|
|
97
|
+
def close(self) -> None:
|
|
98
|
+
with self._lock:
|
|
99
|
+
if self._closed:
|
|
100
|
+
return
|
|
101
|
+
self._closed = True
|
|
102
|
+
self.logger.removeHandler(self._handler)
|
|
103
|
+
self._handler.close()
|
|
104
|
+
self._stream.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _FlushFileHandler(logging.Handler):
|
|
108
|
+
def __init__(self, stream: Any, lock: threading.RLock) -> None:
|
|
109
|
+
super().__init__()
|
|
110
|
+
self.stream = stream
|
|
111
|
+
self._lock = lock
|
|
112
|
+
|
|
113
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
114
|
+
try:
|
|
115
|
+
with self._lock:
|
|
116
|
+
self.stream.write(self.format(record) + "\n")
|
|
117
|
+
self.stream.flush()
|
|
118
|
+
except Exception:
|
|
119
|
+
self.handleError(record)
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
try:
|
|
123
|
+
self.stream.flush()
|
|
124
|
+
finally:
|
|
125
|
+
super().close()
|
algorithm_plugin_sdk/service.py
CHANGED
|
@@ -6,16 +6,19 @@ import logging
|
|
|
6
6
|
import random
|
|
7
7
|
import threading
|
|
8
8
|
import time
|
|
9
|
+
import traceback
|
|
9
10
|
import uuid
|
|
10
11
|
from concurrent.futures import Future, ThreadPoolExecutor
|
|
11
12
|
from contextlib import asynccontextmanager
|
|
12
13
|
from dataclasses import dataclass, field, replace
|
|
14
|
+
from pathlib import Path
|
|
13
15
|
from typing import Any, Callable
|
|
14
16
|
|
|
15
17
|
from .context import ExecutionContext, ProgressSnapshot, utc_now
|
|
16
18
|
from .errors import (ExecutionCancelled, ExecutionNotFinished,
|
|
17
19
|
ExecutionNotFound, IdempotencyConflict)
|
|
18
20
|
from .models import AlgorithmRequest, AlgorithmResult
|
|
21
|
+
from .log_manager import ExecutionLog
|
|
19
22
|
from .release import ReleaseManifest
|
|
20
23
|
from .runner import AlgorithmRunner
|
|
21
24
|
from .webui_app import announce_webui, mount_webui
|
|
@@ -34,6 +37,8 @@ logger = logging.getLogger(__name__)
|
|
|
34
37
|
class ExecutionRecord:
|
|
35
38
|
execution_id: str
|
|
36
39
|
request: AlgorithmRequest
|
|
40
|
+
job_id: str
|
|
41
|
+
task_id: str
|
|
37
42
|
accepted_at: str
|
|
38
43
|
state: str = "accepted"
|
|
39
44
|
started_at: str | None = None
|
|
@@ -41,6 +46,7 @@ class ExecutionRecord:
|
|
|
41
46
|
result: AlgorithmResult | None = None
|
|
42
47
|
error: str | None = None
|
|
43
48
|
context: ExecutionContext | None = None
|
|
49
|
+
log: ExecutionLog | None = None
|
|
44
50
|
future: Future[None] | None = None
|
|
45
51
|
attempt: int = 0
|
|
46
52
|
max_attempts: int = 1
|
|
@@ -113,8 +119,13 @@ class ExecutionManager:
|
|
|
113
119
|
self,
|
|
114
120
|
runner: AlgorithmRunner,
|
|
115
121
|
*,
|
|
122
|
+
default_parameters: dict[str, Any] | None = None,
|
|
116
123
|
max_concurrent_executions: int = 1,
|
|
117
124
|
scratch_dir: str | None = None,
|
|
125
|
+
repository: str | Path | None = None,
|
|
126
|
+
log_dir: str | Path | None = None,
|
|
127
|
+
local_scratch: bool = False,
|
|
128
|
+
local_output: bool = False,
|
|
118
129
|
gpu_ids: list[int] | None = None,
|
|
119
130
|
runner_factory: RunnerFactory | None = None,
|
|
120
131
|
max_attempts: int = MAX_ATTEMPTS,
|
|
@@ -130,6 +141,17 @@ class ExecutionManager:
|
|
|
130
141
|
raise ValueError("retry_seconds must be positive")
|
|
131
142
|
if not 0 <= retry_jitter_ratio <= 1:
|
|
132
143
|
raise ValueError("retry_jitter_ratio must be in [0, 1]")
|
|
144
|
+
if not isinstance(local_scratch, bool) or not isinstance(
|
|
145
|
+
local_output, bool
|
|
146
|
+
):
|
|
147
|
+
raise ValueError("local_scratch and local_output must be booleans")
|
|
148
|
+
repository_path = (
|
|
149
|
+
Path(repository).expanduser().resolve()
|
|
150
|
+
if repository is not None
|
|
151
|
+
else None
|
|
152
|
+
)
|
|
153
|
+
if (local_scratch or local_output) and repository_path is None:
|
|
154
|
+
raise ValueError("repository is required for local scratch or output")
|
|
133
155
|
configured_gpu_ids = None if gpu_ids is None else list(gpu_ids)
|
|
134
156
|
if configured_gpu_ids is not None and (
|
|
135
157
|
any(
|
|
@@ -144,7 +166,24 @@ class ExecutionManager:
|
|
|
144
166
|
self._runner_factory = runner_factory or (
|
|
145
167
|
lambda: AlgorithmRunner(algorithm_class())
|
|
146
168
|
)
|
|
169
|
+
if default_parameters is not None and not isinstance(default_parameters, dict):
|
|
170
|
+
raise ValueError("default_parameters must be a JSON object")
|
|
171
|
+
self.default_parameters = dict(default_parameters or {})
|
|
147
172
|
self.scratch_dir = scratch_dir
|
|
173
|
+
self._local_scratch_root = (
|
|
174
|
+
repository_path / ".scratch" if local_scratch else None
|
|
175
|
+
)
|
|
176
|
+
self._local_output_root = (
|
|
177
|
+
repository_path / ".output" if local_output else None
|
|
178
|
+
)
|
|
179
|
+
for local_root in (self._local_scratch_root, self._local_output_root):
|
|
180
|
+
if local_root is not None:
|
|
181
|
+
local_root.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
self.log_dir = (
|
|
183
|
+
Path(log_dir).expanduser()
|
|
184
|
+
if log_dir is not None
|
|
185
|
+
else (repository_path / "logs" if repository_path is not None else None)
|
|
186
|
+
)
|
|
148
187
|
self.gpu_ids = configured_gpu_ids
|
|
149
188
|
self.max_concurrent_executions = max_concurrent_executions
|
|
150
189
|
self.max_attempts = max_attempts
|
|
@@ -175,14 +214,61 @@ class ExecutionManager:
|
|
|
175
214
|
self.runner.start()
|
|
176
215
|
self._started = True
|
|
177
216
|
|
|
217
|
+
def _apply_local_paths(
|
|
218
|
+
self,
|
|
219
|
+
request: AlgorithmRequest,
|
|
220
|
+
job_id: str,
|
|
221
|
+
) -> AlgorithmRequest:
|
|
222
|
+
workspace = dict(request.workspace or {})
|
|
223
|
+
inputs = request.inputs
|
|
224
|
+
if self._local_scratch_root is not None:
|
|
225
|
+
workspace["scratchRoot"] = str(self._local_scratch_root)
|
|
226
|
+
if self._local_output_root is not None:
|
|
227
|
+
workspace["outputRoot"] = str(self._local_output_root)
|
|
228
|
+
job_output = self._job_output_dir(job_id)
|
|
229
|
+
if len(inputs) == 1:
|
|
230
|
+
output_paths = [job_output]
|
|
231
|
+
else:
|
|
232
|
+
output_paths = [
|
|
233
|
+
job_output / f"{index}-{Path(item.output).name or 'output'}"
|
|
234
|
+
for index, item in enumerate(inputs)
|
|
235
|
+
]
|
|
236
|
+
for output_path in output_paths:
|
|
237
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
inputs = [
|
|
239
|
+
replace(item, output=str(output_path))
|
|
240
|
+
for item, output_path in zip(inputs, output_paths)
|
|
241
|
+
]
|
|
242
|
+
return replace(request, inputs=inputs, workspace=workspace or None)
|
|
243
|
+
|
|
244
|
+
def _job_output_dir(self, job_id: str) -> Path:
|
|
245
|
+
assert self._local_output_root is not None
|
|
246
|
+
if Path(job_id).name != job_id or job_id in {"", ".", ".."}:
|
|
247
|
+
raise ValueError(f"job ID is unsafe for local output: {job_id!r}")
|
|
248
|
+
output = (self._local_output_root / job_id).resolve()
|
|
249
|
+
if (
|
|
250
|
+
output == self._local_output_root
|
|
251
|
+
or self._local_output_root not in output.parents
|
|
252
|
+
):
|
|
253
|
+
raise ValueError(f"job output escapes local root: {job_id!r}")
|
|
254
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
return output
|
|
256
|
+
|
|
178
257
|
def submit(
|
|
179
258
|
self,
|
|
180
259
|
request: AlgorithmRequest,
|
|
181
260
|
*,
|
|
182
261
|
idempotency_key: str | None = None,
|
|
262
|
+
job_id: str | None = None,
|
|
263
|
+
task_id: str | None = None,
|
|
183
264
|
) -> dict[str, Any]:
|
|
184
265
|
if self.gpu_ids is not None:
|
|
185
266
|
request = replace(request, gpu_ids=self.gpu_ids)
|
|
267
|
+
if self.default_parameters:
|
|
268
|
+
request = replace(
|
|
269
|
+
request,
|
|
270
|
+
parameters={**self.default_parameters, **request.parameters},
|
|
271
|
+
)
|
|
186
272
|
self.start()
|
|
187
273
|
canonical_request = json.dumps(
|
|
188
274
|
request.to_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
|
@@ -201,22 +287,51 @@ class ExecutionManager:
|
|
|
201
287
|
"acceptedAt": record.accepted_at,
|
|
202
288
|
}
|
|
203
289
|
execution_id = f"exec-{uuid.uuid4().hex}"
|
|
290
|
+
resolved_job_id = str(job_id or execution_id)
|
|
291
|
+
resolved_task_id = str(task_id or execution_id)
|
|
292
|
+
request = self._apply_local_paths(request, resolved_job_id)
|
|
204
293
|
record = ExecutionRecord(
|
|
205
294
|
execution_id=execution_id,
|
|
206
295
|
request=request,
|
|
296
|
+
job_id=resolved_job_id,
|
|
297
|
+
task_id=resolved_task_id,
|
|
207
298
|
accepted_at=utc_now(),
|
|
208
299
|
max_attempts=self.max_attempts,
|
|
209
300
|
)
|
|
210
301
|
record.context = ExecutionContext(
|
|
211
302
|
execution_id,
|
|
212
303
|
[item.input_dataset for item in request.inputs],
|
|
213
|
-
scratch_dir=(
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
else self.scratch_dir
|
|
304
|
+
scratch_dir=(record.request.workspace or {}).get(
|
|
305
|
+
"scratchRoot",
|
|
306
|
+
self.scratch_dir,
|
|
217
307
|
),
|
|
218
308
|
progress_callback=record.add_progress,
|
|
219
309
|
)
|
|
310
|
+
if self.log_dir is not None:
|
|
311
|
+
record.log = ExecutionLog(
|
|
312
|
+
log_dir=self.log_dir,
|
|
313
|
+
job_id=record.job_id,
|
|
314
|
+
task_id=record.task_id,
|
|
315
|
+
execution_id=record.execution_id,
|
|
316
|
+
logger=record.context.logger,
|
|
317
|
+
)
|
|
318
|
+
workspace = record.request.workspace or {}
|
|
319
|
+
record.log.write_event(
|
|
320
|
+
"request",
|
|
321
|
+
jobId=record.job_id,
|
|
322
|
+
taskId=record.task_id,
|
|
323
|
+
job_id=record.job_id,
|
|
324
|
+
task_id=record.task_id,
|
|
325
|
+
executionId=record.execution_id,
|
|
326
|
+
execution_id=record.execution_id,
|
|
327
|
+
inputs=[item.to_dict() for item in record.request.inputs],
|
|
328
|
+
inputRoot=workspace.get("inputRoot"),
|
|
329
|
+
outputRoot=workspace.get("outputRoot"),
|
|
330
|
+
scratchRoot=workspace.get("scratchRoot") or self.scratch_dir,
|
|
331
|
+
merge=record.request.merge,
|
|
332
|
+
parameters=record.request.parameters,
|
|
333
|
+
gpuIds=record.request.gpu_ids,
|
|
334
|
+
)
|
|
220
335
|
with self._lock:
|
|
221
336
|
if self._closed:
|
|
222
337
|
raise RuntimeError("execution manager is closed")
|
|
@@ -328,6 +443,9 @@ class ExecutionManager:
|
|
|
328
443
|
cancelled_before_start = bool(record.future and record.future.cancel())
|
|
329
444
|
if cancelled_before_start:
|
|
330
445
|
record.set_state("cancelled", error=reason)
|
|
446
|
+
if record.log is not None:
|
|
447
|
+
record.log.write_event("result", state="cancelled", error=reason)
|
|
448
|
+
record.log.close()
|
|
331
449
|
return {
|
|
332
450
|
"executionId": execution_id,
|
|
333
451
|
"state": record.state,
|
|
@@ -349,6 +467,9 @@ class ExecutionManager:
|
|
|
349
467
|
record.context.cancel("service shutdown")
|
|
350
468
|
if record.future and record.future.cancel():
|
|
351
469
|
record.set_state("cancelled", error="service shutdown")
|
|
470
|
+
if record.log is not None:
|
|
471
|
+
record.log.write_event("result", state="cancelled", error="service shutdown")
|
|
472
|
+
record.log.close()
|
|
352
473
|
self._executor.shutdown(wait=True, cancel_futures=True)
|
|
353
474
|
self.runner.close()
|
|
354
475
|
|
|
@@ -362,17 +483,34 @@ class ExecutionManager:
|
|
|
362
483
|
def _run(self, record: ExecutionRecord) -> None:
|
|
363
484
|
record.set_state("running")
|
|
364
485
|
assert record.context is not None
|
|
486
|
+
if record.log is not None:
|
|
487
|
+
record.log.write_event("started", jobId=record.job_id, taskId=record.task_id)
|
|
365
488
|
try:
|
|
366
489
|
result = self._run_with_restarts(record)
|
|
367
490
|
except ExecutionCancelled as exc:
|
|
368
491
|
record.context.mark_unfinished("cancelled", str(exc))
|
|
369
492
|
record.set_state("cancelled", error=str(exc))
|
|
493
|
+
if record.log is not None:
|
|
494
|
+
record.log.write_event("result", state="cancelled", error=str(exc))
|
|
370
495
|
except Exception as exc:
|
|
371
496
|
error = f"{type(exc).__name__}: {exc}"
|
|
497
|
+
stack_trace = traceback.format_exc()
|
|
372
498
|
record.context.mark_unfinished("failed", error)
|
|
373
499
|
record.set_state("failed", error=error)
|
|
500
|
+
if record.log is not None:
|
|
501
|
+
record.log.write_event(
|
|
502
|
+
"result",
|
|
503
|
+
state="failed",
|
|
504
|
+
error=error,
|
|
505
|
+
traceback=stack_trace,
|
|
506
|
+
)
|
|
374
507
|
else:
|
|
375
508
|
record.set_state(result.status, result=result)
|
|
509
|
+
if record.log is not None:
|
|
510
|
+
record.log.write_event("result", state=record.state, result=result.to_dict())
|
|
511
|
+
finally:
|
|
512
|
+
if record.log is not None:
|
|
513
|
+
record.log.close()
|
|
376
514
|
|
|
377
515
|
def _run_with_restarts(self, record: ExecutionRecord) -> AlgorithmResult:
|
|
378
516
|
assert record.context is not None
|
|
@@ -398,7 +536,7 @@ class ExecutionManager:
|
|
|
398
536
|
self.retry_seconds * (1 - self.retry_jitter_ratio),
|
|
399
537
|
self.retry_seconds * (1 + self.retry_jitter_ratio),
|
|
400
538
|
)
|
|
401
|
-
logger.warning(
|
|
539
|
+
record.context.logger.warning(
|
|
402
540
|
"algorithm execution %s failed on attempt %d/%d; "
|
|
403
541
|
"releasing and restarting algorithm in %.3f seconds: %s: %s",
|
|
404
542
|
record.execution_id,
|
|
@@ -407,16 +545,18 @@ class ExecutionManager:
|
|
|
407
545
|
delay,
|
|
408
546
|
type(exc).__name__,
|
|
409
547
|
exc,
|
|
548
|
+
exc_info=True,
|
|
410
549
|
)
|
|
411
550
|
else:
|
|
412
551
|
delay = 0.0
|
|
413
|
-
logger.error(
|
|
552
|
+
record.context.logger.error(
|
|
414
553
|
"algorithm execution %s exhausted %d attempts; "
|
|
415
554
|
"releasing and restarting algorithm for future requests: %s: %s",
|
|
416
555
|
record.execution_id,
|
|
417
556
|
self.max_attempts,
|
|
418
557
|
type(exc).__name__,
|
|
419
558
|
exc,
|
|
559
|
+
exc_info=True,
|
|
420
560
|
)
|
|
421
561
|
finally:
|
|
422
562
|
self._release_runner()
|
|
@@ -585,7 +725,12 @@ def create_app(
|
|
|
585
725
|
async def create_execution(request: Request) -> Any:
|
|
586
726
|
try:
|
|
587
727
|
body = await request.json()
|
|
588
|
-
|
|
728
|
+
if not isinstance(body, dict):
|
|
729
|
+
raise ValueError("request body must be a mapping")
|
|
730
|
+
job_id = next((body.get(name) for name in ("jobId", "job_id") if body.get(name)), None)
|
|
731
|
+
task_id = next((body.get(name) for name in ("taskId", "task_id") if body.get(name)), None)
|
|
732
|
+
request_body = {key: value for key, value in body.items() if key not in {"jobId", "job_id", "taskId", "task_id"}}
|
|
733
|
+
execution_request = AlgorithmRequest.from_dict(request_body)
|
|
589
734
|
if execution_request.workspace is None:
|
|
590
735
|
raise ValueError("workspace is required")
|
|
591
736
|
except Exception as exc:
|
|
@@ -593,9 +738,14 @@ def create_app(
|
|
|
593
738
|
status_code=422,
|
|
594
739
|
content={"error": f"{type(exc).__name__}: {exc}"},
|
|
595
740
|
)
|
|
741
|
+
idempotency_key = request.headers.get("Idempotency-Key")
|
|
742
|
+
job_id = job_id or next((request.headers.get(name) for name in ("X-LDP-Job-ID", "X-Job-ID", "Job-ID") if request.headers.get(name)), None)
|
|
743
|
+
task_id = task_id or next((request.headers.get(name) for name in ("X-LDP-Task-ID", "X-Task-ID", "Task-ID") if request.headers.get(name)), None)
|
|
596
744
|
return manager.submit(
|
|
597
745
|
execution_request,
|
|
598
|
-
idempotency_key=
|
|
746
|
+
idempotency_key=idempotency_key,
|
|
747
|
+
job_id=job_id or idempotency_key,
|
|
748
|
+
task_id=task_id,
|
|
599
749
|
)
|
|
600
750
|
|
|
601
751
|
@application.get("/v1/executions/{execution_id}")
|
|
@@ -2,20 +2,22 @@ algorithm_plugin_sdk/__init__.py,sha256=OKj2VcNXTwZmRgPlECI6QUBlxW1YLtflr4RAgs6w
|
|
|
2
2
|
algorithm_plugin_sdk/algorithm.py,sha256=INjX-ygMQfHk8D1AZbEEdCw1P4VNQLGF989lP1J1H1k,1025
|
|
3
3
|
algorithm_plugin_sdk/cli.py,sha256=V9HFJ98iyhM1q6PZqd-366g1AoKuieC_Fn4u9RJcMNI,1252
|
|
4
4
|
algorithm_plugin_sdk/context.py,sha256=Kj-eDZg7PgX6xNzrWrQs37UfepTP8fXv4_8hCgsAIDA,11016
|
|
5
|
-
algorithm_plugin_sdk/deployment.py,sha256=
|
|
5
|
+
algorithm_plugin_sdk/deployment.py,sha256=5WlZfML9ymWrAFsmdX-bBHTP2kuVY4WO5wKHs35Dkd4,14936
|
|
6
6
|
algorithm_plugin_sdk/errors.py,sha256=DNmI2c36Pd7K6tTaonqfEgpBzwX0o6pXzYrc8r2Z--k,730
|
|
7
|
+
algorithm_plugin_sdk/gpu_isolation.py,sha256=toXUQ1vfzfLPiideJ8dWkfa9NW2Lc8V0sbNx-75YTjg,794
|
|
7
8
|
algorithm_plugin_sdk/loader.py,sha256=XKmyqbJMoUMdKoo2eRQFGdbsmlz6NBgtUeIdqzumJFs,2561
|
|
9
|
+
algorithm_plugin_sdk/log_manager.py,sha256=fmqSivxi-BCOWs4Qomms9nHZC7MRl5NUVuRiTL0J12Q,3723
|
|
8
10
|
algorithm_plugin_sdk/models.py,sha256=FIwUm8QDK8L37pV7AcCMnmYlDP-2l93zp8yc0KSu4yo,12196
|
|
9
11
|
algorithm_plugin_sdk/registration.py,sha256=s5oQkf12_uRjdA4CYMYM8SDQjhsk1rzDDF-3w36U_lA,10474
|
|
10
12
|
algorithm_plugin_sdk/release.py,sha256=KM-kRM1oFYMgjDFnXdu6BLR1b4dsvWWDCEgTNq8cb3w,9439
|
|
11
13
|
algorithm_plugin_sdk/runner.py,sha256=583Y9_7efvW5rrmM2nt316NEpCRO6kd8dKYjFRjgtCk,3183
|
|
12
|
-
algorithm_plugin_sdk/service.py,sha256=
|
|
14
|
+
algorithm_plugin_sdk/service.py,sha256=HQLDW5nlGGXJNZ0KMFU4I1rPzWHMf_LlE-tsY42PHnc,31045
|
|
13
15
|
algorithm_plugin_sdk/webui_app.py,sha256=Gvt2FPt0xZBjYi39sqTQqcosllifYeJam49_mGfkEVw,1530
|
|
14
16
|
algorithm_plugin_sdk/cli_impl/__init__.py,sha256=uLknQVGipNd7eSJegDHZS0NVowMa0qAaOf63wFI3zHM,78
|
|
15
|
-
algorithm_plugin_sdk/cli_impl/configure.py,sha256=
|
|
17
|
+
algorithm_plugin_sdk/cli_impl/configure.py,sha256=hO9vwFfZ4yqeQUyYc3heE1MDLK1lppN69OoF2zeVVaM,13761
|
|
16
18
|
algorithm_plugin_sdk/cli_impl/parsing.py,sha256=TDX45Spu4WMrczuzTMN9ZC2SYyHE11OPBCvN45yPsxs,1399
|
|
17
|
-
algorithm_plugin_sdk/cli_impl/run.py,sha256=
|
|
18
|
-
algorithm_plugin_sdk/cli_impl/serve.py,sha256=
|
|
19
|
+
algorithm_plugin_sdk/cli_impl/run.py,sha256=C4raNwhj4KZJc7_aTJeoiwNMQtgeXyMWyLTZEVUL0mM,6041
|
|
20
|
+
algorithm_plugin_sdk/cli_impl/serve.py,sha256=1Ma08MHMjKk9wKO8tGdymVvedcGC-jaKdaUCVBTPMro,5187
|
|
19
21
|
algorithm_plugin_sdk/examples/__init__.py,sha256=WxZZzUzxKhqZg3KibVY-7CB960DNjt6IfjciZ8JXbq0,57
|
|
20
22
|
algorithm_plugin_sdk/examples/example_algorithm.py,sha256=j2RsVaRPxpJn3kK6_H_JqAceKTpJE8txNm-oTrH2v6E,3787
|
|
21
23
|
algorithm_plugin_sdk/examples/simulated_algorithm.py,sha256=bIaneSMM09CJfV5-oGuZ5QwBDWV1u-7Fn0WDN1TuwGw,2944
|
|
@@ -23,8 +25,8 @@ algorithm_plugin_sdk/webui/__init__.py,sha256=cvtaktJXz_DYG4QbV5ppoqY2bFaMLuiUcT
|
|
|
23
25
|
algorithm_plugin_sdk/webui/app.css,sha256=5HGDKlbUFLZMgCknlRuwCjiNcCr8poIF5YGHchyaamE,2965
|
|
24
26
|
algorithm_plugin_sdk/webui/app.js,sha256=0YsXsocEY3yDseG3jh0RhzI8xo_bYmsbSxdN3FdCswQ,7657
|
|
25
27
|
algorithm_plugin_sdk/webui/index.html,sha256=Z222OuaacWdbr0s-sjJq6wmg45u96tIneM5-LP55Z8I,2261
|
|
26
|
-
ls_algorithm_plugin_sdk-0.3.
|
|
27
|
-
ls_algorithm_plugin_sdk-0.3.
|
|
28
|
-
ls_algorithm_plugin_sdk-0.3.
|
|
29
|
-
ls_algorithm_plugin_sdk-0.3.
|
|
30
|
-
ls_algorithm_plugin_sdk-0.3.
|
|
28
|
+
ls_algorithm_plugin_sdk-0.3.4.dist-info/METADATA,sha256=S21Rk6HWDt6q5H94F_pj8DpUtY-bKCicc7Vlmq1zWxA,2317
|
|
29
|
+
ls_algorithm_plugin_sdk-0.3.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
30
|
+
ls_algorithm_plugin_sdk-0.3.4.dist-info/entry_points.txt,sha256=TU0R_TxuB5OuOiZ5BHOEmUPtHvN3v8ARUW8d3PztHcE,67
|
|
31
|
+
ls_algorithm_plugin_sdk-0.3.4.dist-info/top_level.txt,sha256=8lsgxZ8HJGLlzJ5Rt8CiVzw9Ccme286i3akbmeMjBos,21
|
|
32
|
+
ls_algorithm_plugin_sdk-0.3.4.dist-info/RECORD,,
|
|
File without changes
|
{ls_algorithm_plugin_sdk-0.3.1.dist-info → ls_algorithm_plugin_sdk-0.3.4.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{ls_algorithm_plugin_sdk-0.3.1.dist-info → ls_algorithm_plugin_sdk-0.3.4.dist-info}/top_level.txt
RENAMED
|
File without changes
|