ds_crawler 2.7.2__tar.gz → 2.9.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.9.0}/PKG-INFO +1 -1
  2. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/README.md +150 -0
  3. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/__init__.py +5 -0
  4. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/artifact_builder.py +47 -8
  5. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/artifacts.py +15 -2
  6. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/cli.py +52 -2
  7. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/config.py +75 -7
  8. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/migration.py +81 -13
  9. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/operations.py +547 -69
  10. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/parser.py +52 -9
  11. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/schema.py +29 -6
  12. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/validation.py +37 -7
  13. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/writer.py +19 -18
  14. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/zip_utils.py +280 -20
  15. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/pyproject.toml +1 -1
  16. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_artifact_builder.py +44 -0
  17. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_inline_splits.py +74 -0
  18. ds_crawler-2.9.0/tests/test_metadata_scopes.py +313 -0
  19. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_new_metadata.py +30 -0
  20. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_parser.py +33 -0
  21. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/.github/workflows/workflow.yml +0 -0
  22. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/.gitignore +0 -0
  23. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/config_example.json +0 -0
  24. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/_dataset_contract.py +0 -0
  25. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/_euler_modalities.py +0 -0
  26. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/_version.py +0 -0
  27. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/handlers/__init__.py +0 -0
  28. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/handlers/base.py +0 -0
  29. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/handlers/generic.py +0 -0
  30. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/handlers/zip_handler.py +0 -0
  31. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/layout.py +0 -0
  32. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/path_filters.py +0 -0
  33. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/ds_crawler/traversal.py +0 -0
  34. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/example-output-rds.json +0 -0
  35. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/examples/augmented_rgb_example.py +0 -0
  36. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/examples/config.json +0 -0
  37. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/examples/example.py +0 -0
  38. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/examples/example_output.json +0 -0
  39. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/examples/run.sh +0 -0
  40. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/examples/sample_realdrivesim.py +0 -0
  41. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/meta/__init__.py +0 -0
  42. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/meta/build_meta_schema.py +0 -0
  43. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/meta/schema.json +0 -0
  44. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/package-lock.json +0 -0
  45. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/split_rds.py +0 -0
  46. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/__init__.py +0 -0
  47. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/conftest.py +0 -0
  48. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/current_helpers.py +0 -0
  49. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_align_datasets.py +0 -0
  50. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_attributes_passthrough.py +0 -0
  51. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_config.py +0 -0
  52. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_copy_dataset.py +0 -0
  53. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_extract_datasets.py +0 -0
  54. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_handlers.py +0 -0
  55. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_integration.py +0 -0
  56. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_layout.py +0 -0
  57. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_meta_schema.py +0 -0
  58. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_split_dataset.py +0 -0
  59. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_validation.py +0 -0
  60. {ds_crawler-2.7.2 → ds_crawler-2.9.0}/tests/test_writer.py +0 -0
  61. {ds_crawler-2.7.2 → ds_crawler-2.9.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.9.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
@@ -220,6 +260,58 @@ MultiModalDataset(
220
260
  A runnable end-to-end example is in
221
261
  [`examples/augmented_rgb_example.py`](examples/augmented_rgb_example.py).
222
262
 
263
+ ### Recipe: root-level calibration shared by every sample
264
+
265
+ Some archives keep calibration at the dataset root, for example
266
+ `rgb.zip/calib.json`, while regular samples live below path-based hierarchy
267
+ folders. Model that calibration as a hierarchical modality with no
268
+ `indexing.hierarchy` block. ds-crawler then stores `calib.json` directly in
269
+ the root `index.files` list, i.e. at hierarchy path `()`. Consumers such as
270
+ `euler-loading` treat root-level hierarchical files as ancestors of every
271
+ sample, so the calibration applies globally.
272
+
273
+ ```json
274
+ {
275
+ "indexing": {
276
+ "id": {"regex": "^(calib)\\.json$", "join_char": "+"},
277
+ "files": {
278
+ "extensions": [".json"],
279
+ "path_filters": {"include_regex": ["^calib\\.json$"]}
280
+ },
281
+ "constraints": {"flat_ids_unique": true}
282
+ }
283
+ }
284
+ ```
285
+
286
+ This is the same hierarchy model as per-scene or per-camera calibration: the
287
+ only difference is that the applicable hierarchy level is the root rather than
288
+ `scene:<id>` or `camera:<id>`. Do not add a `hierarchy.regex` for this case;
289
+ there is no path segment to extract.
290
+
291
+ Use `indexing.id.override` when the matched file's natural name is not the
292
+ logical calibration slot. For example, a file named `calib.json` or a UUID JSON
293
+ can be indexed as `intrinsics`, `extrinsics`, or `calibration`:
294
+
295
+ ```json
296
+ {
297
+ "indexing": {
298
+ "id": {
299
+ "regex": "^(calib)\\.json$",
300
+ "join_char": "+",
301
+ "override": "intrinsics"
302
+ }
303
+ }
304
+ }
305
+ ```
306
+
307
+ The override replaces the ID extracted by `id.regex` after the regex has
308
+ matched. This matters for hierarchical modalities because downstream consumers
309
+ use the file ID as the semantic key, and repeated IDs at different ancestor
310
+ levels intentionally mean "the deeper file overrides the shallower one". Do not
311
+ set `id.override` when multiple distinct files can exist at the same hierarchy
312
+ level; they would all receive the same ID and ds-crawler will treat all but one
313
+ as duplicates.
314
+
223
315
  ### 4. Create named split artifacts
224
316
 
225
317
  ```python
@@ -237,6 +329,19 @@ print(train_output["split"]["name"])
237
329
  print(train_output["execution"]["split"])
238
330
  ```
239
331
 
332
+ Scoped split artifacts use the same namespace as the index:
333
+
334
+ ```python
335
+ create_dataset_splits(
336
+ "/data/muses",
337
+ split_names=["train", "val"],
338
+ ratios=[80, 20],
339
+ metadata_scope="rgb",
340
+ )
341
+
342
+ train_output = load_dataset_split("/data/muses", "train", metadata_scope="rgb")
343
+ ```
344
+
240
345
  To reuse an existing partition on a sibling dataset (same qualified file
241
346
  IDs), copy the splits over instead of recomputing them:
242
347
 
@@ -290,6 +395,26 @@ Directory and `.zip` datasets are both supported. With multiple source paths,
290
395
  the split rules are applied to the intersection of hierarchy-qualified IDs so
291
396
  the generated split artifacts stay aligned across modalities.
292
397
 
398
+ To split from an explicit JSON mapping of split name to full qualified file
399
+ IDs, use `create_mapped_dataset_splits`:
400
+
401
+ ```python
402
+ from ds_crawler import create_mapped_dataset_splits
403
+
404
+ create_mapped_dataset_splits(
405
+ "/data/foggy_rgb",
406
+ {
407
+ "fog": ["camera_0~fog~day~0001", "camera_1~fog~day~0002"],
408
+ "clear": ["camera_0~clear~day~0003"],
409
+ },
410
+ )
411
+ ```
412
+
413
+ String IDs are split by `qualified_id_separator="~"` into
414
+ `(*hierarchy_keys, file_id)`. If a path segment itself contains `~`, pass that
415
+ ID as a JSON array of path segments instead. All requested IDs are validated
416
+ before writing, so an invalid mapping never leaves partial split artifacts.
417
+
293
418
  ### 5. Write generated outputs back to disk
294
419
 
295
420
  ```python
@@ -497,6 +622,9 @@ The main public entry points are re-exported from `ds_crawler`.
497
622
 
498
623
  Indexing and config:
499
624
 
625
+ - `build_crawler_config(...)`
626
+ - `build_dataset_artifacts_from_files(...)`
627
+ - `build_dataset_head(...)`
500
628
  - `index_dataset(...)`
501
629
  - `index_dataset_from_files(...)`
502
630
  - `index_dataset_from_path(...)`
@@ -505,14 +633,22 @@ Indexing and config:
505
633
  - `validate_dataset(...)`
506
634
  - `validate_output(...)`
507
635
 
636
+ `index_dataset(...)`, `index_dataset_from_path(...)`, `load_dataset_config(...)`,
637
+ and `validate_dataset(...)` accept `metadata_scope=...` for scoped metadata.
638
+ `build_dataset_artifacts_from_files(...)` also accepts `metadata_scope=...` and
639
+ returns scoped artifact names plus `scopes.json`.
640
+
508
641
  Dataset metadata:
509
642
 
510
643
  - `get_dataset_contract(source)`
511
644
  - `get_dataset_properties(source)`
512
645
  - `extract_dataset_properties(mapping)`
646
+ - `list_metadata_scopes(path)`
513
647
 
514
648
  `get_dataset_contract(...)` returns a `DatasetHeadContract`. Use
515
649
  `contract.get_namespace("euler_train")` to access addon payloads.
650
+ `get_dataset_contract(path, metadata_scope=...)` and
651
+ `get_dataset_properties(path, metadata_scope=...)` read scoped heads.
516
652
 
517
653
  Traversal and filtering:
518
654
 
@@ -532,19 +668,33 @@ Operations:
532
668
  - `create_dataset_splits(...)`
533
669
  - `create_aligned_dataset_splits(...)`
534
670
  - `create_hierarchy_dataset_splits(...)`
671
+ - `create_mapped_dataset_splits(...)`
535
672
  - `list_dataset_splits(...)`
536
673
  - `load_dataset_split(...)`
537
674
 
675
+ Split, copy, align, and extraction operations accept `metadata_scope=...`.
676
+ `copy_dataset(...)`, `copy_dataset_splits(...)`, and `split_dataset(...)` also
677
+ accept source/target scope overrides when the two sides use different scope
678
+ names. `split_datasets(...)` and `extract_datasets(...)` accept per-source or
679
+ per-config scope lists for multi-dataset workflows.
680
+
538
681
  Writers:
539
682
 
540
683
  - `DatasetWriter(...)`
541
684
  - `ZipDatasetWriter(...)`
542
685
 
686
+ Both writers accept `metadata_scope=...` and write scoped artifacts plus the
687
+ `scopes.json` manifest.
688
+
543
689
  Migration helpers:
544
690
 
545
691
  - `migrate_dataset_metadata(...)`
546
692
  - `migrate_dataset_zip(...)`
547
693
  - `migrate_dataset_zips_in_folder(...)`
694
+ - `migrate_inline_splits(...)`
695
+
696
+ Migration helpers and `ds-crawler migrate` also accept `metadata_scope=...`
697
+ or `--metadata-scope` for scoped legacy metadata conversion.
548
698
 
549
699
  ## ZIP support
550
700
 
@@ -32,6 +32,7 @@ from .operations import (
32
32
  create_aligned_dataset_splits,
33
33
  create_dataset_splits,
34
34
  create_hierarchy_dataset_splits,
35
+ create_mapped_dataset_splits,
35
36
  extract_datasets,
36
37
  HierarchySplitClause,
37
38
  HierarchySplitRule,
@@ -46,6 +47,7 @@ from .migration import (
46
47
  migrate_dataset_zips_in_folder,
47
48
  )
48
49
  from .writer import DatasetWriter, ZipDatasetWriter
50
+ from .zip_utils import list_metadata_scopes, validate_metadata_scope
49
51
  from .layout import (
50
52
  EULER_LAYOUT_ADDON,
51
53
  EULER_LAYOUT_VERSION,
@@ -74,6 +76,7 @@ __all__ = [
74
76
  "create_aligned_dataset_splits",
75
77
  "create_dataset_splits",
76
78
  "create_hierarchy_dataset_splits",
79
+ "create_mapped_dataset_splits",
77
80
  "extract_datasets",
78
81
  "HierarchySplitClause",
79
82
  "HierarchySplitRule",
@@ -86,6 +89,7 @@ __all__ = [
86
89
  "index_dataset_from_files",
87
90
  "index_dataset_from_path",
88
91
  "list_dataset_splits",
92
+ "list_metadata_scopes",
89
93
  "load_dataset_config",
90
94
  "load_dataset_split",
91
95
  "migrate_dataset_metadata",
@@ -96,6 +100,7 @@ __all__ = [
96
100
  "split_qualified_ids",
97
101
  "validate_crawler_config",
98
102
  "validate_dataset",
103
+ "validate_metadata_scope",
99
104
  "validate_layout_addon",
100
105
  "validate_output",
101
106
  "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)