Skip to content

Add support for partial Zarr download and upload#1816

Open
yarikoptic wants to merge 10 commits into
masterfrom
partial-zarr
Open

Add support for partial Zarr download and upload#1816
yarikoptic wants to merge 10 commits into
masterfrom
partial-zarr

Conversation

@yarikoptic

@yarikoptic yarikoptic commented Mar 2, 2026

Copy link
Copy Markdown
Member

Summary

Design and implementation for partial zarr download and upload support, addressing #1462, #1474, and related archive issues.

The PR covers five areas:

  1. --zarr TYPE:PATTERN filtering for dandi download — glob, path, and regex filters for selecting entries within zarr assets, with a metadata alias for common zarr metadata files
  2. URL parsing with zarr boundary detectionAssetZarrEntryURL to handle URLs like dandi://dandi/000108/.../file.ome.zarr/0/0/0
  3. --zarr-mode {full, patch} for dandi upload — patch mode uploads changed files without deleting remote files absent locally
  4. Checksums and manifests — documents that per-directory checksums are computed hierarchically by the zarr_checksum library but are NOT persisted (only the root digest is stored in the DB); legacy .checksum files exist on S3 at zarr-checksums/ for ~72% of older zarrs but are orphaned since Dec 2022
  5. dandi ls for zarr contents — listing files within a zarr asset when URL points into a zarr

Key findings from investigation

  • The zarr_checksum algorithm IS hierarchical (Merkle tree, bottom-up via ZarrChecksumTree)
  • The archive's ingest_zarr_archive task computes checksums entirely in memory and stores only the root digest
  • Per-directory .checksum files on S3 (zarr-checksums/ prefix) were written by ZarrChecksumFileUpdater, removed in dandi-archive PRs Testing started to fail due to an error in parsing (?) + new deprecationwarning #1390/[FEAT] dandi fsspec filesystem #1395 (Dec 2022). Legacy files remain for older zarrs but no API exposes them
  • Subtree checksum verification is not possible today without recomputation from file ETags

Implementation

New files

  • dandi/zarr_filter.pyZarrFilter dataclass with glob/path/regex matching, parse_zarr_filter(), make_zarr_entry_filter(), ZARR_FILTER_ALIASES (includes metadata alias)
  • dandi/tests/test_zarr_filter.py — 52 unit tests covering all filter types, parsing, aliases, edge cases, invalid regex validation

Modified files

  • dandi/dandiarchive.pysplit_zarr_location(), AssetZarrEntryURL class, updated parse_dandi_url() to detect zarr boundaries
  • dandi/download.pyzarr_filters parameter on download(), filter threading through Downloader and _download_zarr(), skip deletion/checksum when filter active
  • dandi/files/zarr.pyzarr_mode: Literal["full", "patch"] on iter_upload(), patch mode skips remote file deletion and client-side checksum verification
  • dandi/upload.pyzarr_mode parameter on upload(), conditional pass-through for ZarrAsset
  • dandi/cli/cmd_download.py--zarr click option (multiple, OR logic)
  • dandi/cli/cmd_upload.py--zarr-mode click option
  • dandi/cli/cmd_ls.py — list zarr entries when URL is AssetZarrEntryURL
  • dandi/cli/tests/test_download.py — updated mock expectations for new zarr_filters parameter

Integration tests

  • dandi/tests/test_download.py — 6 tests (glob filter, metadata alias, no-delete, path filter, nonexistent filter, sync conflict)
  • dandi/tests/test_upload.py — 3 tests (patch no-delete, full delete, patch updates)
  • dandi/tests/test_dandiarchive.py — 3 URL parsing cases + 8 split_zarr_location cases

Review checklist

Please review the design at doc/design/partial-zarr.md and comment on:

  • --zarr TYPE:PATTERN syntax — is the filter approach right? Are glob/path/regex the right types?
  • metadata alias expansion — does glob:**/.z* + glob:**/zarr.json + glob:**/.zmetadata cover all cases?
  • --zarr-mode patch semantics — is "upload without deleting" the right default for patch? Should subtree cleanup happen?
  • URL parsing — is AssetZarrEntryURL with zarr boundary detection the right approach?
  • Checksum strategy — relying on per-file ETags for partial ops, deferring subtree checksums to future manifests
  • Open questions in the doc (OR composition chosen, --sync conflict raises error, server-side glob deferred)
  • Should legacy zarr-checksums/ files on S3 be cleaned up as part of this or separately?

TODO

  • Implement dandi/zarr_filter.py — filter parsing and matching
  • Implement AssetZarrEntryURL and split_zarr_location() in dandi/dandiarchive.py
  • Add --zarr option to dandi download CLI
  • Modify _download_zarr() for partial download support
  • Add --zarr-mode option to dandi upload CLI
  • Implement patch mode in iter_upload() (dandi/files/zarr.py)
  • Thread zarr_mode through dandi/upload.py
  • dandi ls zarr contents support
  • Unit tests for zarr_filter.py (52 tests)
  • Unit tests for URL parsing and split_zarr_location (11 test cases)
  • Integration tests for download filtering (6 tests)
  • Integration tests for upload patch mode (3 tests)
  • Fix existing CLI mock test expectations
  • Code review fixes (regex pre-compilation, Literal typing, ** collapse, redundant override removal)
  • Run integration tests against docker-compose archive instance
  • Coordinate with dandi-archive on manifest design (#2702) for future subtree checksum support

🤖 Generated with Claude Code

@yarikoptic yarikoptic marked this pull request as draft March 2, 2026 21:37
@yarikoptic yarikoptic requested review from kabilar and satra March 2, 2026 21:38
@yarikoptic yarikoptic changed the title Design: partial zarr download/upload Partial zarr download/upload (stage: Design) Mar 2, 2026
@codecov

codecov Bot commented Mar 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.96217% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.29%. Comparing base (3f2b6d1) to head (4c24ad0).

Files with missing lines Patch % Lines
dandi/dandiarchive.py 64.28% 10 Missing ⚠️
dandi/download.py 85.07% 10 Missing ⚠️
dandi/upload.py 37.50% 5 Missing ⚠️
dandi/zarr_filter.py 95.31% 3 Missing ⚠️
dandi/cli/cmd_ls.py 60.00% 2 Missing ⚠️
dandi/tests/test_download.py 96.15% 2 Missing ⚠️
dandi/files/zarr.py 94.73% 1 Missing ⚠️
dandi/tests/test_upload.py 98.63% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1816      +/-   ##
==========================================
+ Coverage   76.88%   77.29%   +0.40%     
==========================================
  Files          88       90       +2     
  Lines       12901    13234     +333     
==========================================
+ Hits         9919    10229     +310     
- Misses       2982     3005      +23     
Flag Coverage Δ
unittests 77.29% <91.96%> (+0.40%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@yarikoptic yarikoptic added enhancement New feature or request minor Increment the minor version when merged labels Mar 3, 2026
yarikoptic and others added 3 commits March 6, 2026 08:10
Covers five areas:
- --zarr TYPE:PATTERN filtering for download (glob, path, regex)
- URL parsing with zarr boundary detection (AssetZarrEntryURL)
- --zarr-mode {full, patch} for upload
- Checksums and manifests (per-directory checksums are NOT
  persisted on the archive; legacy .checksum files exist on S3
  under zarr-checksums/ for ~72% of older zarrs but are orphaned)
- dandi ls for zarr contents

Related: #1462, #1474

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add --zarr CLI option for download to filter entries within zarr assets
(glob/path/regex patterns with predefined 'metadata' alias), and
--zarr-mode option for upload to support 'patch' mode (upload/update
without deleting remote-only files).

Key changes:
- New dandi/zarr_filter.py: filter parsing, matching, and aliases
- URL parsing: AssetZarrEntryURL for URLs pointing into zarr assets
- Download pipeline: thread zarr_entry_filter through Downloader and
  _download_zarr, skip deletion and checksum when filter active
- Upload pipeline: zarr_mode='patch' skips remote file deletion and
  client-side checksum verification
- dandi ls: list zarr entries when URL points into a zarr

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Pre-compile regex patterns at ZarrFilter construction time, catching
  invalid patterns early instead of on every matches() call (B1)
- Remove redundant get_asset_download_path override in AssetZarrEntryURL
  that was identical to the inherited SingleAssetURL method (H1)
- Use Literal["full", "patch"] for zarr_mode parameter instead of bare
  str to prevent silent misbehavior on invalid values (H2)
- Collapse consecutive ** glob segments to avoid exponential
  backtracking in _glob_match_parts (H3)
- Simplify split_zarr_location to use str.split instead of
  PurePosixPath (M1)
- Add explanatory comment for type: ignore in parse_zarr_filter (M2)
- Yield {"status": "done"} when zarr filter matches zero entries (M4)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@yarikoptic yarikoptic changed the title Partial zarr download/upload (stage: Design) Partial zarr download/upload (stage: Design + Implementation) Mar 9, 2026
yarikoptic and others added 3 commits March 11, 2026 11:34
click.Choice returns str at runtime, but upload() expects
Literal["full", "patch"]. Add typing cast at the call site.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Follow existing codebase pattern (UploadExisting, DownloadExisting, etc.)
to define ZarrMode as a str Enum in upload.py.  Eliminates the duplicated
Literal["full", "patch"] across three files and the cast() workaround in
cmd_upload.py.

Uses TYPE_CHECKING guard in files/zarr.py to avoid circular import
(files/zarr.py -> upload.py -> .files).

Co-Authored-By: Claude Code 2.1.63 / Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Code 2.1.63 / Claude Opus 4.6 <noreply@anthropic.com>
Comment thread doc/design/partial-zarr.md Outdated
Comment on lines +31 to +45
## Primary Use Case

From @kabilar (#1462):

```bash
# 1. Download just the metadata files from a zarr
dandi download --zarr glob:'**/.z*' --zarr glob:'**/zarr.json' \
dandi://dandi/001289/rawdata/.../PC.ome.zarr

# 2. Edit .zattrs locally
vim PC.ome.zarr/.zattrs

# 3. Upload changes without deleting remote data chunks
dandi upload --zarr-mode patch
```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @yarikoptic. An additional use case would be adding shards to an existing Zarr. For example, we have 355+ TB from a subject that were are stitching together into a single Zarr. We don't have enough space locally so will need to upload a shard, delete the local copy, and repeat the process.

cc @satra @ayendiki

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you see currently proposed/implemented interface be sufficient for this use case?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, thanks. Should we add this as a use case in the design doc?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could -- just push it to this branch or send a PR against it if would like a review

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## Primary Use Case
From @kabilar (#1462):
```bash
# 1. Download just the metadata files from a zarr
dandi download --zarr glob:'**/.z*' --zarr glob:'**/zarr.json' \
dandi://dandi/001289/rawdata/.../PC.ome.zarr
# 2. Edit .zattrs locally
vim PC.ome.zarr/.zattrs
# 3. Upload changes without deleting remote data chunks
dandi upload --zarr-mode patch
```
## Use Cases
### Use Case 1 - Update metadata
1. Download just the metadata files from a Zarr.
`dandi download --zarr glob:'**/.z*' --zarr glob:'**/zarr.json' \
dandi://dandi/001289/rawdata/.../PC.ome.zarr`
2. Edit metadata locally.
`vim PC.ome.zarr/zarr.json`
3. Upload changes without deleting remote data chunks.
`dandi upload --zarr-mode patch`
### Use Case 2 - Incrementally add shards to an existing Zarr
We are generating a very large dataset for which there is not enough space to house the entire dataset locally.
1. Add shard(s) locally to a Zarr that exists on DANDI.
2. Upload shard(s) without deleting remote Zarr objects (that don't exist locally).
3. Remove local shard(s) and repeat the process.

Comment thread doc/design/partial-zarr.md Outdated
Comment thread doc/design/partial-zarr.md
Comment thread doc/design/partial-zarr.md
Comment thread doc/design/partial-zarr.md
Comment thread doc/design/partial-zarr.md
Comment thread doc/design/partial-zarr.md
Co-authored-by: Kabilar Gunalan <kabilar.gunalan@gmail.com>
@kabilar

kabilar commented Jul 8, 2026

Copy link
Copy Markdown
Member

Hi @yarikoptic, thanks for this pull request. I was able to add objects to an existing zarr, delete objects, and modify metadata (i.e. zarr.json).

I just have some minor feedback on the terminal output.

When I run dandi upload for a dandiset with a zarr that is the same between the local storage and dandi, the STATUS is done instead of skipped and the MESSAGE is exists - reuploading instead of file exists:

PATH                                              SIZE      ERRORS PROGRESS STATUS                         MESSAGE                  
dandiset.yaml                                     1.8 kB                    skipped                        should be edited online  
.bidsignore                                       13 Bytes    0             skipped                        file exists              
dataset_description.json                          404 Bytes   0             skipped                        file exists              
...atives/dandi-cli-1816-test/HG9_Z1_Y49.nii.zarr 5.9 GB      0             done                           exists - reuploading     
Summary:                                          5.9 GB                    3 skipped                      1 should be edited online
                                                                            1 done                         2 file exists            
                                                                                                           1 exists - reuploading 

And then when I modify the zarr locally and run dandi upload, the STATUS and MESSAGE are the same:

PATH                                             SIZE      ERRORS   PROGRESS STATUS                        MESSAGE                  
dandiset.yaml                                    1.8 kB                      skipped                       should be edited online  
.bidsignore                                      13 Bytes    0               skipped                       file exists              
dataset_description.json                         404 Bytes   0               skipped                       file exists              
...tives/dandi-cli-1816-test/HG9_Z1_Y49.nii.zarr 6.6 GB      0          100% done                          exists - reuploading     
Summary:                                         6.6 GB             3.3 MB/s 3 skipped                     1 should be edited online
                                                                             1 done                        2 file exists            
                                                                                                           1 exists - reuploading 

So I would propose the following changes to the terminal output:

  1. If the zarr exists on DANDI and is the same as the local version, the STATUS should be skipped and MESSAGE should be file exists.
  2. If the zarr exists on DANDI and is different than the local version, the MESSAGE should be more specific to what is happening to the zarr. For example:
    • file exists - uploading additional objects (2 GB)
    • file exists - deleting objects (2 GB)
    • file exists - modifying objects (2 GB)
    • Note that I have also added (2 GB) in the example to denote how many of the objects in the zarr are being uploaded/deleted/modified, since the SIZE is that of the entire Zarr tree.

@kabilar

kabilar commented Jul 8, 2026

Copy link
Copy Markdown
Member

Just explicitly noting that dandi download --zarr metadata <path> and dandi upload --zarr-mode patch both work as intended.

For dandi download --zarr metadata dandi://dandi/001769@draft/derivatives/dandi-cli-1816-test/HG9_Z1_Y49.nii.zarr, this downloaded all the zarr.json files in the Zarr tree.

For dandi upload --zarr-mode patch, only modified local files were uploaded and remote files (that are absent locally) were not deleted.

Six files had conflicts, all driven by three orthogonal upstream changes
overlapping with the partial-zarr work:

1. --sync redesign (upstream PR "Redesign --sync as optional-value option",
   cb394e8): --sync switched from is_flag=True (bool) to an optional-value
   option resolving to None / "ask" / "do", with a SyncMode enum in
   dandi.consts.  Function signatures for download() and upload() went
   from `sync: bool` to `sync: bool | SyncMode | None`, and the CLI now
   converts the string via `SyncMode(sync) if sync is not None else None`.

2. Zarr cleanup consolidation (upstream, commits db6bf4b..976122c):
   the "deleting extra files" phase in download.py was reworked into a
   single-pass tree scan with lazy "deleting extra files" status
   announcement (only yielded when something is actually deleted) and
   debug logging.

3. Validation companion file (upstream PR series around 3873319):
   upload() gained a `validation_log_path` parameter, cmd_upload.py
   imports SyncMode from ..consts, and computes a companion path from
   the CLI logfile.

Partial-zarr's own additions that overlapped:
- --zarr FILTER option on `dandi download` and a `zarr_filters` param
  threaded through to download().
- ZarrMode(str, Enum) in dandi.upload and --zarr-mode on `dandi upload`.
- Skip extra-file deletion and whole-zarr checksum verification when a
  zarr filter is active (partial download semantics).

Conflict resolutions:

* dandi/cli/cmd_download.py
  Kept upstream's `sync: str | None` signature and
  `sync=SyncMode(sync) if sync is not None else None` conversion at the
  download() call site.  Preserved partial-zarr's `zarr_filters` param
  and `zarr_filters=zarr_filters` kwarg.

* dandi/cli/cmd_upload.py
  Merged imports: kept `SyncMode` from ..consts (upstream) alongside
  `ZarrMode` from ..upload (partial-zarr).  In the upload_() call,
  combined upstream's `sync=SyncMode(sync) if sync is not None else None`
  and `validation_log_path=companion` with partial-zarr's
  `zarr_mode=zarr_mode`.

* dandi/cli/tests/test_download.py
  Updated seven mock assertions to expect `sync=None` (upstream's new
  default) plus `zarr_filters=()` (partial-zarr's new kwarg).

* dandi/download.py (signature)
  Signature is `sync: bool | SyncMode | None = False,
  zarr_filters: Sequence[str] = ()`.  Bool default kept for backward
  compatibility with programmatic callers; upstream already handles
  `sync is True` by normalizing to SyncMode.ASK.

* dandi/download.py (zarr cleanup block)
  Kept partial-zarr's outer guard
  `if zarr_entry_filter is not None: ... else:` so partial downloads
  skip both extra-file deletion and whole-zarr checksum verification.
  Inside the `else` branch, adopted upstream's single-pass scan with
  the `announced` flag, typed `dirs: deque[Path]`, and debug logging
  for both file deletion and empty-directory removal.  The
  "skipped in final_out['message']" checksum block remains nested
  inside the same else, consistent with the pre-existing partial-zarr
  layout.

* dandi/files/zarr.py
  Combined imports at the top of the file: `TYPE_CHECKING, Any,
  Optional` (partial-zarr, needed for the `ZarrMode` forward reference)
  plus `import urllib.parse` (upstream), and a TYPE_CHECKING guarded
  `from ..upload import ZarrMode` block to avoid a circular import.

* dandi/upload.py
  Signature is `sync: bool | SyncMode | None = False,
  zarr_mode: ZarrMode = ZarrMode.FULL,
  validation_log_path: str | Path | None = None`.  All three params
  are used later in the function (sync normalization at ~line 525,
  zarr_mode threaded into upload kwargs at ~line 415,
  validation_log_path at ~line 315).

No conflicts in dandi/dandiarchive.py, dandi/tests/test_dandiarchive.py,
dandi/tests/test_download.py, dandi/tests/test_upload.py — auto-merged.

Co-Authored-By: Claude Code 2.1.138 / Claude Opus 4.7 <noreply@anthropic.com>
@kabilar kabilar changed the title Partial zarr download/upload (stage: Design + Implementation) Add support for partial Zarr download and upload Jul 9, 2026
@kabilar kabilar marked this pull request as ready for review July 9, 2026 18:35
Restructure the design doc's "Primary Use Case" section into a
"Use Cases" section covering:

  1. Update metadata (the original scenario)
  2. Incrementally add shards to an existing Zarr

per @kabilar's review suggestion on PR #1816
(#1816 (comment)).
The motivation for Use Case 2 is producing large datasets in shards
when the full Zarr cannot fit locally.

No source changes are needed -- the existing --zarr-mode patch code
path already supports Use Case 2 by construction: new local files
are uploaded, remote-only files are preserved, and whole-Zarr
checksum verification is skipped so a pruned local checkout does
not spuriously mismatch.

Add a regression test test_upload_zarr_patch_mode_incremental_shards
that explicitly exercises Use Case 2: prunes the fixture Zarr's local
tree down to metadata files, drops in a synthetic shard, uploads with
zarr_mode="patch", and asserts both that the new shard reached the
remote and that every previously-pruned remote entry is still present.

Co-Authored-By: Kabilar Gunalan <871137+kabilar@users.noreply.github.com>
Co-Authored-By: Claude Code 2.1.138 / Claude Opus 4.7 <noreply@anthropic.com>
…o partial-zarr

* 'partial-zarr' of https://github.com/dandi/dandi-cli:
  DOC: Minor tune up in phrasing of the design doc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request minor Increment the minor version when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants