Skip to content

Reading zarr v3 datasets fails: type=Iterable fields and a direct len() call #2234

Description

@rly

Summary

pynwb cannot construct several neurodata types when a field is backed by a zarr v3 array (or any array-API-standard array that provides ndim/shape/__getitem__ but not __len__/__iter__). This surfaces when reading NWB files through the zarr backend (hdmf-zarr's zarr v3 migration): lazily-read datasets come back as raw zarr.Array objects and are passed straight into the container constructors.

Two independent root causes, both in pynwb code:

  1. docval fields typed collections.abc.Iterable reject zarr.Array, because isinstance(zarr_array, Iterable) is False (the ABC checks for __iter__; zarr v3 arrays don't implement it, even though they iterate fine via the __getitem__ sequence protocol).
  2. a direct len() call on array-like data raises TypeError: object of type 'Array' has no len(), because zarr v3 arrays don't implement __len__.

hdmf already handles zarr v3 arrays in its own type system (it registers zarr.Array in the array_data docval macro and uses ndim-based collection detection), so both issues are pynwb-side.

Environment

  • pynwb 4.0.0, hdmf 6.1.0, zarr 3.2.1, numpy 2.5.1

Reproduce

Problem 1 — type=Iterable field rejects a zarr array

import numpy as np, zarr
from pynwb import TimeSeries

TimeSeries(
    name="ts",
    data=np.arange(3, dtype=float),
    unit="v",
    timestamps=np.arange(3, dtype=float),
    control=zarr.array(np.arange(3, dtype=np.uint8)),   # docval type=Iterable
    control_description=["a", "b", "c"],
)
# TypeError: TimeSeries.__init__: incorrect type for 'control' (got 'Array', expected 'Iterable')

Problem 2 — direct len() on a zarr-backed dataset

ElectricalSeries.__init__, src/pynwb/ecephys.py:152-153:

data_shape = get_data_shape(kwargs['data'], strict_no_data_load=True)   # line 148 — already zarr-safe
if (
    data_shape is not None
    and len(data_shape) == 2
    and data_shape[1] != len(args_to_set['electrodes'].data)   # line 152 — len() on a raw zarr.Array
):
    if data_shape[0] == len(args_to_set['electrodes'].data):    # line 153
        ...
# TypeError: object of type 'Array' has no len()

Line 148 already uses get_data_shape (zarr-safe); the failure is the len(...) on electrodes.data two lines down.

Root cause detail

  • iter(zarr_array) / for x in zarr_array work (old __getitem__ sequence protocol), but isinstance(zarr_array, collections.abc.Iterable) is False since the ABC only recognizes __iter__. docval resolves an actual-type argtype via isinstance(value, argtype), so the value is rejected.
  • len(zarr_array) raises because zarr v3 removed __len__ from Array (zarr.Array.__len__ removed in v3 rewrite zarr-developers/zarr-python#3740). zarr_array.shape[0] (or get_data_shape(...)[0]) is the array-API-safe replacement.

Proposed fix

1. Retype array-valued fields Iterable -> ('array_data', 'data')

The array_data docval macro already includes np.ndarray, list, tuple, h5py.Dataset, pd.Series, ExtensionArray, zarr.Array, and hdmf datasets; data adds DataIO. These fields are all genuinely array-valued and are read back as datasets:

Field Type Location
control, control_description TimeSeries (base class) base.py:162,164
dimension, starting_frame ImageSeries image.py:100,89
features, feature_units AbstractFeatureSeries misc.py:77,75
waveform_mean, waveform_sd, peak_over_rms SpikeEventSeries / FeatureExtraction ecephys.py:340,342,303

Behavior change to note in the changelog: ('array_data', 'data') accepts zarr.Array (the fix) but no longer accepts non-array iterables that Iterable allowed (str, bytes, set, dict, range, generators). For these array-valued fields that is a desirable tightening (a bare str into a shape=(None,) slot was a latent bug); the only realistically-passable inputs affected are range(...) and generator expressions.

Do not change NWBFile.electrode_groups (file.py:413). It is an iterable of ElectrodeGroup objects, never backed by a dataset, so it has no zarr issue; retyping it to array_data would give no benefit and would needlessly reject a set/generator of groups. Leave it as Iterable (or give it a purpose-fit object-collection type).

2. Replace the direct len() call

In ElectricalSeries.__init__, use get_data_shape(...)[0] / .shape[0] instead of len(electrodes.data), matching the zarr-safe pattern already used at ecephys.py:148. Audit other constructors for direct len()/positional indexing on possibly-lazy datasets.

Suggested tests

Add coverage constructing/round-tripping with zarr-backed values for at least TimeSeries.control, ImageSeries.dimension, and ElectricalSeries (electrodes region), so this does not silently regress.

Alternative / upstream

zarr-python restoring __len__/__iter__ on Array would fix both problems without any pynwb change: zarr-developers/zarr-python#3740 (__len__), #3741 (scalar __getitem__ returning a 0-d ndarray).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions