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
fraclab_sdk/cli.py ADDED
@@ -0,0 +1,381 @@
1
+ """Fraclab SDK CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import traceback
8
+ from collections.abc import Callable
9
+ from functools import wraps
10
+ from pathlib import Path
11
+ from typing import TypeVar
12
+
13
+ import typer
14
+
15
+ from fraclab_sdk.algorithm import AlgorithmLibrary
16
+ from fraclab_sdk.config import SDKConfig
17
+ from fraclab_sdk.errors import ExitCode, FraclabError
18
+ from fraclab_sdk.results import ResultReader
19
+ from fraclab_sdk.run import RunManager
20
+ from fraclab_sdk.run.logs import tail_stderr, tail_stdout
21
+ from fraclab_sdk.run.manager import RunStatus
22
+ from fraclab_sdk.selection.model import SelectionModel
23
+ from fraclab_sdk.snapshot import SnapshotLibrary
24
+
25
+ app = typer.Typer(help="Fraclab SDK CLI")
26
+ snapshot_app = typer.Typer()
27
+ algo_app = typer.Typer()
28
+ run_app = typer.Typer()
29
+ results_app = typer.Typer()
30
+ validate_app = typer.Typer()
31
+
32
+ app.add_typer(snapshot_app, name="snapshot")
33
+ app.add_typer(algo_app, name="algo")
34
+ app.add_typer(run_app, name="run")
35
+ app.add_typer(results_app, name="results")
36
+ app.add_typer(validate_app, name="validate")
37
+
38
+
39
+ def _error(msg: str) -> None:
40
+ """Print error message to stderr."""
41
+ typer.echo(f"Error: {msg}", err=True)
42
+
43
+
44
+ def _get_libs() -> tuple[SDKConfig, SnapshotLibrary, AlgorithmLibrary, RunManager]:
45
+ cfg = SDKConfig()
46
+ return cfg, SnapshotLibrary(cfg), AlgorithmLibrary(cfg), RunManager(cfg)
47
+
48
+
49
+ F = TypeVar("F", bound=Callable)
50
+
51
+
52
+ def handle_errors(func: F) -> F:
53
+ """Decorator to handle FraclabError and other exceptions uniformly."""
54
+
55
+ @wraps(func)
56
+ def wrapper(*args, **kwargs):
57
+ try:
58
+ return func(*args, **kwargs)
59
+ except typer.Exit:
60
+ # Re-raise typer.Exit unchanged (it's used for normal exits)
61
+ raise
62
+ except FraclabError as e:
63
+ _error(str(e))
64
+ raise typer.Exit(e.exit_code) from None
65
+ except FileNotFoundError as e:
66
+ _error(f"File not found: {e.filename or e}")
67
+ raise typer.Exit(ExitCode.INPUT_ERROR) from None
68
+ except json.JSONDecodeError as e:
69
+ _error(f"Invalid JSON: {e.msg} at line {e.lineno}")
70
+ raise typer.Exit(ExitCode.INPUT_ERROR) from None
71
+ except Exception as e:
72
+ _error(f"Internal error: {e}")
73
+ if "--debug" in sys.argv:
74
+ traceback.print_exc(file=sys.stderr)
75
+ raise typer.Exit(ExitCode.INTERNAL_ERROR) from None
76
+
77
+ return wrapper # type: ignore
78
+
79
+
80
+ @snapshot_app.command("import")
81
+ @handle_errors
82
+ def snapshot_import(path: Path):
83
+ """Import a snapshot (dir or zip)."""
84
+ _, snap_lib, _, _ = _get_libs()
85
+ snap_id = snap_lib.import_snapshot(path)
86
+ typer.echo(f"Imported snapshot: {snap_id}")
87
+
88
+
89
+ @snapshot_app.command("list")
90
+ @handle_errors
91
+ def snapshot_list():
92
+ """List snapshots."""
93
+ _, snap_lib, _, _ = _get_libs()
94
+ snaps = snap_lib.list_snapshots()
95
+ if not snaps:
96
+ typer.echo("No snapshots")
97
+ raise typer.Exit(0)
98
+ for s in snaps:
99
+ typer.echo(f"{s.snapshot_id}\t{ s.bundle_id }\t{ s.created_at }")
100
+
101
+
102
+ @algo_app.command("import")
103
+ @handle_errors
104
+ def algo_import(path: Path):
105
+ """Import an algorithm (dir or zip)."""
106
+ _, _, algo_lib, _ = _get_libs()
107
+ algo_id, ver = algo_lib.import_algorithm(path)
108
+ typer.echo(f"Imported algorithm: {algo_id}:{ver}")
109
+
110
+
111
+ @algo_app.command("list")
112
+ @handle_errors
113
+ def algo_list():
114
+ """List algorithms."""
115
+ _, _, algo_lib, _ = _get_libs()
116
+ algos = algo_lib.list_algorithms()
117
+ if not algos:
118
+ typer.echo("No algorithms")
119
+ raise typer.Exit(0)
120
+ for a in algos:
121
+ typer.echo(f"{a.algorithm_id}\t{a.version}\t{a.imported_at}")
122
+
123
+
124
+ @algo_app.command("compile")
125
+ @handle_errors
126
+ def algo_compile(
127
+ workspace: Path = typer.Argument(..., help="Path to algorithm workspace"),
128
+ bundle: Path | None = typer.Option(None, "--bundle", "-b", help="Bundle path for drs.json"),
129
+ skip_inputspec: bool = typer.Option(False, "--skip-inputspec", help="Skip InputSpec compilation (schema.inputspec:INPUT_SPEC)"),
130
+ skip_output_contract: bool = typer.Option(
131
+ False, "--skip-output-contract", help="Skip OutputContract compilation"
132
+ ),
133
+ ):
134
+ """Compile algorithm workspace to generate dist/ artifacts.
135
+
136
+ Generates:
137
+ - dist/params.schema.json (from schema.inputspec:INPUT_SPEC)
138
+ - dist/output_contract.json (from schema.output_contract:OUTPUT_CONTRACT)
139
+ - dist/drs.json (from bundle)
140
+ """
141
+ from fraclab_sdk.devkit.compile import compile_algorithm
142
+
143
+ result = compile_algorithm(
144
+ workspace=workspace,
145
+ bundle_path=bundle,
146
+ skip_inputspec=skip_inputspec,
147
+ skip_output_contract=skip_output_contract,
148
+ )
149
+
150
+ typer.echo(f"Compiled algorithm workspace: {workspace}")
151
+ typer.echo(f" params.schema.json: {result.params_schema_path}")
152
+ typer.echo(f" output_contract.json: {result.output_contract_path}")
153
+ typer.echo(f" drs.json: {result.drs_path}")
154
+ if result.bound_bundle:
155
+ typer.echo(f" Bound bundle hashes: {result.bound_bundle}")
156
+
157
+
158
+ @algo_app.command("export")
159
+ @handle_errors
160
+ def algo_export(
161
+ workspace: Path = typer.Argument(..., help="Path to algorithm workspace"),
162
+ output: Path = typer.Argument(..., help="Output path (.zip or directory)"),
163
+ auto_compile: bool = typer.Option(False, "--auto-compile", "-c", help="Auto-compile if needed"),
164
+ bundle: Path | None = typer.Option(None, "--bundle", "-b", help="Bundle path for auto-compile"),
165
+ ):
166
+ """Export algorithm workspace as distributable package.
167
+
168
+ Creates a package containing main.py, manifest.json, and dist/ artifacts.
169
+ """
170
+ from fraclab_sdk.devkit.export import export_algorithm_package
171
+
172
+ result = export_algorithm_package(
173
+ workspace=workspace,
174
+ output=output,
175
+ auto_compile=auto_compile,
176
+ bundle_path=bundle,
177
+ )
178
+
179
+ typer.echo(f"Exported algorithm to: {result.output_path}")
180
+ typer.echo(f" Files included: {len(result.files_included)}")
181
+ if result.files_rejected:
182
+ typer.echo(f" Files rejected: {len(result.files_rejected)}", err=True)
183
+
184
+
185
+ @run_app.command("create")
186
+ @handle_errors
187
+ def run_create(
188
+ snapshot_id: str = typer.Argument(...),
189
+ algorithm_id: str = typer.Argument(...),
190
+ algorithm_version: str = typer.Argument(...),
191
+ params_path: Path | None = typer.Option(None, "--params", "-p", help="JSON file with params"),
192
+ ):
193
+ """Create a run selecting all items."""
194
+ _, snap_lib, algo_lib, run_mgr = _get_libs()
195
+ snapshot = snap_lib.get_snapshot(snapshot_id)
196
+ algorithm = algo_lib.get_algorithm(algorithm_id, algorithm_version)
197
+
198
+ selection = SelectionModel.from_snapshot_and_drs(snapshot, algorithm.drs)
199
+ # select all items for each dataset
200
+ for ds in selection.get_selectable_datasets():
201
+ selection.set_selected(ds.dataset_key, list(range(ds.total_items)))
202
+
203
+ params: dict = {}
204
+ if params_path:
205
+ params = json.loads(params_path.read_text())
206
+
207
+ run_id = run_mgr.create_run(
208
+ snapshot_id=snapshot_id,
209
+ algorithm_id=algorithm_id,
210
+ algorithm_version=algorithm_version,
211
+ selection=selection,
212
+ params=params,
213
+ )
214
+ typer.echo(run_id)
215
+
216
+
217
+ @run_app.command("exec")
218
+ @handle_errors
219
+ def run_exec(run_id: str, timeout: int | None = typer.Option(None, "--timeout", "-t")):
220
+ """Execute a run."""
221
+ _, _, _, run_mgr = _get_libs()
222
+ result = run_mgr.execute(run_id, timeout_s=timeout)
223
+ typer.echo(f"{result.status.value} (exit_code={result.exit_code})")
224
+ if result.status == RunStatus.TIMEOUT:
225
+ _error(result.error or f"Timeout after {timeout}s")
226
+ raise typer.Exit(ExitCode.TIMEOUT)
227
+ if result.status == RunStatus.FAILED:
228
+ _error(result.error or "Run failed")
229
+ raise typer.Exit(ExitCode.RUN_FAILED)
230
+
231
+
232
+ @run_app.command("tail")
233
+ @handle_errors
234
+ def run_tail(run_id: str, stderr: bool = typer.Option(False, "--stderr")):
235
+ """Tail stdout/stderr."""
236
+ _, _, _, run_mgr = _get_libs()
237
+ run_dir = run_mgr.get_run_dir(run_id)
238
+ if not run_dir.exists():
239
+ _error(f"Run directory not found: {run_id}")
240
+ raise typer.Exit(ExitCode.INPUT_ERROR)
241
+ if stderr:
242
+ typer.echo(tail_stderr(run_dir))
243
+ else:
244
+ typer.echo(tail_stdout(run_dir))
245
+
246
+
247
+ @results_app.command("list")
248
+ @handle_errors
249
+ def results_list(run_id: str):
250
+ """List artifacts for a run."""
251
+ _, _, _, run_mgr = _get_libs()
252
+ run_dir = run_mgr.get_run_dir(run_id)
253
+ if not run_dir.exists():
254
+ _error(f"Run directory not found: {run_id}")
255
+ raise typer.Exit(ExitCode.INPUT_ERROR)
256
+ reader = ResultReader(run_dir)
257
+ if not reader.has_manifest():
258
+ _error("Manifest not found (run may not have completed)")
259
+ raise typer.Exit(ExitCode.INPUT_ERROR)
260
+ manifest = reader.read_manifest()
261
+ typer.echo(f"Status: {manifest.status}")
262
+ for art in manifest.list_all_artifacts():
263
+ typer.echo(f"{art.artifactKey}\t{art.type}\t{art.uri or ''}")
264
+
265
+
266
+ @validate_app.command("inputspec")
267
+ @handle_errors
268
+ def validate_inputspec_cmd(
269
+ workspace: Path = typer.Argument(..., help="Path to algorithm workspace"),
270
+ ):
271
+ """Validate InputSpec (schema.inputspec:INPUT_SPEC).
272
+
273
+ Checks json_schema_extra fields, show_when conditions, and enum_labels.
274
+ """
275
+ from fraclab_sdk.devkit.validate import ValidationSeverity, validate_inputspec
276
+
277
+ result = validate_inputspec(workspace)
278
+
279
+ if result.valid:
280
+ typer.echo("InputSpec validation passed")
281
+ else:
282
+ typer.echo("InputSpec validation failed", err=True)
283
+
284
+ for issue in result.issues:
285
+ prefix = "ERROR" if issue.severity == ValidationSeverity.ERROR else "WARN"
286
+ path_str = f" at {issue.path}" if issue.path else ""
287
+ typer.echo(f" [{prefix}] {issue.code}{path_str}: {issue.message}", err=True)
288
+
289
+ if not result.valid:
290
+ raise typer.Exit(ExitCode.INPUT_ERROR)
291
+
292
+
293
+ @validate_app.command("output-contract")
294
+ @handle_errors
295
+ def validate_output_contract_cmd(
296
+ workspace_or_path: Path = typer.Argument(..., help="Workspace or output_contract.json path"),
297
+ ):
298
+ """Validate OutputContract structure.
299
+
300
+ Checks key uniqueness, kind/schema consistency, and dimensions.
301
+ """
302
+ from fraclab_sdk.devkit.validate import ValidationSeverity, validate_output_contract
303
+
304
+ result = validate_output_contract(workspace_or_path)
305
+
306
+ if result.valid:
307
+ typer.echo("OutputContract validation passed")
308
+ else:
309
+ typer.echo("OutputContract validation failed", err=True)
310
+
311
+ for issue in result.issues:
312
+ prefix = "ERROR" if issue.severity == ValidationSeverity.ERROR else "WARN"
313
+ path_str = f" at {issue.path}" if issue.path else ""
314
+ typer.echo(f" [{prefix}] {issue.code}{path_str}: {issue.message}", err=True)
315
+
316
+ if not result.valid:
317
+ raise typer.Exit(ExitCode.INPUT_ERROR)
318
+
319
+
320
+ @validate_app.command("bundle")
321
+ @handle_errors
322
+ def validate_bundle_cmd(
323
+ bundle_path: Path = typer.Argument(..., help="Path to bundle directory"),
324
+ ):
325
+ """Validate bundle hash integrity.
326
+
327
+ Checks ds.json and drs.json hashes against manifest.
328
+ """
329
+ from fraclab_sdk.devkit.validate import ValidationSeverity, validate_bundle
330
+
331
+ result = validate_bundle(bundle_path)
332
+
333
+ if result.valid:
334
+ typer.echo("Bundle validation passed")
335
+ else:
336
+ typer.echo("Bundle validation failed", err=True)
337
+
338
+ for issue in result.issues:
339
+ prefix = "ERROR" if issue.severity == ValidationSeverity.ERROR else "WARN"
340
+ typer.echo(f" [{prefix}] {issue.code}: {issue.message}", err=True)
341
+
342
+ if not result.valid:
343
+ raise typer.Exit(ExitCode.INPUT_ERROR)
344
+
345
+
346
+ @validate_app.command("run-manifest")
347
+ @handle_errors
348
+ def validate_run_manifest_cmd(
349
+ manifest_path: Path = typer.Argument(..., help="Path to run output manifest.json"),
350
+ contract_path: Path | None = typer.Option(
351
+ None, "--contract", "-c", help="Path to output_contract.json for alignment check"
352
+ ),
353
+ ):
354
+ """Validate run output manifest against OutputContract.
355
+
356
+ If contract is provided, checks that all required datasets/items/artifacts are present.
357
+ """
358
+ from fraclab_sdk.devkit.validate import ValidationSeverity, validate_run_manifest
359
+
360
+ result = validate_run_manifest(manifest_path, contract_path)
361
+
362
+ if result.valid:
363
+ typer.echo("Run manifest validation passed")
364
+ else:
365
+ typer.echo("Run manifest validation failed", err=True)
366
+
367
+ for issue in result.issues:
368
+ prefix = "ERROR" if issue.severity == ValidationSeverity.ERROR else "WARN"
369
+ path_str = f" at {issue.path}" if issue.path else ""
370
+ typer.echo(f" [{prefix}] {issue.code}{path_str}: {issue.message}", err=True)
371
+
372
+ if not result.valid:
373
+ raise typer.Exit(ExitCode.INPUT_ERROR)
374
+
375
+
376
+ def main():
377
+ app()
378
+
379
+
380
+ if __name__ == "__main__":
381
+ main()
fraclab_sdk/config.py ADDED
@@ -0,0 +1,54 @@
1
+ """SDK configuration and path resolution."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ class SDKConfig:
8
+ """Configuration for Fraclab SDK paths.
9
+
10
+ Resolves SDK home directory from environment variable FRACLAB_SDK_HOME
11
+ or falls back to ~/.fraclab.
12
+ """
13
+
14
+ def __init__(self, sdk_home: Path | None = None) -> None:
15
+ """Initialize SDK configuration.
16
+
17
+ Args:
18
+ sdk_home: Optional explicit SDK home path. If None, resolves from
19
+ FRACLAB_SDK_HOME env var or defaults to ~/.fraclab.
20
+ """
21
+ if sdk_home is not None:
22
+ self._sdk_home = Path(sdk_home).expanduser().resolve()
23
+ else:
24
+ env_home = os.environ.get("FRACLAB_SDK_HOME")
25
+ if env_home:
26
+ self._sdk_home = Path(env_home).expanduser().resolve()
27
+ else:
28
+ self._sdk_home = (Path.home() / ".fraclab").expanduser().resolve()
29
+
30
+ @property
31
+ def sdk_home(self) -> Path:
32
+ """Root SDK home directory."""
33
+ return self._sdk_home
34
+
35
+ @property
36
+ def snapshots_dir(self) -> Path:
37
+ """Directory for snapshot storage."""
38
+ return self._sdk_home / "snapshots"
39
+
40
+ @property
41
+ def algorithms_dir(self) -> Path:
42
+ """Directory for algorithm storage."""
43
+ return self._sdk_home / "algorithms"
44
+
45
+ @property
46
+ def runs_dir(self) -> Path:
47
+ """Directory for run storage."""
48
+ return self._sdk_home / "runs"
49
+
50
+ def ensure_dirs(self) -> None:
51
+ """Create all SDK directories if they don't exist."""
52
+ self.snapshots_dir.mkdir(parents=True, exist_ok=True)
53
+ self.algorithms_dir.mkdir(parents=True, exist_ok=True)
54
+ self.runs_dir.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,25 @@
1
+ """Development toolkit for algorithm compilation and validation.
2
+
3
+ This module provides tools for:
4
+ - Compiling algorithm workspaces (generating dist/ artifacts)
5
+ - Exporting algorithms as distributable packages
6
+ - Validating InputSpec, OutputContract, and run manifests
7
+ """
8
+
9
+ from fraclab_sdk.devkit.compile import compile_algorithm
10
+ from fraclab_sdk.devkit.export import export_algorithm_package
11
+ from fraclab_sdk.devkit.validate import (
12
+ validate_bundle,
13
+ validate_inputspec,
14
+ validate_output_contract,
15
+ validate_run_manifest,
16
+ )
17
+
18
+ __all__ = [
19
+ "compile_algorithm",
20
+ "export_algorithm_package",
21
+ "validate_bundle",
22
+ "validate_inputspec",
23
+ "validate_output_contract",
24
+ "validate_run_manifest",
25
+ ]