Skip to content

refactor(api): build zarr.open, open_array and open_group on one metadata reader - #4366

Draft
d-v-b wants to merge 10 commits into
zarr-developers:mainfrom
d-v-b:claude/open-single-probe-v2
Draft

d-v-b wants to merge 10 commits into
zarr-developers:mainfrom
d-v-b:claude/open-single-probe-v2

Conversation

@d-v-b

@d-v-b d-v-b commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

What

zarr.open used to look for an array and, failing that, open a group, and the two steps read overlapping keys: seven requests for a group, five of them for a format 3 group whose single zarr.json already held everything. Fixing that exposed the shape of the code underneath, so this PR restructures how nodes are opened rather than patching the one path.

How

Three read implementations existed: get_array_metadata (arrays), AsyncGroup.open (groups), and the _read_metadata_v2/v3 helpers behind AsyncGroup.getitem (either kind, explicit format only). Each fused reading with interpreting, and the first two each detected the format on their own. open had no primitive to build on, so it was trial-and-error over the specific openers, with exceptions carrying "found something else."

Now there is one reader and everything is a thin layer on it:

  • read_v3_metadata(store, path) and read_v2_metadata(store, path, *, consolidated_key=None) each read one format's documents once and return parsed metadata, whichever kind the node is, or None; the v2 reader attaches a consolidated document to a group when asked to read one. read_node_metadata(store, path, zarr_format, *, consolidated_key=None) is their composition: with zarr_format=None it tries v3 first and reads the format 2 documents only if there is no zarr.json.
  • Construction is from metadata objects: _build_node (now taking config), and for groups _apply_use_consolidated enforces the caller's use_consolidated on what was read. AsyncGroup._from_bytes_v2/v3 and _from_dict_v3 are gone; AsyncGroup.from_dict is a plain constructor.
  • _open_node(store_path, *, zarr_format, use_consolidated, config) = read + consolidated policy + build, or None. No mode policy, no exceptions for "nothing here."
  • open, open_array, open_group, AsyncArray.open, AsyncGroup.open, get_node are mode policy and a kind filter over _open_node. get_array_metadata is kept for callers that want the document.
  • open has a written contract (in its docstring, replacing the TODO: the mode check below seems wrong!): with shape it behaves as open_array; otherwise the reading modes (r, r+, a) open whatever node is there, r/r+ raising NodeNotFoundError and a creating a group when there is none, and the creating modes (w, w-) create a group, w replacing whatever is there.
  • One node_type policy: a format 3 document's node_type must be array or group, else NodeTypeValidationError. Finding the wrong kind is ContainsArrayError / ContainsGroupError, never "not found."

Reads via zarr.open

main this PR
v3 array or group 3 / 7 1
v2 group 7 5 (4 with use_consolidated=False)
v2 array 3 5 (4), one extra round trip

The v2 array row is the price of format 2 being the fallback; format 3 is the default, so one read for any format 3 node is the number that matters.

Behavior changes

All deliberate, each pinned by a test:

  1. zarr.open(mode="w") without shape creates a group over whatever is there, including an existing array, which it used to return untouched.
  2. zarr.open(shape=...) on a group raises ContainsGroupError; it used to open the group.
  3. zarr.open(mode="r" | "r+") on nothing raises NodeNotFoundError rather than GroupNotFoundError (both are FileNotFoundError; GroupNotFoundError is the narrower class, so code catching only that no longer catches this).
  4. A format 3 document with a missing or unknown node_type raises NodeTypeValidationError everywhere. A missing node_type used to open as a group through open/open_group; an unknown one raised GroupNotFoundError.
  5. Wrong kind, every format and mode: opening an array as a group raises ContainsArrayError (format 2 used to say FileNotFoundError); opening a group as an array raises ContainsGroupError (format 3 used to say NodeTypeValidationError, format 2 ArrayNotFoundError).
  6. open_array(mode="w") on a group replaces it in both formats; format 3 used to raise.
  7. open_array(config=...) on an existing array now applies config; it was silently ignored.
  8. A path holding both formats opens as format 3 without reading or warning about the other, in every opener.
  9. Requesting missing format 2 consolidated metadata raises with the same sentence format 3 uses, instead of ValueError(".zmetadata"); a consolidated document with no metadata object raises MetadataValidationError instead of KeyError.

Tests

test_open_mode_contract is the contract as a table: 30 cells of what-is-there × mode × with/without shape, checking what comes back and whether it was opened (attributes kept) or created. test_open_reads_only_what_the_node_needs (84 cases) asserts the exact keys read, each once, and that the node is what open_array/open_group return. Small tests pin each error type above.

This supersedes d-v-b#192.

🤖 Generated with Claude Code

`zarr.open` looks for an array first and opens a group when it doesn't find
one. Both steps read `zarr.json` and `.zattrs`, so a format-detecting open
of a group made seven requests where five would do, a cost paid on every
call against a remote store.

Split the array lookup into `_probe_array_metadata`, which reports "no array
here" as a value instead of an exception and hands back the documents it
read. `zarr.open` passes those to `AsyncGroup.open`, which then reads only
the keys it is still missing. `get_array_metadata` keeps its signature and
its exceptions, and becomes a thin wrapper over the probe.

Assisted-by: ClaudeCode:claude-opus-5
@github-actions github-actions Bot added the needs release notes Automatically applied to PRs which haven't added release notes label Sep 16, 2026
Assisted-by: ClaudeCode:claude-opus-5
@github-actions github-actions Bot removed the needs release notes Automatically applied to PRs which haven't added release notes label Sep 16, 2026
@read-the-docs-community

read-the-docs-community Bot commented Sep 16, 2026

Copy link
Copy Markdown

Documentation build overview

📚 zarr-indexing | 🛠️ Build #34592486 | 📁 Comparing 80243e9 against latest (dfa18e8)

  🔍 Preview build  

1 file changed
± api/transform/index.html

@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.68874% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.20%. Comparing base (9f0ae4e) to head (80243e9).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/zarr/core/group.py 96.26% 4 Missing ⚠️
src/zarr/core/array.py 92.85% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4366      +/-   ##
==========================================
- Coverage   94.22%   94.20%   -0.03%     
==========================================
  Files          92       92              
  Lines       12942    12908      -34     
