ds_crawler 2.7.2__tar.gz → 2.8.0__tar.gz

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 (61) hide show
  1. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/PKG-INFO +1 -1
  2. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/README.md +77 -0
  3. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/__init__.py +3 -0
  4. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/artifact_builder.py +47 -8
  5. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/artifacts.py +15 -2
  6. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/cli.py +52 -2
  7. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/config.py +75 -7
  8. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/migration.py +81 -13
  9. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/operations.py +323 -69
  10. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/parser.py +52 -9
  11. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/schema.py +29 -6
  12. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/validation.py +37 -7
  13. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/writer.py +19 -18
  14. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/zip_utils.py +280 -20
  15. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/pyproject.toml +1 -1
  16. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_artifact_builder.py +44 -0
  17. ds_crawler-2.8.0/tests/test_metadata_scopes.py +313 -0
  18. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_new_metadata.py +30 -0
  19. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/.github/workflows/workflow.yml +0 -0
  20. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/.gitignore +0 -0
  21. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/config_example.json +0 -0
  22. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/_dataset_contract.py +0 -0
  23. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/_euler_modalities.py +0 -0
  24. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/_version.py +0 -0
  25. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/handlers/__init__.py +0 -0
  26. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/handlers/base.py +0 -0
  27. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/handlers/generic.py +0 -0
  28. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/handlers/zip_handler.py +0 -0
  29. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/layout.py +0 -0
  30. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/path_filters.py +0 -0
  31. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/ds_crawler/traversal.py +0 -0
  32. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/example-output-rds.json +0 -0
  33. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/examples/augmented_rgb_example.py +0 -0
  34. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/examples/config.json +0 -0
  35. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/examples/example.py +0 -0
  36. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/examples/example_output.json +0 -0
  37. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/examples/run.sh +0 -0
  38. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/examples/sample_realdrivesim.py +0 -0
  39. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/meta/__init__.py +0 -0
  40. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/meta/build_meta_schema.py +0 -0
  41. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/meta/schema.json +0 -0
  42. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/package-lock.json +0 -0
  43. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/split_rds.py +0 -0
  44. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/__init__.py +0 -0
  45. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/conftest.py +0 -0
  46. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/current_helpers.py +0 -0
  47. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_align_datasets.py +0 -0
  48. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_attributes_passthrough.py +0 -0
  49. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_config.py +0 -0
  50. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_copy_dataset.py +0 -0
  51. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_extract_datasets.py +0 -0
  52. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_handlers.py +0 -0
  53. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_inline_splits.py +0 -0
  54. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_integration.py +0 -0
  55. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_layout.py +0 -0
  56. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_meta_schema.py +0 -0
  57. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_parser.py +0 -0
  58. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_split_dataset.py +0 -0
  59. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_validation.py +0 -0
  60. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_writer.py +0 -0
  61. {ds_crawler-2.7.2 → ds_crawler-2.8.0}/tests/test_zip.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ds_crawler
3
- Version: 2.7.2
3
+ Version: 2.8.0
4
4
  Summary: Regex-based dataset metadata crawler and indexer
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: euler-dataset-contract>=0.1.0
@@ -43,6 +43,31 @@ The files have distinct roles:
43
43
  | `.ds_crawler/index.json` | Materialized full dataset index. No duplicated head/config metadata. |
44
44
  | `.ds_crawler/split_<name>.json` | Named split artifact with its own contract, provenance, and filtered `index` node. |
45
45
 
46
+ When several logical modalities share one physical root or zip, store each
47
+ metadata set under a scope:
48
+
49
+ ```text
50
+ dataset_root/
51
+ ├── .ds_crawler/
52
+ │ ├── scopes.json
53
+ │ ├── rgb/
54
+ │ │ ├── dataset-head.json
55
+ │ │ ├── ds-crawler.json
56
+ │ │ └── index.json
57
+ │ └── camera_extrinsics/
58
+ │ ├── dataset-head.json
59
+ │ ├── ds-crawler.json
60
+ │ └── index.json
61
+ ├── calib.json
62
+ └── ...
63
+ ```
64
+
65
+ Pass `metadata_scope="rgb"` or `metadata_scope="camera_extrinsics"` to the
66
+ path-based APIs to read/write that scoped artifact set. Scoped writes update
67
+ `.ds_crawler/scopes.json`, a discoverability manifest listing known scopes and
68
+ their artifact filenames. Omitting `metadata_scope` preserves the legacy
69
+ unscoped layout exactly.
70
+
46
71
  The in-memory objects returned by `index_dataset_from_path(...)` and
