Skip to content

Refactor/rename v2 metadata fields - #2301

Merged
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields
Oct 10, 2024
Merged

Refactor/rename v2 metadata fields#2301
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields

Conversation

@d-v-b

@d-v-bd-v-b commented Oct 4, 2024

Copy link
Copy Markdown
Contributor

The first change is to alters the structure of ArrayV2Metadata to ensure that it matches the zarr v2 metadata document. This is simple:

  • I changed the data_type attribute to dtype
  • I changed the chunk_grid attribute to chunks

These changes make it difficult for ArrayV2Metadata to inherit from ArrayMetadata, because that class defines dtype as a property, which I learned cannot be overridden by an attribute. This observation made me rather skeptical of the utility of the ArrayMetadata class. See #2300 for more on that. So this PR removes the inheritance relationship between ArrayV2Metadata / ArrayV3Metadata and ArrayMetadata.

I could then replace the annotation of the metadata attribute of the AsyncArray class with ArrayV2Metadata | ArrayV3Metadata, but this type annotation is not quite accurate, because we know that some operations only result in an AsyncArray with ArrayV3Metadata, and others only make an AsyncArray with ArrayV2Metadata (namely, _create_v3 and _create_v2). So I attempted to solve this by making AsyncArray generic with a TypeVar with 2 bounds, ArrayV2Metadata and ArrayV3Metadata. This lets us express the invariances I mentioned above. Mypy was unhappy in various places and I plugged those holes with type: ignore statements. If there's a cleaner solution I would appreciate it.

This will remain a draft until we answer a few questions:

  • should we remove ArrayMetadata entirely?
  • can we make mypy happy without the type: ignores? maybe I'm doing something wrong with type annotations.
  • should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

TODO:

  • Add unit tests and/or doctests in docstrings
  • Add docstrings and API docs for any new/modified user-facing classes and functions
  • New/modified features documented in docs/tutorial.rst
  • Changes documented in docs/release.rst
  • GitHub Actions have all passed
  • Test coverage is 100% (Codecov passes)

Comment threadsrc/zarr/core/array.py Outdated
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
return array
# type inference is inconsistent here and seems to conclude

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I thought the issue might be from AsyncArray.metadata being set to metadata_parsed = parse_array_metadata(metadata), which doesn't know that parse_array_metadata(ArrayV2Metadata) should be ArrayV2Metadata. I thought that adding an overload would fix that:

@overloaddefparse_array_metadata(data: ArrayV2Metadata) ->ArrayV2Metadata: ...
@overloaddefparse_array_metadata(data: ArrayV3Metadata) ->ArrayV3Metadata: ...
@overloaddefparse_array_metadata(data: dict[str, JSON]) ->ArrayV2Metadata|ArrayV3Metadata: ...
defparse_array_metadata(
data: ArrayV2Metadata|ArrayV3Metadata|dict[str, JSON],
) ->ArrayV2Metadata|ArrayV3Metadata:

But no luck. I'll take a longer look later.

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's a simple example that works:

Details
from __future__ importannotationsimportdataclassesfromtypingimportGeneric, Literal, TypeVar, cast, overload, reveal_typeclassA: ...
classB: ...
T=TypeVar("T", A, B)
@dataclasses.dataclass(frozen=True)classArray(Generic[T]):
version: Tdef__init__(self, version: A|B) ->None:
object.__setattr__(self, "version", version)
@overload@classmethoddefcreate(cls, version: Literal[2]) ->Array[A]: ...
@overload@classmethoddefcreate(cls, version: Literal[3]) ->Array[B]: ...
@overload@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]: ...
@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]:
ifversion==2:
returncls.create_2()
elifversion==3:
returncls.create_3()
else:
raise@classmethoddefcreate_2(cls) ->Array[A]:
returnArray(version=A())
@classmethoddefcreate_3(cls) ->Array[B]:
returnArray(version=B())
reveal_type(Array(A()))
reveal_type(Array(B()))
reveal_type(Array.create_2())
reveal_type(Array.create_3())
reveal_type(Array.create(version=2))
reveal_type(Array.create(version=3))

That reveals the expected types everywhere:

❯ mypy check.py
check.py:49: note: Revealed type is "check.Array[check.A]"
check.py:50: note: Revealed type is "check.Array[check.A]"
check.py:52: note: Revealed type is "check.Array[check.A]"
check.py:53: note: Revealed type is "check.Array[check.B]"
check.py:55: note: Revealed type is "check.Array[check.A]"
check.py:56: note: Revealed type is "check.Array[check.B]"
Success: no issues found in 1 source file

I've tried making similar changes on your branch, but haven't gotten it working yet. I think the things that might matter are:

  1. We might need overloads for AsyncArray.create to ensure that AsyncArray.create(..., zarr_format=3) returns an AsyncArray[v3]. likewise for 2
  2. AsyncArray.__init__ also accepts a dict for metadata. The actual value set is the output of parse_arary_metadata. We need to ensure that the specialized type isn't lost (which the overload should do, but maybe I messed that up).

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure what I messed up earlier, but these overloads on create seemed to do the trick (the only differences in the signature are zarr_format being Literal[2] or Literal[3] rather than Literal[2, 3], and the return type being specialized to v2 or v3):

diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py
index 96431dec..5699935f 100644
--- a/src/zarr/core/array.py+++ b/src/zarr/core/array.py@@ -4,7 +4,7 @@ import json
from asyncio import gather
from dataclasses import dataclass, field, replace
from logging import getLogger
-from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast+from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import numpy as np
import numpy.typing as npt
@@ -191,6 +191,72 @@ class AsyncArray(Generic[TArrayMeta]):
object.__setattr__(self, "order", order_parsed)
object.__setattr__(self, "codec_pipeline", create_codec_pipeline(metadata=metadata_parsed))
+ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[2],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV2Metadata]:...++ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[3],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV3Metadata]:...+
@classmethod
async def create(
cls,
@@ -349,7 +415,7 @@ class AsyncArray(Generic[TArrayMeta]):
await array._save_metadata(metadata, ensure_parents=True)
# type inference is inconsistent here and seems to conclude
# that array has type Array[ArrayV2Metadata]
- return array # type: ignore[return-value]+ return array
@classmethod
async def _create_v2(
@@ -388,7 +454,7 @@ class AsyncArray(Generic[TArrayMeta]):
)
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
- return array # type: ignore[return-value]+ return array
@classmethod
def from_dict(

Checking that here:

file: check2.pyfromtypingimportreveal_typeimportnumpyasnpfromzarr.core.arrayimportAsyncArrayfromzarr.storage.commonimportStorePathfromzarr.storage.memoryimportMemoryStoreasyncdefmain() ->None:
store=StorePath(MemoryStore(), "/")
r2=awaitAsyncArray._create_v2(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,))
reveal_type(r2) # v2r3=awaitAsyncArray._create_v3(store, shape=(0,), dtype=np.dtype("float64"), chunk_shape=(0,))
reveal_type(r3) # v3rr2=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=2)
reveal_type(rr2) # v2rr3=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=3)
reveal_type(rr3) # v3

gives

❯ mypy check2.py
check2.py:13: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:16: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
check2.py:19: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:22: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
Success: no issues found in 1 source file

@TomAugspurger

Copy link
Copy Markdown
Contributor

should we remove ArrayMetadata entirely?

That, or define ArrayMetadata = ArrayV2Metadata | ArrayV3Metadata for the few places where we write out that union.

should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

Probably. I think being able to know that a Group[V3].getitem returns Group[V3] | Array[V3] and not Group[T] | Array[T] is useful.

@d-v-b
d-v-b marked this pull request as ready for review October 8, 2024 17:09
@d-v-b

d-v-b commented Oct 8, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger thanks for the suggestion to use @overload to fix the type inference for AsyncArray.create. A few other changes were necessary, namely
defining minimal TypedDict instances for the dict form of ArrayV2Metadata and ArrayV3Metadata. With these dict classes we can declare that creating AsyncArray from dicts like {'zarr_format': 2, ...} will produce AsyncArray[ArrayV2Metadata]. These classes will be replaced by the result of #2099.

This is blocked on some very confusing mypy errors. If anyone has ideas I would love to hear them!

@TomAugspurger

TomAugspurger commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Looks like a mypy limitation at first glance:

With this

 resized = sync(self._async_array.resize(new_shape))
reveal_type(resized)

mypy gives

src/zarr/core/array.py:2475: note: Revealed type is "builtins.object"

pylance / pyright get this one correct: Type of "resized" is "AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata]".

python/mypy#15295 looks potentially related. I'd recommend casting the result of sync(self._async_array.resize(new_shape)) to AsyncArray to help mypy out.

@rabernat

rabernat commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Mypy errors notwithstanding, does this fix #2269?

@TomAugspurger

Copy link
Copy Markdown
Contributor

Not quite. One this branch that raises with

---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:142, in parse_shapelike(data)
141try:
--> 142 data_tuple = tuple(data)
143exceptTypeErroras e:
TypeError: 'RegularChunkGrid' object is not iterable
The above exception was the direct cause of the following exception:
TypeError Traceback (most recent call last)
Cell In[4], line 16
7 data = np.arange(0, 8, dtype="uint16")
8 a = Array.create(
9 store /"simple_v2",
10 zarr_format=2,
(...)
14 fill_value=0,
15 )
---> 16 a.attrs.put({"key": 0})
File ~/gh/zarr-developers/zarr-python/src/zarr/core/attributes.py:53, in Attributes.put(self, d)
39defput(self, d: dict[str, JSON]) -> None:
40"""41 Overwrite all attributes with the values from`d`.
42
(...)
51 {'a': 3, 'c': 4}
52"""
---> 53 self._obj = self._obj.update_attributes(d)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:2478, in Array.update_attributes(self, new_attributes)
2477 def update_attributes(self, new_attributes: dict[str, JSON]) -> Array:
-> 2478 return type(self)(sync(self._async_array.update_attributes(new_attributes)))
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:91, in sync(coro, loop, timeout)
88 return_result =next(iter(finished)).result()
90ifisinstance(return_result, BaseException):
---> 91 raise return_result
92else:
93return return_result
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:50, in _runner(coro)
45"""46 Await a coroutine andreturn the result of running it. If awaiting the coroutine raises an
47 exception, the exception will be returned.
48"""49try:
---> 50 return await coro
51exceptExceptionas ex:
52return ex
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:913, in AsyncArray.update_attributes(self, new_attributes)
912asyncdefupdate_attributes(self, new_attributes: dict[str, JSON]) -> Self:
--> 913 new_metadata = self.metadata.update_attributes(new_attributes)
915# Write new metadata916awaitself._save_metadata(new_metadata)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:208, in ArrayV2Metadata.update_attributes(self, attributes)
207defupdate_attributes(self, attributes: dict[str, JSON]) -> Self:
--> 208 return replace(self, attributes=attributes)
File ~/mambaforge/envs/python=3.12/lib/python3.12/dataclasses.py:1588, in replace(obj, **changes)
1581 changes[f.name] = getattr(obj, f.name)
1583 # Create the new object, which calls __init__() and
1584 # __post_init__() (if defined), using all of the init fields we've
1585 # added and/or left in 'changes'. If there are values supplied in
1586 # changes that aren't fields, this will correctly raise a
1587 # TypeError.
-> 1588 return obj.__class__(**changes)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:72, in ArrayV2Metadata.__init__(self, shape, dtype, chunks, fill_value, order, dimension_separator, compressor, filters, attributes)
70 shape_parsed = parse_shapelike(shape)
71 dtype_parsed = parse_dtype(dtype)
---> 72 chunks_parsed = parse_shapelike(chunks)
73 compressor_parsed = parse_compressor(compressor)
74 order_parsed = parse_indexing_order(order)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:145, in parse_shapelike(data)
143exceptTypeErroras e:
144 msg =f"Expected an integer or an iterable of integers. Got {data} instead."
--> 145 raise TypeError(msg) from e
147ifnotall(isinstance(v, int) for v in data_tuple):
148 msg =f"Expected an iterable of integers. Got {data} instead."TypeError: Expected an integer or an iterable of integers. Got RegularChunkGrid(chunk_shape=(4,)) instead.

We'll need to update the various parse_* functions to gracefully handle the already parsed versions.

@d-v-b this is one of the reasons in our serialization discussion (#2144) I mentioned preferring plain __init__ methods that simply set the attributes (plus a tiny bit of validation that type checkers can't catch, like ensuring that the length of chunks and shape match). Because __init__ always runs anytime you create an instance I like to keep them simple, and "complicated" stuff like parsing tuples of ints into their concrete form can be done in alternative constructors / on the boundary. But it does open up the risk of people using the class constructor incorrectly and later hitting an error / incorrect behavior (maybe, if they have code that relies on that, but that's just what you get with duck typing).

@d-v-b

d-v-b commented Oct 9, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger that's a good point! I think there's a significant difference between "parse the inputs to ensure validity" and "parse the inputs to ensure validity, and return a new data type that's not part of the metadata model". By turning the chunks attribute from a JSON array of numbers to a RegularChunkGrid object we were doing the second one, when I think we should always do the first one.

So I altered ArrayV2Metadata such that ArrayV2Metadata.chunks is a plain tuple of ints, and ArrayV2Metadata.chunk_grid is a cached property that returns a RegularChunkGrid. I also added some casts to deal with the type inference issues. I checked the test case from #2269 and it passed with these changes.

@TomAugspurgerTomAugspurger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, I think this looks good overall.

Comment threadsrc/zarr/api/asynchronous.py
Comment threadsrc/zarr/core/metadata/__init__.py
metadata = ArrayV3Metadata.from_dict(_metadata)
else:
metadata = ArrayV3Metadata.from_dict(metadata)
raise ValueError(f"Invalid zarr_format: {zarr_format}. Expected 2 or 3")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#2310 (not yet merged) is adding some special exceptions for parsing invalid metadata, which we could use here if we want.

Comment threadsrc/zarr/core/array.py
@TomAugspurgerTomAugspurger added the downstream Downstream libraries using zarr label Oct 9, 2024

@jhammanjhamman left a comment

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.

I'm not 1000% sold on some of the typing changes (particularly the AsyncArray generic) but this is a solid improvement so my questions are non-blocking.

storage_options: dict[str, Any] | None = None,
**kwargs: Any, # TODO: type kwargs as valid args to open_array
) -> AsyncArray | AsyncGroup:
) -> AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata] | AsyncGroup:

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.

This question applies here and everywhere else you use the v2/v3 Metadata types.

Since the generic only includes two variants (ArrayV2Metadata and ArrayV3Metadata), what value do we get by using both together here (as apposed to simply using the AsyncArray type?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that is purely to silence mypy complaining about a generic missing its type parameter. If there's a cleaner way to annotate it then I'll do it. Would AsyncArray[Any] work there? (I can play around with this locally)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

playing around with AsyncArray[Any] wasn't satisfactory. I think the only shortcut we can use here is assigning a type alias to the union AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata], and I'm not sure that's worth it.

Comment threadsrc/zarr/core/array.py Outdated
Comment on lines +169 to +173
TArrayMeta = TypeVar("TArrayMeta", ArrayV2Metadata, ArrayV3Metadata)


@dataclass(frozen=True)
class AsyncArray:
metadata: ArrayMetadata
class AsyncArray(Generic[TArrayMeta]):

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.

Does this foreclose the opportunity to use other generics here (namely over dtype)?

xref: #2137 (comment)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that's a good question, I have no idea! It seems like we would want to make the metadata class generic w.r.t dtype, but then have the array class be generic over dtype too... not sure how to solve that

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought a bit about this, and I don't see why we couldn't make AsyncArray inherit from Generic[TArrayMeta, TDtype].

Comment threadsrc/zarr/core/array.py Outdated
Comment threadsrc/zarr/core/array.py Outdated
…/zarr-python into refactor/rename-v2-metadata-fields
@jhammanjhamman added this to the 3.0.0.beta milestone Oct 9, 2024
@jhammanjhamman added the V3 label Oct 9, 2024
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

downstreamDownstream libraries using zarr

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

@d-v-b@TomAugspurger@rabernat@jhamman
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Refactor/rename v2 metadata fields by d-v-b · Pull Request #2301 · zarr-developers/zarr-python · GitHub
Skip to content

Refactor/rename v2 metadata fields - #2301

Merged
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields
Oct 10, 2024
Merged

Refactor/rename v2 metadata fields#2301
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields

Conversation

@d-v-b

@d-v-bd-v-b commented Oct 4, 2024

Copy link
Copy Markdown
Contributor

The first change is to alters the structure of ArrayV2Metadata to ensure that it matches the zarr v2 metadata document. This is simple:

  • I changed the data_type attribute to dtype
  • I changed the chunk_grid attribute to chunks

These changes make it difficult for ArrayV2Metadata to inherit from ArrayMetadata, because that class defines dtype as a property, which I learned cannot be overridden by an attribute. This observation made me rather skeptical of the utility of the ArrayMetadata class. See #2300 for more on that. So this PR removes the inheritance relationship between ArrayV2Metadata / ArrayV3Metadata and ArrayMetadata.

I could then replace the annotation of the metadata attribute of the AsyncArray class with ArrayV2Metadata | ArrayV3Metadata, but this type annotation is not quite accurate, because we know that some operations only result in an AsyncArray with ArrayV3Metadata, and others only make an AsyncArray with ArrayV2Metadata (namely, _create_v3 and _create_v2). So I attempted to solve this by making AsyncArray generic with a TypeVar with 2 bounds, ArrayV2Metadata and ArrayV3Metadata. This lets us express the invariances I mentioned above. Mypy was unhappy in various places and I plugged those holes with type: ignore statements. If there's a cleaner solution I would appreciate it.

This will remain a draft until we answer a few questions:

  • should we remove ArrayMetadata entirely?
  • can we make mypy happy without the type: ignores? maybe I'm doing something wrong with type annotations.
  • should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

TODO:

  • Add unit tests and/or doctests in docstrings
  • Add docstrings and API docs for any new/modified user-facing classes and functions
  • New/modified features documented in docs/tutorial.rst
  • Changes documented in docs/release.rst
  • GitHub Actions have all passed
  • Test coverage is 100% (Codecov passes)

Comment threadsrc/zarr/core/array.py Outdated
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
return array
# type inference is inconsistent here and seems to conclude

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I thought the issue might be from AsyncArray.metadata being set to metadata_parsed = parse_array_metadata(metadata), which doesn't know that parse_array_metadata(ArrayV2Metadata) should be ArrayV2Metadata. I thought that adding an overload would fix that:

@overloaddefparse_array_metadata(data: ArrayV2Metadata) ->ArrayV2Metadata: ...
@overloaddefparse_array_metadata(data: ArrayV3Metadata) ->ArrayV3Metadata: ...
@overloaddefparse_array_metadata(data: dict[str, JSON]) ->ArrayV2Metadata|ArrayV3Metadata: ...
defparse_array_metadata(
data: ArrayV2Metadata|ArrayV3Metadata|dict[str, JSON],
) ->ArrayV2Metadata|ArrayV3Metadata:

But no luck. I'll take a longer look later.

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's a simple example that works:

Details
from __future__ importannotationsimportdataclassesfromtypingimportGeneric, Literal, TypeVar, cast, overload, reveal_typeclassA: ...
classB: ...
T=TypeVar("T", A, B)
@dataclasses.dataclass(frozen=True)classArray(Generic[T]):
version: Tdef__init__(self, version: A|B) ->None:
object.__setattr__(self, "version", version)
@overload@classmethoddefcreate(cls, version: Literal[2]) ->Array[A]: ...
@overload@classmethoddefcreate(cls, version: Literal[3]) ->Array[B]: ...
@overload@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]: ...
@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]:
ifversion==2:
returncls.create_2()
elifversion==3:
returncls.create_3()
else:
raise@classmethoddefcreate_2(cls) ->Array[A]:
returnArray(version=A())
@classmethoddefcreate_3(cls) ->Array[B]:
returnArray(version=B())
reveal_type(Array(A()))
reveal_type(Array(B()))
reveal_type(Array.create_2())
reveal_type(Array.create_3())
reveal_type(Array.create(version=2))
reveal_type(Array.create(version=3))

That reveals the expected types everywhere:

❯ mypy check.py
check.py:49: note: Revealed type is "check.Array[check.A]"
check.py:50: note: Revealed type is "check.Array[check.A]"
check.py:52: note: Revealed type is "check.Array[check.A]"
check.py:53: note: Revealed type is "check.Array[check.B]"
check.py:55: note: Revealed type is "check.Array[check.A]"
check.py:56: note: Revealed type is "check.Array[check.B]"
Success: no issues found in 1 source file

I've tried making similar changes on your branch, but haven't gotten it working yet. I think the things that might matter are:

  1. We might need overloads for AsyncArray.create to ensure that AsyncArray.create(..., zarr_format=3) returns an AsyncArray[v3]. likewise for 2
  2. AsyncArray.__init__ also accepts a dict for metadata. The actual value set is the output of parse_arary_metadata. We need to ensure that the specialized type isn't lost (which the overload should do, but maybe I messed that up).

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure what I messed up earlier, but these overloads on create seemed to do the trick (the only differences in the signature are zarr_format being Literal[2] or Literal[3] rather than Literal[2, 3], and the return type being specialized to v2 or v3):

diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py
index 96431dec..5699935f 100644
--- a/src/zarr/core/array.py+++ b/src/zarr/core/array.py@@ -4,7 +4,7 @@ import json
from asyncio import gather
from dataclasses import dataclass, field, replace
from logging import getLogger
-from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast+from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import numpy as np
import numpy.typing as npt
@@ -191,6 +191,72 @@ class AsyncArray(Generic[TArrayMeta]):
object.__setattr__(self, "order", order_parsed)
object.__setattr__(self, "codec_pipeline", create_codec_pipeline(metadata=metadata_parsed))
+ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[2],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV2Metadata]:...++ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[3],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV3Metadata]:...+
@classmethod
async def create(
cls,
@@ -349,7 +415,7 @@ class AsyncArray(Generic[TArrayMeta]):
await array._save_metadata(metadata, ensure_parents=True)
# type inference is inconsistent here and seems to conclude
# that array has type Array[ArrayV2Metadata]
- return array # type: ignore[return-value]+ return array
@classmethod
async def _create_v2(
@@ -388,7 +454,7 @@ class AsyncArray(Generic[TArrayMeta]):
)
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
- return array # type: ignore[return-value]+ return array
@classmethod
def from_dict(

Checking that here:

file: check2.pyfromtypingimportreveal_typeimportnumpyasnpfromzarr.core.arrayimportAsyncArrayfromzarr.storage.commonimportStorePathfromzarr.storage.memoryimportMemoryStoreasyncdefmain() ->None:
store=StorePath(MemoryStore(), "/")
r2=awaitAsyncArray._create_v2(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,))
reveal_type(r2) # v2r3=awaitAsyncArray._create_v3(store, shape=(0,), dtype=np.dtype("float64"), chunk_shape=(0,))
reveal_type(r3) # v3rr2=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=2)
reveal_type(rr2) # v2rr3=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=3)
reveal_type(rr3) # v3

gives

❯ mypy check2.py
check2.py:13: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:16: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
check2.py:19: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:22: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
Success: no issues found in 1 source file

@TomAugspurger

Copy link
Copy Markdown
Contributor

should we remove ArrayMetadata entirely?

That, or define ArrayMetadata = ArrayV2Metadata | ArrayV3Metadata for the few places where we write out that union.

should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

Probably. I think being able to know that a Group[V3].getitem returns Group[V3] | Array[V3] and not Group[T] | Array[T] is useful.

@d-v-b
d-v-b marked this pull request as ready for review October 8, 2024 17:09
@d-v-b

d-v-b commented Oct 8, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger thanks for the suggestion to use @overload to fix the type inference for AsyncArray.create. A few other changes were necessary, namely
defining minimal TypedDict instances for the dict form of ArrayV2Metadata and ArrayV3Metadata. With these dict classes we can declare that creating AsyncArray from dicts like {'zarr_format': 2, ...} will produce AsyncArray[ArrayV2Metadata]. These classes will be replaced by the result of #2099.

This is blocked on some very confusing mypy errors. If anyone has ideas I would love to hear them!

@TomAugspurger

TomAugspurger commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Looks like a mypy limitation at first glance:

With this

 resized = sync(self._async_array.resize(new_shape))
reveal_type(resized)

mypy gives

src/zarr/core/array.py:2475: note: Revealed type is "builtins.object"

pylance / pyright get this one correct: Type of "resized" is "AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata]".

python/mypy#15295 looks potentially related. I'd recommend casting the result of sync(self._async_array.resize(new_shape)) to AsyncArray to help mypy out.

@rabernat

rabernat commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Mypy errors notwithstanding, does this fix #2269?

@TomAugspurger

Copy link
Copy Markdown
Contributor

Not quite. One this branch that raises with

---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:142, in parse_shapelike(data)
141try:
--> 142 data_tuple = tuple(data)
143exceptTypeErroras e:
TypeError: 'RegularChunkGrid' object is not iterable
The above exception was the direct cause of the following exception:
TypeError Traceback (most recent call last)
Cell In[4], line 16
7 data = np.arange(0, 8, dtype="uint16")
8 a = Array.create(
9 store /"simple_v2",
10 zarr_format=2,
(...)
14 fill_value=0,
15 )
---> 16 a.attrs.put({"key": 0})
File ~/gh/zarr-developers/zarr-python/src/zarr/core/attributes.py:53, in Attributes.put(self, d)
39defput(self, d: dict[str, JSON]) -> None:
40"""41 Overwrite all attributes with the values from`d`.
42
(...)
51 {'a': 3, 'c': 4}
52"""
---> 53 self._obj = self._obj.update_attributes(d)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:2478, in Array.update_attributes(self, new_attributes)
2477 def update_attributes(self, new_attributes: dict[str, JSON]) -> Array:
-> 2478 return type(self)(sync(self._async_array.update_attributes(new_attributes)))
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:91, in sync(coro, loop, timeout)
88 return_result =next(iter(finished)).result()
90ifisinstance(return_result, BaseException):
---> 91 raise return_result
92else:
93return return_result
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:50, in _runner(coro)
45"""46 Await a coroutine andreturn the result of running it. If awaiting the coroutine raises an
47 exception, the exception will be returned.
48"""49try:
---> 50 return await coro
51exceptExceptionas ex:
52return ex
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:913, in AsyncArray.update_attributes(self, new_attributes)
912asyncdefupdate_attributes(self, new_attributes: dict[str, JSON]) -> Self:
--> 913 new_metadata = self.metadata.update_attributes(new_attributes)
915# Write new metadata916awaitself._save_metadata(new_metadata)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:208, in ArrayV2Metadata.update_attributes(self, attributes)
207defupdate_attributes(self, attributes: dict[str, JSON]) -> Self:
--> 208 return replace(self, attributes=attributes)
File ~/mambaforge/envs/python=3.12/lib/python3.12/dataclasses.py:1588, in replace(obj, **changes)
1581 changes[f.name] = getattr(obj, f.name)
1583 # Create the new object, which calls __init__() and
1584 # __post_init__() (if defined), using all of the init fields we've
1585 # added and/or left in 'changes'. If there are values supplied in
1586 # changes that aren't fields, this will correctly raise a
1587 # TypeError.
-> 1588 return obj.__class__(**changes)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:72, in ArrayV2Metadata.__init__(self, shape, dtype, chunks, fill_value, order, dimension_separator, compressor, filters, attributes)
70 shape_parsed = parse_shapelike(shape)
71 dtype_parsed = parse_dtype(dtype)
---> 72 chunks_parsed = parse_shapelike(chunks)
73 compressor_parsed = parse_compressor(compressor)
74 order_parsed = parse_indexing_order(order)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:145, in parse_shapelike(data)
143exceptTypeErroras e:
144 msg =f"Expected an integer or an iterable of integers. Got {data} instead."
--> 145 raise TypeError(msg) from e
147ifnotall(isinstance(v, int) for v in data_tuple):
148 msg =f"Expected an iterable of integers. Got {data} instead."TypeError: Expected an integer or an iterable of integers. Got RegularChunkGrid(chunk_shape=(4,)) instead.

We'll need to update the various parse_* functions to gracefully handle the already parsed versions.

@d-v-b this is one of the reasons in our serialization discussion (#2144) I mentioned preferring plain __init__ methods that simply set the attributes (plus a tiny bit of validation that type checkers can't catch, like ensuring that the length of chunks and shape match). Because __init__ always runs anytime you create an instance I like to keep them simple, and "complicated" stuff like parsing tuples of ints into their concrete form can be done in alternative constructors / on the boundary. But it does open up the risk of people using the class constructor incorrectly and later hitting an error / incorrect behavior (maybe, if they have code that relies on that, but that's just what you get with duck typing).

@d-v-b

d-v-b commented Oct 9, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger that's a good point! I think there's a significant difference between "parse the inputs to ensure validity" and "parse the inputs to ensure validity, and return a new data type that's not part of the metadata model". By turning the chunks attribute from a JSON array of numbers to a RegularChunkGrid object we were doing the second one, when I think we should always do the first one.

So I altered ArrayV2Metadata such that ArrayV2Metadata.chunks is a plain tuple of ints, and ArrayV2Metadata.chunk_grid is a cached property that returns a RegularChunkGrid. I also added some casts to deal with the type inference issues. I checked the test case from #2269 and it passed with these changes.

@TomAugspurgerTomAugspurger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, I think this looks good overall.

Comment threadsrc/zarr/api/asynchronous.py
Comment threadsrc/zarr/core/metadata/__init__.py
metadata = ArrayV3Metadata.from_dict(_metadata)
else:
metadata = ArrayV3Metadata.from_dict(metadata)
raise ValueError(f"Invalid zarr_format: {zarr_format}. Expected 2 or 3")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#2310 (not yet merged) is adding some special exceptions for parsing invalid metadata, which we could use here if we want.

Comment threadsrc/zarr/core/array.py
@TomAugspurgerTomAugspurger added the downstream Downstream libraries using zarr label Oct 9, 2024

@jhammanjhamman left a comment

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.

I'm not 1000% sold on some of the typing changes (particularly the AsyncArray generic) but this is a solid improvement so my questions are non-blocking.

storage_options: dict[str, Any] | None = None,
**kwargs: Any, # TODO: type kwargs as valid args to open_array
) -> AsyncArray | AsyncGroup:
) -> AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata] | AsyncGroup:

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.

This question applies here and everywhere else you use the v2/v3 Metadata types.

