diff --git a/python/zarrs/_internal.pyi b/python/zarrs/_internal.pyi index 0e4e76f..46b4dd4 100644 --- a/python/zarrs/_internal.pyi +++ b/python/zarrs/_internal.pyi @@ -7,10 +7,6 @@ import typing import numpy.typing import zarr.abc.store -@typing.final -class Basic: - def __new__(cls, byte_interface: typing.Any, chunk_spec: typing.Any) -> Basic: ... - @typing.final class CodecPipelineImpl: def __new__( @@ -40,8 +36,9 @@ class CodecPipelineImpl: class WithSubset: def __new__( cls, - item: Basic, + key: builtins.str, chunk_subset: typing.Sequence[slice], + chunk_shape: typing.Sequence[builtins.int], subset: typing.Sequence[slice], shape: typing.Sequence[builtins.int], ) -> WithSubset: ... diff --git a/python/zarrs/pipeline.py b/python/zarrs/pipeline.py index a3e4959..ac87e94 100644 --- a/python/zarrs/pipeline.py +++ b/python/zarrs/pipeline.py @@ -63,7 +63,7 @@ def get_codec_pipeline_impl( num_threads=config.get("threading.max_workers", None), direct_io=config.get("codec_pipeline.direct_io", False), ) - except TypeError as e: + except (TypeError, ValueError) as e: if strict: raise UnsupportedMetadataError() from e diff --git a/python/zarrs/utils.py b/python/zarrs/utils.py index 114d30c..8318cc3 100644 --- a/python/zarrs/utils.py +++ b/python/zarrs/utils.py @@ -10,7 +10,7 @@ from zarr.core.array_spec import ArraySpec from zarr.core.indexing import SelectorTuple, is_integer -from zarrs._internal import Basic, WithSubset +from zarrs._internal import WithSubset if TYPE_CHECKING: from collections.abc import Iterable @@ -178,7 +178,6 @@ def make_chunk_info_for_rust_with_indices( chunk_spec.config, chunk_spec.prototype, ) - chunk_info = Basic(byte_getter, chunk_spec) out_selection_as_slices = selector_tuple_to_slice_selection(out_selection) chunk_selection_as_slices = selector_tuple_to_slice_selection(chunk_selection) shape_chunk_selection_slices = get_shape_for_selector( @@ -196,8 +195,9 @@ def make_chunk_info_for_rust_with_indices( ) chunk_info_with_indices.append( WithSubset( - chunk_info, + key=byte_getter.path, chunk_subset=chunk_selection_as_slices, + chunk_shape=chunk_spec.shape, subset=out_selection_as_slices, shape=shape, ) diff --git a/src/chunk_item.rs b/src/chunk_item.rs index fdd9a15..f7516bd 100644 --- a/src/chunk_item.rs +++ b/src/chunk_item.rs @@ -1,10 +1,10 @@ use std::num::NonZeroU64; use pyo3::{ - Bound, PyAny, PyErr, PyResult, - exceptions::{PyIndexError, PyRuntimeError, PyValueError}, + Bound, PyErr, PyResult, + exceptions::{PyIndexError, PyValueError}, pyclass, pymethods, - types::{PyAnyMethods, PyBytes, PyBytesMethods, PyInt, PySlice, PySliceMethods as _}, + types::{PySlice, PySliceMethods as _}, }; use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; use zarrs::{ @@ -15,87 +15,27 @@ use zarrs::{ use crate::utils::PyErrExt; -pub(crate) trait ChunksItem { - fn key(&self) -> &StoreKey; - fn shape(&self) -> &[NonZeroU64]; - fn data_type(&self) -> &DataType; - fn fill_value(&self) -> &FillValue; -} - -#[derive(Clone)] -#[gen_stub_pyclass] -#[pyclass] -pub(crate) struct Basic { - key: StoreKey, - shape: ChunkShape, - data_type: DataType, - fill_value: FillValue, -} - -fn fill_value_to_bytes(dtype: &str, fill_value: &Bound<'_, PyAny>) -> PyResult> { - if dtype == "string" { - // Match zarr-python 2.x.x string fill value behaviour with a 0 fill value - // See https://github.com/zarr-developers/zarr-python/issues/2792#issuecomment-2644362122 - if let Ok(fill_value_downcast) = fill_value.cast::() { - let fill_value_usize: usize = fill_value_downcast.extract()?; - if fill_value_usize == 0 { - return Ok(vec![]); - } - Err(PyErr::new::(format!( - "Cannot understand non-zero integer {fill_value_usize} fill value for dtype {dtype}" - )))?; - } - } - - if let Ok(fill_value_downcast) = fill_value.cast::() { - Ok(fill_value_downcast.as_bytes().to_vec()) - } else if fill_value.hasattr("tobytes")? { - Ok(fill_value.call_method0("tobytes")?.extract()?) - } else { - Err(PyErr::new::(format!( - "Unsupported fill value {fill_value:?}" - ))) - } -} - -#[gen_stub_pymethods] -#[pymethods] -impl Basic { - #[new] - fn new(byte_interface: &Bound<'_, PyAny>, chunk_spec: &Bound<'_, PyAny>) -> PyResult { - let path: String = byte_interface.getattr("path")?.extract()?; - - let shape: Vec = chunk_spec.getattr("shape")?.extract()?; - - let mut dtype: String = chunk_spec - .getattr("dtype")? - .call_method0("to_native_dtype")? - .call_method0("__str__")? - .extract()?; - if dtype == "object" { - // zarrs doesn't understand `object` which is the output of `np.dtype("|O").__str__()` - // but maps it to "string" internally https://github.com/LDeakin/zarrs/blob/0532fe983b7b42b59dbf84e50a2fe5e6f7bad4ce/zarrs_metadata/src/v2_to_v3.rs#L288 - dtype = String::from("string"); - } - let data_type = get_data_type_from_dtype(&dtype)?; - let fill_value: Bound<'_, PyAny> = chunk_spec.getattr("fill_value")?; - let fill_value = FillValue::new(fill_value_to_bytes(&dtype, &fill_value)?); - Ok(Self { - key: StoreKey::new(path).map_py_err::()?, - shape, - data_type, - fill_value, +fn to_nonzero_u64_vec(v: Vec) -> PyResult> { + v.into_iter() + .map(|dim| { + NonZeroU64::new(dim).ok_or_else(|| { + PyErr::new::( + "subset dimensions must be greater than zero".to_string(), + ) + }) }) - } + .collect::>>() } #[derive(Clone)] #[gen_stub_pyclass] #[pyclass] pub(crate) struct WithSubset { - pub item: Basic, + key: StoreKey, pub chunk_subset: ArraySubset, pub subset: ArraySubset, + shape: Vec, + pub num_elements: u64, } #[gen_stub_pymethods] @@ -104,23 +44,17 @@ impl WithSubset { #[new] #[allow(clippy::needless_pass_by_value)] fn new( - item: Basic, + key: String, chunk_subset: Vec>, + chunk_shape: Vec, subset: Vec>, shape: Vec, ) -> PyResult { - let chunk_subset = selection_to_array_subset(&chunk_subset, &item.shape)?; - let shape: Vec = shape - .into_iter() - .map(|dim| { - NonZeroU64::new(dim).ok_or_else(|| { - PyErr::new::( - "subset dimensions must be greater than zero".to_string(), - ) - }) - }) - .collect::>>()?; - let subset = selection_to_array_subset(&subset, &shape)?; + let num_elements = chunk_shape.iter().product(); + let shape_nonzero_u64 = to_nonzero_u64_vec(shape)?; + let chunk_shape_nonzero_u64 = to_nonzero_u64_vec(chunk_shape)?; + let chunk_subset = selection_to_array_subset(&chunk_subset, &chunk_shape_nonzero_u64)?; + let subset = selection_to_array_subset(&subset, &shape_nonzero_u64)?; // Check that subset and chunk_subset have the same number of elements. // This permits broadcasting of a constant input. if subset.num_elements() != chunk_subset.num_elements() && subset.num_elements() > 1 { @@ -128,48 +62,23 @@ impl WithSubset { "the size of the chunk subset {chunk_subset} and input/output subset {subset} are incompatible", ))); } + Ok(Self { - item, + key: StoreKey::new(key).map_py_err::()?, chunk_subset, subset, + shape: chunk_shape_nonzero_u64, + num_elements, }) } } - -impl ChunksItem for Basic { - fn key(&self) -> &StoreKey { +impl WithSubset { + pub fn key(&self) -> &StoreKey { &self.key } - fn shape(&self) -> &[NonZeroU64] { + pub fn shape(&self) -> &[NonZeroU64] { &self.shape } - fn data_type(&self) -> &DataType { - &self.data_type - } - fn fill_value(&self) -> &FillValue { - &self.fill_value - } -} - -impl ChunksItem for WithSubset { - fn key(&self) -> &StoreKey { - &self.item.key - } - fn shape(&self) -> &[NonZeroU64] { - &self.item.shape - } - fn data_type(&self) -> &DataType { - &self.item.data_type - } - fn fill_value(&self) -> &FillValue { - &self.item.fill_value - } -} - -fn get_data_type_from_dtype(dtype: &str) -> PyResult { - let data_type = - NamedDataType::try_from(&MetadataV3::new(dtype)).map_py_err::()?; - Ok(data_type.into()) } fn slice_to_range(slice: &Bound<'_, PySlice>, length: isize) -> PyResult> { diff --git a/src/concurrency.rs b/src/concurrency.rs index a8b6007..ff45cf4 100644 --- a/src/concurrency.rs +++ b/src/concurrency.rs @@ -4,7 +4,7 @@ use zarrs::array::{ concurrency::calc_concurrency_outer_inner, }; -use crate::{CodecPipelineImpl, chunk_item::ChunksItem, utils::PyCodecErrExt as _}; +use crate::{CodecPipelineImpl, chunk_item::WithSubset, utils::PyCodecErrExt as _}; pub trait ChunkConcurrentLimitAndCodecOptions { fn get_chunk_concurrent_limit_and_codec_options( @@ -13,22 +13,19 @@ pub trait ChunkConcurrentLimitAndCodecOptions { ) -> PyResult>; } -impl ChunkConcurrentLimitAndCodecOptions for Vec -where - T: ChunksItem, -{ +impl ChunkConcurrentLimitAndCodecOptions for Vec { fn get_chunk_concurrent_limit_and_codec_options( &self, codec_pipeline_impl: &CodecPipelineImpl, ) -> PyResult> { let num_chunks = self.len(); - let Some(chunk_descriptions0) = self.first() else { + let Some(item) = self.first() else { return Ok(None); }; let codec_concurrency = codec_pipeline_impl .codec_chain - .recommended_concurrency(chunk_descriptions0.shape(), chunk_descriptions0.data_type()) + .recommended_concurrency(item.shape(), &codec_pipeline_impl.data_type) .map_codec_err()?; let min_concurrent_chunks = diff --git a/src/lib.rs b/src/lib.rs index d490571..94bbf6c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,15 +23,11 @@ use zarrs::array::codec::{ ArrayPartialDecoderTraits, ArrayToBytesCodecTraits, CodecOptions, StoragePartialDecoder, }; use zarrs::array::{ - ArrayBytes, ArrayBytesFixedDisjointView, ArrayMetadata, ArraySubset, ChunkShapeTraits, - CodecChain, DataTypeExt, FillValue, copy_fill_value_into, update_array_bytes, + ArrayBytes, ArrayBytesFixedDisjointView, ArrayMetadata, CodecChain, DataType, DataTypeExt, + FillValue, copy_fill_value_into, update_array_bytes, }; use zarrs::config::global_config; -use zarrs::convert::{ - ArrayMetadataV2ToV3Error, codec_metadata_v2_to_v3, data_type_metadata_v2_to_v3, -}; -use zarrs::metadata::v2::data_type_metadata_v2_to_endianness; -use zarrs::metadata::v3::MetadataV3; +use zarrs::convert::array_metadata_v2_to_v3; use zarrs::storage::{ReadableWritableListableStorage, StorageHandle, StoreKey}; mod chunk_item; @@ -42,7 +38,6 @@ mod store; mod tests; mod utils; -use crate::chunk_item::ChunksItem; use crate::concurrency::ChunkConcurrentLimitAndCodecOptions; use crate::store::StoreConfig; use crate::utils::{PyCodecErrExt, PyErrExt as _, PyUntypedArrayExt as _}; @@ -57,12 +52,14 @@ pub struct CodecPipelineImpl { pub(crate) chunk_concurrent_minimum: usize, pub(crate) chunk_concurrent_maximum: usize, pub(crate) num_threads: usize, + pub(crate) fill_value: FillValue, + pub(crate) data_type: DataType, } impl CodecPipelineImpl { - fn retrieve_chunk_bytes<'a, I: ChunksItem>( + fn retrieve_chunk_bytes<'a>( &self, - item: &I, + item: &WithSubset, codec_chain: &CodecChain, codec_options: &CodecOptions, ) -> PyResult> { @@ -73,42 +70,38 @@ impl CodecPipelineImpl { .decode( value_encoded.into(), item.shape(), - item.data_type(), - item.fill_value(), + &self.data_type, + &self.fill_value, codec_options, ) .map_codec_err()? } else { - ArrayBytes::new_fill_value( - item.data_type(), - item.shape().num_elements_u64(), - item.fill_value(), - ) - .map_py_err::()? + ArrayBytes::new_fill_value(&self.data_type, item.num_elements, &self.fill_value) + .map_py_err::()? }; Ok(value_decoded) } - fn store_chunk_bytes( + fn store_chunk_bytes( &self, - item: &I, + item: &WithSubset, codec_chain: &CodecChain, value_decoded: ArrayBytes, codec_options: &CodecOptions, ) -> PyResult<()> { value_decoded - .validate(item.shape().num_elements_u64(), item.data_type()) + .validate(item.num_elements, &self.data_type) .map_codec_err()?; - if value_decoded.is_fill_value(item.fill_value()) { + if value_decoded.is_fill_value(&self.fill_value) { self.store.erase(item.key()).map_py_err::() } else { let value_encoded = codec_chain .encode( value_decoded, item.shape(), - item.data_type(), - item.fill_value(), + &self.data_type, + &self.fill_value, codec_options, ) .map(Cow::into_owned) @@ -121,31 +114,29 @@ impl CodecPipelineImpl { } } - fn store_chunk_subset_bytes( + fn store_chunk_subset_bytes( &self, - item: &I, + item: &WithSubset, codec_chain: &CodecChain, chunk_subset_bytes: ArrayBytes, - chunk_subset: &ArraySubset, codec_options: &CodecOptions, ) -> PyResult<()> { let array_shape = item.shape(); + let chunk_subset = &item.chunk_subset; if !chunk_subset.inbounds_shape(bytemuck::must_cast_slice(array_shape)) { return Err(PyErr::new::(format!( "chunk subset ({chunk_subset}) is out of bounds for array shape ({array_shape:?})" ))); } - let data_type_size = item.data_type().size(); + let data_type_size = self.data_type.size(); - if chunk_subset.start().iter().all(|&o| o == 0) - && chunk_subset.shape() == bytemuck::must_cast_slice::<_, u64>(array_shape) - { + if is_whole_chunk(item) { // Fast path if the chunk subset spans the entire chunk, no read required self.store_chunk_bytes(item, codec_chain, chunk_subset_bytes, codec_options) } else { // Validate the chunk subset bytes chunk_subset_bytes - .validate(chunk_subset.num_elements(), item.data_type()) + .validate(chunk_subset.num_elements(), &self.data_type) .map_codec_err()?; // Retrieve the chunk @@ -217,28 +208,6 @@ impl CodecPipelineImpl { } } -fn array_metadata_to_codec_metadata_v3( - metadata: ArrayMetadata, -) -> Result, ArrayMetadataV2ToV3Error> { - match metadata { - ArrayMetadata::V3(metadata) => Ok(metadata.codecs), - ArrayMetadata::V2(metadata) => { - let endianness = data_type_metadata_v2_to_endianness(&metadata.dtype) - .map_err(ArrayMetadataV2ToV3Error::InvalidEndianness)?; - let data_type = data_type_metadata_v2_to_v3(&metadata.dtype)?; - - codec_metadata_v2_to_v3( - metadata.order, - metadata.shape.len(), - &data_type, - endianness, - &metadata.filters, - &metadata.compressor, - ) - } - } -} - #[gen_stub_pymethods] #[pymethods] impl CodecPipelineImpl { @@ -250,7 +219,7 @@ impl CodecPipelineImpl { chunk_concurrent_minimum=None, chunk_concurrent_maximum=None, num_threads=None, - direct_io=false + direct_io=false, ))] #[new] fn new( @@ -263,13 +232,13 @@ impl CodecPipelineImpl { direct_io: bool, ) -> PyResult { store_config.direct_io(direct_io); - let metadata: ArrayMetadata = - serde_json::from_str(array_metadata).map_py_err::()?; - let codec_metadata = - array_metadata_to_codec_metadata_v3(metadata).map_py_err::()?; + let metadata = match serde_json::from_str(array_metadata).map_py_err::()? { + ArrayMetadata::V2(v2) => array_metadata_v2_to_v3(&v2).map_py_err::()?, + ArrayMetadata::V3(v3) => v3, + }; + let codec_metadata = metadata.codecs; let codec_chain = Arc::new(CodecChain::from_metadata(&codec_metadata).map_py_err::()?); - let codec_options = CodecOptions::default().with_validate_checksums(validate_checksums); let chunk_concurrent_minimum = @@ -281,6 +250,11 @@ impl CodecPipelineImpl { let store: ReadableWritableListableStorage = (&store_config).try_into().map_py_err::()?; + let data_type = DataType::from_metadata(&metadata.data_type).map_py_err::()?; + let fill_value = data_type + .fill_value(&metadata.fill_value) + .map_py_err::()?; + Ok(Self { store, codec_chain, @@ -288,6 +262,8 @@ impl CodecPipelineImpl { chunk_concurrent_minimum, chunk_concurrent_maximum, num_threads, + fill_value, + data_type, }) } @@ -309,19 +285,16 @@ impl CodecPipelineImpl { }; // Assemble partial decoders ahead of time and in parallel - let partial_chunk_descriptions = chunk_descriptions + let partial_chunk_items = chunk_descriptions .iter() .filter(|item| !(is_whole_chunk(item))) - .unique_by(|item| item.key()) + .unique_by(|item| item.key().clone()) .collect::>(); let mut partial_decoder_cache: HashMap> = HashMap::new(); - if !partial_chunk_descriptions.is_empty() { - let key_decoder_pairs = iter_concurrent_limit!( - chunk_concurrent_limit, - partial_chunk_descriptions, - map, - |item| { + if !partial_chunk_items.is_empty() { + let key_decoder_pairs = + iter_concurrent_limit!(chunk_concurrent_limit, partial_chunk_items, map, |item| { let storage_handle = Arc::new(StorageHandle::new(self.store.clone())); let input_handle = StoragePartialDecoder::new(storage_handle, item.key().clone()); @@ -331,15 +304,14 @@ impl CodecPipelineImpl { .partial_decoder( Arc::new(input_handle), item.shape(), - item.data_type(), - item.fill_value(), + &self.data_type, + &self.fill_value, &codec_options, ) .map_codec_err()?; Ok((item.key().clone(), partial_decoder)) - } - ) - .collect::>>()?; + }) + .collect::>>()?; partial_decoder_cache.extend(key_decoder_pairs); } @@ -347,12 +319,8 @@ impl CodecPipelineImpl { // FIXME: the `decode_into` methods only support fixed length data types. // For variable length data types, need a codepath with non `_into` methods. // Collect all the subsets and copy into value on the Python side? - let update_chunk_subset = |item: chunk_item::WithSubset| { - let chunk_item::WithSubset { - item, - subset, - chunk_subset, - } = item; + let update_chunk_subset = |item: WithSubset| { + let shape = item.shape(); let mut output_view = unsafe { // TODO: Is the following correct? // can we guarantee that when this function is called from Python with arbitrary arguments? @@ -360,20 +328,18 @@ impl CodecPipelineImpl { ArrayBytesFixedDisjointView::new( output, // TODO: why is data_type in `item`, it should be derived from `output`, no? - item.data_type() + self.data_type .fixed_size() .ok_or("variable length data type not supported") .map_py_err::()?, &output_shape, - subset, + item.subset.clone(), ) .map_py_err::()? }; - + let target = ArrayBytesDecodeIntoTarget::Fixed(&mut output_view); // See zarrs::array::Array::retrieve_chunk_subset_into - if chunk_subset.start().iter().all(|&o| o == 0) - && chunk_subset.shape() == bytemuck::must_cast_slice::<_, u64>(item.shape()) - { + if is_whole_chunk(&item) { // See zarrs::array::Array::retrieve_chunk_into if let Some(chunk_encoded) = self.store.get(item.key()).map_py_err::()? @@ -383,29 +349,21 @@ impl CodecPipelineImpl { self.codec_chain.decode_into( Cow::Owned(chunk_encoded), item.shape(), - item.data_type(), - item.fill_value(), - ArrayBytesDecodeIntoTarget::Fixed(&mut output_view), + &self.data_type, + &self.fill_value, + target, &codec_options, ) } else { // The chunk is missing, write the fill value - copy_fill_value_into( - item.data_type(), - item.fill_value(), - ArrayBytesDecodeIntoTarget::Fixed(&mut output_view), - ) + copy_fill_value_into(&self.data_type, &self.fill_value, target) } } else { let key = item.key(); let partial_decoder = partial_decoder_cache.get(key).ok_or_else(|| { PyRuntimeError::new_err(format!("Partial decoder not found for key: {key}")) })?; - partial_decoder.partial_decode_into( - &chunk_subset, - ArrayBytesDecodeIntoTarget::Fixed(&mut output_view), - &codec_options, - ) + partial_decoder.partial_decode_into(&item.chunk_subset, target, &codec_options) } .map_codec_err() }; @@ -452,22 +410,21 @@ impl CodecPipelineImpl { codec_options.set_store_empty_chunks(write_empty_chunks); py.detach(move || { - let store_chunk = |item: chunk_item::WithSubset| match &input { + let store_chunk = |item: WithSubset| match &input { InputValue::Array(input) => { let chunk_subset_bytes = input - .extract_array_subset(&item.subset, &input_shape, item.item.data_type()) + .extract_array_subset(&item.subset, &input_shape, &self.data_type) .map_codec_err()?; self.store_chunk_subset_bytes( &item, &self.codec_chain, chunk_subset_bytes, - &item.chunk_subset, &codec_options, ) } InputValue::Constant(constant_value) => { let chunk_subset_bytes = ArrayBytes::new_fill_value( - item.data_type(), + &self.data_type, item.chunk_subset.num_elements(), constant_value, ) @@ -477,7 +434,6 @@ impl CodecPipelineImpl { &item, &self.codec_chain, chunk_subset_bytes, - &item.chunk_subset, &codec_options, ) } @@ -500,7 +456,6 @@ impl CodecPipelineImpl { fn _internal(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add("__version__", env!("CARGO_PKG_VERSION"))?; m.add_class::()?; - m.add_class::()?; m.add_class::()?; Ok(()) } diff --git a/src/utils.rs b/src/utils.rs index c87acf5..863849a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -4,7 +4,7 @@ use numpy::{PyUntypedArray, PyUntypedArrayMethods}; use pyo3::{Bound, PyErr, PyResult, PyTypeInfo}; use zarrs::array::codec::CodecError; -use crate::{ChunksItem, WithSubset}; +use crate::WithSubset; pub(crate) trait PyErrExt { fn map_py_err(self) -> PyResult; diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 3dd34df..8fe0793 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -151,7 +151,7 @@ def maybe_convert( def gen_arr(fill_value, tmp_path, dimensionality, format) -> zarr.Array: return zarr.create( (axis_size_,) * dimensionality, - store=LocalStore(root=tmp_path / ".zarr"), + store=LocalStore(root=tmp_path / "store.zarr"), chunks=(chunk_size_,) * dimensionality, dtype=np.int16, fill_value=fill_value, diff --git a/tests/test_v2.py b/tests/test_v2.py index c75877f..6e95a6e 100644 --- a/tests/test_v2.py +++ b/tests/test_v2.py @@ -301,7 +301,7 @@ def test_parse_structured_fill_value_valid( ) @pytest.mark.filterwarnings( # TODO: Fix handling of string fill values for Zarr v2 bytes data - "ignore:Array is unsupported by ZarrsCodecPipeline. incompatible fill value .eAAAAAAAAA==. for data type bytes:UserWarning" + "ignore:Array is unsupported by ZarrsCodecPipeline. incompatible fill value 0 for data type r56:UserWarning" ) @pytest.mark.parametrize("fill_value", [None, b"x"], ids=["no_fill", "fill"]) def test_other_dtype_roundtrip(fill_value, tmp_path) -> None: