Skip to content

Commit a069d5b

Browse files
authored
Merge pull request #1816 from dandi/partial-zarr
Add support for partial Zarr download and upload
2 parents ff82dab + 15529ad commit a069d5b

15 files changed

Lines changed: 1724 additions & 98 deletions

‎dandi/cli/cmd_download.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,17 @@
132132
"With 'ask' (the default when --sync is passed without a value), prompt before "
133133
"deleting. With 'do', delete without prompting.",
134134
)
135+
@click.option(
136+
"--zarr",
137+
"zarr_filters",
138+
multiple=True,
139+
metavar="FILTER",
140+
help=(
141+
"Filter entries within Zarr assets. Format: TYPE:PATTERN where TYPE "
142+
"is 'glob', 'path', or 'regex'. Predefined: 'metadata'. "
143+
"Can be specified multiple times (OR logic)."
144+
),
145+
)
135146
@instance_option(
136147
default=None,
137148
help=(
@@ -165,6 +176,7 @@ def download(
165176
format: DownloadFormat,
166177
download_types: set[str],
167178
sync: str | None,
179+
zarr_filters: tuple[str, ...],
168180
dandi_instance: str,
169181
path_type: PathType,
170182
preserve_tree: bool,
@@ -205,6 +217,7 @@ def download(
205217
get_assets="assets" in download_types or preserve_tree,
206218
preserve_tree=preserve_tree,
207219
sync=SyncMode(sync) if sync is not None else None,
220+
zarr_filters=zarr_filters,
208221
path_type=path_type,
209222
# develop_debug=develop_debug
210223
)

‎dandi/cli/cmd_ls.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
from .base import devel_option, lgr, map_to_click_exceptions
99
from .formatter import JSONFormatter, JSONLinesFormatter, PYOUTFormatter, YAMLFormatter
1010
from ..consts import ZARR_EXTENSIONS, metadata_all_fields
11-
from ..dandiarchive import DandisetURL, _dandi_url_parser, parse_dandi_url
11+
from ..dandiapi import BaseRemoteZarrAsset
12+
from ..dandiarchive import (
13+
AssetZarrEntryURL,
14+
DandisetURL,
15+
_dandi_url_parser,
16+
parse_dandi_url,
17+
)
1218
from ..dandiset import Dandiset
1319
from ..misctypes import Digest
1420
from ..support.pyout import PYOUT_SHORT_NAMES, PYOUT_SHORT_NAMES_rev
@@ -143,6 +149,17 @@ def assets_gen():
143149
if metadata in ("all", "assets"):
144150
rec["metadata"] = a.get_raw_metadata()
145151
yield rec
152+
# If URL points into a zarr, also list entries
153+
if isinstance(parsed_url, AssetZarrEntryURL) and isinstance(
154+
a, BaseRemoteZarrAsset
155+
):
156+
for entry in a.iterfiles(
157+
prefix=parsed_url.zarr_subpath
158+
):
159+
yield {
160+
"path": f"{a.path}/{entry}",
161+
"size": entry.size,
162+
}
146163
else:
147164
# For now we support only individual files
148165
yield path

‎dandi/cli/cmd_upload.py‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
)
1313
from ..consts import SyncMode
1414
from ..exceptions import UploadValidationError
15-
from ..upload import UploadExisting, UploadValidation
15+
from ..upload import UploadExisting, UploadValidation, ZarrMode
1616

1717

1818
@click.command()
@@ -58,6 +58,16 @@
5858
default="require",
5959
show_default=True,
6060
)
61+
@click.option(
62+
"--zarr-mode",
63+
type=EnumChoice(ZarrMode),
64+
default="full",
65+
help=(
66+
"Zarr sync mode: 'full' (default) syncs completely; "
67+
"'patch' uploads/updates without deleting remote files."
68+
),
69+
show_default=True,
70+
)
6171
@click.argument("paths", nargs=-1) # , type=click.Path(exists=True, dir_okay=False))
6272
# &
6373
# Development options: Set DANDI_DEVEL for them to become available
@@ -85,6 +95,7 @@ def upload(
8595
dandi_instance: str,
8696
existing: UploadExisting,
8797
validation: UploadValidation,
98+
zarr_mode: ZarrMode,
8899
# Development options should come as kwargs
89100
allow_any_path: bool = False,
90101
upload_dandiset_metadata: bool = False,
@@ -132,6 +143,7 @@ def upload(
132143
jobs=jobs,
133144
jobs_per_file=jobs_per_file,
134145
sync=SyncMode(sync) if sync is not None else None,
146+
zarr_mode=zarr_mode,
135147
validation_log_path=companion,
136148
)
137149
except UploadValidationError as exc:

‎dandi/cli/tests/test_cmd_upload.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from ..base import map_to_click_exceptions
66
from ..cmd_upload import upload
77
from ...exceptions import UploadValidationError
8+
from ...upload import ZarrMode
89

910

1011
@pytest.mark.ai_generated
@@ -20,3 +21,31 @@ def test_upload_validation_error_has_no_traceback(mocker: MockerFixture) -> None
2021
assert result.exit_code == 1
2122
assert result.output == "Error: failed validation\n"
2223
assert "Traceback" not in result.output
24+
25+
26+
@pytest.mark.ai_generated
27+
@pytest.mark.parametrize(
28+
"args,expected",
29+
[
30+
([], ZarrMode.FULL),
31+
(["--zarr-mode", "full"], ZarrMode.FULL),
32+
(["--zarr-mode", "patch"], ZarrMode.PATCH),
33+
],
34+
)
35+
def test_upload_zarr_mode_option(
36+
mocker: MockerFixture, args: list[str], expected: ZarrMode
37+
) -> None:
38+
"""`--zarr-mode` takes the enum *values* and reaches `upload()`.
39+
40+
Regression test: with ``click >= 8.2`` a plain ``click.Choice`` over an
41+
``Enum`` matches members on ``.name``, so the command line would have
42+
required ``FULL``/``PATCH`` and even the ``full`` default would fail to
43+
validate -- making *every* ``dandi upload`` invocation exit with a usage
44+
error (exit code 2). ``EnumChoice`` matches on ``.value`` instead.
45+
"""
46+
upload_mock = mocker.patch("dandi.upload.upload")
47+
48+
result = CliRunner().invoke(upload, args)
49+
50+
assert result.exit_code == 0, result.output
51+
assert upload_mock.call_args.kwargs["zarr_mode"] == expected

‎dandi/cli/tests/test_download.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ def test_download_defaults(mocker):
2525
get_assets=True,
2626
preserve_tree=False,
2727
sync=None,
28+
zarr_filters=(),
2829
path_type=PathType.EXACT,
2930
)
3031

@@ -44,6 +45,7 @@ def test_download_all_types(mocker):
4445
get_assets=True,
4546
preserve_tree=False,
4647
sync=None,
48+
zarr_filters=(),
4749
path_type=PathType.EXACT,
4850
)
4951

@@ -63,6 +65,7 @@ def test_download_metadata_only(mocker):
6365
get_assets=False,
6466
preserve_tree=False,
6567
sync=None,
68+
zarr_filters=(),
6669
path_type=PathType.EXACT,
6770
)
6871

@@ -82,6 +85,7 @@ def test_download_assets_only(mocker):
8285
get_assets=True,
8386
preserve_tree=False,
8487
sync=None,
88+
zarr_filters=(),
8589
path_type=PathType.EXACT,
8690
)
8791

@@ -117,6 +121,7 @@ def test_download_gui_instance_in_dandiset(mocker, tmp_path, monkeypatch):
117121
get_assets=True,
118122
preserve_tree=False,
119123
sync=None,
124+
zarr_filters=(),
120125
path_type=PathType.EXACT,
121126
)
122127

@@ -144,6 +149,7 @@ def test_download_api_instance_in_dandiset(mocker, tmp_path, monkeypatch):
144149
get_assets=True,
145150
preserve_tree=False,
146151
sync=None,
152+
zarr_filters=(),
147153
path_type=PathType.EXACT,
148154
)
149155

@@ -170,6 +176,7 @@ def test_download_url_instance_match(mocker):
170176
get_assets=True,
171177
preserve_tree=False,
172178
sync=None,
179+
zarr_filters=(),
173180
path_type=PathType.EXACT,
174181
)
175182

‎dandi/dandiarchive.py‎

