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
|
@@ -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()
|