47
72
  `load_dataset_split(...)` are hydrated outputs. They include `head` and
48
73
  `indexing` for convenience, even though the on-disk `index.json` only stores
@@ -146,6 +171,21 @@ print(output["index"].keys())
146
171
  - `.ds_crawler/ds-crawler.json`
147
172
  - `.ds_crawler/index.json`
148
173
 
174
+ For a shared physical root, pass a metadata scope:
175
+
176
+ ```python
177
+ rgb = index_dataset_from_path(
178
+ "/data/muses",
179
+ save_index=True,
180
+ metadata_scope="rgb",
181
+ )
182
+ extrinsics = index_dataset_from_path(
183
+ "/data/muses",
184
+ save_index=True,
185
+ metadata_scope="camera_extrinsics",
186
+ )
187
+ ```
188
+
149
189
  ### 3. Align multiple modalities
150
190
 
151
191
  ```python
@@ -237,6 +277,19 @@ print(train_output["split"]["name"])
237
277
  print(train_output["execution"]["split"])
238
278
  ```
239
279
 
280
+ Scoped split artifacts use the same namespace as the index:
281
+
282
+ ```python
283
+ create_dataset_splits(
284
+ "/data/muses",
285
+ split_names=["train", "val"],
286
+ ratios=[80, 20],
287
+ metadata_scope="rgb",
288
+ )
289
+
290
+ train_output = load_dataset_split("/data/muses", "train", metadata_scope="rgb")
291
+ ```
292
+
240
293
  To reuse an existing partition on a sibling dataset (same qualified file
241
294
  IDs), copy the splits over instead of recomputing them:
242
295
 
@@ -497,6 +550,9 @@ The main public entry points are re-exported from `ds_crawler`.
497
550
 
498
551
  Indexing and config:
499
552
 
553
+ - `build_crawler_config(...)`
554
+ - `build_dataset_artifacts_from_files(...)`
555
+ - `build_dataset_head(...)`
500
556
  - `index_dataset(...)`
501
557
  - `index_dataset_from_files(...)`
502
558
  - `index_dataset_from_path(...)`
@@ -505,14 +561,22 @@ Indexing and config:
505
561
  - `validate_dataset(...)`
506
562
  - `validate_output(...)`
507
563
 
564
+ `index_dataset(...)`, `index_dataset_from_path(...)`, `load_dataset_config(...)`,
565
+ and `validate_dataset(...)` accept `metadata_scope=...` for scoped metadata.
566
+ `build_dataset_artifacts_from_files(...)` also accepts `metadata_scope=...` and
567
+ returns scoped artifact names plus `scopes.json`.
568
+
508
569
  Dataset metadata:
509
570
 
510
571
  - `get_dataset_contract(source)`
511
572
  - `get_dataset_properties(source)`
512
573
  - `extract_dataset_properties(mapping)`
574
+ - `list_metadata_scopes(path)`
513
575
 
514
576
  `get_dataset_contract(...)` returns a `DatasetHeadContract`. Use
515
577
  `contract.get_namespace("euler_train")` to access addon payloads.
578
+ `get_dataset_contract(path, metadata_scope=...)` and
579
+ `get_dataset_properties(path, metadata_scope=...)` read scoped heads.
516
580
 
517
581
  Traversal and filtering:
518
582
 
@@ -535,16 +599,29 @@ Operations:
535
599
  - `list_dataset_splits(...)`
536
600
  - `load_dataset_split(...)`
537
601
 
602
+ Split, copy, align, and extraction operations accept `metadata_scope=...`.
603
+ `copy_dataset(...)`, `copy_dataset_splits(...)`, and `split_dataset(...)` also
604
+ accept source/target scope overrides when the two sides use different scope
605
+ names. `split_datasets(...)` and `extract_datasets(...)` accept per-source or
606
+ per-config scope lists for multi-dataset workflows.
607
+
538
608
  Writers:
539
609
 
540
610
  - `DatasetWriter(...)`
541
611
  - `ZipDatasetWriter(...)`
542
612
 
613
+ Both writers accept `metadata_scope=...` and write scoped artifacts plus the
614
+ `scopes.json` manifest.
615
+
543
616
  Migration helpers:
544
617
 
545
618
  - `migrate_dataset_metadata(...)`
546
619
  - `migrate_dataset_zip(...)`
547
620
  - `migrate_dataset_zips_in_folder(...)`
621
+ - `migrate_inline_splits(...)`
622
+
623
+ Migration helpers and `ds-crawler migrate` also accept `metadata_scope=...`
624
+ or `--metadata-scope` for scoped legacy metadata conversion.
548
625
 
549
626
  ## ZIP support
550
627
 
@@ -46,6 +46,7 @@ from .migration import (
46
46
  migrate_dataset_zips_in_folder,
47
47
  )
48
48
  from .writer import DatasetWriter, ZipDatasetWriter
49
+ from .zip_utils import list_metadata_scopes, validate_metadata_scope
49
50
  from .layout import (
50
51
  EULER_LAYOUT_ADDON,
51
52
  EULER_LAYOUT_VERSION,
@@ -86,6 +87,7 @@ __all__ = [
86
87
  "index_dataset_from_files",
87
88
  "index_dataset_from_path",
88
89
  "list_dataset_splits",
90
+ "list_metadata_scopes",
89
91
  "load_dataset_config",
90
92
  "load_dataset_split",
91
93
  "migrate_dataset_metadata",
@@ -96,6 +98,7 @@ __all__ = [
96
98
  "split_qualified_ids",
97
99
  "validate_crawler_config",
98
100
  "validate_dataset",
101
+ "validate_metadata_scope",
99
102
  "validate_layout_addon",
100
103
  "validate_output",
101
104
  "extract_dataset_properties",
@@ -15,7 +15,14 @@ from .artifacts import build_index_artifact
15
15
  from .config import CONFIG_FILENAME, CRAWLER_CONFIG_KIND, CRAWLER_CONFIG_VERSION, DatasetConfig
16
16
  from .parser import index_dataset_from_files
17
17
  from .traversal import get_files
18
- from .zip_utils import DATASET_HEAD_FILENAME, OUTPUT_FILENAME
18
+ from .zip_utils import (
19
+ DATASET_HEAD_FILENAME,
20
+ OUTPUT_FILENAME,
21
+ SCOPES_FILENAME,
22
+ _scopes_manifest_with,
23
+ get_metadata_filename,
24
+ validate_metadata_scope,
25
+ )
19
26
 
20
27
 
21
28
  def _require_mapping(value: Any, context: str) -> dict[str, Any]:
@@ -125,6 +132,7 @@ def build_crawler_config(
125
132
  source_path: str = ".",
126
133
  head_file: str = DATASET_HEAD_FILENAME,
127
134
  prebuilt_index_file: str | None = None,
135
+ metadata_scope: str | None = None,
128
136
  ) -> dict[str, Any]:
129
137
  """Build and validate a canonical ``ds-crawler.json`` mapping."""
130
138
  indexing = _require_mapping(indexing, "indexing")
@@ -150,6 +158,8 @@ def build_crawler_config(
150
158
  }
151
159
  if prebuilt_index_file is not None:
152
160
  config["source"]["prebuilt_index_file"] = prebuilt_index_file
161
+ if metadata_scope is not None:
162
+ config["metadata_scope"] = validate_metadata_scope(metadata_scope)
153
163
 
154
164
  runtime_config = dict(config)
155
165
  runtime_config["head"] = deepcopy(head)
@@ -172,10 +182,20 @@ def build_dataset_artifacts_from_files(
172
182
  strict: bool = False,
173
183
  sample: int | None = None,
174
184
  match_index: dict[str, Any] | None = None,
185
+ metadata_scope: str | None = None,
175
186
  ) -> dict[str, Any]:
176
187
  """Build canonical dataset artifacts from a parameter set and file list."""
188
+ normalized_scope = (
189
+ validate_metadata_scope(metadata_scope)
190
+ if metadata_scope is not None
191
+ else None
192
+ )
177
193
  head = build_dataset_head(dataset=dataset, modality=modality, addons=addons)
178
- config = build_crawler_config(head=head, indexing=indexing)
194
+ config = build_crawler_config(
195
+ head=head,
196
+ indexing=indexing,
197
+ metadata_scope=normalized_scope,
198
+ )
179
199
 
180
200
  runtime_config = dict(config)
181
201
  runtime_config["head"] = deepcopy(head)
@@ -193,14 +213,33 @@ def build_dataset_artifacts_from_files(
193
213
  CONFIG_FILENAME: config,
194
214
  OUTPUT_FILENAME: build_index_artifact(output),
195
215
  }
216
+ if normalized_scope is not None:
217
+ unscoped_artifact_names = list(artifacts)
218
+ artifacts = {
219
+ get_metadata_filename(
220
+ filename,
221
+ metadata_scope=normalized_scope,
222
+ ): payload
223
+ for filename, payload in artifacts.items()
224
+ }
225
+ artifacts[SCOPES_FILENAME] = _scopes_manifest_with(
226
+ None,
227
+ metadata_scope=normalized_scope,
228
+ filenames=unscoped_artifact_names,
229
+ )
230
+
231
+ summary = {
232
+ "dataset_id": output["head"]["dataset"]["id"],
233
+ "dataset_name": output["head"]["dataset"]["name"],
234
+ "modality_key": output["head"]["modality"]["key"],
235
+ "file_count": len(get_files(output)),
236
+ }
237
+ if normalized_scope is not None:
238
+ summary["metadata_scope"] = normalized_scope
239
+
196
240
  return {
197
241
  "artifacts": artifacts,
198
- "summary": {
199
- "dataset_id": output["head"]["dataset"]["id"],
200
- "dataset_name": output["head"]["dataset"]["name"],
201
- "modality_key": output["head"]["modality"]["key"],
202
- "file_count": len(get_files(output)),
203
- },
242
+ "summary": summary,
204
243
  }
205
244
 
206
245
 
@@ -173,13 +173,21 @@ def load_saved_output(
173
173
  dataset_path: str | Path,
174
174
  *,
175
175
  filename: str = OUTPUT_FILENAME,
176
+ metadata_scope: str | None = None,
176
177
  ) -> dict[str, Any] | None:
177
178
  """Load and hydrate a persisted dataset index artifact from disk."""
178
179
  dataset_root = Path(dataset_path)
179
- index_artifact = read_metadata_json(dataset_root, filename)
180
+ index_artifact = read_metadata_json(
181
+ dataset_root,
182
+ filename,
183
+ metadata_scope=metadata_scope,
184
+ )
180
185
  if index_artifact is None:
181
186
  return None
182
- ds_config = load_dataset_config({"path": str(dataset_root)})
187
+ ds_config = load_dataset_config(
188
+ {"path": str(dataset_root)},
189
+ metadata_scope=metadata_scope,
190
+ )
183
191
  return hydrate_index_artifact(index_artifact, ds_config)
184
192
 
185
193
 
@@ -196,6 +204,7 @@ def load_prebuilt_output(ds_config: DatasetConfig) -> dict[str, Any]:
196
204
  index_artifact = read_metadata_json(
197
205
  Path(ds_config.dataset_root),
198
206
  prebuilt_path.name,
207
+ metadata_scope=ds_config.metadata_scope,
199
208
  )
200
209
  if index_artifact is None:
201
210
  raise FileNotFoundError(
@@ -210,6 +219,7 @@ def save_output_artifacts(
210
219
  output: dict[str, Any],
211
220
  *,
212
221
  filename: str = OUTPUT_FILENAME,
222
+ metadata_scope: str | None = None,
213
223
  ) -> Path:
214
224
  """Persist head/config/index artifacts for a dataset output."""
215
225
  dataset_root = Path(dataset_path)
@@ -227,8 +237,11 @@ def save_output_artifacts(
227
237
  CONFIG_FILENAME: build_crawler_config_for_output(output),
228
238
  filename: build_index_artifact(output),
229
239
  },
240
+ metadata_scope=metadata_scope,
230
241
  )
231
242
 
232
243
  if is_zip_path(dataset_root):
233
244
  return dataset_root
245
+ if metadata_scope is not None:
246
+ return dataset_root / METADATA_DIR / metadata_scope / filename
234
247
  return dataset_root / METADATA_DIR / filename
@@ -85,13 +85,27 @@ def _run_index(argv: list[str]) -> int:
85
85
  metavar="PATH",
86
86
  help="Path to an index.json whose file IDs are used as a filter.",
87
87
  )
88
+ parser.add_argument(
89
+ "--metadata-scope",
90
+ type=str,
91
+ default=None,
92
+ metavar="SCOPE",
93
+ help=(
94
+ "Read/write ds-crawler artifacts below .ds_crawler/SCOPE/ "
95
+ "instead of the root .ds_crawler directory."
96
+ ),
97
+ )
88
98
 
89
99
  args = parser.parse_args(argv)
90
100
 
91
101
  setup_logging(args.verbose)
92
102
 
93
103
  try:
94
- config = Config.from_file(args.config, workdir=args.workdir)
104
+ config = Config.from_file(
105
+ args.config,
106
+ workdir=args.workdir,
107
+ metadata_scope=args.metadata_scope,
108
+ )
95
109
  except FileNotFoundError as e:
96
110
  print(f"Error: {e}", file=sys.stderr)
97
111
  return 1
@@ -122,7 +136,9 @@ def _run_index(argv: list[str]) -> int:
122
136
  parser_instance.write_output(args.output)
123
137
  logger.info(f"Output written to: {args.output}")
124
138
  else:
125
- output_paths = parser_instance.write_outputs_per_dataset()
139
+ output_paths = parser_instance.write_outputs_per_dataset(
140
+ metadata_scope=args.metadata_scope,
141
+ )
126
142
  for path in output_paths:
127
143
  logger.info(f"Output written to: {path}")
128
144
  except Exception as e:
@@ -165,6 +181,13 @@ def _run_migrate(argv: list[str]) -> int:
165
181
  action="store_true",
166
182
  help="When used with --scan-zips, only scan the provided directory itself.",
167
183
  )
184
+ parser.add_argument(
185
+ "--metadata-scope",
186
+ help=(
187
+ "Read/write legacy migration artifacts below "
188
+ ".ds_crawler/SCOPE/."
189
+ ),
190
+ )
168
191
  parser.add_argument(
169
192
  "-v",
170
193
  "--verbose",
@@ -188,6 +211,7 @@ def _run_migrate(argv: list[str]) -> int:
188
211
  result = migrate_inline_splits(
189
212
  path,
190
213
  logger=logger,
214
+ metadata_scope=args.metadata_scope,
191
215
  )
192
216
  logger.info(
193
217
  "Migrated inline splits %s (splits=%d)",
@@ -202,6 +226,7 @@ def _run_migrate(argv: list[str]) -> int:
202
226
  recursive=not args.top_level_only,
203
227
  write_output=not args.no_index,
204
228
  logger=logger,
229
+ metadata_scope=args.metadata_scope,
205
230
  )
206
231
  if result["failed"]:
207
232
  failed = True
@@ -222,6 +247,7 @@ def _run_migrate(argv: list[str]) -> int:
222
247
  path,
223
248
  write_output=not args.no_index,
224
249
  logger=logger,
250
+ metadata_scope=args.metadata_scope,
225
251
  )
226
252
  except Exception as exc:
227
253
  failed = True
@@ -274,6 +300,27 @@ def _run_copy_splits(argv: list[str]) -> int:
274
300
  action="store_true",
275
301
  help="Replace existing splits on the target if they share a name.",
276
302
  )
303
+ parser.add_argument(
304
+ "--metadata-scope",
305
+ type=str,
306
+ default=None,
307
+ metavar="SCOPE",
308
+ help="Use the same scoped .ds_crawler/SCOPE metadata for source and target.",
309
+ )
310
+ parser.add_argument(
311
+ "--source-metadata-scope",
312
+ type=str,
313
+ default=None,
314
+ metavar="SCOPE",
315
+ help="Read source splits from .ds_crawler/SCOPE/.",
316
+ )
317
+ parser.add_argument(
318
+ "--target-metadata-scope",
319
+ type=str,
320
+ default=None,
321
+ metavar="SCOPE",
322
+ help="Write target splits to .ds_crawler/SCOPE/.",
323
+ )
277
324
  parser.add_argument(
278
325
  "-v",
279
326
  "--verbose",
@@ -291,6 +338,9 @@ def _run_copy_splits(argv: list[str]) -> int:
291
338
  args.target,
292
339
  split_names=args.splits,
293
340
  override=args.override,
341
+ metadata_scope=args.metadata_scope,
342
+ source_metadata_scope=args.source_metadata_scope,
343
+ target_metadata_scope=args.target_metadata_scope,
294
344
  )
295
345
  except (FileNotFoundError, ValueError) as exc:
296
346
  print(f"Error: {exc}", file=sys.stderr)
@@ -16,7 +16,11 @@ from ._dataset_contract import (
16
16
  validate_contract_version,
17
17
  )
18
18
  from .path_filters import PathFilters
19
- from .zip_utils import DATASET_HEAD_FILENAME, read_metadata_json
19
+ from .zip_utils import (
20
+ DATASET_HEAD_FILENAME,
21
+ read_metadata_json,
22
+ validate_metadata_scope,
23
+ )
20
24
 
21
25
 
22
26
  CONFIG_FILENAME = "ds-crawler.json"
@@ -81,6 +85,7 @@ class DatasetConfig:
81
85
  path_filters: dict[str, Any] | None = None
82
86
  prebuilt_index_file: str | None = None
83
87
  file_extensions: list[str] | None = None
88
+ metadata_scope: str | None = None
84
89
  compiled_path_filters: PathFilters = field(init=False, repr=False)
85
90
 
86
91
  def __post_init__(self) -> None:
@@ -167,6 +172,8 @@ class DatasetConfig:
167
172
  }
168
173
  if self.prebuilt_index_file is not None:
169
174
  result["source"]["prebuilt_index_file"] = self.prebuilt_index_file
175
+ if self.metadata_scope is not None:
176
+ result["metadata_scope"] = self.metadata_scope
170
177
  return result
171
178
 
172
179
  def _normalize_file_extensions(self) -> None:
@@ -242,11 +249,22 @@ class DatasetConfig:
242
249
  workdir: str | Path | None = None,
243
250
  dataset_root: str | Path | None = None,
244
251
  dataset_head: DatasetHeadContract | dict[str, Any] | None = None,
252
+ metadata_scope: str | None = None,
245
253
  ) -> "DatasetConfig":
246
254
  if not isinstance(data, dict):
247
255
  raise ValueError("Crawler config must be a JSON object")
248
256
 
249
257
  _normalize_config_contract(data, "config")
258
+ raw_metadata_scope = metadata_scope
259
+ if raw_metadata_scope is None:
260
+ config_scope = data.get("metadata_scope")
261
+ if config_scope is not None:
262
+ raw_metadata_scope = config_scope
263
+ normalized_metadata_scope = (
264
+ validate_metadata_scope(raw_metadata_scope)
265
+ if raw_metadata_scope is not None
266
+ else None
267
+ )
250
268
 
251
269
  source = _require_mapping(data.get("source", {}), "config.source")
252
270
  raw_source_path = source.get("path", ".")
@@ -271,10 +289,19 @@ class DatasetConfig:
271
289
  if embedded_head is not None:
272
290
  dataset_head = embedded_head
273
291
  else:
274
- raw_head = read_metadata_json(dataset_root_path, head_file)
292
+ raw_head = read_metadata_json(
293
+ dataset_root_path,
294
+ head_file,
295
+ metadata_scope=normalized_metadata_scope,
296
+ )
275
297
  if raw_head is None:
276
298
  raise FileNotFoundError(
277
299
  f"No {head_file} found at: {dataset_root_path}"
300
+ + (
301
+ f" (metadata_scope={normalized_metadata_scope!r})"
302
+ if normalized_metadata_scope is not None
303
+ else ""
304
+ )
278
305
  )