==========================================
- Hits        12195    12160      -35     
- Misses        747      748       +1     
Files with missing lines Coverage Δ
src/zarr/api/asynchronous.py 96.25% <100.00%> (-0.07%) ⬇️
src/zarr/api/synchronous.py 92.95% <ø> (ø)
src/zarr/core/sync_group.py 100.00% <100.00%> (ø)
src/zarr/core/array.py 97.85% <92.85%> (-0.24%) ⬇️
src/zarr/core/group.py 95.31% <96.26%> (+0.09%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

`zarr.json` wins whenever it is present, so whether the probe's metadata
came from it is a fact about `docs`, not a second field that has to agree
with `docs`. Also name the fetcher for what it reads: the array document
set, never `.zgroup`.

Assisted-by: ClaudeCode:claude-fable-5-1
A dataclass field set to None could mean the key was read and held nothing,
or was never read at all, so the group open could only reuse documents under
a guard at the call site. With NotRequired keys, presence is the signal: the
group open takes whatever was actually read, and zarr.open passes the probe's
documents along unconditionally.

Assisted-by: ClaudeCode:claude-fable-5-1
The probe learns one of three things: nothing is here, an array is, or a
Zarr format 3 group is. Say that with `node_type: Literal["array",
"group"] | None` instead of deriving `is_array` and `from_zarr_json` from
the documents after the fact.

That makes the classification explicit. A `.zarray` is an array. A
`zarr.json` is whatever its `node_type` says, with a missing `node_type`
read as a group, which is the leniency `GroupMetadata.from_dict` already
grants and so what `zarr.open` already did. Any other value is now rejected
by the probe with `NodeTypeValidationError`, the same error the group open
raised for it after the fallback, only earlier and with a clearer message.

Assisted-by: ClaudeCode:claude-fable-5-1
…in functions

Drop the `_ArrayProbe` class. `_fetch_array_metadata_docs` returns the
`_MetadataDocs` TypedDict and `_array_metadata_from_docs` interprets it,
raising exactly what `get_array_metadata` raised before; that function is
now their composition. `zarr.open` reads the docs once ahead of its
existing try/except, which is otherwise unchanged, and passes them to the
group open on the fallback.

Pin the two odd-document behaviors `zarr.open` already had: a zarr.json
without a node_type opens as a group, and one with an unknown node_type
ends in GroupNotFoundError.

Assisted-by: ClaudeCode:claude-fable-5-1
With zarr.json already in hand, the format-detecting branch of
AsyncGroup.open knows the format before it reads anything else. A format 3
group needs nothing more, so it now costs no reads at all on top of the
array lookup; a format 2 group reads .zgroup, plus the consolidated document
only when use_consolidated is not False, which is the rule the explicit
format 2 branch already followed. The same rule now applies to the
un-pre-fetched path, which used to read .zmetadata and then discard it.

zarr.open on a group: format 3 goes from 5 reads to 3, format 2 with
use_consolidated=False from 5 to 4. The one thing given up is the warning
for a store holding both zarr.json and .zgroup on the zarr.open path, since
.zgroup is no longer read when zarr.json is present; open_group with
zarr_format=None still emits it.

Assisted-by: ClaudeCode:claude-fable-5-1
@d-v-b d-v-b changed the title perf(api): read metadata once when zarr.open falls back to a group perf(api): give zarr.open a Zarr format 3 path and a format 2 fallback Sep 16, 2026
zarr.open used to look for an array and, failing that, open a group, and
the two steps read overlapping keys: seven requests for a group, five of
them for a format 3 group whose single zarr.json already held everything.

Now zarr.open builds the node itself from what it reads. _open_v3 reads
zarr.json once; its node_type says array or group and the document holds
everything needed to open either. Only if there is no zarr.json does
_open_v2 read .zarray, .zgroup, .zattrs and, when it might be used, the
consolidated document, in one concurrent round. The existing open_group
fallback still handles create-or-raise, and the mode="w" quirk is kept: an
existing array is returned, an existing group is overwritten.

The pieces zarr.open needs come out of AsyncGroup.open without changing it:
_resolve_use_consolidated settles use_consolidated against the store, the
format 2 missing/discard check moves into _from_bytes_v2 so it mirrors
_from_bytes_v3, and _from_dict_v3 builds a group from a parsed document.
No signature that users see changes.

Reads via zarr.open: a format 3 array or group costs one; a format 2 group
five (four with use_consolidated=False), as before; a format 2 array five
where it cost three, plus one round trip, the price of being the fallback.
When both zarr.json and .zarray exist at a path, zarr.open takes the format
3 node without reading the other and no longer warns; open_array still
does. zarr.open(mode="w", use_consolidated=...) now runs the consolidated
metadata checks before overwriting instead of ignoring the request.

Assisted-by: ClaudeCode:claude-fable-5-1
@d-v-b d-v-b changed the title perf(api): give zarr.open a Zarr format 3 path and a format 2 fallback refactor(api): build zarr.open, open_array and open_group on one metadata reader Sep 16, 2026
…data reader

Three read implementations existed: get_array_metadata for arrays,
AsyncGroup.open for groups, and the _read_metadata_v2/v3 helpers behind
AsyncGroup.getitem for either kind with an explicit format. Each fused
reading with interpreting, the first two each detected the format on their
own, and zarr.open, with no primitive to build on, was trial-and-error over
the specific openers with exceptions carrying "found something else."

Now read_node_metadata reads a node's documents once and returns parsed
metadata whichever kind and format the node is, trying Zarr format 3 first
when the format is not given; _open_node adds the use_consolidated policy
and builds the node; and open, open_array, open_group, AsyncArray.open,
AsyncGroup.open and get_node are mode policy and a kind filter over that.
AsyncGroup._from_bytes_v2/v3 and _from_dict_v3 are gone, and _build_node
takes the array config.

open has a written contract, replacing the "mode check seems wrong" TODO:
with shape it behaves as open_array; otherwise the reading modes open
whatever node is there, 'a' creating a group when there is none, and the
creating modes create a group, 'w' replacing whatever is there.

One node_type policy: a format 3 document's node_type must be array or
group, else NodeTypeValidationError; finding the wrong kind is
ContainsArrayError or ContainsGroupError, never "not found."

Behavior changes, each pinned by a test: open(mode="w") without shape
replaces an existing array with a group instead of returning it;
open(shape=...) on a group raises ContainsGroupError instead of opening it;
open(mode="r") on nothing raises NodeNotFoundError; a missing or unknown
node_type raises everywhere (a missing one used to open as a group); wrong
kind raises Contains*Error in every format and mode; open_array(mode="w")
on a group replaces it; open_array applies config to an existing array; a
path holding both formats opens as format 3 without a warning; requesting
missing format 2 consolidated metadata raises with the format 3 message.

Reads via zarr.open: a format 3 node costs one; a format 2 group five (four
with use_consolidated=False); a format 2 array five where it cost three,
the price of being the fallback.

Assisted-by: ClaudeCode:claude-fable-5-1
read_v3_metadata and read_v2_metadata are the reusable pieces: each reads
one format's documents and returns metadata or None. read_node_metadata is
their composition, trying format 3 first when the format is not given,
rather than inlining both and hanging the per-format wrappers off itself.

Assisted-by: ClaudeCode:claude-fable-5-1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant