ds-crawler 2.13.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.
ds_crawler/__init__.py ADDED
@@ -0,0 +1,119 @@
1
+ """Dataset crawler package."""
2
+
3
+ from .artifact_builder import (
4
+ build_crawler_config,
5
+ build_dataset_artifacts_from_files,
6
+ build_dataset_head,
7
+ )
8
+ from .config import Config, DatasetConfig, load_dataset_config
9
+ from .layout import (
10
+ EULER_LAYOUT_ADDON,
11
+ EULER_LAYOUT_VERSION,
12
+ build_layout_addon,
13
+ get_layout_addon,
14
+ validate_layout_addon,
15
+ )
16
+ from .migration import (
17
+ migrate_dataset_metadata,
18
+ migrate_dataset_zip,
19
+ migrate_dataset_zips_in_folder,
20
+ )
21
+ from .operations import (
22
+ HierarchySplitClause,
23
+ HierarchySplitRule,
24
+ align_datasets,
25
+ copy_dataset,
26
+ copy_dataset_splits,
27
+ create_aligned_dataset_splits,
28
+ create_dataset_splits,
29
+ create_hierarchy_dataset_splits,
30
+ create_mapped_dataset_splits,
31
+ extract_datasets,
32
+ list_dataset_splits,
33
+ load_dataset_split,
34
+ split_dataset,
35
+ split_datasets,
36
+ )
37
+ from .parser import (
38
+ DatasetParser,
39
+ index_dataset,
40
+ index_dataset_from_files,
41
+ index_dataset_from_path,
42
+ )
43
+ from .schema import (
44
+ DatasetDescriptor,
45
+ extract_dataset_properties,
46
+ get_dataset_contract,
47
+ get_dataset_properties,
48
+ )
49
+ from .traversal import (
50
+ collect_qualified_ids,
51
+ filter_index_by_qualified_ids,
52
+ get_files,
53
+ split_qualified_ids,
54
+ )
55
+ from .validation import validate_crawler_config, validate_dataset, validate_output
56
+ from .writer import DatasetWriter, ZipDatasetWriter
57
+ from .zip_utils import list_metadata_scopes, validate_metadata_scope
58
+
59
+ __all__ = [
60
+ 'ValidatedDatasetWriter',
61
+ 'finalize_output',
62
+ 'read_record',
63
+ 'validate_output_records',
64
+
65
+ "DatasetDescriptor",
66
+ "DatasetWriter",
67
+ "ZipDatasetWriter",
68
+ "Config",
69
+ "DatasetConfig",
70
+ "DatasetParser",
71
+ "EULER_LAYOUT_ADDON",
72
+ "EULER_LAYOUT_VERSION",
73
+ "align_datasets",
74
+ "build_crawler_config",
75
+ "build_dataset_artifacts_from_files",
76
+ "build_dataset_head",
77
+ "build_layout_addon",
78
+ "collect_qualified_ids",
79
+ "copy_dataset",
80
+ "copy_dataset_splits",
81
+ "create_aligned_dataset_splits",
82
+ "create_dataset_splits",
83
+ "create_hierarchy_dataset_splits",
84
+ "create_mapped_dataset_splits",
85
+ "extract_datasets",
86
+ "HierarchySplitClause",
87
+ "HierarchySplitRule",
88
+ "filter_index_by_qualified_ids",
89
+ "get_files",
90
+ "get_dataset_contract",
91
+ "get_dataset_properties",
92
+ "get_layout_addon",
93
+ "index_dataset",
94
+ "index_dataset_from_files",
95
+ "index_dataset_from_path",
96
+ "list_dataset_splits",
97
+ "list_metadata_scopes",
98
+ "load_dataset_config",
99
+ "load_dataset_split",
100
+ "migrate_dataset_metadata",
101
+ "migrate_dataset_zip",
102
+ "migrate_dataset_zips_in_folder",
103
+ "split_dataset",
104
+ "split_datasets",
105
+ "split_qualified_ids",
106
+ "validate_crawler_config",
107
+ "validate_dataset",
108
+ "validate_metadata_scope",
109
+ "validate_layout_addon",
110
+ "validate_output",
111
+ "extract_dataset_properties",
112
+ ]
113
+
114
+ from .records import ( # noqa: F401
115
+ ValidatedDatasetWriter,
116
+ finalize_output,
117
+ read_record,
118
+ validate_output_records,
119
+ )
@@ -0,0 +1,45 @@
1
+ """Imports from the shared ``euler-dataset-contract`` package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from euler_dataset_contract import ( # type: ignore[attr-defined]
6
+ DATASET_CONTRACT_VERSION,
7
+ DATASET_HEAD_KIND,
8
+ MODALITY_META_SCHEMAS,
9
+ DatasetHeadContract,
10
+ build_default_meta,
11
+ build_meta_schema,
12
+ get_registered_addon_validators,
13
+ normalize_meta_dict,
14
+ parse_dataset_head,
15
+ register_addon_validator,
16
+ validate_addon_version,
17
+ validate_contract_kind,
18
+ validate_contract_version,
19
+ validate_dataset_head,
20
+ validate_meta_dict,
21
+ validate_slot,
22
+ validate_string_list,
23
+ validate_token,
24
+ )
25
+
26
+ __all__ = [
27
+ "DATASET_CONTRACT_VERSION",
28
+ "DATASET_HEAD_KIND",
29
+ "MODALITY_META_SCHEMAS",
30
+ "DatasetHeadContract",
31
+ "build_default_meta",
32
+ "build_meta_schema",
33
+ "get_registered_addon_validators",
34
+ "normalize_meta_dict",
35
+ "parse_dataset_head",
36
+ "register_addon_validator",
37
+ "validate_addon_version",
38
+ "validate_contract_kind",
39
+ "validate_contract_version",
40
+ "validate_dataset_head",
41
+ "validate_meta_dict",
42
+ "validate_slot",
43
+ "validate_string_list",
44
+ "validate_token",
45
+ ]
ds_crawler/_version.py ADDED
@@ -0,0 +1,13 @@
1
+ """Package version helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+
8
+ def get_package_version() -> str:
9
+ """Return the installed ds_crawler package version, or ``"0"`` as fallback."""
10
+ try:
11
+ return version("ds_crawler")
12
+ except PackageNotFoundError:
13
+ return "0"
@@ -0,0 +1,255 @@
1
+ """Public helpers for building canonical dataset artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from pathlib import Path
7
+ from typing import Any, Iterable
8
+
9
+ from ._dataset_contract import (
10
+ DATASET_CONTRACT_VERSION,
11
+ build_default_meta,
12
+ parse_dataset_head,
13
+ )
14
+ from .artifacts import build_index_artifact
15
+ from .config import (
16
+ CONFIG_FILENAME,
17
+ CRAWLER_CONFIG_KIND,
18
+ CRAWLER_CONFIG_VERSION,
19
+ DatasetConfig,
20
+ )
21
+ from .parser import index_dataset_from_files
22
+ from .traversal import get_files
23
+ from .zip_utils import (
24
+ DATASET_HEAD_FILENAME,
25
+ OUTPUT_FILENAME,
26
+ SCOPES_FILENAME,
27
+ _scopes_manifest_with,
28
+ get_metadata_filename,
29
+ validate_metadata_scope,
30
+ )
31
+
32
+
33
+ def _require_mapping(value: Any, context: str) -> dict[str, Any]:
34
+ if not isinstance(value, dict):
35
+ raise ValueError(f"{context} must be an object")
36
+ return value
37
+
38
+
39
+ def _slugify(value: str) -> str:
40
+ cleaned = "".join(
41
+ ch.lower() if ch.isalnum() else "_"
42
+ for ch in value.strip()
43
+ )
44
+ cleaned = "_".join(token for token in cleaned.split("_") if token)
45
+ return cleaned or "dataset"
46
+
47
+
48
+ def _reject_unknown_keys(
49
+ value: dict[str, Any],
50
+ *,
51
+ allowed: set[str],
52
+ context: str,
53
+ ) -> None:
54
+ unknown = sorted(set(value) - allowed)
55
+ if unknown:
56
+ joined = ", ".join(unknown)
57
+ raise ValueError(f"Unknown {context} key(s): {joined}")
58
+
59
+
60
+ def _merge_meta_defaults(
61
+ modality_key: str,
62
+ raw_meta: Any,
63
+ ) -> dict[str, Any] | None:
64
+ defaults = build_default_meta(modality_key)
65
+ if raw_meta is None:
66
+ return defaults
67
+ if not isinstance(raw_meta, dict):
68
+ raise ValueError("modality.meta must be an object")
69
+ if defaults is None:
70
+ return dict(raw_meta)
71
+ merged = dict(defaults)
72
+ merged.update(raw_meta)
73
+ return merged
74
+
75
+
76
+ def build_dataset_head(
77
+ *,
78
+ dataset: dict[str, Any],
79
+ modality: dict[str, Any],
80
+ addons: dict[str, Any] | None = None,
81
+ ) -> dict[str, Any]:
82
+ """Build and validate a canonical ``dataset-head.json`` mapping."""
83
+ dataset = _require_mapping(dataset, "dataset")
84
+ modality = _require_mapping(modality, "modality")
85
+ addons = {} if addons is None else _require_mapping(addons, "addons")
86
+
87
+ _reject_unknown_keys(dataset, allowed={"id", "name", "attributes"}, context="dataset")
88
+ _reject_unknown_keys(modality, allowed={"key", "meta"}, context="modality")
89
+
90
+ dataset_name = dataset.get("name")
91
+ if not isinstance(dataset_name, str) or not dataset_name:
92
+ raise ValueError("dataset.name must be a non-empty string")
93
+
94
+ dataset_id = dataset.get("id")
95
+ if dataset_id is None:
96
+ dataset_id = _slugify(dataset_name)
97
+ elif not isinstance(dataset_id, str) or not dataset_id:
98
+ raise ValueError("dataset.id must be a non-empty string when provided")
99
+
100
+ attributes = dataset.get("attributes", {})
101
+ if attributes is None:
102
+ attributes = {}
103
+ if not isinstance(attributes, dict):
104
+ raise ValueError("dataset.attributes must be an object")
105
+
106
+ modality_key = modality.get("key")
107
+ if not isinstance(modality_key, str) or not modality_key:
108
+ raise ValueError("modality.key must be a non-empty string")
109
+
110
+ head = {
111
+ "contract": {
112
+ "kind": "dataset_head",
113
+ "version": DATASET_CONTRACT_VERSION,
114
+ },
115
+ "dataset": {
116
+ "id": dataset_id,
117
+ "name": dataset_name,
118
+ },
119
+ "modality": {
120
+ "key": modality_key,
121
+ "meta": _merge_meta_defaults(modality_key, modality.get("meta")),
122
+ },
123
+ "addons": deepcopy(addons),
124
+ }
125
+ if attributes:
126
+ head["dataset"]["attributes"] = deepcopy(attributes)
127
+ if not head["addons"]:
128
+ head.pop("addons")
129
+
130
+ return parse_dataset_head(head, context="dataset_head").to_mapping()
131
+
132
+
133
+ def build_crawler_config(
134
+ *,
135
+ head: dict[str, Any],
136
+ indexing: dict[str, Any],
137
+ source_path: str = ".",
138
+ head_file: str = DATASET_HEAD_FILENAME,
139
+ prebuilt_index_file: str | None = None,
140
+ metadata_scope: str | None = None,
141
+ ) -> dict[str, Any]:
142
+ """Build and validate a canonical ``ds-crawler.json`` mapping."""
143
+ indexing = _require_mapping(indexing, "indexing")
144
+ if not isinstance(source_path, str) or not source_path:
145
+ raise ValueError("source_path must be a non-empty string")
146
+ if not isinstance(head_file, str) or not head_file:
147
+ raise ValueError("head_file must be a non-empty string")
148
+ if prebuilt_index_file is not None and (
149
+ not isinstance(prebuilt_index_file, str) or not prebuilt_index_file
150
+ ):
151
+ raise ValueError("prebuilt_index_file must be a non-empty string when provided")
152
+
153
+ config: dict[str, Any] = {
154
+ "contract": {
155
+ "kind": CRAWLER_CONFIG_KIND,
156
+ "version": CRAWLER_CONFIG_VERSION,
157
+ },
158
+ "head_file": head_file,
159
+ "source": {
160
+ "path": source_path,
161
+ },
162
+ "indexing": deepcopy(indexing),
163
+ }
164
+ if prebuilt_index_file is not None:
165
+ config["source"]["prebuilt_index_file"] = prebuilt_index_file
166
+ if metadata_scope is not None:
167
+ config["metadata_scope"] = validate_metadata_scope(metadata_scope)
168
+
169
+ runtime_config = dict(config)
170
+ runtime_config["head"] = deepcopy(head)
171
+ dataset_config = DatasetConfig.from_dict(
172
+ runtime_config,
173
+ dataset_root=Path(source_path),
174
+ dataset_head=head,
175
+ )
176
+ return dataset_config.to_crawler_mapping()
177
+
178
+
179
+ def build_dataset_artifacts_from_files(
180
+ *,
181
+ dataset: dict[str, Any],
182
+ modality: dict[str, Any],
183
+ indexing: dict[str, Any],
184
+ files: Iterable[str | Path],
185
+ addons: dict[str, Any] | None = None,
186
+ base_path: str | Path | None = None,
187
+ strict: bool = False,
188
+ sample: int | None = None,
189
+ match_index: dict[str, Any] | None = None,
190
+ metadata_scope: str | None = None,
191
+ ) -> dict[str, Any]:
192
+ """Build canonical dataset artifacts from a parameter set and file list."""
193
+ normalized_scope = (
194
+ validate_metadata_scope(metadata_scope)
195
+ if metadata_scope is not None
196
+ else None
197
+ )
198
+ head = build_dataset_head(dataset=dataset, modality=modality, addons=addons)
199
+ config = build_crawler_config(
200
+ head=head,
201
+ indexing=indexing,
202
+ metadata_scope=normalized_scope,
203
+ )
204
+
205
+ runtime_config = dict(config)
206
+ runtime_config["head"] = deepcopy(head)
207
+ output = index_dataset_from_files(
208
+ runtime_config,
209
+ files,
210
+ base_path=base_path,
211
+ strict=strict,
212
+ sample=sample,
213
+ match_index=match_index,
214
+ )
215
+
216
+ artifacts = {
217
+ DATASET_HEAD_FILENAME: deepcopy(output["head"]),
218
+ CONFIG_FILENAME: config,
219
+ OUTPUT_FILENAME: build_index_artifact(output),
220
+ }
221
+ if normalized_scope is not None:
222
+ unscoped_artifact_names = list(artifacts)
223
+ artifacts = {
224
+ get_metadata_filename(
225
+ filename,
226
+ metadata_scope=normalized_scope,
227
+ ): payload
228
+ for filename, payload in artifacts.items()
229
+ }
230
+ artifacts[SCOPES_FILENAME] = _scopes_manifest_with(
231
+ None,
232
+ metadata_scope=normalized_scope,
233
+ filenames=unscoped_artifact_names,
234
+ )
235
+
236
+ summary = {
237
+ "dataset_id": output["head"]["dataset"]["id"],
238
+ "dataset_name": output["head"]["dataset"]["name"],
239
+ "modality_key": output["head"]["modality"]["key"],
240
+ "file_count": len(get_files(output)),
241
+ }
242
+ if normalized_scope is not None:
243
+ summary["metadata_scope"] = normalized_scope
244
+
245
+ return {
246
+ "artifacts": artifacts,
247
+ "summary": summary,
248
+ }
249
+
250
+
251
+ __all__ = [
252
+ "build_crawler_config",
253
+ "build_dataset_artifacts_from_files",
254
+ "build_dataset_head",
255
+ ]
@@ -0,0 +1,247 @@
1
+ """Helpers for persisting and hydrating dataset metadata artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from copy import deepcopy
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .config import (
11
+ CONFIG_FILENAME,
12
+ CRAWLER_CONFIG_KIND,
13
+ CRAWLER_CONFIG_VERSION,
14
+ DatasetConfig,
15
+ load_dataset_config,
16
+ )
17
+ from .zip_utils import (
18
+ DATASET_HEAD_FILENAME,
19
+ METADATA_DIR,
20
+ OUTPUT_FILENAME,
21
+ is_zip_path,
22
+ read_metadata_json,
23
+ write_metadata_json_batch,
24
+ )
25
+
26
+ DATASET_SPLIT_KIND = "dataset_split"
27
+ DATASET_SPLIT_VERSION = "1.0"
28
+
29
+
30
+ def build_index_artifact(output: dict[str, Any]) -> dict[str, Any]:
31
+ """Return the minimal on-disk index artifact for a full dataset output."""
32
+ if not isinstance(output, dict):
33
+ raise ValueError("output must be an object")
34
+ if "contract" not in output or "index" not in output:
35
+ raise ValueError("output must contain contract and index")
36
+
37
+ artifact = {
38
+ "contract": output["contract"],
39
+ "index": output["index"],
40
+ }
41
+ generator = output.get("generator")
42
+ if generator is not None:
43
+ artifact["generator"] = generator
44
+ execution = output.get("execution")
45
+ if execution is not None:
46
+ artifact["execution"] = execution
47
+ return artifact
48
+
49
+
50
+ def build_split_artifact(
51
+ output: dict[str, Any],
52
+ *,
53
+ split_name: str,
54
+ source_index_file: str = OUTPUT_FILENAME,
55
+ execution: dict[str, Any] | None = None,
56
+ ) -> dict[str, Any]:
57
+ """Return the on-disk artifact for an inline split."""
58
+ if not isinstance(output, dict):
59
+ raise ValueError("output must be an object")
60
+ if "index" not in output or not isinstance(output["index"], dict):
61
+ raise ValueError("output.index must be an object")
62
+ if not isinstance(split_name, str) or not split_name:
63
+ raise ValueError("split_name must be a non-empty string")
64
+ if not isinstance(source_index_file, str) or not source_index_file:
65
+ raise ValueError("source_index_file must be a non-empty string")
66
+
67
+ artifact = {
68
+ "contract": {
69
+ "kind": DATASET_SPLIT_KIND,
70
+ "version": DATASET_SPLIT_VERSION,
71
+ },
72
+ "split": {
73
+ "name": split_name,
74
+ "source_index_file": source_index_file,
75
+ },
76
+ "index": deepcopy(output["index"]),
77
+ }
78
+ generator = output.get("generator")
79
+ if generator is not None:
80
+ artifact["generator"] = deepcopy(generator)
81
+ if execution:
82
+ artifact["execution"] = deepcopy(execution)
83
+ return artifact
84
+
85
+
86
+ def build_crawler_config_for_output(output: dict[str, Any]) -> dict[str, Any]:
87
+ """Derive a crawler config mapping from a full dataset output."""
88
+ if not isinstance(output, dict):
89
+ raise ValueError("output must be an object")
90
+ if "head" not in output or not isinstance(output["head"], dict):
91
+ raise ValueError("output.head must be an object")
92
+
93
+ head_file = output.get("head_file", DATASET_HEAD_FILENAME)
94
+ if not isinstance(head_file, str) or not head_file:
95
+ raise ValueError("output.head_file must be a non-empty string")
96
+
97
+ indexing = output.get("indexing", {})
98
+ if not isinstance(indexing, dict):
99
+ raise ValueError("output.indexing must be an object")
100
+
101
+ config = {
102
+ "contract": {
103
+ "kind": CRAWLER_CONFIG_KIND,
104
+ "version": CRAWLER_CONFIG_VERSION,
105
+ },
106
+ "head_file": head_file,
107
+ "source": {
108
+ "path": ".",
109
+ },
110
+ "indexing": dict(indexing),
111
+ }
112
+
113
+ id_cfg = indexing.get("id")
114
+ has_id_regex = (
115
+ isinstance(id_cfg, dict)
116
+ and isinstance(id_cfg.get("regex"), str)
117
+ and bool(id_cfg.get("regex"))
118
+ )
119
+ if not has_id_regex:
120
+ config["source"]["prebuilt_index_file"] = OUTPUT_FILENAME
121
+
122
+ return config
123
+
124
+
125
+ def hydrate_index_artifact(
126
+ index_artifact: dict[str, Any],
127
+ ds_config: DatasetConfig,
128
+ ) -> dict[str, Any]:
129
+ """Expand an on-disk index artifact into the richer in-memory output shape."""
130
+ if not isinstance(index_artifact, dict):
131
+ raise ValueError("index_artifact must be an object")
132
+ if "contract" not in index_artifact or "index" not in index_artifact:
133
+ raise ValueError("index_artifact must contain contract and index")
134
+
135
+ hydrated = dict(index_artifact)
136
+ hydrated["head_file"] = ds_config.head_file
137
+ hydrated["head"] = ds_config.head_mapping()
138
+ hydrated["indexing"] = ds_config.to_indexing_dict()
139
+ hydrated.setdefault("generator", {})
140
+ hydrated.setdefault("execution", {})
141
+ return hydrated
142
+
143
+
144
+ def hydrate_split_artifact(
145
+ split_artifact: dict[str, Any],
146
+ base_output: dict[str, Any],
147
+ ) -> dict[str, Any]:
148
+ """Expand a split artifact into the richer in-memory output shape."""
149
+ if not isinstance(split_artifact, dict):
150
+ raise ValueError("split_artifact must be an object")
151
+ if "index" not in split_artifact or "split" not in split_artifact:
152
+ raise ValueError("split_artifact must contain split and index")
153
+ if not isinstance(base_output, dict):
154
+ raise ValueError("base_output must be an object")
155
+
156
+ hydrated = dict(base_output)
157
+ hydrated["index"] = deepcopy(split_artifact["index"])
158
+ hydrated["split"] = deepcopy(split_artifact["split"])
159
+
160
+ split_execution = split_artifact.get("execution")
161
+ if isinstance(split_execution, dict) and split_execution:
162
+ execution = dict(hydrated.get("execution") or {})
163
+ execution["split"] = deepcopy(split_execution)
164
+ hydrated["execution"] = execution
165
+
166
+ generator = split_artifact.get("generator")
167
+ if "generator" not in hydrated and isinstance(generator, dict):
168
+ hydrated["generator"] = deepcopy(generator)
169
+ return hydrated
170
+
171
+
172
+ def load_saved_output(
173
+ dataset_path: str | Path,
174
+ *,
175
+ filename: str = OUTPUT_FILENAME,
176
+ metadata_scope: str | None = None,
177
+ ) -> dict[str, Any] | None:
178
+ """Load and hydrate a persisted dataset index artifact from disk."""
179
+ dataset_root = Path(dataset_path)
180
+ index_artifact = read_metadata_json(
181
+ dataset_root,
182
+ filename,
183
+ metadata_scope=metadata_scope,
184
+ )
185
+ if index_artifact is None:
186
+ return None
187
+ ds_config = load_dataset_config(
188
+ {"path": str(dataset_root)},
189
+ metadata_scope=metadata_scope,
190
+ )
191
+ return hydrate_index_artifact(index_artifact, ds_config)
192
+
193
+
194
+ def load_prebuilt_output(ds_config: DatasetConfig) -> dict[str, Any]:
195
+ """Load and hydrate a prebuilt index configured by ``prebuilt_index_file``."""
196
+ if ds_config.prebuilt_index_file is None:
197
+ raise FileNotFoundError("Dataset config has no prebuilt index file")
198
+
199
+ prebuilt_path = Path(ds_config.prebuilt_index_file)
200
+ if prebuilt_path.is_file():
201
+ with open(prebuilt_path) as f:
202
+ index_artifact = json.load(f)
203
+ else:
204
+ index_artifact = read_metadata_json(
205
+ Path(ds_config.dataset_root),
206
+ prebuilt_path.name,
207
+ metadata_scope=ds_config.metadata_scope,
208
+ )
209
+ if index_artifact is None:
210
+ raise FileNotFoundError(
211
+ f"No prebuilt index found at {ds_config.prebuilt_index_file}"
212
+ )
213
+
214
+ return hydrate_index_artifact(index_artifact, ds_config)
215
+
216
+
217
+ def save_output_artifacts(
218
+ dataset_path: str | Path,
219
+ output: dict[str, Any],
220
+ *,
221
+ filename: str = OUTPUT_FILENAME,
222
+ metadata_scope: str | None = None,
223
+ ) -> Path:
224
+ """Persist head/config/index artifacts for a dataset output."""
225
+ dataset_root = Path(dataset_path)
226
+ if "head" not in output or not isinstance(output["head"], dict):
227
+ raise ValueError("output.head must be an object")
228
+
229
+ head_file = output.get("head_file", DATASET_HEAD_FILENAME)
230
+ if not isinstance(head_file, str) or not head_file:
231
+ raise ValueError("output.head_file must be a non-empty string")
232
+
233
+ write_metadata_json_batch(
234
+ dataset_root,
235
+ {
236
+ head_file: output["head"],
237
+ CONFIG_FILENAME: build_crawler_config_for_output(output),
238
+ filename: build_index_artifact(output),
239
+ },
240
+ metadata_scope=metadata_scope,
241
+ )
242
+
243
+ if is_zip_path(dataset_root):
244
+ return dataset_root
245
+ if metadata_scope is not None:
246
+ return dataset_root / METADATA_DIR / metadata_scope / filename
247
+ return dataset_root / METADATA_DIR / filename