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.
Files changed (47) hide show
  1. README.md +1601 -0
  2. fraclab_sdk/__init__.py +34 -0
  3. fraclab_sdk/algorithm/__init__.py +13 -0
  4. fraclab_sdk/algorithm/export.py +1 -0
  5. fraclab_sdk/algorithm/library.py +378 -0
  6. fraclab_sdk/cli.py +381 -0
  7. fraclab_sdk/config.py +54 -0
  8. fraclab_sdk/devkit/__init__.py +25 -0
  9. fraclab_sdk/devkit/compile.py +342 -0
  10. fraclab_sdk/devkit/export.py +354 -0
  11. fraclab_sdk/devkit/validate.py +1043 -0
  12. fraclab_sdk/errors.py +124 -0
  13. fraclab_sdk/materialize/__init__.py +8 -0
  14. fraclab_sdk/materialize/fsops.py +125 -0
  15. fraclab_sdk/materialize/hash.py +28 -0
  16. fraclab_sdk/materialize/materializer.py +241 -0
  17. fraclab_sdk/models/__init__.py +52 -0
  18. fraclab_sdk/models/bundle_manifest.py +51 -0
  19. fraclab_sdk/models/dataspec.py +65 -0
  20. fraclab_sdk/models/drs.py +47 -0
  21. fraclab_sdk/models/output_contract.py +111 -0
  22. fraclab_sdk/models/run_output_manifest.py +119 -0
  23. fraclab_sdk/results/__init__.py +25 -0
  24. fraclab_sdk/results/preview.py +150 -0
  25. fraclab_sdk/results/reader.py +329 -0
  26. fraclab_sdk/run/__init__.py +10 -0
  27. fraclab_sdk/run/logs.py +42 -0
  28. fraclab_sdk/run/manager.py +403 -0
  29. fraclab_sdk/run/subprocess_runner.py +153 -0
  30. fraclab_sdk/runtime/__init__.py +11 -0
  31. fraclab_sdk/runtime/artifacts.py +303 -0
  32. fraclab_sdk/runtime/data_client.py +123 -0
  33. fraclab_sdk/runtime/runner_main.py +286 -0
  34. fraclab_sdk/runtime/snapshot_provider.py +1 -0
  35. fraclab_sdk/selection/__init__.py +11 -0
  36. fraclab_sdk/selection/model.py +247 -0
  37. fraclab_sdk/selection/validate.py +54 -0
  38. fraclab_sdk/snapshot/__init__.py +12 -0
  39. fraclab_sdk/snapshot/index.py +94 -0
  40. fraclab_sdk/snapshot/library.py +205 -0
  41. fraclab_sdk/snapshot/loader.py +217 -0
  42. fraclab_sdk/specs/manifest.py +89 -0
  43. fraclab_sdk/utils/io.py +32 -0
  44. fraclab_sdk-0.1.0.dist-info/METADATA +1622 -0
  45. fraclab_sdk-0.1.0.dist-info/RECORD +47 -0
  46. fraclab_sdk-0.1.0.dist-info/WHEEL +4 -0
  47. fraclab_sdk-0.1.0.dist-info/entry_points.txt +4 -0
@@ -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,13 @@
1
+ """Algorithm management."""
2
+
3
+ from fraclab_sdk.algorithm.library import (
4
+ AlgorithmHandle,
5
+ AlgorithmLibrary,
6
+ AlgorithmMeta,
7
+ )
8
+
9
+ __all__ = [
10
+ "AlgorithmHandle",
11
+ "AlgorithmLibrary",
12
+ "AlgorithmMeta",
13
+ ]
@@ -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)