279
306
  dataset_head = raw_head
280
307
 
@@ -375,30 +402,61 @@ class DatasetConfig:
375
402
  path_filters=files_cfg.get("path_filters"),
376
403
  prebuilt_index_file=prebuilt_index_file,
377
404
  file_extensions=file_extensions,
405
+ metadata_scope=normalized_metadata_scope,
378
406
  )
379
407
 
380
408
 
381
409
  def load_dataset_config(
382
410
  data: dict[str, Any],
383
411
  workdir: str | Path | None = None,
412
+ *,
413
+ metadata_scope: str | None = None,
384
414
  ) -> DatasetConfig:
385
415
  """Load a DatasetConfig, resolving from ``ds-crawler.json`` when needed."""
386
416
  if not isinstance(data, dict):
387
417
  raise ValueError("Crawler config must be a JSON object")
388
418
 
419
+ raw_metadata_scope = metadata_scope
420
+ if raw_metadata_scope is None:
421
+ config_scope = data.get("metadata_scope")
422
+ if config_scope is not None:
423
+ raw_metadata_scope = config_scope
424
+ normalized_metadata_scope = (
425
+ validate_metadata_scope(raw_metadata_scope)
426
+ if raw_metadata_scope is not None
427
+ else None
428
+ )
429
+
389
430
  if "indexing" in data or "source" in data:
390
- return DatasetConfig.from_dict(data, workdir=workdir)
431
+ return DatasetConfig.from_dict(
432
+ data,
433
+ workdir=workdir,
434
+ metadata_scope=normalized_metadata_scope,
435
+ )
391
436
 
392
437
  if "path" not in data:
393
438
  raise ValueError("Crawler config shorthand requires a 'path' field")
394
439
 
395
440
  ds_root = Path(_resolve_path(data["path"], base_path=workdir))
396
- file_config = read_metadata_json(ds_root, CONFIG_FILENAME)
441
+ file_config = read_metadata_json(
442
+ ds_root,
443
+ CONFIG_FILENAME,
444
+ metadata_scope=normalized_metadata_scope,
445
+ )
397
446
  if file_config is None:
398
447
  raise FileNotFoundError(
399
448
  f"No {CONFIG_FILENAME} found at: {ds_root}"
449
+ + (
450
+ f" (metadata_scope={normalized_metadata_scope!r})"
451
+ if normalized_metadata_scope is not None
452
+ else ""
453
+ )
400
454
  )
401
- return DatasetConfig.from_dict(file_config, dataset_root=ds_root)
455
+ return DatasetConfig.from_dict(
456
+ file_config,
457
+ dataset_root=ds_root,
458
+ metadata_scope=normalized_metadata_scope,
459
+ )
402
460
 
403
461
 
404
462
  @dataclass
@@ -412,6 +470,8 @@ class Config:
412
470
  cls,
413
471
  path: str | Path,
414
472
  workdir: str | Path | None = None,
473
+ *,
474
+ metadata_scope: str | None = None,
415
475
  ) -> "Config":
416
476
  path = Path(path)
417
477
  if not path.exists():
@@ -423,13 +483,21 @@ class Config:
423
483
  base_workdir = Path(workdir) if workdir is not None else path.parent
424
484
 
425
485
  if "datasets" not in data:
426
- dataset = load_dataset_config(data, workdir=base_workdir)
486
+ dataset = load_dataset_config(
487
+ data,
488
+ workdir=base_workdir,
489
+ metadata_scope=metadata_scope,
490
+ )
427
491
  return cls(datasets=[dataset])
428
492
 
429
493
  datasets: list[DatasetConfig] = []
430
494
  for i, ds_data in enumerate(data["datasets"]):
431
495
  try:
432
- ds_config = load_dataset_config(ds_data, workdir=base_workdir)
496
+ ds_config = load_dataset_config(
497
+ ds_data,
498
+ workdir=base_workdir,
499
+ metadata_scope=metadata_scope,
500
+ )
433
501
  datasets.append(ds_config)
434
502
  except FileNotFoundError:
435
503
  raise