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 +119 -0
- ds_crawler/_dataset_contract.py +45 -0
- ds_crawler/_version.py +13 -0
- ds_crawler/artifact_builder.py +255 -0
- ds_crawler/artifacts.py +247 -0
- ds_crawler/cli.py +375 -0
- ds_crawler/config.py +509 -0
- ds_crawler/handlers/__init__.py +36 -0
- ds_crawler/handlers/base.py +30 -0
- ds_crawler/handlers/generic.py +28 -0
- ds_crawler/handlers/zip_handler.py +53 -0
- ds_crawler/layout.py +165 -0
- ds_crawler/migration.py +775 -0
- ds_crawler/operations.py +1994 -0
- ds_crawler/parser.py +831 -0
- ds_crawler/path_filters.py +229 -0
- ds_crawler/records.py +462 -0
- ds_crawler/schema.py +159 -0
- ds_crawler/traversal.py +391 -0
- ds_crawler/validation.py +444 -0
- ds_crawler/writer.py +758 -0
- ds_crawler/zip_utils.py +722 -0
- ds_crawler-2.13.0.dist-info/METADATA +342 -0
- ds_crawler-2.13.0.dist-info/RECORD +30 -0
- ds_crawler-2.13.0.dist-info/WHEEL +4 -0
- ds_crawler-2.13.0.dist-info/entry_points.txt +3 -0
- ds_crawler-2.13.0.dist-info/licenses/LICENSE +21 -0
- meta/__init__.py +0 -0
- meta/build_meta_schema.py +26 -0
- meta/schema.json +374 -0
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
|
+
]
|
ds_crawler/artifacts.py
ADDED
|
@@ -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
|