fraclab-sdk 0.1.0__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.
- README.md +1601 -0
- fraclab_sdk/__init__.py +34 -0
- fraclab_sdk/algorithm/__init__.py +13 -0
- fraclab_sdk/algorithm/export.py +1 -0
- fraclab_sdk/algorithm/library.py +378 -0
- fraclab_sdk/cli.py +381 -0
- fraclab_sdk/config.py +54 -0
- fraclab_sdk/devkit/__init__.py +25 -0
- fraclab_sdk/devkit/compile.py +342 -0
- fraclab_sdk/devkit/export.py +354 -0
- fraclab_sdk/devkit/validate.py +1043 -0
- fraclab_sdk/errors.py +124 -0
- fraclab_sdk/materialize/__init__.py +8 -0
- fraclab_sdk/materialize/fsops.py +125 -0
- fraclab_sdk/materialize/hash.py +28 -0
- fraclab_sdk/materialize/materializer.py +241 -0
- fraclab_sdk/models/__init__.py +52 -0
- fraclab_sdk/models/bundle_manifest.py +51 -0
- fraclab_sdk/models/dataspec.py +65 -0
- fraclab_sdk/models/drs.py +47 -0
- fraclab_sdk/models/output_contract.py +111 -0
- fraclab_sdk/models/run_output_manifest.py +119 -0
- fraclab_sdk/results/__init__.py +25 -0
- fraclab_sdk/results/preview.py +150 -0
- fraclab_sdk/results/reader.py +329 -0
- fraclab_sdk/run/__init__.py +10 -0
- fraclab_sdk/run/logs.py +42 -0
- fraclab_sdk/run/manager.py +403 -0
- fraclab_sdk/run/subprocess_runner.py +153 -0
- fraclab_sdk/runtime/__init__.py +11 -0
- fraclab_sdk/runtime/artifacts.py +303 -0
- fraclab_sdk/runtime/data_client.py +123 -0
- fraclab_sdk/runtime/runner_main.py +286 -0
- fraclab_sdk/runtime/snapshot_provider.py +1 -0
- fraclab_sdk/selection/__init__.py +11 -0
- fraclab_sdk/selection/model.py +247 -0
- fraclab_sdk/selection/validate.py +54 -0
- fraclab_sdk/snapshot/__init__.py +12 -0
- fraclab_sdk/snapshot/index.py +94 -0
- fraclab_sdk/snapshot/library.py +205 -0
- fraclab_sdk/snapshot/loader.py +217 -0
- fraclab_sdk/specs/manifest.py +89 -0
- fraclab_sdk/utils/io.py +32 -0
- fraclab_sdk-0.1.0.dist-info/METADATA +1622 -0
- fraclab_sdk-0.1.0.dist-info/RECORD +47 -0
- fraclab_sdk-0.1.0.dist-info/WHEEL +4 -0
- fraclab_sdk-0.1.0.dist-info/entry_points.txt +4 -0
fraclab_sdk/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Fraclab SDK - Snapshot, Algorithm, and Run Management."""
|
|
2
|
+
|
|
3
|
+
from fraclab_sdk.algorithm import AlgorithmHandle, AlgorithmLibrary, AlgorithmMeta
|
|
4
|
+
from fraclab_sdk.config import SDKConfig
|
|
5
|
+
from fraclab_sdk.materialize import Materializer, MaterializeResult
|
|
6
|
+
from fraclab_sdk.results import ResultReader
|
|
7
|
+
from fraclab_sdk.run import RunManager, RunMeta, RunResult, RunStatus
|
|
8
|
+
from fraclab_sdk.selection.model import SelectionModel
|
|
9
|
+
from fraclab_sdk.snapshot import SnapshotHandle, SnapshotLibrary, SnapshotMeta
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
# Config
|
|
13
|
+
"SDKConfig",
|
|
14
|
+
# Snapshot
|
|
15
|
+
"SnapshotLibrary",
|
|
16
|
+
"SnapshotHandle",
|
|
17
|
+
"SnapshotMeta",
|
|
18
|
+
# Algorithm
|
|
19
|
+
"AlgorithmLibrary",
|
|
20
|
+
"AlgorithmHandle",
|
|
21
|
+
"AlgorithmMeta",
|
|
22
|
+
# Selection
|
|
23
|
+
"SelectionModel",
|
|
24
|
+
# Materialize
|
|
25
|
+
"Materializer",
|
|
26
|
+
"MaterializeResult",
|
|
27
|
+
# Run
|
|
28
|
+
"RunManager",
|
|
29
|
+
"RunMeta",
|
|
30
|
+
"RunResult",
|
|
31
|
+
"RunStatus",
|
|
32
|
+
# Results
|
|
33
|
+
"ResultReader",
|
|
34
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Algorithm export implementation."""
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Algorithm library implementation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import zipfile
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from fraclab_sdk.config import SDKConfig
|
|
12
|
+
from fraclab_sdk.errors import AlgorithmError, PathTraversalError
|
|
13
|
+
from fraclab_sdk.models import DRS
|
|
14
|
+
from fraclab_sdk.specs.manifest import FracLabAlgorithmManifestV1
|
|
15
|
+
from fraclab_sdk.utils.io import atomic_write_json
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_safe_path(path: str) -> bool:
|
|
19
|
+
"""Check if a path is safe (no traversal attacks)."""
|
|
20
|
+
if path.startswith("/") or path.startswith("\\"):
|
|
21
|
+
return False
|
|
22
|
+
if ".." in path:
|
|
23
|
+
return False
|
|
24
|
+
return not any(c in path for c in [":", "*", "?", '"', "<", ">", "|"])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AlgorithmMeta:
|
|
29
|
+
"""Metadata for an indexed algorithm."""
|
|
30
|
+
|
|
31
|
+
algorithm_id: str
|
|
32
|
+
version: str # = codeVersion
|
|
33
|
+
contract_version: str
|
|
34
|
+
name: str
|
|
35
|
+
summary: str
|
|
36
|
+
notes: str | None = None
|
|
37
|
+
imported_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AlgorithmHandle:
|
|
41
|
+
"""Handle for accessing algorithm contents."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, algorithm_dir: Path) -> None:
|
|
44
|
+
"""Initialize algorithm handle.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
algorithm_dir: Path to the algorithm version directory.
|
|
48
|
+
"""
|
|
49
|
+
self._dir = algorithm_dir
|
|
50
|
+
self._manifest: FracLabAlgorithmManifestV1 | None = None
|
|
51
|
+
self._drs: DRS | None = None
|
|
52
|
+
self._params_schema: dict | None = None
|
|
53
|
+
self._manifest_data: dict[str, Any] | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def directory(self) -> Path:
|
|
57
|
+
"""Get algorithm directory path."""
|
|
58
|
+
return self._dir
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def manifest(self) -> FracLabAlgorithmManifestV1:
|
|
62
|
+
"""Get algorithm manifest."""
|
|
63
|
+
if self._manifest is None:
|
|
64
|
+
manifest_path = self._dir / "manifest.json"
|
|
65
|
+
if not manifest_path.exists():
|
|
66
|
+
raise AlgorithmError(f"manifest.json not found: {manifest_path}")
|
|
67
|
+
data = json.loads(manifest_path.read_text())
|
|
68
|
+
self._manifest = FracLabAlgorithmManifestV1.model_validate(data)
|
|
69
|
+
self._manifest_data = data
|
|
70
|
+
return self._manifest
|
|
71
|
+
|
|
72
|
+
def _resolve_manifest_file(self, files_key: str, default_rel: str) -> Path:
|
|
73
|
+
"""
|
|
74
|
+
Resolve a file path declared in manifest.json under `files.*`.
|
|
75
|
+
Fallback to `default_rel` for backward compatibility.
|
|
76
|
+
"""
|
|
77
|
+
if self._manifest_data is None:
|
|
78
|
+
_ = self.manifest # loads manifest and manifest_data
|
|
79
|
+
files = self._manifest_data.get("files") or {}
|
|
80
|
+
rel = files.get(files_key, default_rel)
|
|
81
|
+
if not isinstance(rel, str) or not rel:
|
|
82
|
+
raise AlgorithmError(f"Invalid manifest.files.{files_key}: {rel!r}")
|
|
83
|
+
if not _is_safe_path(rel):
|
|
84
|
+
raise AlgorithmError(f"Unsafe manifest path files.{files_key}: {rel}")
|
|
85
|
+
p = (self._dir / rel).resolve()
|
|
86
|
+
if not p.exists():
|
|
87
|
+
raise AlgorithmError(f"{rel} not found: {p}")
|
|
88
|
+
return p
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def drs(self) -> DRS:
|
|
92
|
+
"""Get data requirement specification."""
|
|
93
|
+
if self._drs is None:
|
|
94
|
+
drs_path = self._resolve_manifest_file("drsPath", "drs.json")
|
|
95
|
+
self._drs = DRS.model_validate_json(drs_path.read_text(encoding="utf-8"))
|
|
96
|
+
return self._drs
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def params_schema(self) -> dict[str, Any]:
|
|
100
|
+
"""Get parameters JSON schema."""
|
|
101
|
+
if self._params_schema is None:
|
|
102
|
+
schema_path = self._resolve_manifest_file("paramsSchemaPath", "params.schema.json")
|
|
103
|
+
self._params_schema = json.loads(schema_path.read_text(encoding="utf-8"))
|
|
104
|
+
return self._params_schema
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def algorithm_path(self) -> Path:
|
|
108
|
+
"""Get path to algorithm entrypoint."""
|
|
109
|
+
main_path = self._dir / "main.py"
|
|
110
|
+
if not main_path.exists():
|
|
111
|
+
raise AlgorithmError(f"Entrypoint not found: {main_path}")
|
|
112
|
+
return main_path
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AlgorithmIndex:
|
|
116
|
+
"""Manages the algorithm index file."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, algorithms_dir: Path) -> None:
|
|
119
|
+
"""Initialize algorithm index."""
|
|
120
|
+
self._algorithms_dir = algorithms_dir
|
|
121
|
+
self._index_path = algorithms_dir / "index.json"
|
|
122
|
+
|
|
123
|
+
def _load(self) -> dict[str, dict]:
|
|
124
|
+
"""Load index from disk."""
|
|
125
|
+
if not self._index_path.exists():
|
|
126
|
+
return {}
|
|
127
|
+
return json.loads(self._index_path.read_text())
|
|
128
|
+
|
|
129
|
+
def _save(self, data: dict[str, dict]) -> None:
|
|
130
|
+
"""Save index to disk."""
|
|
131
|
+
self._algorithms_dir.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
atomic_write_json(self._index_path, data)
|
|
133
|
+
|
|
134
|
+
def _make_key(self, algorithm_id: str, version: str) -> str:
|
|
135
|
+
"""Create index key from algorithm_id and version."""
|
|
136
|
+
return f"{algorithm_id}:{version}"
|
|
137
|
+
|
|
138
|
+
def add(self, meta: AlgorithmMeta) -> None:
|
|
139
|
+
"""Add an algorithm to the index."""
|
|
140
|
+
data = self._load()
|
|
141
|
+
key = self._make_key(meta.algorithm_id, meta.version)
|
|
142
|
+
data[key] = {
|
|
143
|
+
"algorithm_id": meta.algorithm_id,
|
|
144
|
+
"version": meta.version,
|
|
145
|
+
"contract_version": meta.contract_version,
|
|
146
|
+
"name": meta.name,
|
|
147
|
+
"summary": meta.summary,
|
|
148
|
+
"notes": meta.notes,
|
|
149
|
+
"imported_at": meta.imported_at,
|
|
150
|
+
}
|
|
151
|
+
self._save(data)
|
|
152
|
+
|
|
153
|
+
def remove(self, algorithm_id: str, version: str) -> None:
|
|
154
|
+
"""Remove an algorithm from the index."""
|
|
155
|
+
data = self._load()
|
|
156
|
+
key = self._make_key(algorithm_id, version)
|
|
157
|
+
if key in data:
|
|
158
|
+
del data[key]
|
|
159
|
+
self._save(data)
|
|
160
|
+
|
|
161
|
+
def get(self, algorithm_id: str, version: str) -> AlgorithmMeta | None:
|
|
162
|
+
"""Get algorithm metadata."""
|
|
163
|
+
data = self._load()
|
|
164
|
+
key = self._make_key(algorithm_id, version)
|
|
165
|
+
if key not in data:
|
|
166
|
+
return None
|
|
167
|
+
entry = data[key]
|
|
168
|
+
return AlgorithmMeta(
|
|
169
|
+
algorithm_id=entry["algorithm_id"],
|
|
170
|
+
version=entry["version"],
|
|
171
|
+
contract_version=entry.get("contract_version", ""),
|
|
172
|
+
name=entry.get("name", ""),
|
|
173
|
+
summary=entry.get("summary", ""),
|
|
174
|
+
notes=entry.get("notes"),
|
|
175
|
+
imported_at=entry.get("imported_at", ""),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def list_all(self) -> list[AlgorithmMeta]:
|
|
179
|
+
"""List all indexed algorithms."""
|
|
180
|
+
data = self._load()
|
|
181
|
+
return [
|
|
182
|
+
AlgorithmMeta(
|
|
183
|
+
algorithm_id=entry["algorithm_id"],
|
|
184
|
+
version=entry["version"],
|
|
185
|
+
contract_version=entry.get("contract_version", ""),
|
|
186
|
+
name=entry.get("name", ""),
|
|
187
|
+
summary=entry.get("summary", ""),
|
|
188
|
+
notes=entry.get("notes"),
|
|
189
|
+
imported_at=entry.get("imported_at", ""),
|
|
190
|
+
)
|
|
191
|
+
for entry in data.values()
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class AlgorithmLibrary:
|
|
196
|
+
"""Library for managing algorithms."""
|
|
197
|
+
|
|
198
|
+
# Core required files that must exist at root
|
|
199
|
+
REQUIRED_ROOT_FILES = ["main.py", "manifest.json"]
|
|
200
|
+
|
|
201
|
+
def __init__(self, config: SDKConfig | None = None) -> None:
|
|
202
|
+
"""Initialize algorithm library.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
config: SDK configuration. If None, uses default.
|
|
206
|
+
"""
|
|
207
|
+
self._config = config or SDKConfig()
|
|
208
|
+
self._index = AlgorithmIndex(self._config.algorithms_dir)
|
|
209
|
+
|
|
210
|
+
def import_algorithm(self, path: Path) -> tuple[str, str]:
|
|
211
|
+
"""Import an algorithm from a directory or zip file.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
path: Path to algorithm directory or zip file.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Tuple of (algorithm_id, version).
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
AlgorithmError: If import fails.
|
|
221
|
+
PathTraversalError: If zip contains unsafe paths.
|
|
222
|
+
"""
|
|
223
|
+
path = path.resolve()
|
|
224
|
+
if not path.exists():
|
|
225
|
+
raise AlgorithmError(f"Path does not exist: {path}")
|
|
226
|
+
|
|
227
|
+
if path.is_file() and path.suffix == ".zip":
|
|
228
|
+
return self._import_from_zip(path)
|
|
229
|
+
elif path.is_dir():
|
|
230
|
+
return self._import_from_dir(path)
|
|
231
|
+
else:
|
|
232
|
+
raise AlgorithmError(f"Path must be a directory or .zip file: {path}")
|
|
233
|
+
|
|
234
|
+
def _import_from_zip(self, zip_path: Path) -> tuple[str, str]:
|
|
235
|
+
"""Import algorithm from zip file."""
|
|
236
|
+
import tempfile
|
|
237
|
+
|
|
238
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
239
|
+
tmp_path = Path(tmp_dir)
|
|
240
|
+
|
|
241
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
242
|
+
for name in zf.namelist():
|
|
243
|
+
if not _is_safe_path(name):
|
|
244
|
+
raise PathTraversalError(name)
|
|
245
|
+
zf.extractall(tmp_path)
|
|
246
|
+
|
|
247
|
+
algorithm_root = self._find_algorithm_root(tmp_path)
|
|
248
|
+
return self._import_from_dir(algorithm_root)
|
|
249
|
+
|
|
250
|
+
def _find_algorithm_root(self, path: Path) -> Path:
|
|
251
|
+
"""Find the algorithm root directory (contains manifest.json)."""
|
|
252
|
+
if (path / "manifest.json").exists():
|
|
253
|
+
return path
|
|
254
|
+
|
|
255
|
+
for subdir in path.iterdir():
|
|
256
|
+
if subdir.is_dir() and (subdir / "manifest.json").exists():
|
|
257
|
+
return subdir
|
|
258
|
+
|
|
259
|
+
raise AlgorithmError(f"No manifest.json found in {path}")
|
|
260
|
+
|
|
261
|
+
def _import_from_dir(self, source_dir: Path) -> tuple[str, str]:
|
|
262
|
+
"""Import algorithm from directory."""
|
|
263
|
+
# Validate core root files exist
|
|
264
|
+
for filename in self.REQUIRED_ROOT_FILES:
|
|
265
|
+
file_path = source_dir / filename
|
|
266
|
+
if not file_path.exists():
|
|
267
|
+
raise AlgorithmError(f"{filename} not found in {source_dir}")
|
|
268
|
+
|
|
269
|
+
# Parse algorithm manifest
|
|
270
|
+
manifest_path = source_dir / "manifest.json"
|
|
271
|
+
manifest = FracLabAlgorithmManifestV1.model_validate_json(manifest_path.read_text())
|
|
272
|
+
|
|
273
|
+
# Validate files referenced in manifest.json exist
|
|
274
|
+
manifest_data = json.loads(manifest_path.read_text())
|
|
275
|
+
files_section = manifest_data.get("files", {})
|
|
276
|
+
|
|
277
|
+
# Check required file references
|
|
278
|
+
for key, default_path in [
|
|
279
|
+
("paramsSchemaPath", "params.schema.json"),
|
|
280
|
+
("drsPath", "drs.json"),
|
|
281
|
+
]:
|
|
282
|
+
file_path_str = files_section.get(key, default_path)
|
|
283
|
+
file_path = source_dir / file_path_str
|
|
284
|
+
if not file_path.exists():
|
|
285
|
+
raise AlgorithmError(f"{file_path_str} not found in {source_dir}")
|
|
286
|
+
|
|
287
|
+
algorithm_id = manifest.algorithmId
|
|
288
|
+
version = manifest.codeVersion # version = codeVersion (pinned)
|
|
289
|
+
|
|
290
|
+
# Create target directory
|
|
291
|
+
self._config.ensure_dirs()
|
|
292
|
+
target_dir = self._config.algorithms_dir / algorithm_id / version
|
|
293
|
+
|
|
294
|
+
if target_dir.exists():
|
|
295
|
+
# Already imported
|
|
296
|
+
return algorithm_id, version
|
|
297
|
+
|
|
298
|
+
# Copy to library
|
|
299
|
+
target_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
shutil.copytree(source_dir, target_dir)
|
|
301
|
+
|
|
302
|
+
# Add to index
|
|
303
|
+
self._index.add(
|
|
304
|
+
AlgorithmMeta(
|
|
305
|
+
algorithm_id=algorithm_id,
|
|
306
|
+
version=version,
|
|
307
|
+
contract_version=manifest.contractVersion,
|
|
308
|
+
name=manifest.name,
|
|
309
|
+
summary=manifest.summary,
|
|
310
|
+
notes=manifest.notes,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return algorithm_id, version
|
|
315
|
+
|
|
316
|
+
def list_algorithms(self) -> list[AlgorithmMeta]:
|
|
317
|
+
"""List all imported algorithms.
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
List of algorithm metadata.
|
|
321
|
+
"""
|
|
322
|
+
return self._index.list_all()
|
|
323
|
+
|
|
324
|
+
def get_algorithm(self, algorithm_id: str, version: str) -> AlgorithmHandle:
|
|
325
|
+
"""Get a handle to an algorithm.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
algorithm_id: The algorithm ID.
|
|
329
|
+
version: The algorithm version (codeVersion).
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
AlgorithmHandle for accessing algorithm contents.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
AlgorithmError: If algorithm not found.
|
|
336
|
+
"""
|
|
337
|
+
algorithm_dir = self._config.algorithms_dir / algorithm_id / version
|
|
338
|
+
if not algorithm_dir.exists():
|
|
339
|
+
raise AlgorithmError(f"Algorithm not found: {algorithm_id}:{version}")
|
|
340
|
+
return AlgorithmHandle(algorithm_dir)
|
|
341
|
+
|
|
342
|
+
def delete_algorithm(self, algorithm_id: str, version: str) -> None:
|
|
343
|
+
"""Delete an algorithm from the library.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
algorithm_id: The algorithm ID.
|
|
347
|
+
version: The algorithm version.
|
|
348
|
+
|
|
349
|
+
Raises:
|
|
350
|
+
AlgorithmError: If algorithm not found.
|
|
351
|
+
"""
|
|
352
|
+
algorithm_dir = self._config.algorithms_dir / algorithm_id / version
|
|
353
|
+
if not algorithm_dir.exists():
|
|
354
|
+
raise AlgorithmError(f"Algorithm not found: {algorithm_id}:{version}")
|
|
355
|
+
|
|
356
|
+
shutil.rmtree(algorithm_dir)
|
|
357
|
+
self._index.remove(algorithm_id, version)
|
|
358
|
+
|
|
359
|
+
# Clean up empty parent directory
|
|
360
|
+
parent_dir = self._config.algorithms_dir / algorithm_id
|
|
361
|
+
if parent_dir.exists() and not any(parent_dir.iterdir()):
|
|
362
|
+
parent_dir.rmdir()
|
|
363
|
+
|
|
364
|
+
def export_algorithm(
|
|
365
|
+
self, algorithm_id: str, version: str, out_path: Path
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Export an algorithm to a directory.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
algorithm_id: The algorithm ID.
|
|
371
|
+
version: The algorithm version.
|
|
372
|
+
out_path: Output directory path.
|
|
373
|
+
|
|
374
|
+
Raises:
|
|
375
|
+
AlgorithmError: If algorithm not found.
|
|
376
|
+
"""
|
|
377
|
+
handle = self.get_algorithm(algorithm_id, version)
|
|
378
|
+
shutil.copytree(handle.directory, out_path)
|