Lines changed: 85 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
REDIRECT_HEAD_TIMEOUT,
4848
RETRY_STATUSES,
4949
VERSION_REGEX,
50+
ZARR_EXTENSIONS,
5051
DandiInstance,
5152
known_instances,
5253
)
@@ -446,6 +447,72 @@ def get_assets(
446447
)
447448

448449

450+
def split_zarr_location(location: str) -> tuple[str, str] | None:
451+
"""Split a location into ``(asset_path, zarr_subpath)`` if it crosses a zarr boundary.
452+
453+
Scans path components for segments ending with a Zarr extension
454+
(``.zarr``, ``.ngff``). If found **and** there are remaining components
455+
after the boundary, returns the split; otherwise returns ``None``.
456+
457+
Parameters
458+
----------
459+
location : str
460+
A POSIX-style path, e.g. ``"sub-1/file.ome.zarr/0/0/0"``.
461+
462+
Returns
463+
-------
464+
tuple[str, str] | None
465+
``(asset_path, zarr_subpath)`` when a zarr boundary with a subpath
466+
is detected, otherwise ``None``.
467+
468+
Examples
469+
--------
470+
>>> split_zarr_location("sub-1/file.ome.zarr/0/0/0")
471+
('sub-1/file.ome.zarr', '0/0/0')
472+
>>> split_zarr_location("sub-1/file.ome.zarr") # no subpath
473+
>>> split_zarr_location("sub-1/file.nwb") # no zarr extension
474+
"""
475+
parts = [p for p in location.split("/") if p]
476+
for i, part in enumerate(parts):
477+
if any(part.endswith(ext) for ext in ZARR_EXTENSIONS):
478+
asset_path = "/".join(parts[: i + 1])
479+
zarr_subpath = "/".join(parts[i + 1 :])
480+
return (asset_path, zarr_subpath) if zarr_subpath else None
481+
return None
482+
483+
484+
@dataclass
485+
class AssetZarrEntryURL(SingleAssetURL):
486+
"""Parsed from a URL that points into entries within a Zarr asset.
487+
488+
For example, ``dandi://dandi/000108/sub-1/file.ome.zarr/0/0/0`` would
489+
produce ``asset_path="sub-1/file.ome.zarr"`` and ``zarr_subpath="0/0/0"``.
490+
"""
491+
492+
asset_path: str # e.g., "sub-1/file.ome.zarr"
493+
zarr_subpath: str # e.g., "0/0/0"
494+
495+
def get_assets(
496+
self, client: DandiAPIClient, order: str | None = None, strict: bool = False
497+
) -> Iterator[BaseRemoteAsset]:
498+
"""Yield the zarr asset whose path equals ``asset_path``.
499+
500+
If the asset does not exist, a `NotFoundError` is raised when
501+
``strict`` is true; otherwise nothing is yielded.
502+
"""
503+
try:
504+
dandiset = self.get_dandiset(client, lazy=not strict)
505+
assert dandiset is not None
506+
dandiset.version_id # Force version evaluation
507+
except NotFoundError:
508+
if strict:
509+
raise
510+
else:
511+
return
512+
with _maybe_strict(strict):
513+
yield dandiset.get_asset_by_path(self.asset_path)
514+
515+
449516
@dataclass
450517
class AssetFolderURL(MultiAssetURL):
451518
"""
@@ -846,12 +913,24 @@ def parse(
846913
path=location,
847914
)
848915
else:
849-
parsed_url = AssetItemURL(
850-
instance=instance,
851-
dandiset_id=dandiset_id,
852-
version_id=version_id,
853-
path=location,
854-
)
916+
# Check if location crosses a zarr boundary
917+
zarr_split = split_zarr_location(location)
918+
if zarr_split is not None:
919+
asset_path, zarr_subpath = zarr_split
920+
parsed_url = AssetZarrEntryURL(
921+
instance=instance,
922+
dandiset_id=dandiset_id,
923+
version_id=version_id,
924+
asset_path=asset_path,
925+
zarr_subpath=zarr_subpath,
926+
)
927+
else:
928+
parsed_url = AssetItemURL(
929+
instance=instance,
930+
dandiset_id=dandiset_id,
931+
version_id=version_id,
932+
path=location,
933+
)
855934
elif asset_id:
856935
if dandiset_id is None:
857936
parsed_url = BaseAssetIDURL(instance=instance, asset_id=asset_id)

0 commit comments

Comments
 (0)