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,303 @@
1
+ """Artifact writer for algorithm runtime."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from fraclab_sdk.errors import OutputContainmentError
9
+
10
+
11
+ @dataclass
12
+ class ArtifactRecord:
13
+ """Record of a written artifact."""
14
+
15
+ dataset_key: str
16
+ owner: dict[str, Any] | None
17
+ dims: dict[str, Any] | None
18
+ meta: dict[str, Any] | None
19
+ inline: dict[str, Any] | None
20
+ item_key: str | None
21
+ artifact_key: str
22
+ artifact_type: str # "scalar", "blob", "json"
23
+ mime_type: str | None = None
24
+ file_uri: str | None = None
25
+ value: Any = None
26
+
27
+
28
+ class ArtifactWriter:
29
+ """Writer for algorithm output artifacts with containment enforcement."""
30
+
31
+ def __init__(self, output_dir: Path) -> None:
32
+ """Initialize artifact writer.
33
+
34
+ Args:
35
+ output_dir: The run output directory. All writes must be under this.
36
+ """
37
+ self._output_dir = output_dir.resolve()
38
+ self._artifacts_dir = self._output_dir / "artifacts"
39
+ self._artifacts_dir.mkdir(parents=True, exist_ok=True)
40
+ self._records: list[ArtifactRecord] = []
41
+
42
+ def _validate_path(self, path: Path) -> Path:
43
+ """Validate that path is within output directory.
44
+
45
+ Args:
46
+ path: Path to validate.
47
+
48
+ Returns:
49
+ Resolved path.
50
+
51
+ Raises:
52
+ OutputContainmentError: If path is outside output directory.
53
+ """
54
+ resolved = path.resolve()
55
+ try:
56
+ resolved.relative_to(self._output_dir)
57
+ except ValueError:
58
+ raise OutputContainmentError(str(resolved), str(self._output_dir)) from None
59
+ return resolved
60
+
61
+ def write_scalar(
62
+ self,
63
+ artifact_key: str,
64
+ value: Any,
65
+ *,
66
+ dataset_key: str = "artifacts",
67
+ owner: dict[str, Any] | None = None,
68
+ dims: dict[str, Any] | None = None,
69
+ meta: dict[str, Any] | None = None,
70
+ inline: dict[str, Any] | None = None,
71
+ item_key: str | None = None,
72
+ ) -> None:
73
+ """Write a scalar artifact (number, string, bool).
74
+
75
+ Args:
76
+ artifact_key: Unique key for the artifact.
77
+ value: Scalar value to store.
78
+ """
79
+ self._records.append(
80
+ ArtifactRecord(
81
+ dataset_key=dataset_key,
82
+ owner=owner,
83
+ dims=dims,
84
+ meta=meta,
85
+ inline=inline,
86
+ item_key=item_key,
87
+ artifact_key=artifact_key,
88
+ artifact_type="scalar",
89
+ value=value,
90
+ )
91
+ )
92
+
93
+ def write_json(
94
+ self,
95
+ artifact_key: str,
96
+ data: Any,
97
+ filename: str | None = None,
98
+ *,
99
+ dataset_key: str = "artifacts",
100
+ owner: dict[str, Any] | None = None,
101
+ dims: dict[str, Any] | None = None,
102
+ meta: dict[str, Any] | None = None,
103
+ inline: dict[str, Any] | None = None,
104
+ item_key: str | None = None,
105
+ ) -> Path:
106
+ """Write a JSON artifact.
107
+
108
+ Args:
109
+ artifact_key: Unique key for the artifact.
110
+ data: Data to serialize as JSON.
111
+ filename: Optional filename. Defaults to {artifact_key}.json.
112
+ dataset_key: Dataset key this artifact belongs to.
113
+ owner: Optional owner map (stageId/wellId/platformId).
114
+ dims: Optional dimensions values.
115
+ meta: Optional meta info.
116
+ inline: Optional inline payload.
117
+ item_key: Optional item key override.
118
+
119
+ Returns:
120
+ Path to the written file.
121
+ """
122
+ if filename is None:
123
+ filename = f"{artifact_key}.json"
124
+
125
+ file_path = self._artifacts_dir / filename
126
+ file_path = self._validate_path(file_path)
127
+
128
+ content = json.dumps(data, indent=2, ensure_ascii=False)
129
+ file_path.write_text(content, encoding="utf-8")
130
+
131
+ file_uri = f"file://{file_path}"
132
+ self._records.append(
133
+ ArtifactRecord(
134
+ dataset_key=dataset_key,
135
+ owner=owner,
136
+ dims=dims,
137
+ meta=meta,
138
+ inline=inline,
139
+ item_key=item_key,
140
+ artifact_key=artifact_key,
141
+ artifact_type="json",
142
+ mime_type="application/json",
143
+ file_uri=file_uri,
144
+ )
145
+ )
146
+ return file_path
147
+
148
+ def write_blob(
149
+ self,
150
+ artifact_key: str,
151
+ data: bytes,
152
+ filename: str,
153
+ mime_type: str | None = None,
154
+ *,
155
+ dataset_key: str = "artifacts",
156
+ owner: dict[str, Any] | None = None,
157
+ dims: dict[str, Any] | None = None,
158
+ meta: dict[str, Any] | None = None,
159
+ inline: dict[str, Any] | None = None,
160
+ item_key: str | None = None,
161
+ ) -> Path:
162
+ """Write a binary blob artifact.
163
+
164
+ Args:
165
+ artifact_key: Unique key for the artifact.
166
+ data: Binary data to write.
167
+ filename: Filename for the blob.
168
+ mime_type: MIME type of the data.
169
+ dataset_key: Dataset key this artifact belongs to.
170
+ owner: Optional owner map (stageId/wellId/platformId).
171
+ dims: Optional dimensions values.
172
+ meta: Optional meta info.
173
+ inline: Optional inline payload.
174
+ item_key: Optional item key override.
175
+
176
+ Returns:
177
+ Path to the written file.
178
+ """
179
+ file_path = self._artifacts_dir / filename
180
+ file_path = self._validate_path(file_path)
181
+
182
+ file_path.write_bytes(data)
183
+
184
+ file_uri = f"file://{file_path}"
185
+ self._records.append(
186
+ ArtifactRecord(
187
+ dataset_key=dataset_key,
188
+ owner=owner,
189
+ dims=dims,
190
+ meta=meta,
191
+ inline=inline,
192
+ item_key=item_key,
193
+ artifact_key=artifact_key,
194
+ artifact_type="blob",
195
+ mime_type=mime_type,
196
+ file_uri=file_uri,
197
+ )
198
+ )
199
+ return file_path
200
+
201
+ def write_file(
202
+ self,
203
+ artifact_key: str,
204
+ source_path: Path,
205
+ filename: str | None = None,
206
+ mime_type: str | None = None,
207
+ *,
208
+ dataset_key: str = "artifacts",
209
+ owner: dict[str, Any] | None = None,
210
+ dims: dict[str, Any] | None = None,
211
+ meta: dict[str, Any] | None = None,
212
+ inline: dict[str, Any] | None = None,
213
+ item_key: str | None = None,
214
+ ) -> Path:
215
+ """Copy a file as an artifact.
216
+
217
+ Args:
218
+ artifact_key: Unique key for the artifact.
219
+ source_path: Path to source file.
220
+ filename: Optional destination filename. Defaults to source name.
221
+ mime_type: MIME type of the file.
222
+ dataset_key: Dataset key this artifact belongs to.
223
+ owner: Optional owner map (stageId/wellId/platformId).
224
+ dims: Optional dimensions values.
225
+ meta: Optional meta info.
226
+ inline: Optional inline payload.
227
+ item_key: Optional item key override.
228
+
229
+ Returns:
230
+ Path to the copied file.
231
+ """
232
+ import shutil
233
+
234
+ if filename is None:
235
+ filename = source_path.name
236
+
237
+ dest_path = self._artifacts_dir / filename
238
+ dest_path = self._validate_path(dest_path)
239
+
240
+ shutil.copy2(source_path, dest_path)
241
+
242
+ file_uri = f"file://{dest_path}"
243
+ self._records.append(
244
+ ArtifactRecord(
245
+ dataset_key=dataset_key,
246
+ owner=owner,
247
+ dims=dims,
248
+ meta=meta,
249
+ inline=inline,
250
+ item_key=item_key,
251
+ artifact_key=artifact_key,
252
+ artifact_type="blob",
253
+ mime_type=mime_type,
254
+ file_uri=file_uri,
255
+ )
256
+ )
257
+ return dest_path
258
+
259
+ def get_records(self) -> list[ArtifactRecord]:
260
+ """Get all artifact records."""
261
+ return self._records.copy()
262
+
263
+ def build_manifest_datasets(self) -> list[dict]:
264
+ """Build dataset -> items structure for output manifest."""
265
+ by_ds: dict[str, list[ArtifactRecord]] = {}
266
+ for rec in self._records:
267
+ by_ds.setdefault(rec.dataset_key, []).append(rec)
268
+
269
+ datasets: list[dict[str, Any]] = []
270
+ for ds_key, records in by_ds.items():
271
+ items: list[dict[str, Any]] = []
272
+ for rec in records:
273
+ artifact = {
274
+ "artifactKey": rec.artifact_key,
275
+ "type": rec.artifact_type,
276
+ }
277
+ if rec.mime_type:
278
+ artifact["mimeType"] = rec.mime_type
279
+ if rec.file_uri is not None:
280
+ artifact["uri"] = rec.file_uri
281
+ if rec.value is not None:
282
+ artifact["value"] = rec.value
283
+ if rec.inline is not None:
284
+ artifact["inline"] = rec.inline
285
+
286
+ item: dict[str, Any] = {
287
+ "itemKey": rec.item_key or rec.artifact_key,
288
+ "artifact": artifact,
289
+ }
290
+ if rec.owner:
291
+ item["owner"] = rec.owner
292
+ if rec.dims:
293
+ item["dims"] = rec.dims
294
+ if rec.meta:
295
+ item["meta"] = rec.meta
296
+ if rec.inline is not None:
297
+ item["inline"] = rec.inline
298
+
299
+ items.append(item)
300
+
301
+ datasets.append({"datasetKey": ds_key, "items": items})
302
+
303
+ return datasets
@@ -0,0 +1,123 @@
1
+ """Data client for algorithm runtime."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from fraclab_sdk.models import DataSpec
7
+
8
+
9
+ class DataClient:
10
+ """Client for reading input data during algorithm execution."""
11
+
12
+ def __init__(self, input_dir: Path) -> None:
13
+ """Initialize data client.
14
+
15
+ Args:
16
+ input_dir: The run input directory containing ds.json and data/.
17
+ """
18
+ self._input_dir = input_dir
19
+ self._dataspec: DataSpec | None = None
20
+
21
+ @property
22
+ def dataspec(self) -> DataSpec:
23
+ """Get the data specification."""
24
+ if self._dataspec is None:
25
+ ds_path = self._input_dir / "ds.json"
26
+ self._dataspec = DataSpec.model_validate_json(ds_path.read_text())
27
+ return self._dataspec
28
+
29
+ def get_dataset_keys(self) -> list[str]:
30
+ """Get list of available dataset keys."""
31
+ return self.dataspec.get_dataset_keys()
32
+
33
+ def get_item_count(self, dataset_key: str) -> int:
34
+ """Get number of items in a dataset."""
35
+ dataset = self.dataspec.get_dataset(dataset_key)
36
+ if dataset is None:
37
+ raise KeyError(f"Dataset not found: {dataset_key}")
38
+ return len(dataset.items)
39
+
40
+ def get_layout(self, dataset_key: str) -> str | None:
41
+ """Get the layout type for a dataset."""
42
+ dataset = self.dataspec.get_dataset(dataset_key)
43
+ if dataset is None:
44
+ raise KeyError(f"Dataset not found: {dataset_key}")
45
+ return dataset.layout
46
+
47
+ def read_object(self, dataset_key: str, item_index: int) -> dict:
48
+ """Read an object from ndjson dataset.
49
+
50
+ Args:
51
+ dataset_key: The dataset key.
52
+ item_index: The item index (0-based, run-indexed).
53
+
54
+ Returns:
55
+ Parsed JSON object.
56
+ """
57
+ layout = self.get_layout(dataset_key)
58
+ if layout != "object_ndjson_lines":
59
+ raise ValueError(
60
+ f"Cannot read object from layout '{layout}', "
61
+ f"expected 'object_ndjson_lines'"
62
+ )
63
+
64
+ ndjson_path = self._input_dir / "data" / dataset_key / "object.ndjson"
65
+ with ndjson_path.open() as f:
66
+ for i, line in enumerate(f):
67
+ if i == item_index:
68
+ return json.loads(line)
69
+
70
+ raise IndexError(f"Item index {item_index} not found")
71
+
72
+ def get_parquet_dir(self, dataset_key: str, item_index: int) -> Path:
73
+ """Get path to parquet item directory.
74
+
75
+ Args:
76
+ dataset_key: The dataset key.
77
+ item_index: The item index (0-based, run-indexed).
78
+
79
+ Returns:
80
+ Path to the item directory.
81
+ """
82
+ layout = self.get_layout(dataset_key)
83
+ if layout != "frame_parquet_item_dirs":
84
+ raise ValueError(
85
+ f"Cannot get parquet dir from layout '{layout}', "
86
+ f"expected 'frame_parquet_item_dirs'"
87
+ )
88
+
89
+ return self._input_dir / "data" / dataset_key / "parquet" / f"item-{item_index:05d}"
90
+
91
+ def get_parquet_files(self, dataset_key: str, item_index: int) -> list[Path]:
92
+ """Get list of parquet files for an item.
93
+
94
+ Args:
95
+ dataset_key: The dataset key.
96
+ item_index: The item index (0-based, run-indexed).
97
+
98
+ Returns:
99
+ List of parquet file paths.
100
+ """
101
+ item_dir = self.get_parquet_dir(dataset_key, item_index)
102
+ return list(item_dir.rglob("*.parquet"))
103
+
104
+ def iterate_objects(self, dataset_key: str):
105
+ """Iterate over all objects in an ndjson dataset.
106
+
107
+ Args:
108
+ dataset_key: The dataset key.
109
+
110
+ Yields:
111
+ Tuple of (index, object dict).
112
+ """
113
+ layout = self.get_layout(dataset_key)
114
+ if layout != "object_ndjson_lines":
115
+ raise ValueError(
116
+ f"Cannot iterate objects from layout '{layout}', "
117
+ f"expected 'object_ndjson_lines'"
118
+ )
119
+
120
+ ndjson_path = self._input_dir / "data" / dataset_key / "object.ndjson"
121
+ with ndjson_path.open() as f:
122
+ for i, line in enumerate(f):
123
+ yield i, json.loads(line)
@@ -0,0 +1,286 @@
1
+ """Main entry point for algorithm runner subprocess."""
2
+
3
+ import importlib.util
4
+ import json
5
+ import logging
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ import traceback
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from fraclab_sdk.runtime.artifacts import ArtifactWriter
16
+ from fraclab_sdk.runtime.data_client import DataClient
17
+
18
+
19
+ def validate_manifest_against_contract(
20
+ manifest: dict[str, Any],
21
+ contract_path: Path,
22
+ logger: logging.Logger,
23
+ ) -> tuple[bool, list[str]]:
24
+ """Validate run output manifest against OutputContract.
25
+
26
+ The manifest has a flat artifacts list, while the contract has hierarchical
27
+ datasets -> items -> artifacts structure. We validate that all contract
28
+ artifacts are present in the manifest.
29
+
30
+ Args:
31
+ manifest: The run output manifest dict.
32
+ contract_path: Path to output_contract.json.
33
+ logger: Logger for diagnostics.
34
+
35
+ Returns:
36
+ Tuple of (is_valid, list of error messages).
37
+ """
38
+ if not contract_path.exists():
39
+ logger.debug(f"No contract found at {contract_path}, skipping validation")
40
+ return True, []
41
+
42
+ try:
43
+ contract = json.loads(contract_path.read_text())
44
+ except (json.JSONDecodeError, OSError) as e:
45
+ logger.warning(f"Failed to load contract: {e}")
46
+ return True, [] # Don't fail on contract load errors
47
+
48
+ errors: list[str] = []
49
+
50
+ # Get all artifact keys from manifest (flat list)
51
+ manifest_artifact_keys = {
52
+ a.get("artifactKey") or a.get("key")
53
+ for a in manifest.get("artifacts", [])
54
+ }
55
+ manifest_artifact_keys.discard(None)
56
+
57
+ # Also check datasets structure if present (for hierarchical manifests)
58
+ for ds in manifest.get("datasets", []):
59
+ for item in ds.get("items", []):
60
+ for art in item.get("artifacts", []):
61
+ key = art.get("artifactKey") or art.get("key")
62
+ if key:
63
+ manifest_artifact_keys.add(key)
64
+
65
+ # Extract all required artifact keys from contract
66
+ required_artifacts: list[tuple[str, str, str]] = [] # (ds_key, item_key, art_key)
67
+ for ds in contract.get("datasets", []):
68
+ ds_key = ds.get("key", "")
69
+ for item in ds.get("items", []):
70
+ item_key = item.get("key", "")
71
+ for art in item.get("artifacts", []):
72
+ art_key = art.get("key", "")
73
+ if art_key:
74
+ required_artifacts.append((ds_key, item_key, art_key))
75
+
76
+ # Check all required artifacts are present
77
+ for ds_key, item_key, art_key in required_artifacts:
78
+ if art_key not in manifest_artifact_keys:
79
+ errors.append(f"Missing artifact: {ds_key}/{item_key}/{art_key}")
80
+
81
+ if errors:
82
+ logger.warning(
83
+ f"Contract validation found {len(errors)} missing artifacts. "
84
+ f"Required: {[a[2] for a in required_artifacts]}, "
85
+ f"Found: {manifest_artifact_keys}"
86
+ )
87
+
88
+ return len(errors) == 0, errors
89
+
90
+
91
+ @dataclass
92
+ class RunContext:
93
+ """Context provided to algorithm's run() function."""
94
+
95
+ data_client: DataClient
96
+ params: dict[str, Any]
97
+ artifacts: ArtifactWriter
98
+ logger: logging.Logger
99
+ run_context: dict[str, Any]
100
+
101
+
102
+ def load_algorithm_module(algorithm_path: Path):
103
+ """Load algorithm.py as a module.
104
+
105
+ Args:
106
+ algorithm_path: Path to algorithm.py file.
107
+
108
+ Returns:
109
+ Loaded module.
110
+ """
111
+ spec = importlib.util.spec_from_file_location("algorithm", algorithm_path)
112
+ if spec is None or spec.loader is None:
113
+ raise RuntimeError(f"Failed to load algorithm from {algorithm_path}")
114
+
115
+ module = importlib.util.module_from_spec(spec)
116
+ sys.modules["algorithm"] = module
117
+ spec.loader.exec_module(module)
118
+ return module
119
+
120
+
121
+ def write_manifest_atomic(output_dir: Path, manifest: dict) -> None:
122
+ """Write manifest.json atomically.
123
+
124
+ Writes to temp file, then renames to ensure atomic operation.
125
+
126
+ Args:
127
+ output_dir: Output directory.
128
+ manifest: Manifest dict to write.
129
+ """
130
+ manifest_path = output_dir / "manifest.json"
131
+ content = json.dumps(manifest, indent=2, ensure_ascii=False)
132
+
133
+ # Write to temp file in same directory (ensures same filesystem)
134
+ fd, tmp_path = tempfile.mkstemp(
135
+ dir=output_dir, prefix="manifest_", suffix=".json.tmp"
136
+ )
137
+ try:
138
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
139
+ f.write(content)
140
+ f.flush()
141
+ os.fsync(f.fileno())
142
+
143
+ # Atomic rename
144
+ os.rename(tmp_path, manifest_path)
145
+ except Exception:
146
+ # Clean up temp file on error
147
+ if os.path.exists(tmp_path):
148
+ os.unlink(tmp_path)
149
+ raise
150
+
151
+
152
+ def run_algorithm(run_dir: Path, algorithm_path: Path) -> int:
153
+ """Run the algorithm.
154
+
155
+ Args:
156
+ run_dir: The run directory.
157
+ algorithm_path: Path to algorithm.py.
158
+
159
+ Returns:
160
+ Exit code (0 for success, 1 for failure).
161
+ """
162
+ input_dir = run_dir / "input"
163
+ output_dir = run_dir / "output"
164
+ output_dir.mkdir(parents=True, exist_ok=True)
165
+
166
+ # Set up logging
167
+ logs_dir = output_dir / "_logs"
168
+ logs_dir.mkdir(exist_ok=True)
169
+
170
+ logger = logging.getLogger("algorithm")
171
+ logger.setLevel(logging.DEBUG)
172
+
173
+ # File handler
174
+ log_file = logs_dir / "algorithm.log"
175
+ file_handler = logging.FileHandler(log_file)
176
+ file_handler.setLevel(logging.DEBUG)
177
+ file_handler.setFormatter(
178
+ logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
179
+ )
180
+ logger.addHandler(file_handler)
181
+
182
+ # Console handler
183
+ console_handler = logging.StreamHandler()
184
+ console_handler.setLevel(logging.INFO)
185
+ console_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
186
+ logger.addHandler(console_handler)
187
+
188
+ start_time = datetime.now()
189
+ exit_code = 0
190
+ error_message = None
191
+
192
+ try:
193
+ # Load input data
194
+ params = json.loads((input_dir / "params.json").read_text())
195
+ run_context_data = json.loads((input_dir / "run_context.json").read_text())
196
+
197
+ # Create context components
198
+ data_client = DataClient(input_dir)
199
+ artifacts = ArtifactWriter(output_dir)
200
+
201
+ ctx = RunContext(
202
+ data_client=data_client,
203
+ params=params,
204
+ artifacts=artifacts,
205
+ logger=logger,
206
+ run_context=run_context_data,
207
+ )
208
+
209
+ # Load and run algorithm
210
+ logger.info(f"Loading algorithm from {algorithm_path}")
211
+ module = load_algorithm_module(algorithm_path)
212
+
213
+ if not hasattr(module, "run"):
214
+ raise RuntimeError("Algorithm module must define a 'run' function")
215
+ logger.info("Starting algorithm execution")
216
+ module.run(ctx)
217
+ logger.info("Algorithm execution completed successfully")
218
+
219
+ except Exception as e:
220
+ exit_code = 1
221
+ error_message = f"{type(e).__name__}: {e}"
222
+ logger.error(f"Algorithm failed: {error_message}")
223
+ logger.debug(traceback.format_exc())
224
+
225
+ end_time = datetime.now()
226
+
227
+ # Build output manifest
228
+ manifest = {
229
+ "schemaVersion": "1.0",
230
+ "run": run_context_data if "run_context_data" in dir() else {},
231
+ "status": "succeeded" if exit_code == 0 else "failed",
232
+ "startedAt": start_time.isoformat(),
233
+ "completedAt": end_time.isoformat(),
234
+ "datasets": artifacts.build_manifest_datasets() if exit_code == 0 else [],
235
+ }
236
+
237
+ if error_message:
238
+ manifest["error"] = error_message
239
+
240
+ # Validate manifest against OutputContract if algorithm succeeded
241
+ if exit_code == 0:
242
+ # Find output_contract.json in algorithm's dist/ directory
243
+ algorithm_dir = algorithm_path.parent
244
+ contract_path = algorithm_dir / "dist" / "output_contract.json"
245
+
246
+ is_valid, validation_errors = validate_manifest_against_contract(
247
+ manifest, contract_path, logger
248
+ )
249
+
250
+ if not is_valid:
251
+ exit_code = 1
252
+ error_message = f"Output validation failed: {'; '.join(validation_errors)}"
253
+ manifest["status"] = "failed"
254
+ manifest["error"] = error_message
255
+ manifest["validationErrors"] = validation_errors
256
+ logger.error(f"Output validation failed: {validation_errors}")
257
+
258
+ # Write manifest atomically
259
+ write_manifest_atomic(output_dir, manifest)
260
+
261
+ return exit_code
262
+
263
+
264
+ def main() -> None:
265
+ """Entry point for fraclab-runner command."""
266
+ if len(sys.argv) != 3:
267
+ print(f"Usage: {sys.argv[0]} <run_dir> <algorithm_path>", file=sys.stderr)
268
+ sys.exit(2)
269
+
270
+ run_dir = Path(sys.argv[1])
271
+ algorithm_path = Path(sys.argv[2])
272
+
273
+ if not run_dir.exists():
274
+ print(f"Run directory not found: {run_dir}", file=sys.stderr)
275
+ sys.exit(2)
276
+
277
+ if not algorithm_path.exists():
278
+ print(f"Algorithm not found: {algorithm_path}", file=sys.stderr)
279
+ sys.exit(2)
280
+
281
+ exit_code = run_algorithm(run_dir, algorithm_path)
282
+ sys.exit(exit_code)
283
+
284
+
285
+ if __name__ == "__main__":
286
+ main()