Since the generic only includes two variants (ArrayV2Metadata and ArrayV3Metadata), what value do we get by using both together here (as apposed to simply using the AsyncArray type?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that is purely to silence mypy complaining about a generic missing its type parameter. If there's a cleaner way to annotate it then I'll do it. Would AsyncArray[Any] work there? (I can play around with this locally)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

playing around with AsyncArray[Any] wasn't satisfactory. I think the only shortcut we can use here is assigning a type alias to the union AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata], and I'm not sure that's worth it.

Comment threadsrc/zarr/core/array.py Outdated
Comment on lines +169 to +173
TArrayMeta = TypeVar("TArrayMeta", ArrayV2Metadata, ArrayV3Metadata)


@dataclass(frozen=True)
class AsyncArray:
metadata: ArrayMetadata
class AsyncArray(Generic[TArrayMeta]):

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.

Does this foreclose the opportunity to use other generics here (namely over dtype)?

xref: #2137 (comment)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that's a good question, I have no idea! It seems like we would want to make the metadata class generic w.r.t dtype, but then have the array class be generic over dtype too... not sure how to solve that

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought a bit about this, and I don't see why we couldn't make AsyncArray inherit from Generic[TArrayMeta, TDtype].

Comment threadsrc/zarr/core/array.py Outdated
Comment threadsrc/zarr/core/array.py Outdated
…/zarr-python into refactor/rename-v2-metadata-fields
@jhammanjhamman added this to the 3.0.0.beta milestone Oct 9, 2024
@jhammanjhamman added the V3 label Oct 9, 2024
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

downstreamDownstream libraries using zarr

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

@d-v-b@TomAugspurger@rabernat@jhamman
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Refactor/rename v2 metadata fields by d-v-b · Pull Request #2301 · zarr-developers/zarr-python · GitHub
Skip to content

Refactor/rename v2 metadata fields - #2301

Merged
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields
Oct 10, 2024
Merged

Refactor/rename v2 metadata fields#2301
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields

Conversation

@d-v-b

@d-v-bd-v-b commented Oct 4, 2024

Copy link
Copy Markdown
Contributor

The first change is to alters the structure of ArrayV2Metadata to ensure that it matches the zarr v2 metadata document. This is simple:

  • I changed the data_type attribute to dtype
  • I changed the chunk_grid attribute to chunks

These changes make it difficult for ArrayV2Metadata to inherit from ArrayMetadata, because that class defines dtype as a property, which I learned cannot be overridden by an attribute. This observation made me rather skeptical of the utility of the ArrayMetadata class. See #2300 for more on that. So this PR removes the inheritance relationship between ArrayV2Metadata / ArrayV3Metadata and ArrayMetadata.

I could then replace the annotation of the metadata attribute of the AsyncArray class with ArrayV2Metadata | ArrayV3Metadata, but this type annotation is not quite accurate, because we know that some operations only result in an AsyncArray with ArrayV3Metadata, and others only make an AsyncArray with ArrayV2Metadata (namely, _create_v3 and _create_v2). So I attempted to solve this by making AsyncArray generic with a TypeVar with 2 bounds, ArrayV2Metadata and ArrayV3Metadata. This lets us express the invariances I mentioned above. Mypy was unhappy in various places and I plugged those holes with type: ignore statements. If there's a cleaner solution I would appreciate it.

This will remain a draft until we answer a few questions:

  • should we remove ArrayMetadata entirely?
  • can we make mypy happy without the type: ignores? maybe I'm doing something wrong with type annotations.
  • should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

TODO:

  • Add unit tests and/or doctests in docstrings
  • Add docstrings and API docs for any new/modified user-facing classes and functions
  • New/modified features documented in docs/tutorial.rst
  • Changes documented in docs/release.rst
  • GitHub Actions have all passed
  • Test coverage is 100% (Codecov passes)

Comment threadsrc/zarr/core/array.py Outdated
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
return array
# type inference is inconsistent here and seems to conclude

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I thought the issue might be from AsyncArray.metadata being set to metadata_parsed = parse_array_metadata(metadata), which doesn't know that parse_array_metadata(ArrayV2Metadata) should be ArrayV2Metadata. I thought that adding an overload would fix that:

@overloaddefparse_array_metadata(data: ArrayV2Metadata) ->ArrayV2Metadata: ...
@overloaddefparse_array_metadata(data: ArrayV3Metadata) ->ArrayV3Metadata: ...
@overloaddefparse_array_metadata(data: dict[str, JSON]) ->ArrayV2Metadata|ArrayV3Metadata: ...
defparse_array_metadata(
data: ArrayV2Metadata|ArrayV3Metadata|dict[str, JSON],
) ->ArrayV2Metadata|ArrayV3Metadata:

But no luck. I'll take a longer look later.

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's a simple example that works:

Details
from __future__ importannotationsimportdataclassesfromtypingimportGeneric, Literal, TypeVar, cast, overload, reveal_typeclassA: ...
classB: ...
T=TypeVar("T", A, B)
@dataclasses.dataclass(frozen=True)classArray(Generic[T]):
version: Tdef__init__(self, version: A|B) ->None:
object.__setattr__(self, "version", version)
@overload@classmethoddefcreate(cls, version: Literal[2]) ->Array[A]: ...
@overload@classmethoddefcreate(cls, version: Literal[3]) ->Array[B]: ...
@overload@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]: ...
@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]:
ifversion==2:
returncls.create_2()
elifversion==3:
returncls.create_3()
else:
raise@classmethoddefcreate_2(cls) ->Array[A]:
returnArray(version=A())
@classmethoddefcreate_3(cls) ->Array[B]:
returnArray(version=B())
reveal_type(Array(A()))
reveal_type(Array(B()))
reveal_type(Array.create_2())
reveal_type(Array.create_3())
reveal_type(Array.create(version=2))
reveal_type(Array.create(version=3))

That reveals the expected types everywhere:

❯ mypy check.py
check.py:49: note: Revealed type is "check.Array[check.A]"
check.py:50: note: Revealed type is "check.Array[check.A]"
check.py:52: note: Revealed type is "check.Array[check.A]"
check.py:53: note: Revealed type is "check.Array[check.B]"
check.py:55: note: Revealed type is "check.Array[check.A]"
check.py:56: note: Revealed type is "check.Array[check.B]"
Success: no issues found in 1 source file

I've tried making similar changes on your branch, but haven't gotten it working yet. I think the things that might matter are:

  1. We might need overloads for AsyncArray.create to ensure that AsyncArray.create(..., zarr_format=3) returns an AsyncArray[v3]. likewise for 2
  2. AsyncArray.__init__ also accepts a dict for metadata. The actual value set is the output of parse_arary_metadata. We need to ensure that the specialized type isn't lost (which the overload should do, but maybe I messed that up).

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure what I messed up earlier, but these overloads on create seemed to do the trick (the only differences in the signature are zarr_format being Literal[2] or Literal[3] rather than Literal[2, 3], and the return type being specialized to v2 or v3):

diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py
index 96431dec..5699935f 100644
--- a/src/zarr/core/array.py+++ b/src/zarr/core/array.py@@ -4,7 +4,7 @@ import json
from asyncio import gather
from dataclasses import dataclass, field, replace
from logging import getLogger
-from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast+from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import numpy as np
import numpy.typing as npt
@@ -191,6 +191,72 @@ class AsyncArray(Generic[TArrayMeta]):
object.__setattr__(self, "order", order_parsed)
object.__setattr__(self, "codec_pipeline", create_codec_pipeline(metadata=metadata_parsed))
+ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[2],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV2Metadata]:...++ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[3],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV3Metadata]:...+
@classmethod
async def create(
cls,
@@ -349,7 +415,7 @@ class AsyncArray(Generic[TArrayMeta]):
await array._save_metadata(metadata, ensure_parents=True)
# type inference is inconsistent here and seems to conclude
# that array has type Array[ArrayV2Metadata]
- return array # type: ignore[return-value]+ return array
@classmethod
async def _create_v2(
@@ -388,7 +454,7 @@ class AsyncArray(Generic[TArrayMeta]):
)
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
- return array # type: ignore[return-value]+ return array
@classmethod
def from_dict(

Checking that here:

file: check2.pyfromtypingimportreveal_typeimportnumpyasnpfromzarr.core.arrayimportAsyncArrayfromzarr.storage.commonimportStorePathfromzarr.storage.memoryimportMemoryStoreasyncdefmain() ->None:
store=StorePath(MemoryStore(), "/")
r2=awaitAsyncArray._create_v2(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,))
reveal_type(r2) # v2r3=awaitAsyncArray._create_v3(store, shape=(0,), dtype=np.dtype("float64"), chunk_shape=(0,))
reveal_type(r3) # v3rr2=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=2)
reveal_type(rr2) # v2rr3=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=3)
reveal_type(rr3) # v3

gives

❯ mypy check2.py
check2.py:13: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:16: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
check2.py:19: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:22: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
Success: no issues found in 1 source file

@TomAugspurger

Copy link
Copy Markdown
Contributor

should we remove ArrayMetadata entirely?

That, or define ArrayMetadata = ArrayV2Metadata | ArrayV3Metadata for the few places where we write out that union.

should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

Probably. I think being able to know that a Group[V3].getitem returns Group[V3] | Array[V3] and not Group[T] | Array[T] is useful.

@d-v-b
d-v-b marked this pull request as ready for review October 8, 2024 17:09
@d-v-b

d-v-b commented Oct 8, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger thanks for the suggestion to use @overload to fix the type inference for AsyncArray.create. A few other changes were necessary, namely
defining minimal TypedDict instances for the dict form of ArrayV2Metadata and ArrayV3Metadata. With these dict classes we can declare that creating AsyncArray from dicts like {'zarr_format': 2, ...} will produce AsyncArray[ArrayV2Metadata]. These classes will be replaced by the result of #2099.

This is blocked on some very confusing mypy errors. If anyone has ideas I would love to hear them!

@TomAugspurger

TomAugspurger commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Looks like a mypy limitation at first glance:

With this

 resized = sync(self._async_array.resize(new_shape))
reveal_type(resized)

mypy gives

src/zarr/core/array.py:2475: note: Revealed type is "builtins.object"

pylance / pyright get this one correct: Type of "resized" is "AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata]".

python/mypy#15295 looks potentially related. I'd recommend casting the result of sync(self._async_array.resize(new_shape)) to AsyncArray to help mypy out.

@rabernat

rabernat commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Mypy errors notwithstanding, does this fix #2269?

@TomAugspurger

Copy link
Copy Markdown
Contributor

Not quite. One this branch that raises with

---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:142, in parse_shapelike(data)
141try:
--> 142 data_tuple = tuple(data)
143exceptTypeErroras e:
TypeError: 'RegularChunkGrid' object is not iterable
The above exception was the direct cause of the following exception:
TypeError Traceback (most recent call last)
Cell In[4], line 16
7 data = np.arange(0, 8, dtype="uint16")
8 a = Array.create(
9 store /"simple_v2",
10 zarr_format=2,
(...)
14 fill_value=0,
15 )
---> 16 a.attrs.put({"key": 0})
File ~/gh/zarr-developers/zarr-python/src/zarr/core/attributes.py:53, in Attributes.put(self, d)
39defput(self, d: dict[str, JSON]) -> None:
40"""41 Overwrite all attributes with the values from`d`.
42
(...)
51 {'a': 3, 'c': 4}
52"""
---> 53 self._obj = self._obj.update_attributes(d)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:2478, in Array.update_attributes(self, new_attributes)
2477 def update_attributes(self, new_attributes: dict[str, JSON]) -> Array:
-> 2478 return type(self)(sync(self._async_array.update_attributes(new_attributes)))
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:91, in sync(coro, loop, timeout)
88 return_result =next(iter(finished)).result()
90ifisinstance(return_result, BaseException):
---> 91 raise return_result
92else:
93return return_result
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:50, in _runner(coro)
45"""46 Await a coroutine andreturn the result of running it. If awaiting the coroutine raises an
47 exception, the exception will be returned.
48"""49try:
---> 50 return await coro
51exceptExceptionas ex:
52return ex
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:913, in AsyncArray.update_attributes(self, new_attributes)
912asyncdefupdate_attributes(self, new_attributes: dict[str, JSON]) -> Self:
--> 913 new_metadata = self.metadata.update_attributes(new_attributes)
915# Write new metadata916awaitself._save_metadata(new_metadata)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:208, in ArrayV2Metadata.update_attributes(self, attributes)
207defupdate_attributes(self, attributes: dict[str, JSON]) -> Self:
--> 208 return replace(self, attributes=attributes)
File ~/mambaforge/envs/python=3.12/lib/python3.12/dataclasses.py:1588, in replace(obj, **changes)
1581 changes[f.name] = getattr(obj, f.name)
1583 # Create the new object, which calls __init__() and
1584 # __post_init__() (if defined), using all of the init fields we've
1585 # added and/or left in 'changes'. If there are values supplied in
1586 # changes that aren't fields, this will correctly raise a
1587 # TypeError.
-> 1588 return obj.__class__(**changes)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:72, in ArrayV2Metadata.__init__(self, shape, dtype, chunks, fill_value, order, dimension_separator, compressor, filters, attributes)
70 shape_parsed = parse_shapelike(shape)
71 dtype_parsed = parse_dtype(dtype)
---> 72 chunks_parsed = parse_shapelike(chunks)
73 compressor_parsed = parse_compressor(compressor)
74 order_parsed = parse_indexing_order(order)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:145, in parse_shapelike(data)
143exceptTypeErroras e:
144 msg =f"Expected an integer or an iterable of integers. Got {data} instead."
--> 145 raise TypeError(msg) from e
147ifnotall(isinstance(v, int) for v in data_tuple):
148 msg =f"Expected an iterable of integers. Got {data} instead."TypeError: Expected an integer or an iterable of integers. Got RegularChunkGrid(chunk_shape=(4,)) instead.

We'll need to update the various parse_* functions to gracefully handle the already parsed versions.

@d-v-b this is one of the reasons in our serialization discussion (#2144) I mentioned preferring plain __init__ methods that simply set the attributes (plus a tiny bit of validation that type checkers can't catch, like ensuring that the length of chunks and shape match). Because __init__ always runs anytime you create an instance I like to keep them simple, and "complicated" stuff like parsing tuples of ints into their concrete form can be done in alternative constructors / on the boundary. But it does open up the risk of people using the class constructor incorrectly and later hitting an error / incorrect behavior (maybe, if they have code that relies on that, but that's just what you get with duck typing).

@d-v-b

d-v-b commented Oct 9, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger that's a good point! I think there's a significant difference between "parse the inputs to ensure validity" and "parse the inputs to ensure validity, and return a new data type that's not part of the metadata model". By turning the chunks attribute from a JSON array of numbers to a RegularChunkGrid object we were doing the second one, when I think we should always do the first one.

So I altered ArrayV2Metadata such that ArrayV2Metadata.chunks is a plain tuple of ints, and ArrayV2Metadata.chunk_grid is a cached property that returns a RegularChunkGrid. I also added some casts to deal with the type inference issues. I checked the test case from #2269 and it passed with these changes.

@TomAugspurgerTomAugspurger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, I think this looks good overall.

Comment threadsrc/zarr/api/asynchronous.py
Comment threadsrc/zarr/core/metadata/__init__.py
metadata = ArrayV3Metadata.from_dict(_metadata)
else:
metadata = ArrayV3Metadata.from_dict(metadata)
raise ValueError(f"Invalid zarr_format: {zarr_format}. Expected 2 or 3")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#2310 (not yet merged) is adding some special exceptions for parsing invalid metadata, which we could use here if we want.

Comment threadsrc/zarr/core/array.py
@TomAugspurgerTomAugspurger added the downstream Downstream libraries using zarr label Oct 9, 2024

@jhammanjhamman left a comment

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.

I'm not 1000% sold on some of the typing changes (particularly the AsyncArray generic) but this is a solid improvement so my questions are non-blocking.

storage_options: dict[str, Any] | None = None,
**kwargs: Any, # TODO: type kwargs as valid args to open_array
) -> AsyncArray | AsyncGroup:
) -> AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata] | AsyncGroup:

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.

This question applies here and everywhere else you use the v2/v3 Metadata types.

Since the generic only includes two variants (ArrayV2Metadata and ArrayV3Metadata), what value do we get by using both together here (as apposed to simply using the AsyncArray type?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that is purely to silence mypy complaining about a generic missing its type parameter. If there's a cleaner way to annotate it then I'll do it. Would AsyncArray[Any] work there? (I can play around with this locally)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

playing around with AsyncArray[Any] wasn't satisfactory. I think the only shortcut we can use here is assigning a type alias to the union AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata], and I'm not sure that's worth it.

Comment threadsrc/zarr/core/array.py Outdated
Comment on lines +169 to +173
TArrayMeta = TypeVar("TArrayMeta", ArrayV2Metadata, ArrayV3Metadata)


@dataclass(frozen=True)
class AsyncArray:
metadata: ArrayMetadata
class AsyncArray(Generic[TArrayMeta]):

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.

Does this foreclose the opportunity to use other generics here (namely over dtype)?

xref: #2137 (comment)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that's a good question, I have no idea! It seems like we would want to make the metadata class generic w.r.t dtype, but then have the array class be generic over dtype too... not sure how to solve that

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought a bit about this, and I don't see why we couldn't make AsyncArray inherit from Generic[TArrayMeta, TDtype].

Comment threadsrc/zarr/core/array.py Outdated
Comment threadsrc/zarr/core/array.py Outdated
…/zarr-python into refactor/rename-v2-metadata-fields
@jhammanjhamman added this to the 3.0.0.beta milestone Oct 9, 2024
@jhammanjhamman added the V3 label Oct 9, 2024
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

downstreamDownstream libraries using zarr

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

@d-v-b@TomAugspurger@rabernat@jhamman
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Refactor/rename v2 metadata fields by d-v-b · Pull Request #2301 · zarr-developers/zarr-python · GitHub
Skip to content

Refactor/rename v2 metadata fields - #2301

Merged
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields
Oct 10, 2024
Merged

Refactor/rename v2 metadata fields#2301
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields

Conversation

@d-v-b

@d-v-bd-v-b commented Oct 4, 2024

Copy link
Copy Markdown
Contributor

The first change is to alters the structure of ArrayV2Metadata to ensure that it matches the zarr v2 metadata document. This is simple:

  • I changed the data_type attribute to dtype
  • I changed the chunk_grid attribute to chunks

These changes make it difficult for ArrayV2Metadata to inherit from ArrayMetadata, because that class defines dtype as a property, which I learned cannot be overridden by an attribute. This observation made me rather skeptical of the utility of the ArrayMetadata class. See #2300 for more on that. So this PR removes the inheritance relationship between ArrayV2Metadata / ArrayV3Metadata and ArrayMetadata.

I could then replace the annotation of the metadata attribute of the AsyncArray class with ArrayV2Metadata | ArrayV3Metadata, but this type annotation is not quite accurate, because we know that some operations only result in an AsyncArray with ArrayV3Metadata, and others only make an AsyncArray with ArrayV2Metadata (namely, _create_v3 and _create_v2). So I attempted to solve this by making AsyncArray generic with a TypeVar with 2 bounds, ArrayV2Metadata and ArrayV3Metadata. This lets us express the invariances I mentioned above. Mypy was unhappy in various places and I plugged those holes with type: ignore statements. If there's a cleaner solution I would appreciate it.

This will remain a draft until we answer a few questions:

  • should we remove ArrayMetadata entirely?
  • can we make mypy happy without the type: ignores? maybe I'm doing something wrong with type annotations.
  • should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

TODO:

  • Add unit tests and/or doctests in docstrings
  • Add docstrings and API docs for any new/modified user-facing classes and functions
  • New/modified features documented in docs/tutorial.rst
  • Changes documented in docs/release.rst
  • GitHub Actions have all passed
  • Test coverage is 100% (Codecov passes)

Comment threadsrc/zarr/core/array.py Outdated
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
return array
# type inference is inconsistent here and seems to conclude

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I thought the issue might be from AsyncArray.metadata being set to metadata_parsed = parse_array_metadata(metadata), which doesn't know that parse_array_metadata(ArrayV2Metadata) should be ArrayV2Metadata. I thought that adding an overload would fix that:

@overloaddefparse_array_metadata(data: ArrayV2Metadata) ->ArrayV2Metadata: ...
@overloaddefparse_array_metadata(data: ArrayV3Metadata) ->ArrayV3Metadata: ...
@overloaddefparse_array_metadata(data: dict[str, JSON]) ->ArrayV2Metadata|ArrayV3Metadata: ...
defparse_array_metadata(
data: ArrayV2Metadata|ArrayV3Metadata|dict[str, JSON],
) ->ArrayV2Metadata|ArrayV3Metadata:

But no luck. I'll take a longer look later.

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's a simple example that works:

Details
from __future__ importannotationsimportdataclassesfromtypingimportGeneric, Literal, TypeVar, cast, overload, reveal_typeclassA: ...
classB: ...
T=TypeVar("T", A, B)
@dataclasses.dataclass(frozen=True)classArray(Generic[T]):
version: Tdef__init__(self, version: A|B) ->None:
object.__setattr__(self, "version", version)
@overload@classmethoddefcreate(cls, version: Literal[2]) ->Array[A]: ...
@overload@classmethoddefcreate(cls, version: Literal[3]) ->Array[B]: ...
@overload@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]: ...
@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]:
ifversion==2:
returncls.create_2()
elifversion==3:
returncls.create_3()
else:
raise@classmethoddefcreate_2(cls) ->Array[A]:
returnArray(version=A())
@classmethoddefcreate_3(cls) ->Array[B]:
returnArray(version=B())
reveal_type(Array(A()))
reveal_type(Array(B()))
reveal_type(Array.create_2())
reveal_type(Array.create_3())
reveal_type(Array.create(version=2))
reveal_type(Array.create(version=3))

That reveals the expected types everywhere:

❯ mypy check.py
check.py:49: note: Revealed type is "check.Array[check.A]"
check.py:50: note: Revealed type is "check.Array[check.A]"
check.py:52: note: Revealed type is "check.Array[check.A]"
check.py:53: note: Revealed type is "check.Array[check.B]"
check.py:55: note: Revealed type is "check.Array[check.A]"
check.py:56: note: Revealed type is "check.Array[check.B]"
Success: no issues found in 1 source file

I've tried making similar changes on your branch, but haven't gotten it working yet. I think the things that might matter are:

  1. We might need overloads for AsyncArray.create to ensure that AsyncArray.create(..., zarr_format=3) returns an AsyncArray[v3]. likewise for 2
  2. AsyncArray.__init__ also accepts a dict for metadata. The actual value set is the output of parse_arary_metadata. We need to ensure that the specialized type isn't lost (which the overload should do, but maybe I messed that up).

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure what I messed up earlier, but these overloads on create seemed to do the trick (the only differences in the signature are zarr_format being Literal[2] or Literal[3] rather than Literal[2, 3], and the return type being specialized to v2 or v3):

diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py
index 96431dec..5699935f 100644
--- a/src/zarr/core/array.py+++ b/src/zarr/core/array.py@@ -4,7 +4,7 @@ import json
from asyncio import gather
from dataclasses import dataclass, field, replace
from logging import getLogger
-from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast+from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import numpy as np
import numpy.typing as npt
@@ -191,6 +191,72 @@ class AsyncArray(Generic[TArrayMeta]):
object.__setattr__(self, "order", order_parsed)
object.__setattr__(self, "codec_pipeline", create_codec_pipeline(metadata=metadata_parsed))
+ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[2],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV2Metadata]:...++ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[3],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV3Metadata]:...+
@classmethod
async def create(
cls,
@@ -349,7 +415,7 @@ class AsyncArray(Generic[TArrayMeta]):
await array._save_metadata(metadata, ensure_parents=True)
# type inference is inconsistent here and seems to conclude
# that array has type Array[ArrayV2Metadata]
- return array # type: ignore[return-value]+ return array
@classmethod
async def _create_v2(
@@ -388,7 +454,7 @@ class AsyncArray(Generic[TArrayMeta]):
)
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
- return array # type: ignore[return-value]+ return array
@classmethod
def from_dict(

Checking that here:

file: check2.pyfromtypingimportreveal_typeimportnumpyasnpfromzarr.core.arrayimportAsyncArrayfromzarr.storage.commonimportStorePathfromzarr.storage.memoryimportMemoryStoreasyncdefmain() ->None:
store=StorePath(MemoryStore(), "/")
r2=awaitAsyncArray._create_v2(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,))
reveal_type(r2) # v2r3=awaitAsyncArray._create_v3(store, shape=(0,), dtype=np.dtype("float64"), chunk_shape=(0,))
reveal_type(r3) # v3rr2=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=2)
reveal_type(rr2) # v2rr3=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=3)
reveal_type(rr3) # v3

gives

❯ mypy check2.py
check2.py:13: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:16: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
check2.py:19: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:22: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
Success: no issues found in 1 source file

@TomAugspurger

Copy link
Copy Markdown
Contributor

should we remove ArrayMetadata entirely?

That, or define ArrayMetadata = ArrayV2Metadata | ArrayV3Metadata for the few places where we write out that union.

should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

Probably. I think being able to know that a Group[V3].getitem returns Group[V3] | Array[V3] and not Group[T] | Array[T] is useful.

@d-v-b
d-v-b marked this pull request as ready for review October 8, 2024 17:09
@d-v-b

d-v-b commented Oct 8, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger thanks for the suggestion to use @overload to fix the type inference for AsyncArray.create. A few other changes were necessary, namely
defining minimal TypedDict instances for the dict form of ArrayV2Metadata and ArrayV3Metadata. With these dict classes we can declare that creating AsyncArray from dicts like {'zarr_format': 2, ...} will produce AsyncArray[ArrayV2Metadata]. These classes will be replaced by the result of #2099.

This is blocked on some very confusing mypy errors. If anyone has ideas I would love to hear them!

@TomAugspurger

TomAugspurger commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Looks like a mypy limitation at first glance:

With this

 resized = sync(self._async_array.resize(new_shape))
reveal_type(resized)

mypy gives

src/zarr/core/array.py:2475: note: Revealed type is "builtins.object"

pylance / pyright get this one correct: Type of "resized" is "AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata]".

python/mypy#15295 looks potentially related. I'd recommend casting the result of sync(self._async_array.resize(new_shape)) to AsyncArray to help mypy out.

@rabernat

rabernat commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Mypy errors notwithstanding, does this fix #2269?

@TomAugspurger

Copy link
Copy Markdown
Contributor

Not quite. One this branch that raises with

---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:142, in parse_shapelike(data)
141try:
--> 142 data_tuple = tuple(data)
143exceptTypeErroras e:
TypeError: 'RegularChunkGrid' object is not iterable
The above exception was the direct cause of the following exception:
TypeError Traceback (most recent call last)
Cell In[4], line 16
7 data = np.arange(0, 8, dtype="uint16")
8 a = Array.create(
9 store /"simple_v2",
10 zarr_format=2,
(...)
14 fill_value=0,
15 )
---> 16 a.attrs.put({"key": 0})
File ~/gh/zarr-developers/zarr-python/src/zarr/core/attributes.py:53, in Attributes.put(self, d)
39defput(self, d: dict[str, JSON]) -> None:
40"""41 Overwrite all attributes with the values from`d`.
42
(...)
51 {'a': 3, 'c': 4}
52"""
---> 53 self._obj = self._obj.update_attributes(d)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:2478, in Array.update_attributes(self, new_attributes)
2477 def update_attributes(self, new_attributes: dict[str, JSON]) -> Array:
-> 2478 return type(self)(sync(self._async_array.update_attributes(new_attributes)))
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:91, in sync(coro, loop, timeout)
88 return_result =next(iter(finished)).result()
90ifisinstance(return_result, BaseException):
---> 91 raise return_result
92else:
93return return_result
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:50, in _runner(coro)
45"""46 Await a coroutine andreturn the result of running it. If awaiting the coroutine raises an
47 exception, the exception will be returned.
48"""49try:
---> 50 return await coro
51exceptExceptionas ex:
52return ex
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:913, in AsyncArray.update_attributes(self, new_attributes)
912asyncdefupdate_attributes(self, new_attributes: dict[str, JSON]) -> Self:
--> 913 new_metadata = self.metadata.update_attributes(new_attributes)
915# Write new metadata916awaitself._save_metadata(new_metadata)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:208, in ArrayV2Metadata.update_attributes(self, attributes)
207defupdate_attributes(self, attributes: dict[str, JSON]) -> Self:
--> 208 return replace(self, attributes=attributes)
File ~/mambaforge/envs/python=3.12/lib/python3.12/dataclasses.py:1588, in replace(obj, **changes)
1581 changes[f.name] = getattr(obj, f.name)
1583 # Create the new object, which calls __init__() and
1584 # __post_init__() (if defined), using all of the init fields we've
1585 # added and/or left in 'changes'. If there are values supplied in
1586 # changes that aren't fields, this will correctly raise a
1587 # TypeError.
-> 1588 return obj.__class__(**changes)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:72, in ArrayV2Metadata.__init__(self, shape, dtype, chunks, fill_value, order, dimension_separator, compressor, filters, attributes)
70 shape_parsed = parse_shapelike(shape)
71 dtype_parsed = parse_dtype(dtype)
---> 72 chunks_parsed = parse_shapelike(chunks)
73 compressor_parsed = parse_compressor(compressor)
74 order_parsed = parse_indexing_order(order)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:145, in parse_shapelike(data)
143exceptTypeErroras e:
144 msg =f"Expected an integer or an iterable of integers. Got {data} instead."
--> 145 raise TypeError(msg) from e
147ifnotall(isinstance(v, int) for v in data_tuple):
148 msg =f"Expected an iterable of integers. Got {data} instead."TypeError: Expected an integer or an iterable of integers. Got RegularChunkGrid(chunk_shape=(4,)) instead.

We'll need to update the various parse_* functions to gracefully handle the already parsed versions.

@d-v-b this is one of the reasons in our serialization discussion (#2144) I mentioned preferring plain __init__ methods that simply set the attributes (plus a tiny bit of validation that type checkers can't catch, like ensuring that the length of chunks and shape match). Because __init__ always runs anytime you create an instance I like to keep them simple, and "complicated" stuff like parsing tuples of ints into their concrete form can be done in alternative constructors / on the boundary. But it does open up the risk of people using the class constructor incorrectly and later hitting an error / incorrect behavior (maybe, if they have code that relies on that, but that's just what you get with duck typing).

@d-v-b

d-v-b commented Oct 9, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger that's a good point! I think there's a significant difference between "parse the inputs to ensure validity" and "parse the inputs to ensure validity, and return a new data type that's not part of the metadata model". By turning the chunks attribute from a JSON array of numbers to a RegularChunkGrid object we were doing the second one, when I think we should always do the first one.

So I altered ArrayV2Metadata such that ArrayV2Metadata.chunks is a plain tuple of ints, and ArrayV2Metadata.chunk_grid is a cached property that returns a RegularChunkGrid. I also added some casts to deal with the type inference issues. I checked the test case from #2269 and it passed with these changes.

@TomAugspurgerTomAugspurger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, I think this looks good overall.

Comment threadsrc/zarr/api/asynchronous.py
Comment threadsrc/zarr/core/metadata/__init__.py
metadata = ArrayV3Metadata.from_dict(_metadata)
else:
metadata = ArrayV3Metadata.from_dict(metadata)
raise ValueError(f"Invalid zarr_format: {zarr_format}. Expected 2 or 3")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#2310 (not yet merged) is adding some special exceptions for parsing invalid metadata, which we could use here if we want.

Comment threadsrc/zarr/core/array.py
@TomAugspurgerTomAugspurger added the downstream Downstream libraries using zarr label Oct 9, 2024

@jhammanjhamman left a comment

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.

I'm not 1000% sold on some of the typing changes (particularly the AsyncArray generic) but this is a solid improvement so my questions are non-blocking.

storage_options: dict[str, Any] | None = None,
**kwargs: Any, # TODO: type kwargs as valid args to open_array
) -> AsyncArray | AsyncGroup:
) -> AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata] | AsyncGroup:

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.

This question applies here and everywhere else you use the v2/v3 Metadata types.

Since the generic only includes two variants (ArrayV2Metadata and ArrayV3Metadata), what value do we get by using both together here (as apposed to simply using the AsyncArray type?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that is purely to silence mypy complaining about a generic missing its type parameter. If there's a cleaner way to annotate it then I'll do it. Would AsyncArray[Any] work there? (I can play around with this locally)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

playing around with AsyncArray[Any] wasn't satisfactory. I think the only shortcut we can use here is assigning a type alias to the union AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata], and I'm not sure that's worth it.

Comment threadsrc/zarr/core/array.py Outdated
Comment on lines +169 to +173
TArrayMeta = TypeVar("TArrayMeta", ArrayV2Metadata, ArrayV3Metadata)


@dataclass(frozen=True)
class AsyncArray:
metadata: ArrayMetadata
class AsyncArray(Generic[TArrayMeta]):

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.

Does this foreclose the opportunity to use other generics here (namely over dtype)?

xref: #2137 (comment)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that's a good question, I have no idea! It seems like we would want to make the metadata class generic w.r.t dtype, but then have the array class be generic over dtype too... not sure how to solve that

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought a bit about this, and I don't see why we couldn't make AsyncArray inherit from Generic[TArrayMeta, TDtype].

Comment threadsrc/zarr/core/array.py Outdated
Comment threadsrc/zarr/core/array.py Outdated
…/zarr-python into refactor/rename-v2-metadata-fields
@jhammanjhamman added this to the 3.0.0.beta milestone Oct 9, 2024
@jhammanjhamman added the V3 label Oct 9, 2024
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

downstreamDownstream libraries using zarr

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

@d-v-b@TomAugspurger@rabernat@jhamman
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Refactor/rename v2 metadata fields by d-v-b · Pull Request #2301 · zarr-developers/zarr-python · GitHub
Skip to content

Refactor/rename v2 metadata fields - #2301

Merged
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields
Oct 10, 2024
Merged

Refactor/rename v2 metadata fields#2301
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields

Conversation

@d-v-b

@d-v-bd-v-b commented Oct 4, 2024

Copy link
Copy Markdown
Contributor

The first change is to alters the structure of ArrayV2Metadata to ensure that it matches the zarr v2 metadata document. This is simple:

  • I changed the data_type attribute to dtype
  • I changed the chunk_grid attribute to chunks

These changes make it difficult for ArrayV2Metadata to inherit from ArrayMetadata, because that class defines dtype as a property, which I learned cannot be overridden by an attribute. This observation made me rather skeptical of the utility of the ArrayMetadata class. See #2300 for more on that. So this PR removes the inheritance relationship between ArrayV2Metadata / ArrayV3Metadata and ArrayMetadata.

I could then replace the annotation of the metadata attribute of the AsyncArray class with ArrayV2Metadata | ArrayV3Metadata, but this type annotation is not quite accurate, because we know that some operations only result in an AsyncArray with ArrayV3Metadata, and others only make an AsyncArray with ArrayV2Metadata (namely, _create_v3 and _create_v2). So I attempted to solve this by making AsyncArray generic with a TypeVar with 2 bounds, ArrayV2Metadata and ArrayV3Metadata. This lets us express the invariances I mentioned above. Mypy was unhappy in various places and I plugged those holes with type: ignore statements. If there's a cleaner solution I would appreciate it.

This will remain a draft until we answer a few questions:

  • should we remove ArrayMetadata entirely?
  • can we make mypy happy without the type: ignores? maybe I'm doing something wrong with type annotations.
  • should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

TODO:

  • Add unit tests and/or doctests in docstrings
  • Add docstrings and API docs for any new/modified user-facing classes and functions
  • New/modified features documented in docs/tutorial.rst
  • Changes documented in docs/release.rst
  • GitHub Actions have all passed
  • Test coverage is 100% (Codecov passes)

Comment threadsrc/zarr/core/array.py Outdated
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
return array
# type inference is inconsistent here and seems to conclude

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I thought the issue might be from AsyncArray.metadata being set to metadata_parsed = parse_array_metadata(metadata), which doesn't know that parse_array_metadata(ArrayV2Metadata) should be ArrayV2Metadata. I thought that adding an overload would fix that:

@overloaddefparse_array_metadata(data: ArrayV2Metadata) ->ArrayV2Metadata: ...
@overloaddefparse_array_metadata(data: ArrayV3Metadata) ->ArrayV3Metadata: ...
@overloaddefparse_array_metadata(data: dict[str, JSON]) ->ArrayV2Metadata|ArrayV3Metadata: ...
defparse_array_metadata(
data: ArrayV2Metadata|ArrayV3Metadata|dict[str, JSON],
) ->ArrayV2Metadata|ArrayV3Metadata:

But no luck. I'll take a longer look later.

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's a simple example that works:

Details
from __future__ importannotationsimportdataclassesfromtypingimportGeneric, Literal, TypeVar, cast, overload, reveal_typeclassA: ...
classB: ...
T=TypeVar("T", A, B)
@dataclasses.dataclass(frozen=True)classArray(Generic[T]):
version: Tdef__init__(self, version: A|B) ->None:
object.__setattr__(self, "version", version)
@overload@classmethoddefcreate(cls, version: Literal[2]) ->Array[A]: ...
@overload@classmethoddefcreate(cls, version: Literal[3]) ->Array[B]: ...
@overload@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]: ...
@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]:
ifversion==2:
returncls.create_2()
elifversion==3:
returncls.create_3()
else:
raise@classmethoddefcreate_2(cls) ->Array[A]:
returnArray(version=A())
@classmethoddefcreate_3(cls) ->Array[B]:
returnArray(version=B())
reveal_type(Array(A()))
reveal_type(Array(B()))
reveal_type(Array.create_2())
reveal_type(Array.create_3())
reveal_type(Array.create(version=2))
reveal_type(Array.create(version=3))

That reveals the expected types everywhere:

❯ mypy check.py
check.py:49: note: Revealed type is "check.Array[check.A]"
check.py:50: note: Revealed type is "check.Array[check.A]"
check.py:52: note: Revealed type is "check.Array[check.A]"
check.py:53: note: Revealed type is "check.Array[check.B]"
check.py:55: note: Revealed type is "check.Array[check.A]"
check.py:56: note: Revealed type is "check.Array[check.B]"
Success: no issues found in 1 source file

I've tried making similar changes on your branch, but haven't gotten it working yet. I think the things that might matter are:

  1. We might need overloads for AsyncArray.create to ensure that AsyncArray.create(..., zarr_format=3) returns an AsyncArray[v3]. likewise for 2
  2. AsyncArray.__init__ also accepts a dict for metadata. The actual value set is the output of parse_arary_metadata. We need to ensure that the specialized type isn't lost (which the overload should do, but maybe I messed that up).

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure what I messed up earlier, but these overloads on create seemed to do the trick (the only differences in the signature are zarr_format being Literal[2] or Literal[3] rather than Literal[2, 3], and the return type being specialized to v2 or v3):

diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py
index 96431dec..5699935f 100644
--- a/src/zarr/core/array.py+++ b/src/zarr/core/array.py@@ -4,7 +4,7 @@ import json
from asyncio import gather
from dataclasses import dataclass, field, replace
from logging import getLogger
-from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast+from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import numpy as np
import numpy.typing as npt
@@ -191,6 +191,72 @@ class AsyncArray(Generic[TArrayMeta]):
object.__setattr__(self, "order", order_parsed)
object.__setattr__(self, "codec_pipeline", create_codec_pipeline(metadata=metadata_parsed))
+ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[2],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV2Metadata]:...++ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[3],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV3Metadata]:...+
@classmethod
async def create(
cls,
@@ -349,7 +415,7 @@ class AsyncArray(Generic[TArrayMeta]):
await array._save_metadata(metadata, ensure_parents=True)
# type inference is inconsistent here and seems to conclude
# that array has type Array[ArrayV2Metadata]
- return array # type: ignore[return-value]+ return array
@classmethod
async def _create_v2(
@@ -388,7 +454,7 @@ class AsyncArray(Generic[TArrayMeta]):
)
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
- return array # type: ignore[return-value]+ return array
@classmethod
def from_dict(

Checking that here:

file: check2.pyfromtypingimportreveal_typeimportnumpyasnpfromzarr.core.arrayimportAsyncArrayfromzarr.storage.commonimportStorePathfromzarr.storage.memoryimportMemoryStoreasyncdefmain() ->None:
store=StorePath(MemoryStore(), "/")
r2=awaitAsyncArray._create_v2(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,))
reveal_type(r2) # v2r3=awaitAsyncArray._create_v3(store, shape=(0,), dtype=np.dtype("float64"), chunk_shape=(0,))
reveal_type(r3) # v3rr2=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=2)
reveal_type(rr2) # v2rr3=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=3)
reveal_type(rr3) # v3

gives

❯ mypy check2.py
check2.py:13: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:16: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
check2.py:19: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:22: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
Success: no issues found in 1 source file

@TomAugspurger

Copy link
Copy Markdown
Contributor

should we remove ArrayMetadata entirely?

That, or define ArrayMetadata = ArrayV2Metadata | ArrayV3Metadata for the few places where we write out that union.

should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

Probably. I think being able to know that a Group[V3].getitem returns Group[V3] | Array[V3] and not Group[T] | Array[T] is useful.

@d-v-b
d-v-b marked this pull request as ready for review October 8, 2024 17:09
@d-v-b

d-v-b commented Oct 8, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger thanks for the suggestion to use @overload to fix the type inference for AsyncArray.create. A few other changes were necessary, namely
defining minimal TypedDict instances for the dict form of ArrayV2Metadata and ArrayV3Metadata. With these dict classes we can declare that creating AsyncArray from dicts like {'zarr_format': 2, ...} will produce AsyncArray[ArrayV2Metadata]. These classes will be replaced by the result of #2099.

This is blocked on some very confusing mypy errors. If anyone has ideas I would love to hear them!

@TomAugspurger

TomAugspurger commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Looks like a mypy limitation at first glance:

With this

 resized = sync(self._async_array.resize(new_shape))
reveal_type(resized)

mypy gives

src/zarr/core/array.py:2475: note: Revealed type is "builtins.object"

pylance / pyright get this one correct: Type of "resized" is "AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata]".

python/mypy#15295 looks potentially related. I'd recommend casting the result of sync(self._async_array.resize(new_shape)) to AsyncArray to help mypy out.

@rabernat

rabernat commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Mypy errors notwithstanding, does this fix #2269?

@TomAugspurger

Copy link
Copy Markdown
Contributor

Not quite. One this branch that raises with

---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:142, in parse_shapelike(data)
141try:
--> 142 data_tuple = tuple(data)
143exceptTypeErroras e:
TypeError: 'RegularChunkGrid' object is not iterable
The above exception was the direct cause of the following exception:
TypeError Traceback (most recent call last)
Cell In[4], line 16
7 data = np.arange(0, 8, dtype="uint16")
8 a = Array.create(
9 store /"simple_v2",
10 zarr_format=2,
(...)
14 fill_value=0,
15 )
---> 16 a.attrs.put({"key": 0})
File ~/gh/zarr-developers/zarr-python/src/zarr/core/attributes.py:53, in Attributes.put(self, d)
39defput(self, d: dict[str, JSON]) -> None:
40"""41 Overwrite all attributes with the values from`d`.
42
(...)
51 {'a': 3, 'c': 4}
52"""
---> 53 self._obj = self._obj.update_attributes(d)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:2478, in Array.update_attributes(self, new_attributes)
2477 def update_attributes(self, new_attributes: dict[str, JSON]) -> Array:
-> 2478 return type(self)(sync(self._async_array.update_attributes(new_attributes)))
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:91, in sync(coro, loop, timeout)
88 return_result =next(iter(finished)).result()
90ifisinstance(return_result, BaseException):
---> 91 raise return_result
92else:
93return return_result
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:50, in _runner(coro)
45"""46 Await a coroutine andreturn the result of running it. If awaiting the coroutine raises an
47 exception, the exception will be returned.
48"""49try:
---> 50 return await coro
51exceptExceptionas ex:
52return ex
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:913, in AsyncArray.update_attributes(self, new_attributes)
912asyncdefupdate_attributes(self, new_attributes: dict[str, JSON]) -> Self:
--> 913 new_metadata = self.metadata.update_attributes(new_attributes)
915# Write new metadata916awaitself._save_metadata(new_metadata)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:208, in ArrayV2Metadata.update_attributes(self, attributes)
207defupdate_attributes(self, attributes: dict[str, JSON]) -> Self:
--> 208 return replace(self, attributes=attributes)
File ~/mambaforge/envs/python=3.12/lib/python3.12/dataclasses.py:1588, in replace(obj, **changes)
1581 changes[f.name] = getattr(obj, f.name)
1583 # Create the new object, which calls __init__() and
1584 # __post_init__() (if defined), using all of the init fields we've
1585 # added and/or left in 'changes'. If there are values supplied in
1586 # changes that aren't fields, this will correctly raise a
1587 # TypeError.
-> 1588 return obj.__class__(**changes)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:72, in ArrayV2Metadata.__init__(self, shape, dtype, chunks, fill_value, order, dimension_separator, compressor, filters, attributes)
70 shape_parsed = parse_shapelike(shape)
71 dtype_parsed = parse_dtype(dtype)
---> 72 chunks_parsed = parse_shapelike(chunks)
73 compressor_parsed = parse_compressor(compressor)
74 order_parsed = parse_indexing_order(order)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:145, in parse_shapelike(data)
143exceptTypeErroras e:
144 msg =f"Expected an integer or an iterable of integers. Got {data} instead."
--> 145 raise TypeError(msg) from e
147ifnotall(isinstance(v, int) for v in data_tuple):
148 msg =f"Expected an iterable of integers. Got {data} instead."TypeError: Expected an integer or an iterable of integers. Got RegularChunkGrid(chunk_shape=(4,)) instead.

We'll need to update the various parse_* functions to gracefully handle the already parsed versions.

@d-v-b this is one of the reasons in our serialization discussion (#2144) I mentioned preferring plain __init__ methods that simply set the attributes (plus a tiny bit of validation that type checkers can't catch, like ensuring that the length of chunks and shape match). Because __init__ always runs anytime you create an instance I like to keep them simple, and "complicated" stuff like parsing tuples of ints into their concrete form can be done in alternative constructors / on the boundary. But it does open up the risk of people using the class constructor incorrectly and later hitting an error / incorrect behavior (maybe, if they have code that relies on that, but that's just what you get with duck typing).

@d-v-b

d-v-b commented Oct 9, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger that's a good point! I think there's a significant difference between "parse the inputs to ensure validity" and "parse the inputs to ensure validity, and return a new data type that's not part of the metadata model". By turning the chunks attribute from a JSON array of numbers to a RegularChunkGrid object we were doing the second one, when I think we should always do the first one.

So I altered ArrayV2Metadata such that ArrayV2Metadata.chunks is a plain tuple of ints, and ArrayV2Metadata.chunk_grid is a cached property that returns a RegularChunkGrid. I also added some casts to deal with the type inference issues. I checked the test case from #2269 and it passed with these changes.

@TomAugspurgerTomAugspurger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, I think this looks good overall.

Comment threadsrc/zarr/api/asynchronous.py
Comment threadsrc/zarr/core/metadata/__init__.py
metadata = ArrayV3Metadata.from_dict(_metadata)
else:
metadata = ArrayV3Metadata.from_dict(metadata)
raise ValueError(f"Invalid zarr_format: {zarr_format}. Expected 2 or 3")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#2310 (not yet merged) is adding some special exceptions for parsing invalid metadata, which we could use here if we want.

Comment threadsrc/zarr/core/array.py
@TomAugspurgerTomAugspurger added the downstream Downstream libraries using zarr label Oct 9, 2024

@jhammanjhamman left a comment

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.

I'm not 1000% sold on some of the typing changes (particularly the AsyncArray generic) but this is a solid improvement so my questions are non-blocking.

storage_options: dict[str, Any] | None = None,
**kwargs: Any, # TODO: type kwargs as valid args to open_array
) -> AsyncArray | AsyncGroup:
) -> AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata] | AsyncGroup:

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.

This question applies here and everywhere else you use the v2/v3 Metadata types.

Since the generic only includes two variants (ArrayV2Metadata and ArrayV3Metadata), what value do we get by using both together here (as apposed to simply using the AsyncArray type?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that is purely to silence mypy complaining about a generic missing its type parameter. If there's a cleaner way to annotate it then I'll do it. Would AsyncArray[Any] work there? (I can play around with this locally)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

playing around with AsyncArray[Any] wasn't satisfactory. I think the only shortcut we can use here is assigning a type alias to the union AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata], and I'm not sure that's worth it.

Comment threadsrc/zarr/core/array.py Outdated
Comment on lines +169 to +173
TArrayMeta = TypeVar("TArrayMeta", ArrayV2Metadata, ArrayV3Metadata)


@dataclass(frozen=True)
class AsyncArray:
metadata: ArrayMetadata
class AsyncArray(Generic[TArrayMeta]):

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.

Does this foreclose the opportunity to use other generics here (namely over dtype)?

xref: #2137 (comment)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that's a good question, I have no idea! It seems like we would want to make the metadata class generic w.r.t dtype, but then have the array class be generic over dtype too... not sure how to solve that

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought a bit about this, and I don't see why we couldn't make AsyncArray inherit from Generic[TArrayMeta, TDtype].

Comment threadsrc/zarr/core/array.py Outdated
Comment threadsrc/zarr/core/array.py Outdated
…/zarr-python into refactor/rename-v2-metadata-fields
@jhammanjhamman added this to the 3.0.0.beta milestone Oct 9, 2024
@jhammanjhamman added the V3 label Oct 9, 2024
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

downstreamDownstream libraries using zarr

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

@d-v-b@TomAugspurger@rabernat@jhamman
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Refactor/rename v2 metadata fields by d-v-b · Pull Request #2301 · zarr-developers/zarr-python · GitHub
Skip to content

Refactor/rename v2 metadata fields - #2301

Merged
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields
Oct 10, 2024
Merged

Refactor/rename v2 metadata fields#2301
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields

Conversation

@d-v-b

@d-v-bd-v-b commented Oct 4, 2024

Copy link
Copy Markdown
Contributor

The first change is to alters the structure of ArrayV2Metadata to ensure that it matches the zarr v2 metadata document. This is simple:

  • I changed the data_type attribute to dtype
  • I changed the chunk_grid attribute to chunks

These changes make it difficult for ArrayV2Metadata to inherit from ArrayMetadata, because that class defines dtype as a property, which I learned cannot be overridden by an attribute. This observation made me rather skeptical of the utility of the ArrayMetadata class. See #2300 for more on that. So this PR removes the inheritance relationship between ArrayV2Metadata / ArrayV3Metadata and ArrayMetadata.

I could then replace the annotation of the metadata attribute of the AsyncArray class with ArrayV2Metadata | ArrayV3Metadata, but this type annotation is not quite accurate, because we know that some operations only result in an AsyncArray with ArrayV3Metadata, and others only make an AsyncArray with ArrayV2Metadata (namely, _create_v3 and _create_v2). So I attempted to solve this by making AsyncArray generic with a TypeVar with 2 bounds, ArrayV2Metadata and ArrayV3Metadata. This lets us express the invariances I mentioned above. Mypy was unhappy in various places and I plugged those holes with type: ignore statements. If there's a cleaner solution I would appreciate it.

This will remain a draft until we answer a few questions:

  • should we remove ArrayMetadata entirely?
  • can we make mypy happy without the type: ignores? maybe I'm doing something wrong with type annotations.
  • should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

TODO:

  • Add unit tests and/or doctests in docstrings
  • Add docstrings and API docs for any new/modified user-facing classes and functions
  • New/modified features documented in docs/tutorial.rst
  • Changes documented in docs/release.rst
  • GitHub Actions have all passed
  • Test coverage is 100% (Codecov passes)

Comment threadsrc/zarr/core/array.py Outdated
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
return array
# type inference is inconsistent here and seems to conclude

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I thought the issue might be from AsyncArray.metadata being set to metadata_parsed = parse_array_metadata(metadata), which doesn't know that parse_array_metadata(ArrayV2Metadata) should be ArrayV2Metadata. I thought that adding an overload would fix that:

@overloaddefparse_array_metadata(data: ArrayV2Metadata) ->ArrayV2Metadata: ...
@overloaddefparse_array_metadata(data: ArrayV3Metadata) ->ArrayV3Metadata: ...
@overloaddefparse_array_metadata(data: dict[str, JSON]) ->ArrayV2Metadata|ArrayV3Metadata: ...
defparse_array_metadata(
data: ArrayV2Metadata|ArrayV3Metadata|dict[str, JSON],
) ->ArrayV2Metadata|ArrayV3Metadata:

But no luck. I'll take a longer look later.

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's a simple example that works:

Details
from __future__ importannotationsimportdataclassesfromtypingimportGeneric, Literal, TypeVar, cast, overload, reveal_typeclassA: ...
classB: ...
T=TypeVar("T", A, B)
@dataclasses.dataclass(frozen=True)classArray(Generic[T]):
version: Tdef__init__(self, version: A|B) ->None:
object.__setattr__(self, "version", version)
@overload@classmethoddefcreate(cls, version: Literal[2]) ->Array[A]: ...
@overload@classmethoddefcreate(cls, version: Literal[3]) ->Array[B]: ...
@overload@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]: ...
@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]:
ifversion==2:
returncls.create_2()
elifversion==3:
returncls.create_3()
else:
raise@classmethoddefcreate_2(cls) ->Array[A]:
returnArray(version=A())
@classmethoddefcreate_3(cls) ->Array[B]:
returnArray(version=B())
reveal_type(Array(A()))
reveal_type(Array(B()))
reveal_type(Array.create_2())
reveal_type(Array.create_3())
reveal_type(Array.create(version=2))
reveal_type(Array.create(version=3))

That reveals the expected types everywhere:

❯ mypy check.py
check.py:49: note: Revealed type is "check.Array[check.A]"
check.py:50: note: Revealed type is "check.Array[check.A]"
check.py:52: note: Revealed type is "check.Array[check.A]"
check.py:53: note: Revealed type is "check.Array[check.B]"
check.py:55: note: Revealed type is "check.Array[check.A]"
check.py:56: note: Revealed type is "check.Array[check.B]"
Success: no issues found in 1 source file

I've tried making similar changes on your branch, but haven't gotten it working yet. I think the things that might matter are:

  1. We might need overloads for AsyncArray.create to ensure that AsyncArray.create(..., zarr_format=3) returns an AsyncArray[v3]. likewise for 2
  2. AsyncArray.__init__ also accepts a dict for metadata. The actual value set is the output of parse_arary_metadata. We need to ensure that the specialized type isn't lost (which the overload should do, but maybe I messed that up).

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure what I messed up earlier, but these overloads on create seemed to do the trick (the only differences in the signature are zarr_format being Literal[2] or Literal[3] rather than Literal[2, 3], and the return type being specialized to v2 or v3):

diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py
index 96431dec..5699935f 100644
--- a/src/zarr/core/array.py+++ b/src/zarr/core/array.py@@ -4,7 +4,7 @@ import json
from asyncio import gather
from dataclasses import dataclass, field, replace
from logging import getLogger
-from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast+from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import numpy as np
import numpy.typing as npt
@@ -191,6 +191,72 @@ class AsyncArray(Generic[TArrayMeta]):
object.__setattr__(self, "order", order_parsed)
object.__setattr__(self, "codec_pipeline", create_codec_pipeline(metadata=metadata_parsed))
+ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[2],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV2Metadata]:...++ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[3],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV3Metadata]:...+
@classmethod
async def create(
cls,
@@ -349,7 +415,7 @@ class AsyncArray(Generic[TArrayMeta]):
await array._save_metadata(metadata, ensure_parents=True)
# type inference is inconsistent here and seems to conclude
# that array has type Array[ArrayV2Metadata]
- return array # type: ignore[return-value]+ return array
@classmethod
async def _create_v2(
@@ -388,7 +454,7 @@ class AsyncArray(Generic[TArrayMeta]):
)
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
- return array # type: ignore[return-value]+ return array
@classmethod
def from_dict(

Checking that here:

file: check2.pyfromtypingimportreveal_typeimportnumpyasnpfromzarr.core.arrayimportAsyncArrayfromzarr.storage.commonimportStorePathfromzarr.storage.memoryimportMemoryStoreasyncdefmain() ->None:
store=StorePath(MemoryStore(), "/")
r2=awaitAsyncArray._create_v2(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,))
reveal_type(r2) # v2r3=awaitAsyncArray._create_v3(store, shape=(0,), dtype=np.dtype("float64"), chunk_shape=(0,))
reveal_type(r3) # v3rr2=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=2)
reveal_type(rr2) # v2rr3=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=3)
reveal_type(rr3) # v3

gives

❯ mypy check2.py
check2.py:13: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:16: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
check2.py:19: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:22: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
Success: no issues found in 1 source file

@TomAugspurger

Copy link
Copy Markdown
Contributor

should we remove ArrayMetadata entirely?

That, or define ArrayMetadata = ArrayV2Metadata | ArrayV3Metadata for the few places where we write out that union.

should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

Probably. I think being able to know that a Group[V3].getitem returns Group[V3] | Array[V3] and not Group[T] | Array[T] is useful.

@d-v-b
d-v-b marked this pull request as ready for review October 8, 2024 17:09
@d-v-b

d-v-b commented Oct 8, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger thanks for the suggestion to use @overload to fix the type inference for AsyncArray.create. A few other changes were necessary, namely
defining minimal TypedDict instances for the dict form of ArrayV2Metadata and ArrayV3Metadata. With these dict classes we can declare that creating AsyncArray from dicts like {'zarr_format': 2, ...} will produce AsyncArray[ArrayV2Metadata]. These classes will be replaced by the result of #2099.

This is blocked on some very confusing mypy errors. If anyone has ideas I would love to hear them!

@TomAugspurger

TomAugspurger commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Looks like a mypy limitation at first glance:

With this

 resized = sync(self._async_array.resize(new_shape))
reveal_type(resized)

mypy gives

src/zarr/core/array.py:2475: note: Revealed type is "builtins.object"

pylance / pyright get this one correct: Type of "resized" is "AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata]".

python/mypy#15295 looks potentially related. I'd recommend casting the result of sync(self._async_array.resize(new_shape)) to AsyncArray to help mypy out.

@rabernat

rabernat commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Mypy errors notwithstanding, does this fix #2269?

@TomAugspurger

Copy link
Copy Markdown
Contributor

Not quite. One this branch that raises with

---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:142, in parse_shapelike(data)
141try:
--> 142 data_tuple = tuple(data)
143exceptTypeErroras e:
TypeError: 'RegularChunkGrid' object is not iterable
The above exception was the direct cause of the following exception:
TypeError Traceback (most recent call last)
Cell In[4], line 16
7 data = np.arange(0, 8, dtype="uint16")
8 a = Array.create(
9 store /"simple_v2",
10 zarr_format=2,
(...)
14 fill_value=0,
15 )
---> 16 a.attrs.put({"key": 0})
File ~/gh/zarr-developers/zarr-python/src/zarr/core/attributes.py:53, in Attributes.put(self, d)
39defput(self, d: dict[str, JSON]) -> None:
40"""41 Overwrite all attributes with the values from`d`.
42
(...)
51 {'a': 3, 'c': 4}
52"""
---> 53 self._obj = self._obj.update_attributes(d)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:2478, in Array.update_attributes(self, new_attributes)
2477 def update_attributes(self, new_attributes: dict[str, JSON]) -> Array:
-> 2478 return type(self)(sync(self._async_array.update_attributes(new_attributes)))
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:91, in sync(coro, loop, timeout)
88 return_result =next(iter(finished)).result()
90ifisinstance(return_result, BaseException):
---> 91 raise return_result
92else:
93return return_result
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:50, in _runner(coro)
45"""46 Await a coroutine andreturn the result of running it. If awaiting the coroutine raises an
47 exception, the exception will be returned.
48"""49try:
---> 50 return await coro
51exceptExceptionas ex:
52return ex
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:913, in AsyncArray.update_attributes(self, new_attributes)
912asyncdefupdate_attributes(self, new_attributes: dict[str, JSON]) -> Self:
--> 913 new_metadata = self.metadata.update_attributes(new_attributes)
915# Write new metadata916awaitself._save_metadata(new_metadata)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:208, in ArrayV2Metadata.update_attributes(self, attributes)
207defupdate_attributes(self, attributes: dict[str, JSON]) -> Self:
--> 208 return replace(self, attributes=attributes)
File ~/mambaforge/envs/python=3.12/lib/python3.12/dataclasses.py:1588, in replace(obj, **changes)
1581 changes[f.name] = getattr(obj, f.name)
1583 # Create the new object, which calls __init__() and
1584 # __post_init__() (if defined), using all of the init fields we've
1585 # added and/or left in 'changes'. If there are values supplied in
1586 # changes that aren't fields, this will correctly raise a
1587 # TypeError.
-> 1588 return obj.__class__(**changes)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:72, in ArrayV2Metadata.__init__(self, shape, dtype, chunks, fill_value, order, dimension_separator, compressor, filters, attributes)
70 shape_parsed = parse_shapelike(shape)
71 dtype_parsed = parse_dtype(dtype)
---> 72 chunks_parsed = parse_shapelike(chunks)
73 compressor_parsed = parse_compressor(compressor)
74 order_parsed = parse_indexing_order(order)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:145, in parse_shapelike(data)
143exceptTypeErroras e:
144 msg =f"Expected an integer or an iterable of integers. Got {data} instead."
--> 145 raise TypeError(msg) from e
147ifnotall(isinstance(v, int) for v in data_tuple):
148 msg =f"Expected an iterable of integers. Got {data} instead."TypeError: Expected an integer or an iterable of integers. Got RegularChunkGrid(chunk_shape=(4,)) instead.

We'll need to update the various parse_* functions to gracefully handle the already parsed versions.

@d-v-b this is one of the reasons in our serialization discussion (#2144) I mentioned preferring plain __init__ methods that simply set the attributes (plus a tiny bit of validation that type checkers can't catch, like ensuring that the length of chunks and shape match). Because __init__ always runs anytime you create an instance I like to keep them simple, and "complicated" stuff like parsing tuples of ints into their concrete form can be done in alternative constructors / on the boundary. But it does open up the risk of people using the class constructor incorrectly and later hitting an error / incorrect behavior (maybe, if they have code that relies on that, but that's just what you get with duck typing).

@d-v-b

d-v-b commented Oct 9, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger that's a good point! I think there's a significant difference between "parse the inputs to ensure validity" and "parse the inputs to ensure validity, and return a new data type that's not part of the metadata model". By turning the chunks attribute from a JSON array of numbers to a RegularChunkGrid object we were doing the second one, when I think we should always do the first one.

So I altered ArrayV2Metadata such that ArrayV2Metadata.chunks is a plain tuple of ints, and ArrayV2Metadata.chunk_grid is a cached property that returns a RegularChunkGrid. I also added some casts to deal with the type inference issues. I checked the test case from #2269 and it passed with these changes.

@TomAugspurgerTomAugspurger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, I think this looks good overall.

Comment threadsrc/zarr/api/asynchronous.py
Comment threadsrc/zarr/core/metadata/__init__.py
metadata = ArrayV3Metadata.from_dict(_metadata)
else:
metadata = ArrayV3Metadata.from_dict(metadata)
raise ValueError(f"Invalid zarr_format: {zarr_format}. Expected 2 or 3")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#2310 (not yet merged) is adding some special exceptions for parsing invalid metadata, which we could use here if we want.

Comment threadsrc/zarr/core/array.py
@TomAugspurgerTomAugspurger added the downstream Downstream libraries using zarr label Oct 9, 2024

@jhammanjhamman left a comment

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.

I'm not 1000% sold on some of the typing changes (particularly the AsyncArray generic) but this is a solid improvement so my questions are non-blocking.

storage_options: dict[str, Any] | None = None,
**kwargs: Any, # TODO: type kwargs as valid args to open_array
) -> AsyncArray | AsyncGroup:
) -> AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata] | AsyncGroup:

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.

This question applies here and everywhere else you use the v2/v3 Metadata types.

Since the generic only includes two variants (ArrayV2Metadata and ArrayV3Metadata), what value do we get by using both together here (as apposed to simply using the AsyncArray type?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that is purely to silence mypy complaining about a generic missing its type parameter. If there's a cleaner way to annotate it then I'll do it. Would AsyncArray[Any] work there? (I can play around with this locally)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

playing around with AsyncArray[Any] wasn't satisfactory. I think the only shortcut we can use here is assigning a type alias to the union AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata], and I'm not sure that's worth it.

Comment threadsrc/zarr/core/array.py Outdated
Comment on lines +169 to +173
TArrayMeta = TypeVar("TArrayMeta", ArrayV2Metadata, ArrayV3Metadata)


@dataclass(frozen=True)
class AsyncArray:
metadata: ArrayMetadata
class AsyncArray(Generic[TArrayMeta]):

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.

Does this foreclose the opportunity to use other generics here (namely over dtype)?

xref: #2137 (comment)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that's a good question, I have no idea! It seems like we would want to make the metadata class generic w.r.t dtype, but then have the array class be generic over dtype too... not sure how to solve that

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought a bit about this, and I don't see why we couldn't make AsyncArray inherit from Generic[TArrayMeta, TDtype].

Comment threadsrc/zarr/core/array.py Outdated
Comment threadsrc/zarr/core/array.py Outdated
…/zarr-python into refactor/rename-v2-metadata-fields
@jhammanjhamman added this to the 3.0.0.beta milestone Oct 9, 2024
@jhammanjhamman added the V3 label Oct 9, 2024
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

downstreamDownstream libraries using zarr

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

@d-v-b@TomAugspurger@rabernat@jhamman
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Refactor/rename v2 metadata fields by d-v-b · Pull Request #2301 · zarr-developers/zarr-python · GitHub
Skip to content

Refactor/rename v2 metadata fields - #2301

Merged
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields
Oct 10, 2024
Merged

Refactor/rename v2 metadata fields#2301
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields

Conversation

@d-v-b

@d-v-bd-v-b commented Oct 4, 2024

Copy link
Copy Markdown
Contributor

The first change is to alters the structure of ArrayV2Metadata to ensure that it matches the zarr v2 metadata document. This is simple:

  • I changed the data_type attribute to dtype
  • I changed the chunk_grid attribute to chunks

These changes make it difficult for ArrayV2Metadata to inherit from ArrayMetadata, because that class defines dtype as a property, which I learned cannot be overridden by an attribute. This observation made me rather skeptical of the utility of the ArrayMetadata class. See #2300 for more on that. So this PR removes the inheritance relationship between ArrayV2Metadata / ArrayV3Metadata and ArrayMetadata.

I could then replace the annotation of the metadata attribute of the AsyncArray class with ArrayV2Metadata | ArrayV3Metadata, but this type annotation is not quite accurate, because we know that some operations only result in an AsyncArray with ArrayV3Metadata, and others only make an AsyncArray with ArrayV2Metadata (namely, _create_v3 and _create_v2). So I attempted to solve this by making AsyncArray generic with a TypeVar with 2 bounds, ArrayV2Metadata and ArrayV3Metadata. This lets us express the invariances I mentioned above. Mypy was unhappy in various places and I plugged those holes with type: ignore statements. If there's a cleaner solution I would appreciate it.

This will remain a draft until we answer a few questions:

  • should we remove ArrayMetadata entirely?
  • can we make mypy happy without the type: ignores? maybe I'm doing something wrong with type annotations.
  • should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

TODO:

  • Add unit tests and/or doctests in docstrings
  • Add docstrings and API docs for any new/modified user-facing classes and functions
  • New/modified features documented in docs/tutorial.rst
  • Changes documented in docs/release.rst
  • GitHub Actions have all passed
  • Test coverage is 100% (Codecov passes)

Comment threadsrc/zarr/core/array.py Outdated
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
return array
# type inference is inconsistent here and seems to conclude

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I thought the issue might be from AsyncArray.metadata being set to metadata_parsed = parse_array_metadata(metadata), which doesn't know that parse_array_metadata(ArrayV2Metadata) should be ArrayV2Metadata. I thought that adding an overload would fix that:

@overloaddefparse_array_metadata(data: ArrayV2Metadata) ->ArrayV2Metadata: ...
@overloaddefparse_array_metadata(data: ArrayV3Metadata) ->ArrayV3Metadata: ...
@overloaddefparse_array_metadata(data: dict[str, JSON]) ->ArrayV2Metadata|ArrayV3Metadata: ...
defparse_array_metadata(
data: ArrayV2Metadata|ArrayV3Metadata|dict[str, JSON],
) ->ArrayV2Metadata|ArrayV3Metadata:

But no luck. I'll take a longer look later.

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's a simple example that works:

Details
from __future__ importannotationsimportdataclassesfromtypingimportGeneric, Literal, TypeVar, cast, overload, reveal_typeclassA: ...
classB: ...
T=TypeVar("T", A, B)
@dataclasses.dataclass(frozen=True)classArray(Generic[T]):
version: Tdef__init__(self, version: A|B) ->None:
object.__setattr__(self, "version", version)
@overload@classmethoddefcreate(cls, version: Literal[2]) ->Array[A]: ...
@overload@classmethoddefcreate(cls, version: Literal[3]) ->Array[B]: ...
@overload@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]: ...
@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]:
ifversion==2:
returncls.create_2()
elifversion==3:
returncls.create_3()
else:
raise@classmethoddefcreate_2(cls) ->Array[A]:
returnArray(version=A())
@classmethoddefcreate_3(cls) ->Array[B]:
returnArray(version=B())
reveal_type(Array(A()))
reveal_type(Array(B()))
reveal_type(Array.create_2())
reveal_type(Array.create_3())
reveal_type(Array.create(version=2))
reveal_type(Array.create(version=3))

That reveals the expected types everywhere:

❯ mypy check.py
check.py:49: note: Revealed type is "check.Array[check.A]"
check.py:50: note: Revealed type is "check.Array[check.A]"
check.py:52: note: Revealed type is "check.Array[check.A]"
check.py:53: note: Revealed type is "check.Array[check.B]"
check.py:55: note: Revealed type is "check.Array[check.A]"
check.py:56: note: Revealed type is "check.Array[check.B]"
Success: no issues found in 1 source file

I've tried making similar changes on your branch, but haven't gotten it working yet. I think the things that might matter are:

  1. We might need overloads for AsyncArray.create to ensure that AsyncArray.create(..., zarr_format=3) returns an AsyncArray[v3]. likewise for 2
  2. AsyncArray.__init__ also accepts a dict for metadata. The actual value set is the output of parse_arary_metadata. We need to ensure that the specialized type isn't lost (which the overload should do, but maybe I messed that up).

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure what I messed up earlier, but these overloads on create seemed to do the trick (the only differences in the signature are zarr_format being Literal[2] or Literal[3] rather than Literal[2, 3], and the return type being specialized to v2 or v3):

diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py
index 96431dec..5699935f 100644
--- a/src/zarr/core/array.py+++ b/src/zarr/core/array.py@@ -4,7 +4,7 @@ import json
from asyncio import gather
from dataclasses import dataclass, field, replace
from logging import getLogger
-from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast+from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import numpy as np
import numpy.typing as npt
@@ -191,6 +191,72 @@ class AsyncArray(Generic[TArrayMeta]):
object.__setattr__(self, "order", order_parsed)
object.__setattr__(self, "codec_pipeline", create_codec_pipeline(metadata=metadata_parsed))
+ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[2],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV2Metadata]:...++ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[3],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV3Metadata]:...+
@classmethod
async def create(
cls,
@@ -349,7 +415,7 @@ class AsyncArray(Generic[TArrayMeta]):
await array._save_metadata(metadata, ensure_parents=True)
# type inference is inconsistent here and seems to conclude
# that array has type Array[ArrayV2Metadata]
- return array # type: ignore[return-value]+ return array
@classmethod
async def _create_v2(
@@ -388,7 +454,7 @@ class AsyncArray(Generic[TArrayMeta]):
)
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
- return array # type: ignore[return-value]+ return array
@classmethod
def from_dict(

Checking that here:

file: check2.pyfromtypingimportreveal_typeimportnumpyasnpfromzarr.core.arrayimportAsyncArrayfromzarr.storage.commonimportStorePathfromzarr.storage.memoryimportMemoryStoreasyncdefmain() ->None:
store=StorePath(MemoryStore(), "/")
r2=awaitAsyncArray._create_v2(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,))
reveal_type(r2) # v2r3=awaitAsyncArray._create_v3(store, shape=(0,), dtype=np.dtype("float64"), chunk_shape=(0,))
reveal_type(r3) # v3rr2=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=2)
reveal_type(rr2) # v2rr3=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=3)
reveal_type(rr3) # v3

gives

❯ mypy check2.py
check2.py:13: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:16: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
check2.py:19: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:22: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
Success: no issues found in 1 source file

@TomAugspurger

Copy link
Copy Markdown
Contributor

should we remove ArrayMetadata entirely?

That, or define ArrayMetadata = ArrayV2Metadata | ArrayV3Metadata for the few places where we write out that union.

should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

Probably. I think being able to know that a Group[V3].getitem returns Group[V3] | Array[V3] and not Group[T] | Array[T] is useful.

@d-v-b
d-v-b marked this pull request as ready for review October 8, 2024 17:09
@d-v-b

d-v-b commented Oct 8, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger thanks for the suggestion to use @overload to fix the type inference for AsyncArray.create. A few other changes were necessary, namely
defining minimal TypedDict instances for the dict form of ArrayV2Metadata and ArrayV3Metadata. With these dict classes we can declare that creating AsyncArray from dicts like {'zarr_format': 2, ...} will produce AsyncArray[ArrayV2Metadata]. These classes will be replaced by the result of #2099.

This is blocked on some very confusing mypy errors. If anyone has ideas I would love to hear them!

@TomAugspurger

TomAugspurger commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Looks like a mypy limitation at first glance:

With this

 resized = sync(self._async_array.resize(new_shape))
reveal_type(resized)

mypy gives

src/zarr/core/array.py:2475: note: Revealed type is "builtins.object"

pylance / pyright get this one correct: Type of "resized" is "AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata]".

python/mypy#15295 looks potentially related. I'd recommend casting the result of sync(self._async_array.resize(new_shape)) to AsyncArray to help mypy out.

@rabernat

rabernat commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Mypy errors notwithstanding, does this fix #2269?

@TomAugspurger

Copy link
Copy Markdown
Contributor

Not quite. One this branch that raises with

---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:142, in parse_shapelike(data)
141try:
--> 142 data_tuple = tuple(data)
143exceptTypeErroras e:
TypeError: 'RegularChunkGrid' object is not iterable
The above exception was the direct cause of the following exception:
TypeError Traceback (most recent call last)
Cell In[4], line 16
7 data = np.arange(0, 8, dtype="uint16")
8 a = Array.create(
9 store /"simple_v2",
10 zarr_format=2,
(...)
14 fill_value=0,
15 )
---> 16 a.attrs.put({"key": 0})
File ~/gh/zarr-developers/zarr-python/src/zarr/core/attributes.py:53, in Attributes.put(self, d)
39defput(self, d: dict[str, JSON]) -> None:
40"""41 Overwrite all attributes with the values from`d`.
42
(...)
51 {'a': 3, 'c': 4}
52"""
---> 53 self._obj = self._obj.update_attributes(d)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:2478, in Array.update_attributes(self, new_attributes)
2477 def update_attributes(self, new_attributes: dict[str, JSON]) -> Array:
-> 2478 return type(self)(sync(self._async_array.update_attributes(new_attributes)))
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:91, in sync(coro, loop, timeout)
88 return_result =next(iter(finished)).result()
90ifisinstance(return_result, BaseException):
---> 91 raise return_result
92else:
93return return_result
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:50, in _runner(coro)
45"""46 Await a coroutine andreturn the result of running it. If awaiting the coroutine raises an
47 exception, the exception will be returned.
48"""49try:
---> 50 return await coro
51exceptExceptionas ex:
52return ex
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:913, in AsyncArray.update_attributes(self, new_attributes)
912asyncdefupdate_attributes(self, new_attributes: dict[str, JSON]) -> Self:
--> 913 new_metadata = self.metadata.update_attributes(new_attributes)
915# Write new metadata916awaitself._save_metadata(new_metadata)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:208, in ArrayV2Metadata.update_attributes(self, attributes)
207defupdate_attributes(self, attributes: dict[str, JSON]) -> Self:
--> 208 return replace(self, attributes=attributes)
File ~/mambaforge/envs/python=3.12/lib/python3.12/dataclasses.py:1588, in replace(obj, **changes)
1581 changes[f.name] = getattr(obj, f.name)
1583 # Create the new object, which calls __init__() and
1584 # __post_init__() (if defined), using all of the init fields we've
1585 # added and/or left in 'changes'. If there are values supplied in
1586 # changes that aren't fields, this will correctly raise a
1587 # TypeError.
-> 1588 return obj.__class__(**changes)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:72, in ArrayV2Metadata.__init__(self, shape, dtype, chunks, fill_value, order, dimension_separator, compressor, filters, attributes)
70 shape_parsed = parse_shapelike(shape)
71 dtype_parsed = parse_dtype(dtype)
---> 72 chunks_parsed = parse_shapelike(chunks)
73 compressor_parsed = parse_compressor(compressor)
74 order_parsed = parse_indexing_order(order)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:145, in parse_shapelike(data)
143exceptTypeErroras e:
144 msg =f"Expected an integer or an iterable of integers. Got {data} instead."
--> 145 raise TypeError(msg) from e
147ifnotall(isinstance(v, int) for v in data_tuple):
148 msg =f"Expected an iterable of integers. Got {data} instead."TypeError: Expected an integer or an iterable of integers. Got RegularChunkGrid(chunk_shape=(4,)) instead.

We'll need to update the various parse_* functions to gracefully handle the already parsed versions.

@d-v-b this is one of the reasons in our serialization discussion (#2144) I mentioned preferring plain __init__ methods that simply set the attributes (plus a tiny bit of validation that type checkers can't catch, like ensuring that the length of chunks and shape match). Because __init__ always runs anytime you create an instance I like to keep them simple, and "complicated" stuff like parsing tuples of ints into their concrete form can be done in alternative constructors / on the boundary. But it does open up the risk of people using the class constructor incorrectly and later hitting an error / incorrect behavior (maybe, if they have code that relies on that, but that's just what you get with duck typing).

@d-v-b

d-v-b commented Oct 9, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger that's a good point! I think there's a significant difference between "parse the inputs to ensure validity" and "parse the inputs to ensure validity, and return a new data type that's not part of the metadata model". By turning the chunks attribute from a JSON array of numbers to a RegularChunkGrid object we were doing the second one, when I think we should always do the first one.

So I altered ArrayV2Metadata such that ArrayV2Metadata.chunks is a plain tuple of ints, and ArrayV2Metadata.chunk_grid is a cached property that returns a RegularChunkGrid. I also added some casts to deal with the type inference issues. I checked the test case from #2269 and it passed with these changes.

@TomAugspurgerTomAugspurger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, I think this looks good overall.

Comment threadsrc/zarr/api/asynchronous.py
Comment threadsrc/zarr/core/metadata/__init__.py
metadata = ArrayV3Metadata.from_dict(_metadata)
else:
metadata = ArrayV3Metadata.from_dict(metadata)
raise ValueError(f"Invalid zarr_format: {zarr_format}. Expected 2 or 3")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#2310 (not yet merged) is adding some special exceptions for parsing invalid metadata, which we could use here if we want.

Comment threadsrc/zarr/core/array.py
@TomAugspurgerTomAugspurger added the downstream Downstream libraries using zarr label Oct 9, 2024

@jhammanjhamman left a comment

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.

I'm not 1000% sold on some of the typing changes (particularly the AsyncArray generic) but this is a solid improvement so my questions are non-blocking.

storage_options: dict[str, Any] | None = None,
**kwargs: Any, # TODO: type kwargs as valid args to open_array
) -> AsyncArray | AsyncGroup:
) -> AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata] | AsyncGroup:

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.

This question applies here and everywhere else you use the v2/v3 Metadata types.

Since the generic only includes two variants (ArrayV2Metadata and ArrayV3Metadata), what value do we get by using both together here (as apposed to simply using the AsyncArray type?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that is purely to silence mypy complaining about a generic missing its type parameter. If there's a cleaner way to annotate it then I'll do it. Would AsyncArray[Any] work there? (I can play around with this locally)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

playing around with AsyncArray[Any] wasn't satisfactory. I think the only shortcut we can use here is assigning a type alias to the union AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata], and I'm not sure that's worth it.

Comment threadsrc/zarr/core/array.py Outdated
Comment on lines +169 to +173
TArrayMeta = TypeVar("TArrayMeta", ArrayV2Metadata, ArrayV3Metadata)


@dataclass(frozen=True)
class AsyncArray:
metadata: ArrayMetadata
class AsyncArray(Generic[TArrayMeta]):

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.

Does this foreclose the opportunity to use other generics here (namely over dtype)?

xref: #2137 (comment)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that's a good question, I have no idea! It seems like we would want to make the metadata class generic w.r.t dtype, but then have the array class be generic over dtype too... not sure how to solve that

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought a bit about this, and I don't see why we couldn't make AsyncArray inherit from Generic[TArrayMeta, TDtype].

Comment threadsrc/zarr/core/array.py Outdated
Comment threadsrc/zarr/core/array.py Outdated
…/zarr-python into refactor/rename-v2-metadata-fields
@jhammanjhamman added this to the 3.0.0.beta milestone Oct 9, 2024
@jhammanjhamman added the V3 label Oct 9, 2024
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

downstreamDownstream libraries using zarr

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

@d-v-b@TomAugspurger@rabernat@jhamman
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Refactor/rename v2 metadata fields by d-v-b · Pull Request #2301 · zarr-developers/zarr-python · GitHub
Skip to content

Refactor/rename v2 metadata fields - #2301

Merged
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields
Oct 10, 2024
Merged

Refactor/rename v2 metadata fields#2301
jhamman merged 15 commits into
zarr-developers:v3from
d-v-b:refactor/rename-v2-metadata-fields

Conversation

@d-v-b

@d-v-bd-v-b commented Oct 4, 2024

Copy link
Copy Markdown
Contributor

The first change is to alters the structure of ArrayV2Metadata to ensure that it matches the zarr v2 metadata document. This is simple:

  • I changed the data_type attribute to dtype
  • I changed the chunk_grid attribute to chunks

These changes make it difficult for ArrayV2Metadata to inherit from ArrayMetadata, because that class defines dtype as a property, which I learned cannot be overridden by an attribute. This observation made me rather skeptical of the utility of the ArrayMetadata class. See #2300 for more on that. So this PR removes the inheritance relationship between ArrayV2Metadata / ArrayV3Metadata and ArrayMetadata.

I could then replace the annotation of the metadata attribute of the AsyncArray class with ArrayV2Metadata | ArrayV3Metadata, but this type annotation is not quite accurate, because we know that some operations only result in an AsyncArray with ArrayV3Metadata, and others only make an AsyncArray with ArrayV2Metadata (namely, _create_v3 and _create_v2). So I attempted to solve this by making AsyncArray generic with a TypeVar with 2 bounds, ArrayV2Metadata and ArrayV3Metadata. This lets us express the invariances I mentioned above. Mypy was unhappy in various places and I plugged those holes with type: ignore statements. If there's a cleaner solution I would appreciate it.

This will remain a draft until we answer a few questions:

  • should we remove ArrayMetadata entirely?
  • can we make mypy happy without the type: ignores? maybe I'm doing something wrong with type annotations.
  • should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

TODO:

  • Add unit tests and/or doctests in docstrings
  • Add docstrings and API docs for any new/modified user-facing classes and functions
  • New/modified features documented in docs/tutorial.rst
  • Changes documented in docs/release.rst
  • GitHub Actions have all passed
  • Test coverage is 100% (Codecov passes)

Comment threadsrc/zarr/core/array.py Outdated
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
return array
# type inference is inconsistent here and seems to conclude

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I thought the issue might be from AsyncArray.metadata being set to metadata_parsed = parse_array_metadata(metadata), which doesn't know that parse_array_metadata(ArrayV2Metadata) should be ArrayV2Metadata. I thought that adding an overload would fix that:

@overloaddefparse_array_metadata(data: ArrayV2Metadata) ->ArrayV2Metadata: ...
@overloaddefparse_array_metadata(data: ArrayV3Metadata) ->ArrayV3Metadata: ...
@overloaddefparse_array_metadata(data: dict[str, JSON]) ->ArrayV2Metadata|ArrayV3Metadata: ...
defparse_array_metadata(
data: ArrayV2Metadata|ArrayV3Metadata|dict[str, JSON],
) ->ArrayV2Metadata|ArrayV3Metadata:

But no luck. I'll take a longer look later.

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Here's a simple example that works:

Details
from __future__ importannotationsimportdataclassesfromtypingimportGeneric, Literal, TypeVar, cast, overload, reveal_typeclassA: ...
classB: ...
T=TypeVar("T", A, B)
@dataclasses.dataclass(frozen=True)classArray(Generic[T]):
version: Tdef__init__(self, version: A|B) ->None:
object.__setattr__(self, "version", version)
@overload@classmethoddefcreate(cls, version: Literal[2]) ->Array[A]: ...
@overload@classmethoddefcreate(cls, version: Literal[3]) ->Array[B]: ...
@overload@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]: ...
@classmethoddefcreate(cls, version: Literal[2, 3]) ->Array[A] |Array[B]:
ifversion==2:
returncls.create_2()
elifversion==3:
returncls.create_3()
else:
raise@classmethoddefcreate_2(cls) ->Array[A]:
returnArray(version=A())
@classmethoddefcreate_3(cls) ->Array[B]:
returnArray(version=B())
reveal_type(Array(A()))
reveal_type(Array(B()))
reveal_type(Array.create_2())
reveal_type(Array.create_3())
reveal_type(Array.create(version=2))
reveal_type(Array.create(version=3))

That reveals the expected types everywhere:

❯ mypy check.py
check.py:49: note: Revealed type is "check.Array[check.A]"
check.py:50: note: Revealed type is "check.Array[check.A]"
check.py:52: note: Revealed type is "check.Array[check.A]"
check.py:53: note: Revealed type is "check.Array[check.B]"
check.py:55: note: Revealed type is "check.Array[check.A]"
check.py:56: note: Revealed type is "check.Array[check.B]"
Success: no issues found in 1 source file

I've tried making similar changes on your branch, but haven't gotten it working yet. I think the things that might matter are:

  1. We might need overloads for AsyncArray.create to ensure that AsyncArray.create(..., zarr_format=3) returns an AsyncArray[v3]. likewise for 2
  2. AsyncArray.__init__ also accepts a dict for metadata. The actual value set is the output of parse_arary_metadata. We need to ensure that the specialized type isn't lost (which the overload should do, but maybe I messed that up).

@TomAugspurgerTomAugspurgerOct 4, 2024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'm not sure what I messed up earlier, but these overloads on create seemed to do the trick (the only differences in the signature are zarr_format being Literal[2] or Literal[3] rather than Literal[2, 3], and the return type being specialized to v2 or v3):

diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py
index 96431dec..5699935f 100644
--- a/src/zarr/core/array.py+++ b/src/zarr/core/array.py@@ -4,7 +4,7 @@ import json
from asyncio import gather
from dataclasses import dataclass, field, replace
from logging import getLogger
-from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast+from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload
import numpy as np
import numpy.typing as npt
@@ -191,6 +191,72 @@ class AsyncArray(Generic[TArrayMeta]):
object.__setattr__(self, "order", order_parsed)
object.__setattr__(self, "codec_pipeline", create_codec_pipeline(metadata=metadata_parsed))
+ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[2],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV2Metadata]:...++ @overload+ @classmethod+ async def create(+ cls,+ store: StoreLike,+ *,+ # v2 and v3+ shape: ShapeLike,+ dtype: npt.DTypeLike,+ zarr_format: Literal[3],+ fill_value: Any | None = None,+ attributes: dict[str, JSON] | None = None,+ # v3 only+ chunk_shape: ChunkCoords | None = None,+ chunk_key_encoding: (+ ChunkKeyEncoding+ | tuple[Literal["default"], Literal[".", "/"]]+ | tuple[Literal["v2"], Literal[".", "/"]]+ | None+ ) = None,+ codecs: Iterable[Codec | dict[str, JSON]] | None = None,+ dimension_names: Iterable[str] | None = None,+ # v2 only+ chunks: ShapeLike | None = None,+ dimension_separator: Literal[".", "/"] | None = None,+ order: Literal["C", "F"] | None = None,+ filters: list[dict[str, JSON]] | None = None,+ compressor: dict[str, JSON] | None = None,+ # runtime+ exists_ok: bool = False,+ data: npt.ArrayLike | None = None,+ ) -> AsyncArray[ArrayV3Metadata]:...+
@classmethod
async def create(
cls,
@@ -349,7 +415,7 @@ class AsyncArray(Generic[TArrayMeta]):
await array._save_metadata(metadata, ensure_parents=True)
# type inference is inconsistent here and seems to conclude
# that array has type Array[ArrayV2Metadata]
- return array # type: ignore[return-value]+ return array
@classmethod
async def _create_v2(
@@ -388,7 +454,7 @@ class AsyncArray(Generic[TArrayMeta]):
)
array = cls(metadata=metadata, store_path=store_path)
await array._save_metadata(metadata, ensure_parents=True)
- return array # type: ignore[return-value]+ return array
@classmethod
def from_dict(

Checking that here:

file: check2.pyfromtypingimportreveal_typeimportnumpyasnpfromzarr.core.arrayimportAsyncArrayfromzarr.storage.commonimportStorePathfromzarr.storage.memoryimportMemoryStoreasyncdefmain() ->None:
store=StorePath(MemoryStore(), "/")
r2=awaitAsyncArray._create_v2(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,))
reveal_type(r2) # v2r3=awaitAsyncArray._create_v3(store, shape=(0,), dtype=np.dtype("float64"), chunk_shape=(0,))
reveal_type(r3) # v3rr2=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=2)
reveal_type(rr2) # v2rr3=awaitAsyncArray.create(store, shape=(0,), dtype=np.dtype("float64"), chunks=(0,), zarr_format=3)
reveal_type(rr3) # v3

gives

❯ mypy check2.py
check2.py:13: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:16: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
check2.py:19: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v2.ArrayV2Metadata]"
check2.py:22: note: Revealed type is "zarr.core.array.AsyncArray[zarr.core.metadata.v3.ArrayV3Metadata]"
Success: no issues found in 1 source file

@TomAugspurger

Copy link
Copy Markdown
Contributor

should we remove ArrayMetadata entirely?

That, or define ArrayMetadata = ArrayV2Metadata | ArrayV3Metadata for the few places where we write out that union.

should we apply the same generic approach to AsyncGroup? To me, it's useful to represent the fact that AsyncGroup[V3] can only ever provide access to AsyncArray[V3] | AsyncGroup[V3], and similarly for v2. Using generic types seems like the simplest path to this. But maybe nobody else finds this useful, or the noisy type annotations are not worth it.

Probably. I think being able to know that a Group[V3].getitem returns Group[V3] | Array[V3] and not Group[T] | Array[T] is useful.

@d-v-b
d-v-b marked this pull request as ready for review October 8, 2024 17:09
@d-v-b

d-v-b commented Oct 8, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger thanks for the suggestion to use @overload to fix the type inference for AsyncArray.create. A few other changes were necessary, namely
defining minimal TypedDict instances for the dict form of ArrayV2Metadata and ArrayV3Metadata. With these dict classes we can declare that creating AsyncArray from dicts like {'zarr_format': 2, ...} will produce AsyncArray[ArrayV2Metadata]. These classes will be replaced by the result of #2099.

This is blocked on some very confusing mypy errors. If anyone has ideas I would love to hear them!

@TomAugspurger

TomAugspurger commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Looks like a mypy limitation at first glance:

With this

 resized = sync(self._async_array.resize(new_shape))
reveal_type(resized)

mypy gives

src/zarr/core/array.py:2475: note: Revealed type is "builtins.object"

pylance / pyright get this one correct: Type of "resized" is "AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata]".

python/mypy#15295 looks potentially related. I'd recommend casting the result of sync(self._async_array.resize(new_shape)) to AsyncArray to help mypy out.

@rabernat

rabernat commented Oct 8, 2024

Copy link
Copy Markdown
Contributor

Mypy errors notwithstanding, does this fix #2269?

@TomAugspurger

Copy link
Copy Markdown
Contributor

Not quite. One this branch that raises with

---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:142, in parse_shapelike(data)
141try:
--> 142 data_tuple = tuple(data)
143exceptTypeErroras e:
TypeError: 'RegularChunkGrid' object is not iterable
The above exception was the direct cause of the following exception:
TypeError Traceback (most recent call last)
Cell In[4], line 16
7 data = np.arange(0, 8, dtype="uint16")
8 a = Array.create(
9 store /"simple_v2",
10 zarr_format=2,
(...)
14 fill_value=0,
15 )
---> 16 a.attrs.put({"key": 0})
File ~/gh/zarr-developers/zarr-python/src/zarr/core/attributes.py:53, in Attributes.put(self, d)
39defput(self, d: dict[str, JSON]) -> None:
40"""41 Overwrite all attributes with the values from`d`.
42
(...)
51 {'a': 3, 'c': 4}
52"""
---> 53 self._obj = self._obj.update_attributes(d)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:2478, in Array.update_attributes(self, new_attributes)
2477 def update_attributes(self, new_attributes: dict[str, JSON]) -> Array:
-> 2478 return type(self)(sync(self._async_array.update_attributes(new_attributes)))
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:91, in sync(coro, loop, timeout)
88 return_result =next(iter(finished)).result()
90ifisinstance(return_result, BaseException):
---> 91 raise return_result
92else:
93return return_result
File ~/gh/zarr-developers/zarr-python/src/zarr/core/sync.py:50, in _runner(coro)
45"""46 Await a coroutine andreturn the result of running it. If awaiting the coroutine raises an
47 exception, the exception will be returned.
48"""49try:
---> 50 return await coro
51exceptExceptionas ex:
52return ex
File ~/gh/zarr-developers/zarr-python/src/zarr/core/array.py:913, in AsyncArray.update_attributes(self, new_attributes)
912asyncdefupdate_attributes(self, new_attributes: dict[str, JSON]) -> Self:
--> 913 new_metadata = self.metadata.update_attributes(new_attributes)
915# Write new metadata916awaitself._save_metadata(new_metadata)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:208, in ArrayV2Metadata.update_attributes(self, attributes)
207defupdate_attributes(self, attributes: dict[str, JSON]) -> Self:
--> 208 return replace(self, attributes=attributes)
File ~/mambaforge/envs/python=3.12/lib/python3.12/dataclasses.py:1588, in replace(obj, **changes)
1581 changes[f.name] = getattr(obj, f.name)
1583 # Create the new object, which calls __init__() and
1584 # __post_init__() (if defined), using all of the init fields we've
1585 # added and/or left in 'changes'. If there are values supplied in
1586 # changes that aren't fields, this will correctly raise a
1587 # TypeError.
-> 1588 return obj.__class__(**changes)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/metadata/v2.py:72, in ArrayV2Metadata.__init__(self, shape, dtype, chunks, fill_value, order, dimension_separator, compressor, filters, attributes)
70 shape_parsed = parse_shapelike(shape)
71 dtype_parsed = parse_dtype(dtype)
---> 72 chunks_parsed = parse_shapelike(chunks)
73 compressor_parsed = parse_compressor(compressor)
74 order_parsed = parse_indexing_order(order)
File ~/gh/zarr-developers/zarr-python/src/zarr/core/common.py:145, in parse_shapelike(data)
143exceptTypeErroras e:
144 msg =f"Expected an integer or an iterable of integers. Got {data} instead."
--> 145 raise TypeError(msg) from e
147ifnotall(isinstance(v, int) for v in data_tuple):
148 msg =f"Expected an iterable of integers. Got {data} instead."TypeError: Expected an integer or an iterable of integers. Got RegularChunkGrid(chunk_shape=(4,)) instead.

We'll need to update the various parse_* functions to gracefully handle the already parsed versions.

@d-v-b this is one of the reasons in our serialization discussion (#2144) I mentioned preferring plain __init__ methods that simply set the attributes (plus a tiny bit of validation that type checkers can't catch, like ensuring that the length of chunks and shape match). Because __init__ always runs anytime you create an instance I like to keep them simple, and "complicated" stuff like parsing tuples of ints into their concrete form can be done in alternative constructors / on the boundary. But it does open up the risk of people using the class constructor incorrectly and later hitting an error / incorrect behavior (maybe, if they have code that relies on that, but that's just what you get with duck typing).

@d-v-b

d-v-b commented Oct 9, 2024

Copy link
Copy Markdown
ContributorAuthor

@TomAugspurger that's a good point! I think there's a significant difference between "parse the inputs to ensure validity" and "parse the inputs to ensure validity, and return a new data type that's not part of the metadata model". By turning the chunks attribute from a JSON array of numbers to a RegularChunkGrid object we were doing the second one, when I think we should always do the first one.

So I altered ArrayV2Metadata such that ArrayV2Metadata.chunks is a plain tuple of ints, and ArrayV2Metadata.chunk_grid is a cached property that returns a RegularChunkGrid. I also added some casts to deal with the type inference issues. I checked the test case from #2269 and it passed with these changes.

@TomAugspurgerTomAugspurger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, I think this looks good overall.

Comment threadsrc/zarr/api/asynchronous.py
Comment threadsrc/zarr/core/metadata/__init__.py
metadata = ArrayV3Metadata.from_dict(_metadata)
else:
metadata = ArrayV3Metadata.from_dict(metadata)
raise ValueError(f"Invalid zarr_format: {zarr_format}. Expected 2 or 3")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

#2310 (not yet merged) is adding some special exceptions for parsing invalid metadata, which we could use here if we want.

Comment threadsrc/zarr/core/array.py
@TomAugspurgerTomAugspurger added the downstream Downstream libraries using zarr label Oct 9, 2024

@jhammanjhamman left a comment

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.

I'm not 1000% sold on some of the typing changes (particularly the AsyncArray generic) but this is a solid improvement so my questions are non-blocking.

storage_options: dict[str, Any] | None = None,
**kwargs: Any, # TODO: type kwargs as valid args to open_array
) -> AsyncArray | AsyncGroup:
) -> AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata] | AsyncGroup:

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.

This question applies here and everywhere else you use the v2/v3 Metadata types.

Since the generic only includes two variants (ArrayV2Metadata and ArrayV3Metadata), what value do we get by using both together here (as apposed to simply using the AsyncArray type?

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that is purely to silence mypy complaining about a generic missing its type parameter. If there's a cleaner way to annotate it then I'll do it. Would AsyncArray[Any] work there? (I can play around with this locally)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

playing around with AsyncArray[Any] wasn't satisfactory. I think the only shortcut we can use here is assigning a type alias to the union AsyncArray[ArrayV2Metadata] | AsyncArray[ArrayV3Metadata], and I'm not sure that's worth it.

Comment threadsrc/zarr/core/array.py Outdated
Comment on lines +169 to +173
TArrayMeta = TypeVar("TArrayMeta", ArrayV2Metadata, ArrayV3Metadata)


@dataclass(frozen=True)
class AsyncArray:
metadata: ArrayMetadata
class AsyncArray(Generic[TArrayMeta]):

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.

Does this foreclose the opportunity to use other generics here (namely over dtype)?

xref: #2137 (comment)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

that's a good question, I have no idea! It seems like we would want to make the metadata class generic w.r.t dtype, but then have the array class be generic over dtype too... not sure how to solve that

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I thought a bit about this, and I don't see why we couldn't make AsyncArray inherit from Generic[TArrayMeta, TDtype].

Comment threadsrc/zarr/core/array.py Outdated
Comment threadsrc/zarr/core/array.py Outdated
…/zarr-python into refactor/rename-v2-metadata-fields
@jhammanjhamman added this to the 3.0.0.beta milestone Oct 9, 2024
@jhammanjhamman added the V3 label Oct 9, 2024
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

downstreamDownstream libraries using zarr

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants

@d-v-b@TomAugspurger@rabernat@jhamman