+
+- [:material-clock-fast:{ .lg .middle } __Quick start__](quick-start.md)
+
+ ---
+
+ New to Zarr? Check out the quick start guide. It contains a brief
+ introduction to Zarr's main concepts and links to additional tutorials.
+
+- [:material-book-open:{ .lg .middle } __User guide__](user-guide/index.md)
+
+ ---
+
+ A detailed guide for how to use Zarr-Python.
+
+- [:material-api:{ .lg .middle } __API Reference__](api/zarr/index.md)
+
+ ---
+
+ The reference guide contains a detailed description of the functions, modules,
+ and objects included in Zarr. The reference describes how the methods work and
+ which parameters can be used. It assumes that you have an understanding of the
+ key concepts.
+
+- [:material-package-variant:{ .lg .middle } __Related projects__](subprojects.md)
+
+ ---
+
+ Companion packages developed in the zarr-python repository and released
+ independently, such as `zarr-metadata` and `zarr-indexing`, plus pointers to
+ the wider Zarr ecosystem.
+
+- [:material-account-group:{ .lg .middle } __Contributor's Guide__](contributing.md)
+
+ ---
+
+ Want to contribute to Zarr? We welcome contributions in the form of bug reports,
+ bug fixes, documentation, enhancement proposals and more. The contributing guidelines
+ will guide you through the process of improving Zarr.
+
+
+
+## Project Status
+
+More information about the Zarr format can be found on the [main website](https://zarr.dev).
+
+If you are using Zarr-Python, we would [love to hear about it](https://github.com/zarr-developers/community/issues/19).
+
+### Funding and Support
+
+The project is fiscally sponsored by [NumFOCUS](https://numfocus.org/), a US
+501(c)(3) public charity, and development has been supported by the
+[MRC Centre for Genomics and Global Health](https://github.com/cggh/)
+and the [Chan Zuckerberg Initiative](https://chanzuckerberg.com/).
+
+[Donate to Zarr](https://numfocus.org/donate-to-zarr) to support the project!
diff --git a/docs/index.rst b/docs/index.rst
deleted file mode 100644
index 83d427e290..0000000000
--- a/docs/index.rst
+++ /dev/null
@@ -1,113 +0,0 @@
-.. _zarr_docs_mainpage:
-
-***********
-Zarr-Python
-***********
-
-.. toctree::
- :maxdepth: 1
- :hidden:
-
- quickstart
- user-guide/index
- API reference
- release-notes
- developers/index
- about
-
-**Version**: |version|
-
-**Useful links**:
-`Source Repository `_ |
-`Issue Tracker `_ |
-`Developer Chat `_ |
-`Zarr specifications `_
-
-Zarr-Python is a Python library for reading and writing Zarr groups and arrays. Highlights include:
-
-* Specification support for both Zarr format 2 and 3.
-* Create and read from N-dimensional arrays using NumPy-like semantics.
-* Flexible storage enables reading and writing from local, cloud and in-memory stores.
-* High performance: Enables fast I/O with support for asynchronous I/O and multi-threading.
-* Extensible: Customizable with user-defined codecs and stores.
-
-.. grid:: 2
-
- .. grid-item-card::
- :img-top: _static/index_getting_started.svg
-
- Quick Start
- ^^^^^^^^^^^
-
- New to Zarr? Check out the quick start guide. It contains a brief
- introduction to Zarr's main concepts and links to additional tutorials.
-
- +++
-
- .. button-ref:: quickstart
- :expand:
- :color: dark
- :click-parent:
-
- To the Quick Start
-
- .. grid-item-card::
- :img-top: _static/index_user_guide.svg
-
- Guide
- ^^^^^
-
- A detailed guide for how to use Zarr-Python.
-
- +++
-
- .. button-ref:: user-guide/index
- :expand:
- :color: dark
- :click-parent:
-
- To the user guide
-
- .. grid-item-card::
- :img-top: _static/index_api.svg
-
- API Reference
- ^^^^^^^^^^^^^
-
- The reference guide contains a detailed description of the functions,
- modules, and objects included in Zarr. The reference describes how the
- methods work and which parameters can be used. It assumes that you have an
- understanding of the key concepts.
-
- +++
-
- .. button-ref:: api/zarr/index
- :expand:
- :color: dark
- :click-parent:
-
- To the API reference
-
- .. grid-item-card::
- :img-top: _static/index_contribute.svg
-
- Contributor's Guide
- ^^^^^^^^^^^^^^^^^^^
-
- Want to contribute to Zarr? We welcome contributions in the form of bug reports,
- bug fixes, documentation, enhancement proposals and more. The contributing guidelines
- will guide you through the process of improving Zarr.
-
- +++
-
- .. button-ref:: developers/contributing
- :expand:
- :color: dark
- :click-parent:
-
- To the contributor's guide
-
-
-**Download documentation**: `PDF/Zipped HTML `_
-
-.. _NumCodecs: https://numcodecs.readthedocs.io
diff --git a/docs/overrides/main.html b/docs/overrides/main.html
new file mode 100644
index 0000000000..d61a1f54dc
--- /dev/null
+++ b/docs/overrides/main.html
@@ -0,0 +1,9 @@
+
+{% extends "base.html" %}
+
+{% block outdated %}
+ You're not viewing the latest version.
+
+ Click here to go to latest.
+
+{% endblock %}
diff --git a/docs/overrides/stylesheets/extra.css b/docs/overrides/stylesheets/extra.css
new file mode 100644
index 0000000000..6cb7c74e8d
--- /dev/null
+++ b/docs/overrides/stylesheets/extra.css
@@ -0,0 +1,209 @@
+:root {
+ --gradient-start: #e58077;
+ --gradient-mid-1: #e57a77;
+ --gradient-mid-2: #e46876;
+ --gradient-mid-3: #e34b75;
+ --gradient-mid-4: #e12374;
+ --gradient-mid-5: #e01073;
+ --gradient-end: #bb1085;
+
+ /* Primary theme colors
+ --md-primary-fg-color: #e34b75;
+ --md-primary-fg-color--light: #e57a77;
+ --md-primary-fg-color--dark: #bb1085;
+
+ /* Accent colors */
+ --md-accent-fg-color: #e01073;
+ --md-accent-fg-color--transparent: rgba(224, 16, 115, 0.1);
+
+ /* Text colors that work well with the palette */
+ --md-text-color: #333333;
+ --md-text-color--light: #666666;
+}
+
+/* Dark mode color adjustments */
+[data-md-color-scheme="slate"] {
+ --md-primary-fg-color: #e57a77;
+ --md-primary-fg-color--light: #e58077;
+ --md-primary-fg-color--dark: #bb1085;
+ --md-accent-fg-color: #e46876;
+ --md-accent-fg-color--transparent: rgba(228, 104, 118, 0.1);
+}
+
+/* Header styling with gradient background */
+.md-header {
+ background: linear-gradient(
+ 135deg,
+ var(--gradient-start) 0%,
+ var(--gradient-mid-1) 16.66%,
+ var(--gradient-mid-2) 33.33%,
+ var(--gradient-mid-3) 50%,
+ var(--gradient-mid-4) 66.66%,
+ var(--gradient-mid-5) 83.33%,
+ var(--gradient-end) 100%
+ );
+ box-shadow: 0 2px 8px rgba(187, 16, 133, 0.15);
+}
+
+/* Ensure header text is readable over gradient */
+.md-header__title,
+.md-header__button,
+.md-header .md-icon {
+ color: white;
+}
+
+/* Search box styling in the header */
+.md-header .md-search__input {
+ background-color: rgba(255, 255, 255, 0.15);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+}
+
+/* Navigation tabs */
+.md-tabs {
+ background: linear-gradient(
+ 90deg,
+ var(--gradient-mid-3) 0%,
+ var(--gradient-mid-4) 50%,
+ var(--gradient-mid-5) 100%
+ );
+}
+
+.md-tabs__link {
+ color: rgba(255, 255, 255, 0.9);
+}
+
+.md-tabs__link--active,
+.md-tabs__link:hover {
+ color: white;
+ opacity: 1;
+}
+
+/* Sidebar navigation */
+.md-nav__link--active {
+ color: var(--md-primary-fg-color);
+ font-weight: 500;
+}
+
+.md-nav__link:hover {
+ color: var(--md-accent-fg-color);
+}
+
+/* Code blocks */
+.highlight {
+ border-left: 4px solid var(--md-accent-fg-color);
+ background-color: rgba(228, 104, 118, 0.05);
+}
+
+/* Admonitions */
+.md-typeset .admonition.note {
+ border-color: var(--md-primary-fg-color);
+}
+
+.md-typeset .admonition.note > .admonition-title {
+ background-color: rgba(227, 75, 117, 0.1);
+ border-color: var(--md-primary-fg-color);
+}
+
+.md-typeset .admonition.tip {
+ border-color: var(--gradient-mid-1);
+}
+
+.md-typeset .admonition.tip > .admonition-title {
+ background-color: rgba(229, 122, 119, 0.1);
+ border-color: var(--gradient-mid-1);
+}
+
+.md-typeset .admonition.warning {
+ border-color: var(--gradient-end);
+}
+
+.md-typeset .admonition.warning > .admonition-title {
+ background-color: rgba(187, 16, 133, 0.1);
+ border-color: var(--gradient-end);
+}
+
+/* Links */
+.md-content a {
+ color: var(--md-accent-fg-color);
+}
+
+.md-content a:hover {
+ color: var(--gradient-end);
+}
+
+/* Table of contents */
+.md-nav--secondary .md-nav__link--active {
+ color: var(--md-accent-fg-color);
+ border-left: 2px solid var(--md-accent-fg-color);
+ padding-left: calc(1rem - 2px);
+}
+
+/* Footer */
+.md-footer {
+ background-color: var(--gradient-end);
+}
+
+/* Buttons and interactive elements */
+.md-button {
+ background: linear-gradient(135deg, var(--md-primary-fg-color), var(--md-accent-fg-color));
+ border: none;
+ color: white;
+ transition: all 0.3s ease;
+}
+
+.md-button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(187, 16, 133, 0.3);
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: rgba(187, 16, 133, 0.1);
+}
+
+::-webkit-scrollbar-thumb {
+ background: linear-gradient(
+ 180deg,
+ var(--md-primary-fg-color),
+ var(--md-accent-fg-color)
+ );
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: linear-gradient(
+ 180deg,
+ var(--md-accent-fg-color),
+ var(--gradient-end)
+ );
+}
+
+/* Search results highlighting */
+.md-search-result__title {
+ color: var(--md-primary-fg-color);
+}
+
+.md-search-result__teaser mark {
+ background-color: rgba(224, 16, 115, 0.2);
+ color: var(--gradient-end);
+}
+
+.md-header__button.md-logo img,
+.md-header__button.md-logo svg {
+ height: 42px !important; /* Increase from default ~24px */
+ width: auto !important;
+ max-height: none !important;
+ padding: 0 0 0 16px !important; /* Keep left padding, remove others */
+ margin: 0 !important; /* Remove any margin */
+}
+
+/* Also remove padding from the logo button container except left */
+.md-header__button.md-logo {
+ padding: 0 0 0 8px !important; /* Keep some left padding on container */
+ margin: 0 !important;
+ min-width: auto !important;
+}
diff --git a/docs/quick-start.md b/docs/quick-start.md
new file mode 100644
index 0000000000..123f05d5e9
--- /dev/null
+++ b/docs/quick-start.md
@@ -0,0 +1,191 @@
+# Quick start
+
+This page will help you get up and running with
+the Zarr library in Python to efficiently manage and analyze multi-dimensional arrays.
+Zarr must be installed first -- see the [installation guide](user-guide/installation.md)
+if you have not installed it yet.
+
+## Creating an Array
+
+To get started, you can create a simple Zarr array:
+
+```python exec="true" session="quickstart"
+import shutil
+shutil.rmtree('data', ignore_errors=True)
+import numpy as np
+from pprint import pprint
+import io
+import warnings
+
+warnings.filterwarnings(
+ "ignore",
+ message="Numcodecs codecs are not in the Zarr version 3 specification*",
+ category=UserWarning
+)
+np.random.seed(0)
+```
+
+```python exec="true" session="quickstart" source="above" result="ansi"
+import zarr
+import numpy as np
+
+# Create a 2D Zarr array
+z = zarr.create_array(
+ store="data/example-1.zarr",
+ shape=(100, 100),
+ chunks=(10, 10),
+ dtype="f4"
+)
+
+# Assign data to the array
+z[:, :] = np.random.random((100, 100))
+print(z.info)
+```
+
+Here, we created a 2D array of shape `(100, 100)`, chunked into blocks of
+`(10, 10)`, and filled it with random floating-point data. This array was
+written to a `LocalStore` in the `data/example-1.zarr` directory.
+
+### Compression and Filters
+
+Zarr supports data compression and filters. For example, to use Blosc compression:
+
+```python exec="true" session="quickstart" source="above" result="ansi"
+
+# Create a 2D Zarr array with Blosc compression
+z = zarr.create_array(
+ store="data/example-2.zarr",
+ shape=(100, 100),
+ chunks=(10, 10),
+ dtype="f4",
+ compressors=zarr.codecs.BloscCodec(
+ cname="zstd",
+ clevel=3,
+ shuffle="shuffle"
+ )
+)
+
+# Assign data to the array
+z[:, :] = np.random.random((100, 100))
+print(z.info)
+```
+
+This compresses the data using the Blosc codec with shuffle enabled for better compression.
+
+## Hierarchical Groups
+
+Zarr allows you to create hierarchical groups, similar to directories:
+
+```python exec="true" session="quickstart" source="above" result="ansi"
+
+# Create nested groups and add arrays
+root = zarr.group("data/example-3.zarr")
+foo = root.create_group(name="foo")
+bar = root.create_array(
+ name="bar", shape=(100, 10), chunks=(10, 10), dtype="f4"
+)
+spam = foo.create_array(name="spam", shape=(10,), dtype="i4")
+
+# Assign values
+bar[:, :] = np.random.random((100, 10))
+spam[:] = np.arange(10)
+
+# print the hierarchy
+print(root.tree())
+```
+
+This creates a group hierarchy with a group (`foo`) and two arrays (`bar` and `spam`).
+
+### Batch Hierarchy Creation
+
+Zarr provides tools for creating a collection of arrays and groups with a single function call.
+Suppose we want to copy existing groups and arrays into a new storage backend:
+
+```python exec="true" session="quickstart" source="above" result="code"
+
+# Create nested groups and add arrays
+root = zarr.group("data/example-4.zarr", attributes={'name': 'root'})
+foo = root.create_group(name="foo")
+bar = root.create_array(
+ name="bar", shape=(100, 10), chunks=(10, 10), dtype="f4"
+)
+nodes = {'': root.metadata} | {k: v.metadata for k,v in root.members()}
+# Report nodes
+output = io.StringIO()
+pprint(nodes, stream=output, width=60, depth=3)
+result = output.getvalue()
+print(result)
+# Create new hierarchy from nodes
+new_nodes = dict(zarr.create_hierarchy(store=zarr.storage.MemoryStore(), nodes=nodes))
+new_root = new_nodes['']
+assert new_root.attrs == root.attrs
+```
+
+Note that [`zarr.create_hierarchy`][] will only initialize arrays and groups -- copying array data must
+be done in a separate step.
+
+## Persistent Storage
+
+Zarr supports persistent storage to disk or cloud-compatible backends. While examples above
+utilized a [`zarr.storage.LocalStore`][], a number of other storage options are available.
+
+A single-file store can also be created using the [`zarr.storage.ZipStore`][]:
+
+```python exec="true" session="quickstart" source="above"
+
+# Store the array in a ZIP file
+store = zarr.storage.ZipStore("data/example-5.zip", mode="w")
+
+z = zarr.create_array(
+ store=store,
+ shape=(100, 100),
+ chunks=(10, 10),
+ dtype="f4"
+)
+
+# write to the array
+z[:, :] = np.random.random((100, 100))
+
+# the ZipStore must be explicitly closed
+store.close()
+```
+
+To open an existing array from a ZIP file:
+
+```python exec="true" session="quickstart" source="above" result="ansi"
+
+# Open the ZipStore in read-only mode
+store = zarr.storage.ZipStore("data/example-5.zip", read_only=True)
+
+z = zarr.open_array(store, mode='r')
+
+# read the data as a NumPy Array
+print(z[:])
+```
+
+Zarr also integrates seamlessly with cloud object storage such as Amazon S3 and Google
+Cloud Storage using external libraries like [s3fs](https://s3fs.readthedocs.io/en/latest/) or
+[gcsfs](https://gcsfs.readthedocs.io/en/latest/). Remote storage support requires the `remote`
+optional dependencies (`pip install "zarr[remote]"`) as well as a filesystem library
+for your storage service, such as `s3fs` for S3:
+
+```python test="true" session="s3demo" markers="s3" source="above"
+import zarr
+import numpy as np
+
+z = zarr.create_array(
+ "s3://example-bucket/foo", shape=(100, 100), chunks=(10, 10), dtype="f4"
+)
+z[:, :] = np.random.random((100, 100))
+```
+
+See the [Remote Store](user-guide/storage.md#remote-store) section of the storage guide
+for more detail, including how to configure the underlying filesystem with `storage_options`.
+
+## Next steps
+
+This page only scratches the surface. Continue with the [User Guide](user-guide/index.md), in particular:
+
+- **[Arrays](user-guide/arrays.md)** - creating, reading, and writing arrays in depth
+- **[Groups](user-guide/groups.md)** - organizing arrays into hierarchies
+- **[Storage](user-guide/storage.md)** - the full range of local, remote, and in-memory storage options
diff --git a/docs/quickstart.rst b/docs/quickstart.rst
deleted file mode 100644
index 66bdae2a2e..0000000000
--- a/docs/quickstart.rst
+++ /dev/null
@@ -1,209 +0,0 @@
-.. only:: doctest
-
- >>> import shutil
- >>> shutil.rmtree('data', ignore_errors=True)
- >>>
- >>> import numpy as np
- >>> np.random.seed(0)
-
-Quickstart
-==========
-
-Welcome to the Zarr-Python Quickstart guide! This page will help you get up and running with
-the Zarr library in Python to efficiently manage and analyze multi-dimensional arrays.
-
-Zarr is a powerful library for storage of n-dimensional arrays, supporting chunking,
-compression, and various backends, making it a versatile choice for scientific and
-large-scale data.
-
-Installation
-------------
-
-Zarr requires Python 3.11 or higher. You can install it via `pip`:
-
-.. code-block:: bash
-
- pip install zarr
-
-or `conda`:
-
-.. code-block:: bash
-
- conda install --channel conda-forge zarr
-
-Creating an Array
------------------
-
-To get started, you can create a simple Zarr array::
-
- >>> import zarr
- >>> import numpy as np
- >>>
- >>> # Create a 2D Zarr array
- >>> z = zarr.create_array(
- ... store="data/example-1.zarr",
- ... shape=(100, 100),
- ... chunks=(10, 10),
- ... dtype="f4"
- ... )
- >>>
- >>> # Assign data to the array
- >>> z[:, :] = np.random.random((100, 100))
- >>> z.info
- Type : Array
- Zarr format : 3
- Data type : DataType.float32
- Shape : (100, 100)
- Chunk shape : (10, 10)
- Order : C
- Read-only : False
- Store type : LocalStore
- Codecs : [{'endian': }, {'level': 0, 'checksum': False}]
- No. bytes : 40000 (39.1K)
-
-Here, we created a 2D array of shape ``(100, 100)``, chunked into blocks of
-``(10, 10)``, and filled it with random floating-point data. This array was
-written to a ``LocalStore`` in the ``data/example-1.zarr`` directory.
-
-Compression and Filters
-~~~~~~~~~~~~~~~~~~~~~~~
-
-Zarr supports data compression and filters. For example, to use Blosc compression::
-
- >>> z = zarr.create_array(
- ... "data/example-3.zarr",
- ... mode="w", shape=(100, 100),
- ... chunks=(10, 10), dtype="f4",
- ... compressors=zarr.codecs.BloscCodec(cname="zstd", clevel=3, shuffle=zarr.codecs.BloscShuffle.shuffle)
- ... )
- >>> z[:, :] = np.random.random((100, 100))
- >>>
- >>> z.info
- Type : Array
- Zarr format : 3
- Data type : DataType.float32
- Shape : (100, 100)
- Chunk shape : (10, 10)
- Order : C
- Read-only : False
- Store type : LocalStore
- Codecs : [{'endian': }, {'level': 0, 'checksum': False}]
- No. bytes : 40000 (39.1K)
-
-This compresses the data using the Zstandard codec with shuffle enabled for better compression.
-
-Hierarchical Groups
--------------------
-
-Zarr allows you to create hierarchical groups, similar to directories::
-
- >>> # Create nested groups and add arrays
- >>> root = zarr.group("data/example-2.zarr")
- >>> foo = root.create_group(name="foo")
- >>> bar = root.create_array(
- ... name="bar", shape=(100, 10), chunks=(10, 10), dtype="f4"
- ... )
- >>> spam = foo.create_array(name="spam", shape=(10,), dtype="i4")
- >>>
- >>> # Assign values
- >>> bar[:, :] = np.random.random((100, 10))
- >>> spam[:] = np.arange(10)
- >>>
- >>> # print the hierarchy
- >>> root.tree()
- /
- ├── bar (100, 10) float32
- └── foo
- └── spam (10,) int32
-
-
-This creates a group with two datasets: ``foo`` and ``bar``.
-
-Batch Hierarchy Creation
-~~~~~~~~~~~~~~~~~~~~~~~~
-
-Zarr provides tools for creating a collection of arrays and groups with a single function call.
-Suppose we want to copy existing groups and arrays into a new storage backend:
-
- >>> # Create nested groups and add arrays
- >>> root = zarr.group("data/example-3.zarr", attributes={'name': 'root'})
- >>> foo = root.create_group(name="foo")
- >>> bar = root.create_array(
- ... name="bar", shape=(100, 10), chunks=(10, 10), dtype="f4"
- ... )
- >>> nodes = {'': root.metadata} | {k: v.metadata for k,v in root.members()}
- >>> print(nodes)
- >>> from zarr.storage import MemoryStore
- >>> new_nodes = dict(zarr.create_hierarchy(store=MemoryStore(), nodes=nodes))
- >>> new_root = new_nodes['']
- >>> assert new_root.attrs == root.attrs
-
-Note that :func:`zarr.create_hierarchy` will only initialize arrays and groups -- copying array data must
-be done in a separate step.
-
-Persistent Storage
-------------------
-
-Zarr supports persistent storage to disk or cloud-compatible backends. While examples above
-utilized a :class:`zarr.storage.LocalStore`, a number of other storage options are available.
-
-Zarr integrates seamlessly with cloud object storage such as Amazon S3 and Google Cloud Storage
-using external libraries like `s3fs `_ or
-`gcsfs `_::
-
- >>> import s3fs # doctest: +SKIP
- >>>
- >>> z = zarr.create_array("s3://example-bucket/foo", mode="w", shape=(100, 100), chunks=(10, 10), dtype="f4") # doctest: +SKIP
- >>> z[:, :] = np.random.random((100, 100)) # doctest: +SKIP
-
-A single-file store can also be created using the the :class:`zarr.storage.ZipStore`::
-
- >>> # Store the array in a ZIP file
- >>> store = zarr.storage.ZipStore("data/example-3.zip", mode='w')
- >>>
- >>> z = zarr.create_array(
- ... store=store,
- ... mode="w",
- ... shape=(100, 100),
- ... chunks=(10, 10),
- ... dtype="f4"
- ... )
- >>>
- >>> # write to the array
- >>> z[:, :] = np.random.random((100, 100))
- >>>
- >>> # the ZipStore must be explicitly closed
- >>> store.close()
-
-To open an existing array from a ZIP file::
-
- >>> # Open the ZipStore in read-only mode
- >>> store = zarr.storage.ZipStore("data/example-3.zip", read_only=True)
- >>>
- >>> z = zarr.open_array(store, mode='r')
- >>>
- >>> # read the data as a NumPy Array
- >>> z[:]
- array([[0.66734236, 0.15667458, 0.98720884, ..., 0.36229587, 0.67443246,
- 0.34315267],
- [0.65787303, 0.9544212 , 0.4830079 , ..., 0.33097172, 0.60423803,
- 0.45621237],
- [0.27632037, 0.9947008 , 0.42434934, ..., 0.94860053, 0.6226942 ,
- 0.6386924 ],
- ...,
- [0.12854576, 0.934397 , 0.19524333, ..., 0.11838563, 0.4967675 ,
- 0.43074256],
- [0.82029045, 0.4671437 , 0.8090906 , ..., 0.7814118 , 0.42650765,
- 0.95929915],
- [0.4335856 , 0.7565437 , 0.7828931 , ..., 0.48119593, 0.66220033,
- 0.6652362 ]], shape=(100, 100), dtype=float32)
-
-Read more about Zarr's storage options in the :ref:`User Guide `.
-
-Next Steps
-----------
-
-Now that you're familiar with the basics, explore the following resources:
-
-- `User Guide `_
-- `API Reference `_
diff --git a/docs/release-notes.md b/docs/release-notes.md
new file mode 100644
index 0000000000..3b54ea993a
--- /dev/null
+++ b/docs/release-notes.md
@@ -0,0 +1,866 @@
+# Release notes
+
+
+
+
+
+## 3.3.0 (2026-07-30)
+
+### Features
+
+- Optimizes reading multiple chunks from a shard. Serial calls to `Store.get()`
+ in the sharding codec have been replaced with a single call to
+ `Store.get_ranges()`, which coalesces nearby byte ranges and fetches them
+ concurrently. ([#3004](https://github.com/zarr-developers/zarr-python/pull/3004))
+- Added a `subchunk_write_order` option to `ShardingCodec` to control the physical order of subchunks within a shard. Supported values are `morton`, `unordered`, `lexicographic`, and `colexicographic`. `unordered` makes no guarantee about subchunk layout. This setting affects only on-disk layout, not the data read back, and is not persisted in array metadata: it applies per codec instance and is not recovered when reopening a sharded array. ([#3826](https://github.com/zarr-developers/zarr-python/pull/3826))
+- Added `SyncByteGetter` and `SyncByteSetter` runtime-checkable protocols and a `get_ranges_sync` method on the `Store` ABC. These let custom byte getters/setters opt into the synchronous codec pipeline's fast path for in-memory IO, which the sharding codec uses for its inner chunks. ([#3885](https://github.com/zarr-developers/zarr-python/pull/3885))
+- Added `FusedCodecPipeline`, an opt-in codec pipeline that runs codec compute synchronously and in bulk (avoiding the per-chunk async scheduling overhead of the default `BatchedCodecPipeline`), giving large speedups for sharded arrays. The default `BatchedCodecPipeline` is unchanged for standard configurations, so existing code keeps working unless you opt in; enable the new pipeline with `zarr.config.set({"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"})`. ([#3885](https://github.com/zarr-developers/zarr-python/pull/3885))
+- Add `zarr.abc.store.Store.get_ranges` for concurrent, coalesced multi-range reads from a single key. The method is defined on the `Store` ABC with a default implementation built on `Store.get`, so every store inherits a working version; stores with native multi-range backends (e.g. `FsspecStore`) can override for efficiency. Coalescing knobs (`max_concurrency`, `max_gap_bytes`, `max_coalesced_bytes`) are passed as keyword arguments to `get_ranges`. Failures from underlying fetches surface as a `BaseExceptionGroup` (PEP 654); callers should use `except*` to filter for specific exception types such as `FileNotFoundError`. ([#3925](https://github.com/zarr-developers/zarr-python/pull/3925))
+- Two new fields on `ArrayConfig` control how the sharding codec coalesces partial-shard reads: `sharding_coalesce_max_gap_bytes` (default 1 MiB) and `sharding_coalesce_max_bytes` (default 16 MiB). When reading multiple chunks from the same shard, nearby byte ranges are merged into a single request to the store if separated by no more than `sharding_coalesce_max_gap_bytes` and the merged read stays within `sharding_coalesce_max_bytes`. Defaults are seeded from the matching `array.sharding_coalesce_max_gap_bytes` / `array.sharding_coalesce_max_bytes` keys in [`zarr.config`][] at array-creation time, and can be overridden per array by passing `config={...}` to [`zarr.create_array`][]. ([#3987](https://github.com/zarr-developers/zarr-python/pull/3987))
+
+- Added `Group.get_array`, `Group.get_group`, `AsyncGroup.get_array`, and `AsyncGroup.get_group`: type-safe accessors that return the child array or group at a given path, raising `ArrayNotFoundError` / `GroupNotFoundError` if no node exists there, and `ContainsGroupError` / `ContainsArrayError` if the node is not of the requested kind. Unlike `Group.__getitem__`, which returns `Array | Group`, these methods have precise return types. Nested paths like `"subgroup/subarray"` are supported. ([#4128](https://github.com/zarr-developers/zarr-python/pull/4128))
+- `ZipStore` now accepts an open binary file-like object in place of a path, enabling
+ zip archives on remote storage (e.g. a file opened with `fsspec` or an
+ `obstore.ReadableFile`). Operations that require a filesystem location
+ (`clear`, `move`) raise `NotImplementedError` for file-object-backed stores. ([#4187](https://github.com/zarr-developers/zarr-python/pull/4187))
+
+### Bugfixes
+
+- Stop emitting an `UnstableSpecificationWarning` when serializing the `struct` data type to Zarr V3 metadata. The `struct` data type now has a stable Zarr V3 specification. The legacy `structured` alias and the unspecified `null_terminated_bytes`, `raw_bytes`, and `variable_length_bytes` data types continue to warn. ([#4100](https://github.com/zarr-developers/zarr-python/pull/4100))
+- Fix equality comparison of `ArrayV2Metadata` and `ArrayV3Metadata` objects with a
+ `NaN` fill value. Such objects are now compared by their JSON-serialized form, so two
+ otherwise-identical metadata objects with a `NaN` (or infinite) fill value compare equal. ([#2929](https://github.com/zarr-developers/zarr-python/issues/2929))
+- Fixed `BytesCodec.from_dict` so that `BytesCodec` instances roundtrip to / from
+ their dict representation. `BytesCodec.from_dict` now interprets a missing
+ `endian` configuration as `endian=None` (matching what `BytesCodec.to_dict`
+ emits), instead of falling back to the system's native byte order. ([#3417](https://github.com/zarr-developers/zarr-python/pull/3417))
+- Fixed `save_array`, `Group.__setitem__`, and `load` for 0-dimensional arrays. ([#3469](https://github.com/zarr-developers/zarr-python/issues/3469))
+- Fixed inner-codec spec evolution for sharded arrays. The sharding codec now threads the array spec through its inner codec chain when evolving codecs, so a codec that changes the dtype upstream of `BytesCodec` no longer leaves the inner chain evolved against the wrong spec (which previously failed at decode time). This runs on the default `BatchedCodecPipeline` as well. Standard inner chains (`[BytesCodec]`, `[BytesCodec, ZstdCodec]`, transpose + bytes) are byte-identical to before. Restores the behavior of #2179. ([#3885](https://github.com/zarr-developers/zarr-python/pull/3885))
+- Make chunk normalization properly handle `-1` as a compact representation of the
+ length of an entire axis. Reject several previously-accepted but ill-defined
+ chunk specifications: `chunks=True` (previously silently produced size-1 chunks),
+ chunk tuples shorter than the array's number of dimensions (previously padded to
+ the array's shape), and `None` as a per-dimension chunk size. These all now
+ raise informative errors. Also fix chunk handling for 0-length array dimensions,
+ and add explicit rejection of 0-length chunks. ([#3899](https://github.com/zarr-developers/zarr-python/pull/3899))
+- Handle missing consolidated metadata in leaf Group nodes. ([#3954](https://github.com/zarr-developers/zarr-python/issues/3954))
+- Corrected the JSON type definitions for the `numpy.datetime64` and
+ `numpy.timedelta64` data types in Zarr V3 metadata: the `configuration` object
+ (holding `unit` and `scale_factor`) is now required, matching the published
+ specifications for these data types. Also updated the specification links in
+ the docstrings to point to the zarr-extensions repository. ([#3955](https://github.com/zarr-developers/zarr-python/pull/3955))
+- Fixed writing to 0-dimensional arrays that use the sharding codec. Previously
+ assigning to a 0-dimensional sharded array raised an error. ([#3966](https://github.com/zarr-developers/zarr-python/pull/3966))
+- Fix flaky stateful test bookkeeping when `delete_dir` matches string prefixes instead of true directory descendants. Previously a path such as `6/faNT…` could be incorrectly removed when deleting `6/f`. (See [issue #3977](https://github.com/zarr-developers/zarr-python/issues/3977).) ([#3977](https://github.com/zarr-developers/zarr-python/issues/3977))
+- `FsspecStore.close()` no longer closes the underlying fsspec filesystem or its
+ network session. fsspec caches and shares filesystem instances across callers,
+ so the store cannot know whether it is the only user, and closing a shared
+ session would break other stores; the filesystem's lifecycle belongs to
+ whoever created it. ([#4165](https://github.com/zarr-developers/zarr-python/pull/4165))
+
+- Fixed an invalid `zarr.create_array` example in the quick-start documentation (it passed an unsupported `mode` argument) and made the cloud-storage example execute against a mock S3 backend in CI. Added a test ensuring every Python code block in the documentation is either executed or explicitly opted out with a documented reason, so an invalid example can no longer go untested. ([#4016](https://github.com/zarr-developers/zarr-python/issues/4016))
+- Fixed `ObjectStore.list_dir` for object-store listings that include a directory-marker object matching the requested non-root prefix. ([#4032](https://github.com/zarr-developers/zarr-python/issues/4032))
+- Prevents mutation of the attributes dict provided by the user by copying them instead of keeping the reference ([#4059](https://github.com/zarr-developers/zarr-python/issues/4059))
+- Fixed several storage and codec bugs:
+
+ - Reading a value with a `SuffixByteRequest` larger than the value now correctly returns the whole value (matching HTTP `bytes=-N` suffix-range semantics), instead of silently returning incorrect data for `MemoryStore`.
+ - `LoggingStore.get_partial_values` and `FsspecStore.get_partial_values` no longer return empty results when `key_ranges` is passed as a one-shot iterable (e.g. a generator).
+ - `Store.getsize_prefix` no longer over-counts sibling keys that merely share a string prefix (e.g. `getsize_prefix("foo")` no longer includes keys under `foobar/`).
+ - `ZipStore.close()` no longer raises `AttributeError` when the store was created but never opened (including when used as a context manager without any I/O).
+ - `codecs_from_list` now raises a descriptive `TypeError` when a `BytesBytesCodec` immediately follows an `ArrayArrayCodec`, instead of a misleading "Required ArrayBytesCodec was not found" `ValueError`.
+
+ ([#4074](https://github.com/zarr-developers/zarr-python/pull/4074))
+
+- Fixed writing Fortran-ordered (F-contiguous) arrays through the variable-length string and bytes codecs and through numcodecs array-array filters such as `Delta`, `FixedScaleOffset` and `PackBits`. Chunks are now passed to numcodecs as C-contiguous arrays, so elements are no longer stored in transposed order. ([#4116](https://github.com/zarr-developers/zarr-python/pull/4116))
+- Fix silent byte-order corruption for structured dtypes with the `bytes` codec: multi-byte fields are now byte-swapped to the codec's configured `endian` on write and decoded honoring it on read, so non-native-endian structured data (e.g. big-endian fields, as produced by virtual references to external data) round-trips correctly. ([#4141](https://github.com/zarr-developers/zarr-python/issues/4141))
+
+- Fix `zarr.api.asynchronous.open_like` so it can create a new array by default when the
+ target path does not already exist. It now defaults to `mode="a"`; when using a read-only
+ store to open an existing array, pass `mode="r"` explicitly. ([#3352](https://github.com/zarr-developers/zarr-python/pull/3352))
+- `MemoryStore` now copies buffers as they are written, so it never retains the
+ caller's memory. Previously an uncompressed write handed the store a zero-copy
+ view of the user's array, and mutating that array afterwards would silently
+ rewrite chunks already committed to the store.
+
+ Only `MemoryStore` is affected: stores that serialize on write, such as
+ `LocalStore` and `ZipStore`, never aliased the caller's memory. Uncompressed
+ writes to a `MemoryStore` are correspondingly slower, since the copy that makes
+ the stored data independent is now actually performed; compressed writes are
+ unchanged. Buffers supplied through the `store_dict` argument remain the
+ caller's responsibility and are stored as-is. ([#4157](https://github.com/zarr-developers/zarr-python/pull/4157))
+
+- Fixed the opt-in `FusedCodecPipeline` for sharded arrays whose inner or index codec chain contains a codec implementing only the async codec interface (no `SupportsSyncCodec`). Such arrays previously raised `TypeError: All codecs must implement SupportsSyncCodec` on both read and write; the pipeline now declines its synchronous fast path for them and falls back to the async path, matching the behavior of the default `BatchedCodecPipeline`. Fully sync-capable codec chains keep the fast path unchanged. ([#4179](https://github.com/zarr-developers/zarr-python/pull/4179))
+- Fixed `TypeError: unhashable type: 'writeable void-scalar'` when writing to sharded arrays whose fill value is a `np.void` scalar, e.g. arrays with a structured dtype.
+
+ `ArraySpec` equality and hashing now compare the fill value by its byte representation rather than numeric equality. As a result, two specs with a `NaN` (or `NaT`) fill value now compare equal, while fill values of `-0.0` and `0.0` now compare unequal. This also restores the sharding codec's per-chunk spec cache, which had been disabled because of this bug. ([#4183](https://github.com/zarr-developers/zarr-python/pull/4183))
+
+- `FusedCodecPipeline` no longer runs chunk IO and codec compute on the thread
+ driving zarr's internal event loop. Previously each read/write executed its
+ synchronous fast path inline on that loop thread, and because every sync-API
+ call from every user thread is serviced by the same loop, concurrent
+ operations serialized behind each other's codec work — reported as the fused
+ pipeline being slower than `BatchedCodecPipeline` for zstd-compressed data
+ under multi-threaded (e.g. dask) access. The synchronous batch now runs on a
+ worker thread (one hop per batch, not per chunk), keeping the loop free.
+ Multi-threaded single-chunk reads of compressed data now scale with reader
+ threads; single-threaded performance is unchanged. ([#4194](https://github.com/zarr-developers/zarr-python/pull/4194))
+- The end-to-end benchmarks no longer invoke `sudo` to drop the OS page cache during a regular `pytest` run. Cache clearing is now opt-in via the `ZARR_BENCHMARK_CLEAR_CACHE` environment variable, which the benchmark CI jobs set. ([#4199](https://github.com/zarr-developers/zarr-python/pull/4199))
+- Fixed the opt-in `FusedCodecPipeline` for serializers that advertise the partial-decode/encode mixins with only the documented async partial methods: the partial dispatch previously asserted on the private `_decode_partial_sync`/`_encode_partial_sync` hooks (an `AssertionError`, or an `AttributeError` mid-IO under `python -O`); such codecs now take the full-chunk sync path. ([#4201](https://github.com/zarr-developers/zarr-python/pull/4201))
+- Fixed `FusedCodecPipeline` (the opt-in synchronous pipeline) silently skipping
+ array-array/bytes-bytes codecs placed outside a sharding serializer on its
+ partial-decode/partial-encode fast paths. With an outer compressor (e.g.
+ `compressors=[GzipCodec()]` around a `ShardingCodec` serializer), the fused
+ pipeline wrote non-conforming stored bytes that `BatchedCodecPipeline` (and any
+ other conforming reader) could not read, and could fail to read data that
+ `BatchedCodecPipeline` had written. With an outer array-array codec (e.g.
+ `TransposeCodec`), it silently returned wrong data in both directions with no
+ error. Only the opt-in `FusedCodecPipeline` was affected; the default
+ `BatchedCodecPipeline` was never impacted. ([#4202](https://github.com/zarr-developers/zarr-python/pull/4202))
+- Fixed silent data corruption in the experimental `FusedCodecPipeline`: reordering or duplicating fancy-index reads (e.g. `arr[perm, :]`, `arr.oindex[[0, 0, 1], :]`) on uncompressed, crc-free sharded arrays could return the shard in natural order because the vectorized whole-shard decode accepted any selection whose output shape matched the shard shape. The bulk decode now fires only for identity full-shard reads, declines structured dtypes (whose byte-order handling it lacks), and requires shard-index offsets to exactly tile the data section, so corrupt indexes with overlapping or out-of-range offsets can no longer be served as array data. ([#4203](https://github.com/zarr-developers/zarr-python/pull/4203))
+- `ManagedMemoryStore.get_sync`/`set_sync`/`delete_sync` now apply the store's
+ `path` prefix, matching the async `get`/`set`/`delete` methods. Previously the
+ sync methods were inherited unchanged from `MemoryStore` and used the raw key,
+ so code that takes the sync fast path (e.g. `FusedCodecPipeline`) would read
+ and write chunks outside the store's `path` prefix, silently returning fill
+ values when the data was re-read through a fresh handle. `GpuMemoryStore.set_sync`
+ now converts its value to a `gpu.Buffer`, matching `set`, so writes through the
+ sync API keep the store's all-values-are-GPU invariant. Also fixed
+ `ManagedMemoryStore.get_partial_values` applying its `path` prefix twice
+ whenever `path` is non-empty, which made it always return `None` for every
+ requested key.
+
+ The shared store test suite (`zarr.testing.store.StoreTests`) gained
+ sync/async parity checks — comparing sync and async observations of the same
+ key on the same store instance, including with a `byte_range` — so every
+ store subclass now exercises this invariant. The suite's former
+ `test_get_bytes`/`test_get_json` methods (and their `_sync` variants) were
+ folded into these parity tests and no longer exist as separate methods. ([#4204](https://github.com/zarr-developers/zarr-python/pull/4204))
+
+- Fixed several small correctness issues from the codec-pipeline performance work: construction-time
+ codec warnings (e.g. sharding's "disables partial reads" warning) no longer fire twice per array
+ open — including for `FusedCodecPipeline`, which previously re-warned via its own codec-chain
+ reconstruction and, on the async fallback path, on every decode/encode call; `concurrent_iter` now
+ schedules its tasks eagerly, matching its documented contract; an invalid
+ `codec_pipeline.max_workers` config/environment value now warns and falls back to the default
+ instead of raising mid-read; and `FusedCodecPipeline`'s async fallback helpers now cancel
+ already-spawned fetch/decode/write tasks instead of abandoning them in the background when one
+ fails. ([#4205](https://github.com/zarr-developers/zarr-python/pull/4205))
+- Fixed `FusedCodecPipeline`'s gating of its synchronous fast paths: stores exposing only part of the sync surface (e.g. `set_sync` without `get_sync`) now fall back cleanly to the async path instead of failing mid-write, and `WrapperStore` now forwards `get_sync`/`set_sync`/`delete_sync` to the wrapped store so wrapped sync-capable stores keep the fast path. The capability decision uses a private, interim convention (`zarr.abc.store._store_supports_sync_io`) rather than new public API, pending a formal sync/async store architecture. Also fixed `LatencyStore`: synchronous reads and writes now pay the configured latency, `get_ranges`/`get_partial_values` no longer bypass latency injection, and derived stores (e.g. from `with_read_only`) keep a stochastic `(loc, scale)` latency configuration instead of freezing a single sample. ([#4206](https://github.com/zarr-developers/zarr-python/pull/4206))
+- `DefaultChunkKeyEncoding.decode_chunk_key` now validates that a chunk key
+ starts with the configured `c` prefix and raises `ValueError` for
+ malformed keys, instead of silently decoding them incorrectly. ([#4219](https://github.com/zarr-developers/zarr-python/pull/4219))
+
+### Improved Documentation
+
+- Document the changes to `zarr.errors` in the 3.0 migration guide, including the removal of v2 exception classes and the introduction of `NodeNotFoundError`. ([#3009](https://github.com/zarr-developers/zarr-python/issues/3009))
+- Clarify the difference between `zarr.load` and `zarr.open` in their docstrings.
+ `load` eagerly reads data into an in-memory array, while `open` returns a
+ lazy `Array` or `Group` backed by the store, with `See Also` cross-references
+ linking the two. ([#3984](https://github.com/zarr-developers/zarr-python/pull/3984))
+- Updated the custom dtype example in `examples/custom_dtype/custom_dtype.py` to
+ use only the public API, eliminating all non-public imports, illustrating what
+ users should do.
+
+ To better support this, the following types and functions were made available
+ from public modules:
+
+ | Type/Function | Non-public module | Public module |
+ | ------------------------- | ------------------------ | ------------- |
+ | `DataTypeValidationError` | `zarr.core.dtype.common` | `zarr.errors` |
+ | `JSON` | `zarr.core.common` | `zarr.types` |
+ | `ZarrFormat` | `zarr.core.common` | `zarr.types` |
+ | `DTypeConfig_V2` | `zarr.core.dtype.common` | `zarr.types` |
+ | `DTypeJSON` | `zarr.core.dtype.common` | `zarr.types` |
+ | `DTypeSpec_V2` | `zarr.core.dtype.common` | `zarr.dtype` |
+ | `check_dtype_spec_v2` | `zarr.core.dtype.common` | `zarr.dtype` |
+
+ `DataTypeValidationError` was *moved* to `zarr.errors`. Importing it from
+ `zarr.core.dtype.common` (its original location), `zarr.core.dtype`, or
+ `zarr.dtype` still works but now raises a `ZarrDeprecationWarning`. The remaining
+ types and functions are simply re-exported from the listed public module. ([#4052](https://github.com/zarr-developers/zarr-python/pull/4052))
+
+- Document a self-merge policy in the contributor guide, describing when a core developer may merge their own pull request without a second reviewer and which changes warrant more caution. ([#4053](https://github.com/zarr-developers/zarr-python/pull/4053))
+- Fixed many documentation errors found in a full review of the user guide, including
+ prose contradicted by rendered example output on the performance page, invisible
+ code blocks, an incorrect S3 example, stale "not yet implemented" claims in the
+ v3 migration guide, and undocumented optional dependency groups. Also improved
+ navigation order, cross-linking between pages, and coverage of group member
+ enumeration, bulk attribute updates, and the `use_consolidated` keyword. ([#4132](https://github.com/zarr-developers/zarr-python/pull/4132))
+- Fixed the documented default of ``max_age_seconds`` in the ``CacheStore`` docstring: the default is ``"infinity"`` (no expiration), not ``None``, which is rejected. Also noted that ``cache_store`` must support deletes. ([#4133](https://github.com/zarr-developers/zarr-python/pull/4133))
+
+- Added a blog section to the documentation, with a post covering two performance
+ highlights of the 3.3.0 release: the opt-in `FusedCodecPipeline` and byte-range
+ coalescing for partial reads of sharded arrays.
+
+ Added two runnable examples that accompany the post:
+ `examples/codec_pipeline_performance` compares the `BatchedCodecPipeline` and
+ `FusedCodecPipeline` on a sharded array across two stores and two codec
+ regimes, showing when the fused pipeline's thread pool helps and when it does
+ not, and `examples/sharding_coalescing` demonstrates how read coalescing
+ reduces the number of store requests when reading subregions of a sharded
+ array.
+
+ Also removed the hardware-specific speedup figures from the `FusedCodecPipeline`
+ release note, since they depend on the array layout, codec, and machine. ([#4191](https://github.com/zarr-developers/zarr-python/pull/4191))
+
+### Deprecations and Removals
+
+- The ``BloscShuffle`` and ``BloscCname`` enums (``zarr.codecs.BloscShuffle``,
+ ``zarr.codecs.BloscCname``) are now deprecated. Pass the equivalent literal
+ string (e.g. ``"zstd"``, ``"bitshuffle"``) when constructing a ``BloscCodec``.
+ The enum classes remain importable but emit ``DeprecationWarning`` on member
+ access, and will be removed in a future release. They are no longer ``Enum``
+ subclasses: constructor calls (e.g. ``BloscCname("zstd")``), iteration, and
+ ``.value`` access no longer work. ``BloscCodec.cname`` and
+ ``BloscCodec.shuffle`` are now plain strings rather than enum members.
+
+ Additional renames in ``zarr.codecs.blosc`` from the same change: the type
+ aliases ``Shuffle`` and ``CName`` are now ``BloscShuffleLiteral`` and
+ ``BloscCnameLiteral``, the constant ``SHUFFLE`` is now ``BLOSC_SHUFFLE``
+ (with a new ``BLOSC_CNAME`` alongside it), and ``BloscShuffle.from_int``
+ now returns a literal string rather than an enum member. ([#3963](https://github.com/zarr-developers/zarr-python/pull/3963))
+
+- The ``Endian`` (``zarr.codecs.bytes.Endian``) and ``ShardingCodecIndexLocation``
+ (``zarr.codecs.ShardingCodecIndexLocation``) enums are now deprecated. Pass the
+ equivalent literal string instead (e.g. ``"little"`` / ``"big"``, ``"start"`` /
+ ``"end"``). The enum classes remain importable but emit ``DeprecationWarning``
+ on member access, and will be removed in a future release. ``BytesCodec.endian``
+ and ``ShardingCodec.index_location`` are now plain strings rather than enum
+ members.
+
+ Two follow-on changes from this deprecation:
+
+ - ``NDBuffer.byteorder`` now returns a literal string (``"little"`` or
+ ``"big"``) rather than an ``Endian`` member. Subclasses overriding this
+ property should update their return type.
+ - The module-level binding ``zarr.codecs.bytes.default_system_endian`` was
+ removed. ``BytesCodec()`` continues to default to ``sys.byteorder``;
+ external callers that imported ``default_system_endian`` should use
+ ``sys.byteorder`` directly.
+
+ Additionally, the module-level function ``zarr.codecs.sharding.parse_index_location``
+ was made private as part of this change.
+
+ ([#3968](https://github.com/zarr-developers/zarr-python/pull/3968))
+
+- Removed the NumPy 1.x implementation of the `VariableLengthUTF8` data type because NumPy 1.x is no longer supported under [SPEC0](https://scientific-python.org/specs/spec-0000/). ([#3973](https://github.com/zarr-developers/zarr-python/pull/3973))
+
+### Misc
+
+- [#4139](https://github.com/zarr-developers/zarr-python/pull/4139), [#4140](https://github.com/zarr-developers/zarr-python/pull/4140), [#3908](https://github.com/zarr-developers/zarr-python/pull/3908), [#3972](https://github.com/zarr-developers/zarr-python/pull/3972), [#3975](https://github.com/zarr-developers/zarr-python/pull/3975), [#3979](https://github.com/zarr-developers/zarr-python/pull/3979), [#3990](https://github.com/zarr-developers/zarr-python/pull/3990), [#3998](https://github.com/zarr-developers/zarr-python/pull/3998), [#4000](https://github.com/zarr-developers/zarr-python/pull/4000), [#4001](https://github.com/zarr-developers/zarr-python/pull/4001), [#4012](https://github.com/zarr-developers/zarr-python/pull/4012), [#4046](https://github.com/zarr-developers/zarr-python/pull/4046), [#4054](https://github.com/zarr-developers/zarr-python/pull/4054), [#4073](https://github.com/zarr-developers/zarr-python/issues/4073), [#4086](https://github.com/zarr-developers/zarr-python/issues/4086), [#4138](https://github.com/zarr-developers/zarr-python/pull/4138)
+
+- [#4172](https://github.com/zarr-developers/zarr-python/pull/4172)
+
+## 3.2.1 (2026-05-05)
+
+### Bugfixes
+
+- Fixed a `CastValue` validation bug where the "can we use an out-of-range mode" check
+ inspected the source dtype instead of the target dtype. This meant arrays with a
+ float source dtype and an integer target dtype incorrectly raised a `ValueError`
+ when configured with a `wrap` out-of-range mode. ([#3938](https://github.com/zarr-developers/zarr-python/pull/3938))
+- Fixed a bug where the codec pipeline evolved each codec against the original
+ array spec instead of the spec produced by upstream array-to-array codecs. This
+ caused failures whenever an upstream codec changed the dtype between codec
+ boundaries — e.g. arrays using `CastValue` to convert a single-byte source dtype
+ (`int8`) to a multi-byte target dtype (`int16`) raised a `ValueError` from
+ `BytesCodec` about a missing `endian` configuration. ([#3941](https://github.com/zarr-developers/zarr-python/pull/3941))
+- Fixed breakage in existing fsspec-dependent workflows caused by associating the "memory" URL scheme with
+instances of `ManagedMemoryStore` instead of fsspec's memory-backed store. After this change, store URLs with a "memory" scheme are handled differently when `fsspec` is installed:
+with `fsspec`, a `FsspecStore` backed by a `MemoryFileSystem` is used. Without `fsspec`,
+a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr-python/pull/3944))
+
+## 3.2.0 (2026-04-30)
+
+### Features
+
+- Adds a new in-memory storage backend called `ManagedMemoryStore`. Instances of `ManagedMemoryStore`
+ function similarly to `MemoryStore`, but instances of `ManagedMemoryStore` can be constructed from
+ a URL like `memory://store`. ([#3679](https://github.com/zarr-developers/zarr-python/pull/3679))
+- Added `array.read_missing_chunks` configuration option. When set to `False`, reading missing chunks raises a `ChunkNotFoundError` instead of filling them with the array's fill value. ([#3748](https://github.com/zarr-developers/zarr-python/pull/3748))
+- Added `Struct` class (subclass of `Structured`) implementing the zarr-extensions `struct` dtype spec. Uses object-style field format and dict fill values. Legacy `Structured` remains available for backward compatibility. ([#3781](https://github.com/zarr-developers/zarr-python/pull/3781))
+- Add support for rectilinear (variable-sized) chunk grids. This feature is experimental and
+ must be explicitly enabled via `zarr.config.set({'array.rectilinear_chunks': True})`.
+
+ Rectilinear chunks can be used through:
+
+ - **Creating arrays**: Pass nested sequences (e.g., `[[10, 20, 30], [50, 50]]`) to `chunks`
+ in `zarr.create_array`, `zarr.from_array`, `zarr.zeros`, `zarr.ones`, `zarr.full`,
+ `zarr.open`, and related functions, or to `chunk_shape` in `zarr.create`.
+ - **Opening existing arrays**: Arrays stored with the `rectilinear` chunk grid are read
+ transparently via `zarr.open` and `zarr.open_array`.
+ - **Rectilinear sharding**: Shard boundaries can be rectilinear while inner chunks remain regular.
+
+ **Breaking change**: The `validate` method on `BaseCodec` and `CodecPipeline` now receives
+ a `ChunkGridMetadata` instance instead of a `ChunkGrid` instance for the `chunk_grid`
+ parameter. Third-party codecs that override `validate` and inspect the chunk grid will need to
+ update their type annotations. No known downstream packages were using this parameter. ([#3802](https://github.com/zarr-developers/zarr-python/pull/3802))
+
+- Add `cast_value` and `scale_offset` codecs. ([#3874](https://github.com/zarr-developers/zarr-python/pull/3874))
+
+### Bugfixes
+
+- Fix `SyncError` raised when assigning a `zarr.Array` as the value in a `__setitem__` call (e.g. `dst[:] = src` where `src` is a zarr array). The source array is now converted to a NumPy array before entering the async codec pipeline. ([#3611](https://github.com/zarr-developers/zarr-python/issues/3611))
+- Fix an issue that prevents the correct parsing of special NumPy `uint32` dtypes resulting e.g.
+ from bit wise operations on `uint32` arrays on Windows. ([#3797](https://github.com/zarr-developers/zarr-python/pull/3797))
+- Fix `ZipStore.list()`, `list_dir()`, and `exists()` to auto-open the zip file when called before `open()`, consistent with the existing behavior of `get()` and `set()`. ([#3846](https://github.com/zarr-developers/zarr-python/issues/3846))
+- Fix handling of `NaT` default fill values for `datetime64` and `timedelta64` data types. Equality checks now use `numpy.isnat` so that the default fill value compares correctly against `NaT`. ([#3863](https://github.com/zarr-developers/zarr-python/pull/3863))
+- Use the unit associated with the `Datetime64` data type when creating the default `Nat` scalar value. ([#3920](https://github.com/zarr-developers/zarr-python/pull/3920))
+
+### Improved Documentation
+
+- Document removal of `zarr.storage.init_group` in v3 migration guide, with replacement using `zarr.open_group`/`zarr.create_group`. ([#2720](https://github.com/zarr-developers/zarr-python/issues/2720))
+- Document the `threading.max_workers` configuration option in the performance guide. ([#3492](https://github.com/zarr-developers/zarr-python/issues/3492))
+- Corrects the type annotation reported for the `batch_info` parameter in the `CodecPipeline.write`
+ method docstring. ([#3836](https://github.com/zarr-developers/zarr-python/pull/3836))
+- Remove result="ansi" from code blocks in the user guide that were causing empty output cells in the rendered documentation. ([#3845](https://github.com/zarr-developers/zarr-python/pull/3845))
+
+### Deprecations and Removals
+
+- Remove deprecated `zarr.convenience` and `zarr.creation` modules. ([#3900](https://github.com/zarr-developers/zarr-python/pull/3900))
+- Remove the deprecated `zarr_version` parameter from several functions and methods. That parameter is replaced with `zarr_format`. ([#3901](https://github.com/zarr-developers/zarr-python/pull/3901))
+- Remove deprecated `Group` methods `array`, `require_dataset`, and `create_dataset`. ([#3902](https://github.com/zarr-developers/zarr-python/pull/3902))
+- Remove deprecated `AsyncArray.create` and `Array.create` methods. ([#3903](https://github.com/zarr-developers/zarr-python/pull/3903))
+
+### Misc
+
+- [#3546](https://github.com/zarr-developers/zarr-python/issues/3546), [#3793](https://github.com/zarr-developers/zarr-python/pull/3793), [#3800](https://github.com/zarr-developers/zarr-python/pull/3800), [#3828](https://github.com/zarr-developers/zarr-python/pull/3828), [#3830](https://github.com/zarr-developers/zarr-python/pull/3830), [#3833](https://github.com/zarr-developers/zarr-python/pull/3833), [#3837](https://github.com/zarr-developers/zarr-python/pull/3837), [#3897](https://github.com/zarr-developers/zarr-python/pull/3897)
+
+
+## 3.1.6 (2026-03-19)
+
+### Features
+
+- Exposes the array runtime configuration as an attribute called `config` on the `Array` and
+ `AsyncArray` classes. The previous `AsyncArray._config` attribute is now a deprecated alias for `AsyncArray.config`. ([#3668](https://github.com/zarr-developers/zarr-python/pull/3668))
+- Adds a method for creating a new `Array` / `AsyncArray` instance with a new runtime configuration, and fixes inaccurate documentation about the `write_empty_chunks` configuration parameter. ([#3668](https://github.com/zarr-developers/zarr-python/pull/3668))
+- Adds synchronous methods to stores that do not benefit from an async event loop. The shape of these methods is defined by protocol classes to support structural subtyping. ([#3725](https://github.com/zarr-developers/zarr-python/pull/3725))
+- Fix near-miss penalty in `_morton_order` with hybrid ceiling+argsort strategy. ([#3718](https://github.com/zarr-developers/zarr-python/pull/3718))
+
+### Bugfixes
+
+- Correct the target bytes number for auto-chunking when auto-sharding. ([#3603](https://github.com/zarr-developers/zarr-python/pull/3603))
+- Fixed a bug in the sharding codec that prevented nested shard reads in certain cases. ([#3655](https://github.com/zarr-developers/zarr-python/pull/3655))
+- Fix obstore `_transform_list_dir` implementation to correctly relativize paths (removing `lstrip` usage). ([#3657](https://github.com/zarr-developers/zarr-python/pull/3657))
+- Raise error when trying to encode `numpy.dtypes.StringDType` with `na_object` set. ([#3695](https://github.com/zarr-developers/zarr-python/pull/3695))
+- `CacheStore`, `LoggingStore` and `LatencyStore` now support with_read_only. ([#3700](https://github.com/zarr-developers/zarr-python/pull/3700))
+- Skip chunk coordinate enumeration in resize when the array is only growing, avoiding unbounded memory usage for large arrays. ([#3702](https://github.com/zarr-developers/zarr-python/pull/3702))
+- Fix a performance bug in morton curve generation. ([#3705](https://github.com/zarr-developers/zarr-python/pull/3705))
+- Add a dedicated in-memory cache for byte-range requests to the experimental `CacheStore`. ([#3710](https://github.com/zarr-developers/zarr-python/pull/3710))
+- `BaseFloat._check_scalar` rejects invalid string values. ([#3586](https://github.com/zarr-developers/zarr-python/issues/3586))
+- Apply drop_axes squeeze in partial decode path for sharding. ([#3763](https://github.com/zarr-developers/zarr-python/pull/3763))
+- Set `copy=False` in reshape operation. ([#3649](https://github.com/zarr-developers/zarr-python/pull/3649))
+- Validate that dask-style chunks have regular shapes. ([#3779](https://github.com/zarr-developers/zarr-python/pull/3779))
+
+### Improved Documentation
+
+- Add documentation example for creating uncompressed arrays in the Compression section of the user guide. ([#3464](https://github.com/zarr-developers/zarr-python/issues/3464))
+- Add AI-assisted code policy to the contributing guide. ([#3769](https://github.com/zarr-developers/zarr-python/pull/3769))
+- Added a glossary. ([#3767](https://github.com/zarr-developers/zarr-python/pull/3767))
+
+### Misc
+
+- [#3562](https://github.com/zarr-developers/zarr-python/pull/3562), [#3605](https://github.com/zarr-developers/zarr-python/pull/3605), [#3619](https://github.com/zarr-developers/zarr-python/pull/3619), [#3623](https://github.com/zarr-developers/zarr-python/pull/3623), [#3636](https://github.com/zarr-developers/zarr-python/pull/3636), [#3648](https://github.com/zarr-developers/zarr-python/pull/3648), [#3656](https://github.com/zarr-developers/zarr-python/pull/3656), [#3658](https://github.com/zarr-developers/zarr-python/pull/3658), [#3673](https://github.com/zarr-developers/zarr-python/pull/3673), [#3704](https://github.com/zarr-developers/zarr-python/pull/3704), [#3706](https://github.com/zarr-developers/zarr-python/pull/3706), [#3708](https://github.com/zarr-developers/zarr-python/pull/3708), [#3712](https://github.com/zarr-developers/zarr-python/pull/3712), [#3713](https://github.com/zarr-developers/zarr-python/pull/3713), [#3717](https://github.com/zarr-developers/zarr-python/pull/3717), [#3721](https://github.com/zarr-developers/zarr-python/pull/3721), [#3728](https://github.com/zarr-developers/zarr-python/pull/3728), [#3778](https://github.com/zarr-developers/zarr-python/pull/3778)
+
+
+## 3.1.5 (2025-11-21)
+
+### Bugfixes
+
+- Fix formatting errors in the release notes section of the docs. ([#3594](https://github.com/zarr-developers/zarr-python/pull/3594))
+
+
+## 3.1.4 (2025-11-20)
+
+### Features
+
+- The `Array` class can now also be parametrized in the same manner as the `AsyncArray` class, allowing Zarr format v2 and v3 `Array`s to be distinguished.
+ New types have been added to `zarr.types` to help with this. ([#3304](https://github.com/zarr-developers/zarr-python/pull/3304))
+- Adds `zarr.experimental.cache_store.CacheStore`, a `Store` that implements caching by combining two other `Store` instances. See the [docs page](https://zarr.readthedocs.io/en/latest/user-guide/experimental#cachestore) for more information about this feature. ([#3366](https://github.com/zarr-developers/zarr-python/pull/3366))
+- Adds a `zarr.experimental` module for unstable user-facing features. ([#3490](https://github.com/zarr-developers/zarr-python/pull/3490))
+- Add a `array.target_shard_size_bytes` to [`zarr.config`][] to allow users to set a maximum number of bytes per-shard when `shards="auto"` in, for example, [`zarr.create_array`][]. ([#3547](https://github.com/zarr-developers/zarr-python/pull/3547))
+- Make `async_array` on the [`zarr.Array`][] class public (`_async_array` will remain untouched, but its stability is not guaranteed). ([#3556](https://github.com/zarr-developers/zarr-python/pull/3556))
+
+### Bugfixes
+
+- Fix a bug that prevented `PCodec` from being properly resolved when loading arrays using that compressor. ([#3483](https://github.com/zarr-developers/zarr-python/pull/3483))
+- Fixed a bug that prevented Zarr Python from opening Zarr V3 array metadata documents that contained
+ extra keys with permissible values (dicts with a `"must_understand"` key set to `"false"`). ([#3530](https://github.com/zarr-developers/zarr-python/pull/3530))
+- Fixed a bug where the `"consolidated_metadata"` key was written to metadata documents even when
+ consolidated metadata was not used, resulting in invalid metadata documents. ([#3535](https://github.com/zarr-developers/zarr-python/pull/3535))
+- Improve write performance to large shards by up to 10x. ([#3560](https://github.com/zarr-developers/zarr-python/issues/3560))
+
+### Improved Documentation
+
+- Use mkdocs-material for Zarr-Python documentation ([#3118](https://github.com/zarr-developers/zarr-python/pull/3118))
+- Document different values of StoreLike with examples in the user guide. ([#3303](https://github.com/zarr-developers/zarr-python/issues/3303))
+- Reorganize the top-level `examples` directory to give each example its own sub-directory. Adds content to the docs for each example. ([#3502](https://github.com/zarr-developers/zarr-python/pull/3502))
+- Updated 3.0 Migration Guide to include function signature change to zarr.Array.resize function. ([#3536](https://github.com/zarr-developers/zarr-python/issues/3536))
+
+### Misc
+
+- [#3515](https://github.com/zarr-developers/zarr-python/pull/3515), [#3532](https://github.com/zarr-developers/zarr-python/pull/3532), [#3533](https://github.com/zarr-developers/zarr-python/pull/3533), [#3553](https://github.com/zarr-developers/zarr-python/pull/3553)
+
+
+## 3.1.3 (2025-09-18)
+
+### Features
+
+- Add a command-line interface to migrate v2 Zarr metadata to v3. Corresponding functions are also provided under zarr.metadata. ([#1798](https://github.com/zarr-developers/zarr-python/issues/1798))
+- Add obstore implementation of delete_dir. ([#3310](https://github.com/zarr-developers/zarr-python/pull/3310))
+- Adds a registry for chunk key encodings for extensibility. This allows users to implement a custom `ChunkKeyEncoding`, which can be registered via `register_chunk_key_encoding` or as an entry point under `zarr.chunk_key_encoding`. ([#3436](https://github.com/zarr-developers/zarr-python/pull/3436))
+- Trying to open a group at a path where an array already exists now raises a helpful error. ([#3444](https://github.com/zarr-developers/zarr-python/pull/3444))
+
+### Bugfixes
+
+- Prevents creation of groups (.create_group) or arrays (.create_array) as children of an existing array. ([#2582](https://github.com/zarr-developers/zarr-python/issues/2582))
+- Fix a bug preventing `ones_like`, `full_like`, `empty_like`, `zeros_like` and `open_like` functions from accepting an explicit specification of array attributes like shape, dtype, chunks etc. The functions `full_like`, `empty_like`, and `open_like` now also more consistently infer a `fill_value` parameter from the provided array. ([#2992](https://github.com/zarr-developers/zarr-python/pull/2992))
+- LocalStore now uses atomic writes, which should prevent some cases of corrupted data. ([#3411](https://github.com/zarr-developers/zarr-python/issues/3411))
+- Fix a potential race condition when using `zarr.create_array` with the `data` parameter set to a NumPy array. Previously Zarr was iterating over the newly created array with a granularity that was too low. Now Zarr chooses a granularity that matches the size of the stored objects for that array. ([#3422](https://github.com/zarr-developers/zarr-python/pull/3422))
+- Fix ChunkGrid definition (broken in 3.1.2) ([#3425](https://github.com/zarr-developers/zarr-python/pull/3425))
+- Ensure syntax like `root['/subgroup']` works equivalently to `root['subgroup']` when using consolidated metadata. ([#3428](https://github.com/zarr-developers/zarr-python/pull/3428))
+- Creating a new group with `zarr.group` no longer errors. This fixes a regression introduced in version 3.1.2. ([#3431](https://github.com/zarr-developers/zarr-python/pull/3431))
+- Setting `fill_value` to a float like `0.0` when the data type of the array is an integer is a common mistake. This change lets Zarr Python read arrays with this erroneous metadata, although Zarr Python will not create such arrays. ([#3448](https://github.com/zarr-developers/zarr-python/pull/3448))
+
+### Deprecations and Removals
+
+- The `Store.set_partial_writes` method, which was not used by Zarr-Python, has been removed. `store.supports_partial_writes` is now always `False`. ([#2859](https://github.com/zarr-developers/zarr-python/issues/2859))
+
+### Misc
+
+- [#3376](https://github.com/zarr-developers/zarr-python/pull/3376), [#3390](https://github.com/zarr-developers/zarr-python/pull/3390), [#3403](https://github.com/zarr-developers/zarr-python/pull/3403), [#3449](https://github.com/zarr-developers/zarr-python/pull/3449)
+
+## 3.1.2 (2025-08-25)
+
+### Features
+
+- Added support for async vectorized and orthogonal indexing. ([#3083](https://github.com/zarr-developers/zarr-python/pull/3083))
+- Make config param optional in init_array ([#3391](https://github.com/zarr-developers/zarr-python/pull/3391))
+
+### Bugfixes
+
+- Ensure that -0.0 is not considered equal to 0.0 when checking if all the values in a chunk are equal to an array's fill value. ([#3144](https://github.com/zarr-developers/zarr-python/issues/3144))
+- Fix a bug in `create_array` caused by iterating over chunk-aligned regions instead of shard-aligned regions when writing data. Additionally, the behavior of `nchunks_initialized` has been adjusted. This function consistently reports the number of chunks present in stored objects, even when the array uses the sharding codec. ([#3299](https://github.com/zarr-developers/zarr-python/pull/3299))
+- Opening an array or group with `mode="r+"` will no longer create new arrays or groups. ([#3307](https://github.com/zarr-developers/zarr-python/pull/3307))
+- Added `zarr.errors.ArrayNotFoundError`, which is raised when attempting to open a zarr array that does not exist, and `zarr.errors.NodeNotFoundError`, which is raised when failing to open an array or a group in a context where either an array or a group was expected. ([#3367](https://github.com/zarr-developers/zarr-python/pull/3367))
+- Ensure passing `config` is handled properly when `open`ing an existing array. ([#3378](https://github.com/zarr-developers/zarr-python/pull/3378))
+- Raise a Zarr-specific error class when a codec can't be found by name when deserializing the given codecs. This avoids hiding this error behind a "not part of a zarr hierarchy" warning. ([#3395](https://github.com/zarr-developers/zarr-python/pull/3395))
+
+### Misc
+
+- [#3098](https://github.com/zarr-developers/zarr-python/pull/3098), [#3288](https://github.com/zarr-developers/zarr-python/pull/3288), [#3318](https://github.com/zarr-developers/zarr-python/pull/3318), [#3368](https://github.com/zarr-developers/zarr-python/issues/3368), [#3371](https://github.com/zarr-developers/zarr-python/pull/3371), [#3372](https://github.com/zarr-developers/zarr-python/pull/3372), [#3374](https://github.com/zarr-developers/zarr-python/pull/3374)
+
+## 3.1.1 (2025-07-28)
+
+### Features
+
+- Add lightweight implementations of `.getsize()` and `.getsize_prefix()` for ObjectStore. ([#3227](https://github.com/zarr-developers/zarr-python/pull/3227))
+
+### Bugfixes
+
+- Creating a Zarr format 2 array with the `order` keyword argument no longer raises a warning. ([#3112](https://github.com/zarr-developers/zarr-python/pull/3112))
+- Fixed the error message when passing both `config` and `write_empty_chunks` arguments to reflect the current behaviour (`write_empty_chunks` takes precedence). ([#3112](https://github.com/zarr-developers/zarr-python/pull/3112))
+- Creating a Zarr format 3 array with the `order` argument now consistently ignores this argument and raises a warning. ([#3112](https://github.com/zarr-developers/zarr-python/pull/3112))
+- When using [`from_array`][zarr.api.asynchronous.from_array] to copy a Zarr format 2 array to a Zarr format 3 array, if the memory order of the input array is `"F"` a warning is raised and the order ignored. This is because Zarr format 3 arrays are always stored in "C" order. ([#3112](https://github.com/zarr-developers/zarr-python/pull/3112))
+- The `config` argument to [`zarr.create`][zarr.create] (and functions that create arrays) is now used - previously it had no effect. ([#3112](https://github.com/zarr-developers/zarr-python/pull/3112))
+- Ensure that all abstract methods of [`ZDType`][zarr.core.dtype.ZDType] raise a `NotImplementedError` when invoked. ([#3251](https://github.com/zarr-developers/zarr-python/pull/3251))
+- Register 'gpu' marker with pytest for downstream StoreTests. ([#3258](https://github.com/zarr-developers/zarr-python/pull/3258))
+- Expand the range of types accepted by `parse_data_type` to include strings and Sequences.
+- Move the functionality of `zarr.core.dtype.parse_data_type` to a new function called `zarr.dtype.parse_dtype`. This change ensures that nomenclature is consistent across the codebase. `zarr.core.dtype.parse_data_type` remains, so this change is not breaking. ([#3264](https://github.com/zarr-developers/zarr-python/pull/3264))
+- Fix a regression introduced in 3.1.0 that prevented `inf`, `-inf`, and `nan` values from being stored in `attributes`. ([#3280](https://github.com/zarr-developers/zarr-python/pull/3280))
+- Fixes [`Group.nmembers()`][zarr.Group.nmembers] ignoring depth when using consolidated metadata. ([#3287](https://github.com/zarr-developers/zarr-python/pull/3287))
+
+### Improved Documentation
+
+- Expand the data type docs to include a demonstration of the `parse_data_type` function. Expand the docstring for the `parse_data_type` function. ([#3249](https://github.com/zarr-developers/zarr-python/pull/3249))
+- Add a section on codecs to the migration guide. ([#3273](https://github.com/zarr-developers/zarr-python/pull/3273))
+
+### Misc
+
+- Remove warnings about vlen-utf8 and vlen-bytes codecs ([#3268](https://github.com/zarr-developers/zarr-python/pull/3268))
+
+## 3.1.0 (2025-07-14)
+
+### Features
+
+- Ensure that invocations of `create_array` use consistent keyword arguments, with consistent defaults.
+
+ [`zarr.api.synchronous.create_array`][] now takes a `write_data` keyword argument
+ The `Group.create_array` method takes `data` and `write_data` keyword arguments.
+ The functions [`zarr.api.asynchronous.create`][], [`zarr.api.asynchronous.create_array`]
+ and the methods `Group.create_array`, `Group.array`, had the default
+ `fill_value` changed from `0` to the `DEFAULT_FILL_VALUE` value, which instructs Zarr to
+ use the default scalar value associated with the array's data type as the fill value. These are
+ all functions or methods for array creation that mirror, wrap or are wrapped by, another function
+ that already has a default `fill_value` set to `DEFAULT_FILL_VALUE`. This change is necessary
+ to make these functions consistent across the entire codebase, but as this changes default values,
+ new data might have a different fill value than expected after this change.
+
+ For data types where 0 is meaningful, like integers or floats, the default scalar is 0, so this
+ change should not be noticeable. For data types where 0 is ambiguous, like fixed-length unicode
+ strings, the default fill value might be different after this change. Users who were relying on how
+ Zarr interpreted `0` as a non-numeric scalar value should set their desired fill value explicitly
+ after this change.
+
+- Added public API for Buffer ABCs and implementations.
+
+ Use `zarr.buffer` to access buffer implementations, and
+ `zarr.abc.buffer` for the interface to implement new buffer types.
+
+ Users previously importing buffer from `zarr.core.buffer` should update their
+ imports to use `zarr.buffer`. As a reminder, all of `zarr.core` is
+ considered a private API that's not covered by zarr-python's versioning policy. ([#2871](https://github.com/zarr-developers/zarr-python/issues/2871))
+
+- Adds zarr-specific data type classes.
+
+ This change adds a `ZDType` base class for Zarr V2 and Zarr V3 data types. Child classes are
+ defined for each NumPy data type. Each child class defines routines for `JSON` serialization.
+ New data types can be created and registered dynamically.
+
+ Prior to this change, Zarr Python had two streams for handling data types. For Zarr V2 arrays,
+ we used NumPy data type identifiers. For Zarr V3 arrays, we used a fixed set of string enums. Both
+ of these systems proved hard to extend.
+
+ This change is largely internal, but it does change the type of the `dtype` and `data_type`
+ fields on the `ArrayV2Metadata` and `ArrayV3Metadata` classes. Previously, `ArrayV2Metadata.dtype`
+ was a NumPy `dtype` object, and `ArrayV3Metadata.data_type` was an internally-defined `enum`.
+ After this change, both `ArrayV2Metadata.dtype` and `ArrayV3Metadata.data_type` are instances of
+ `ZDType`. A NumPy data type can be generated from a `ZDType` via the `ZDType.to_native_dtype()`
+ method. The internally-defined Zarr V3 `enum` class is gone entirely, but the `ZDType.to_json(zarr_format=3)`
+ method can be used to generate either a string, or dictionary that has a string `name` field, that
+ represents the string value previously associated with that `enum`.
+
+ For more on this new feature, see the [documentation](user-guide/data_types.md) ([#2874](https://github.com/zarr-developers/zarr-python/pull/2874))
+
+- Added `NDBuffer.empty` method for faster ndbuffer initialization. ([#3191](https://github.com/zarr-developers/zarr-python/pull/3191))
+
+- The minimum version of NumPy has increased to 1.26. ([#3226](https://github.com/zarr-developers/zarr-python/pull/3226))
+
+- Add an alternate `from_array_metadata_and_store` constructor to `CodecPipeline`. ([#3233](https://github.com/zarr-developers/zarr-python/pull/3233))
+
+### Bugfixes
+
+- Fixes a variety of issues related to string data types.
+
+ - Brings the `VariableLengthUTF8` data type Zarr V3 identifier in alignment with Zarr Python 3.0.8
+ - Disallows creation of 0-length fixed-length data types
+ - Adds a regression test for the `VariableLengthUTF8` data type that checks against version 3.0.8
+ - Allows users to request the `VariableLengthUTF8` data type with `str`, `"str"`, or `"string"`. ([#3170](https://github.com/zarr-developers/zarr-python/pull/3170))
+
+- Add human readable size for No. bytes stored to `info_complete` ([#3190](https://github.com/zarr-developers/zarr-python/pull/3190))
+
+- Restores the ability to create a Zarr V2 array with a `null` fill value by introducing a new
+ class `DefaultFillValue`, and setting the default value of the `fill_value` parameter in array
+ creation routines to an instance of `DefaultFillValue`. For Zarr V3 arrays, `None` will act as an
+ alias for a `DefaultFillValue` instance, thus preserving compatibility with existing code. ([#3198](https://github.com/zarr-developers/zarr-python/pull/3198))
+
+- Fix the type of `ArrayV2Metadata.codec` to constrain it to `numcodecs.abc.Codec | None`.
+ Previously the type was more permissive, allowing objects that can be parsed into Codecs (e.g., the codec name).
+ The constructor of `ArrayV2Metadata` still allows the permissive input when creating new objects. ([#3232](https://github.com/zarr-developers/zarr-python/pull/3232))
+
+### Improved Documentation
+
+- Add a self-contained example of data type extension to the `examples` directory, and expanded
+ the documentation for data types. ([#3157](https://github.com/zarr-developers/zarr-python/pull/3157))
+
+- Add a description on how to create a RemoteStore of a specific filesystem to the `Remote Store` section in `docs/user-guide/storage.md`.
+ State in the docstring of `FsspecStore.from_url` that the filesystem type is inferred from the URL scheme.
+
+ It should help a user handling the case when the type of FsspecStore doesn't match the URL scheme. ([#3212](https://github.com/zarr-developers/zarr-python/pull/3212))
+
+### Deprecations and Removals
+
+- Removes default chunk encoding settings (filters, serializer, compressors) from the global
+ configuration object.
+
+ This removal is justified on the basis that storing chunk encoding settings in the config required
+ a brittle, confusing, and inaccurate categorization of array data types, which was particularly
+ unsuitable after the recent addition of new data types that didn't fit naturally into the
+ pre-existing categories.
+
+ The default chunk encoding is the same (Zstandard compression, and the required object codecs for
+ variable length data types), but the chunk encoding is now generated by functions that cannot be
+ reconfigured at runtime. Users who relied on setting the default chunk encoding via the global configuration object should
+ instead specify the desired chunk encoding explicitly when creating an array.
+
+ This change also adds an extra validation step to the creation of Zarr V2 arrays, which ensures that
+ arrays with a `VariableLengthUTF8` or `VariableLengthBytes` data type cannot be created without the
+ correct "object codec". ([#3228](https://github.com/zarr-developers/zarr-python/pull/3228))
+
+- Removes support for passing keyword-only arguments positionally to the following functions and methods:
+ `save_array`, `open`, `group`, `open_group`, `create`, `get_basic_selection`, `set_basic_selection`,
+ `get_orthogonal_selection`, `set_orthogonal_selection`, `get_mask_selection`, `set_mask_selection`,
+ `get_coordinate_selection`, `set_coordinate_selection`, `get_block_selection`, `set_block_selection`,
+ `Group.create_array`, `Group.empty`, `Group.zeroes`, `Group.ones`, `Group.empty_like`, `Group.full`,
+ `Group.zeros_like`, `Group.ones_like`, `Group.full_like`, `Group.array`. Prior to this change,
+ passing a keyword-only argument positionally to one of these functions or methods would raise a
+ deprecation warning. That warning is now gone. Passing keyword-only arguments to these functions
+ and methods positionally is now an error.
+
+## 3.0.10 (2025-07-03)
+
+### Bugfixes
+
+- Removed an unnecessary check from `_fsspec._make_async` that would raise an exception when
+ creating a read-only store backed by a local file system with `auto_mkdir` set to `False`. ([#3193](https://github.com/zarr-developers/zarr-python/pull/3193))
+
+- Add missing import for AsyncFileSystemWrapper for _make_async in _fsspec.py ([#3195](https://github.com/zarr-developers/zarr-python/pull/3195))
+
+## 3.0.9 (2025-06-30)
+
+### Features
+
+- Add `zarr.storage.FsspecStore.from_mapper()` so that `zarr.open()` supports stores of type `fsspec.mapping.FSMap`. ([#2774](https://github.com/zarr-developers/zarr-python/pull/2774))
+
+- Implemented `move` for `LocalStore` and `ZipStore`. This allows users to move the store to a different root path. ([#3021](https://github.com/zarr-developers/zarr-python/pull/3021))
+
+- Added `zarr.errors.GroupNotFoundError`, which is raised when attempting to open a group that does not exist. ([#3066](https://github.com/zarr-developers/zarr-python/pull/3066))
+
+- Adds `fill_value` to the list of attributes displayed in the output of the `AsyncArray.info()` method. ([#3081](https://github.com/zarr-developers/zarr-python/pull/3081))
+
+- Use `numpy.zeros` instead of `np.full` for a performance speedup when creating a `zarr.core.buffer.NDBuffer` with `fill_value=0`. ([#3082](https://github.com/zarr-developers/zarr-python/pull/3082))
+
+- Port more stateful testing actions from [Icechunk](https://icechunk.io/en/stable/). ([#3130](https://github.com/zarr-developers/zarr-python/pull/3130))
+
+- Adds a `with_read_only` convenience method to the `Store` abstract base class (raises `NotImplementedError`) and implementations to the `MemoryStore`, `ObjectStore`, `LocalStore`, and `FsspecStore` classes. ([#3138](https://github.com/zarr-developers/zarr-python/pull/3138))
+
+### Bugfixes
+
+- Ignore stale child metadata when reconsolidating metadata. ([#2921](https://github.com/zarr-developers/zarr-python/issues/2921))
+
+- For Zarr format 2, allow fixed-length string arrays to be created without automatically inserting a
+ `Vlen-UT8` codec in the array of filters. Fixed-length string arrays do not need this codec. This
+ change fixes a regression where fixed-length string arrays created with Zarr Python 3 could not be read with Zarr Python 2.18. ([#3100](https://github.com/zarr-developers/zarr-python/pull/3100))
+
+- When creating arrays without explicitly specifying a chunk size using `zarr.create` and other
+ array creation routines, the chunk size will now set automatically instead of defaulting to the data shape.
+ For large arrays this will result in smaller default chunk sizes.
+ To retain previous behaviour, explicitly set the chunk shape to the data shape.
+
+ This fix matches the existing chunking behaviour of
+ `zarr.save_array` and `zarr.api.asynchronous.AsyncArray.create`. ([#3103](https://github.com/zarr-developers/zarr-python/pull/3103))
+
+- When `zarr.save` has an argument `path=some/path/` and multiple arrays in `args`, the path resulted in `some/path/some/path` due to using the `path`
+ argument twice while building the array path. This is now fixed. ([#3127](https://github.com/zarr-developers/zarr-python/pull/3127))
+
+- Fix `zarr.open` default for argument `mode` when `store` is `read_only` ([#3128](https://github.com/zarr-developers/zarr-python/pull/3128))
+
+- Suppress `FileNotFoundError` when deleting non-existent keys in the `obstore` adapter.
+
+ When writing empty chunks (i.e. chunks where all values are equal to the array's fill value) to a zarr array, zarr
+ will delete those chunks from the underlying store. For zarr arrays backed by the `obstore` adapter, this will potentially
+ raise a `FileNotFoundError` if the chunk doesn't already exist.
+ Since whether or not a delete of a non-existing object raises an error depends on the behavior of the underlying store,
+ suppressing the error in all cases results in consistent behavior across stores, and is also what `zarr` seems to expect
+ from the store. ([#3140](https://github.com/zarr-developers/zarr-python/pull/3140))
+
+- Trying to open a StorePath/Array with `mode='r'` when the store is not read-only creates a read-only copy of the store. ([#3156](https://github.com/zarr-developers/zarr-python/pull/3156))
+
+## 3.0.8 (2025-05-19)
+
+!!! warning
+
+ In versions 3.0.0 to 3.0.7 opening arrays or groups with `mode='a'` (the default for many builtin functions) would cause any existing paths in the store to be deleted. This is fixed in 3.0.8, and we recommend all users upgrade to avoid this bug that could cause unintentional data loss.
+
+### Features
+
+- Added a `print_debug_info` function for bug reports. ([#2913](https://github.com/zarr-developers/zarr-python/pull/2913))
+
+### Bugfixes
+
+- Fix a bug that prevented the number of initialized chunks being counted properly. ([#2862](https://github.com/zarr-developers/zarr-python/pull/2862))
+- Fixed sharding with GPU buffers. ([#2978](https://github.com/zarr-developers/zarr-python/pull/2978))
+- Fix structured `dtype` fill value serialization for consolidated metadata ([#2998](https://github.com/zarr-developers/zarr-python/issues/2998))
+- It is now possible to specify no compressor when creating a zarr format 2 array.
+ This can be done by passing `compressor=None` to the various array creation routines.
+
+ The default behaviour of automatically choosing a suitable default compressor remains if the compressor argument is not given.
+ To reproduce the behaviour in previous zarr-python versions when `compressor=None` was passed, pass `compressor='auto'` instead. ([#3039](https://github.com/zarr-developers/zarr-python/pull/3039))
+
+- Fixed the typing of `dimension_names` arguments throughout so that it now accepts iterables that contain `None` alongside `str`. ([#3045](https://github.com/zarr-developers/zarr-python/pull/3045))
+- Using various functions to open data with `mode='a'` no longer deletes existing data in the store. ([#3062](https://github.com/zarr-developers/zarr-python/pull/3062))
+- Internally use `typesize` constructor parameter for `numcodecs.blosc.Blosc` to improve compression ratios back to the v2-package levels. ([#2962](https://github.com/zarr-developers/zarr-python/pull/2962))
+- Specifying the memory order of Zarr format 2 arrays using the `order` keyword argument has been fixed. ([#2950](https://github.com/zarr-developers/zarr-python/issues/2950))
+
+### Misc
+
+- [#2972](https://github.com/zarr-developers/zarr-python/pull/2972), [#3027](https://github.com/zarr-developers/zarr-python/pull/3027), [#3049](https://github.com/zarr-developers/zarr-python/pull/3049)
+
+## 3.0.7 (2025-04-22)
+
+### Features
+
+- Add experimental ObjectStore storage class based on obstore. ([#1661](https://github.com/zarr-developers/zarr-python/pull/1661))
+- Add `zarr.from_array` using concurrent streaming of source data ([#2622](https://github.com/zarr-developers/zarr-python/pull/2622))
+
+### Bugfixes
+
+- 0-dimensional arrays are now returning a scalar. Therefore, the return type of `__getitem__` changed
+ to NDArrayLikeOrScalar. This change is to make the behavior of 0-dimensional arrays consistent with
+ `numpy` scalars. ([#2718](https://github.com/zarr-developers/zarr-python/pull/2718))
+- Fix `fill_value` serialization for `NaN` in `ArrayV2Metadata` and add property-based testing of round-trip serialization ([#2802](https://github.com/zarr-developers/zarr-python/pull/2802))
+- Fixes `ConsolidatedMetadata` serialization of `nan`, `inf`, and `-inf` to be
+ consistent with the behavior of `ArrayMetadata`. ([#2996](https://github.com/zarr-developers/zarr-python/pull/2996))
+
+### Improved Documentation
+
+- Updated the 3.0 migration guide to include the removal of "." syntax for getting group members. ([#2991](https://github.com/zarr-developers/zarr-python/issues/2991), [#2997](https://github.com/zarr-developers/zarr-python/pull/2997))
+
+### Misc
+
+- Define a new versioning policy based on Effective Effort Versioning. This replaces the old Semantic
+ Versioning-based policy. ([#2924](https://github.com/zarr-developers/zarr-python/issues/2924), [#2910](https://github.com/zarr-developers/zarr-python/pull/2910))
+- Make warning filters in the tests more specific, so warnings emitted by tests added in the future
+ are more likely to be caught instead of ignored. ([#2714](https://github.com/zarr-developers/zarr-python/pull/2714))
+- Avoid an unnecessary memory copy when writing Zarr to a local file ([#2944](https://github.com/zarr-developers/zarr-python/pull/2944))
+
+## 3.0.6 (2025-03-20)
+
+### Bugfixes
+
+- Restore functionality of `del z.attrs['key']` to actually delete the key. ([#2908](https://github.com/zarr-developers/zarr-python/pull/2908))
+
+## 3.0.5 (2025-03-07)
+
+### Bugfixes
+
+- Fixed a bug where `StorePath` creation would not apply standard path normalization to the `path` parameter,
+ which led to the creation of arrays and groups with invalid keys. ([#2850](https://github.com/zarr-developers/zarr-python/pull/2850))
+- Prevent update_attributes calls from deleting old attributes ([#2870](https://github.com/zarr-developers/zarr-python/pull/2870))
+
+### Misc
+
+- [#2796](https://github.com/zarr-developers/zarr-python/pull/2796)
+
+## 3.0.4 (2025-02-23)
+
+### Features
+
+- Adds functions for concurrently creating multiple arrays and groups. ([#2665](https://github.com/zarr-developers/zarr-python/pull/2665))
+
+### Bugfixes
+
+- Fixed a bug where `ArrayV2Metadata` could save `filters` as an empty array. ([#2847](https://github.com/zarr-developers/zarr-python/pull/2847))
+- Fix a bug when setting values of a smaller last chunk. ([#2851](https://github.com/zarr-developers/zarr-python/pull/2851))
+
+### Misc
+
+- [#2828](https://github.com/zarr-developers/zarr-python/pull/2828)
+
+## 3.0.3 (2025-02-14)
+
+### Features
+
+- Improves performance of FsspecStore.delete_dir for remote filesystems supporting concurrent/batched deletes, e.g., s3fs. ([#2661](https://github.com/zarr-developers/zarr-python/pull/2661))
+- Added `zarr.config.enable_gpu` to update Zarr's configuration to use GPUs. ([#2751](https://github.com/zarr-developers/zarr-python/pull/2751))
+- Avoid reading chunks during writes where possible. [#757](https://github.com/zarr-developers/zarr-python/issues/757) ([#2784](https://github.com/zarr-developers/zarr-python/pull/2784))
+- `LocalStore` learned to `delete_dir`. This makes array and group deletes more efficient. ([#2804](https://github.com/zarr-developers/zarr-python/pull/2804))
+- Add `zarr.testing.strategies.array_metadata` to generate ArrayV2Metadata and ArrayV3Metadata instances. ([#2813](https://github.com/zarr-developers/zarr-python/pull/2813))
+- Add arbitrary `shards` to Hypothesis strategy for generating arrays. ([#2822](https://github.com/zarr-developers/zarr-python/pull/2822))
+
+### Bugfixes
+
+- Fixed bug with Zarr using device memory, instead of host memory, for storing metadata when using GPUs. ([#2751](https://github.com/zarr-developers/zarr-python/pull/2751))
+- The array returned by `zarr.empty` and an empty `zarr.core.buffer.cpu.NDBuffer` will now be filled with the
+ specified fill value, or with zeros if no fill value is provided.
+ This fixes a bug where Zarr format 2 data with no fill value was written with un-predictable chunk sizes. ([#2755](https://github.com/zarr-developers/zarr-python/pull/2755))
+- Fix zip-store path checking for stores with directories listed as files. ([#2758](https://github.com/zarr-developers/zarr-python/pull/2758))
+- Use removeprefix rather than replace when removing filename prefixes in `FsspecStore.list` ([#2778](https://github.com/zarr-developers/zarr-python/pull/2778))
+- Enable automatic removal of `needs release notes` with labeler action ([#2781](https://github.com/zarr-developers/zarr-python/pull/2781))
+- Use the proper label config ([#2785](https://github.com/zarr-developers/zarr-python/pull/2785))
+- Alters the behavior of `create_array` to ensure that any groups implied by the array's name are created if they do not already exist. Also simplifies the type signature for any function that takes an ArrayConfig-like object. ([#2795](https://github.com/zarr-developers/zarr-python/pull/2795))
+- Enitialise empty chunks to the default fill value during writing and add default fill values for datetime, timedelta, structured, and other (void* fixed size) data types ([#2799](https://github.com/zarr-developers/zarr-python/pull/2799))
+- Ensure utf8 compliant strings are used to construct numpy arrays in property-based tests ([#2801](https://github.com/zarr-developers/zarr-python/pull/2801))
+- Fix pickling for ZipStore ([#2807](https://github.com/zarr-developers/zarr-python/pull/2807))
+- Update numcodecs to not overwrite codec configuration ever. Closes [#2800](https://github.com/zarr-developers/zarr-python/issues/2800). ([#2811](https://github.com/zarr-developers/zarr-python/pull/2811))
+- Fix fancy indexing (e.g. arr[5, [0, 1]]) with the sharding codec ([#2817](https://github.com/zarr-developers/zarr-python/pull/2817))
+
+### Improved Documentation
+
+- Added new user guide on GPU. ([#2751](https://github.com/zarr-developers/zarr-python/pull/2751))
+
+## 3.0.2 (2025-01-31)
+
+### Features
+
+- Test `getsize()` and `getsize_prefix()` in `StoreTests`. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- Test that a `ValueError` is raised for invalid byte range syntax in `StoreTests`. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- Separate instantiating and opening a store in `StoreTests`. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- Add a test for using Stores as context managers in `StoreTests`. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- Implemented `LoggingStore.open()`. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- `LoggingStore` is now a generic class. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- Change StoreTest's `test_store_repr`, `test_store_supports_writes`,
+ `test_store_supports_partial_writes`, and `test_store_supports_listing`
+ to be implemented using `@abstractmethod`, rather than raising `NotImplementedError`. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- Test the error raised for invalid buffer arguments in `StoreTests`. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- Test that data can be written to a store that's not yet open using the store.set method in `StoreTests`. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- Adds a new function `init_array` for initializing an array in storage, and refactors `create_array`
+ to use `init_array`. `create_array` takes two new parameters: `data`, an optional array-like object, and `write_data`, a bool which defaults to `True`.
+ If `data` is given to `create_array`, then the `dtype` and `shape` attributes of `data` are used to define the
+ corresponding attributes of the resulting Zarr array. Additionally, if `data` is given and `write_data` is `True`,
+ then the values in `data` will be written to the newly created array. ([#2761](https://github.com/zarr-developers/zarr-python/pull/2761))
+
+### Bugfixes
+
+- Wrap sync fsspec filesystems with `AsyncFileSystemWrapper`. ([#2533](https://github.com/zarr-developers/zarr-python/pull/2533))
+- Added backwards compatibility for Zarr format 2 structured arrays. ([#2681](https://github.com/zarr-developers/zarr-python/pull/2681))
+- Update equality for `LoggingStore` and `WrapperStore` such that 'other' must also be a `LoggingStore` or `WrapperStore` respectively, rather than only checking the types of the stores they wrap. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- Ensure that `ZipStore` is open before getting or setting any values. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- Use stdout rather than stderr as the default stream for `LoggingStore`. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- Match the errors raised by read only stores in `StoreTests`. ([#2693](https://github.com/zarr-developers/zarr-python/pull/2693))
+- Fixed `ZipStore` to make sure the correct attributes are saved when instances are pickled.
+ This fixes a previous bug that prevented using `ZipStore` with a `ProcessPoolExecutor`. ([#2762](https://github.com/zarr-developers/zarr-python/pull/2762))
+- Updated the optional test dependencies to include `botocore` and `fsspec`. ([#2768](https://github.com/zarr-developers/zarr-python/pull/2768))
+- Fixed the fsspec tests to skip if `botocore` is not installed.
+ Previously they would have failed with an import error. ([#2768](https://github.com/zarr-developers/zarr-python/pull/2768))
+- Optimize full chunk writes. ([#2782](https://github.com/zarr-developers/zarr-python/pull/2782))
+
+### Improved Documentation
+
+- Changed the machinery for creating changelog entries.
+ Now individual entries should be added as files to the `changes` directory in the `zarr-python` repository, instead of directly to the changelog file. ([#2736](https://github.com/zarr-developers/zarr-python/pull/2736))
+
+### Other
+
+- Created a type alias `ChunkKeyEncodingLike` to model the union of `ChunkKeyEncoding` instances and the dict form of the
+ parameters of those instances. `ChunkKeyEncodingLike` should be used by high-level functions to provide a convenient
+ way for creating `ChunkKeyEncoding` objects. ([#2763](https://github.com/zarr-developers/zarr-python/pull/2763))
+
+## 3.0.1 (2025-01-17)
+
+* Implement `zarr.from_array` using concurrent streaming ([#2622](https://github.com/zarr-developers/zarr-python/pull/2622)).
+
+### Bug fixes
+
+* Fixes `order` argument for Zarr format 2 arrays ([#2679](https://github.com/zarr-developers/zarr-python/pull/2679)).
+* Fixes a bug that prevented reading Zarr format 2 data with consolidated
+ metadata written using `zarr-python` version 2 ([#2694](https://github.com/zarr-developers/zarr-python/issues/2694)).
+* Ensure that compressor=None results in no compression when writing Zarr
+ format 2 data ([#2708](https://github.com/zarr-developers/zarr-python/issues/2708)).
+* Fix for empty consolidated metadata dataset: backwards compatibility with
+ Zarr-Python 2 ([#2695](https://github.com/zarr-developers/zarr-python/pull/2695)).
+
+### Documentation
+
+* Add v3.0.0 release announcement banner ([#2677](https://github.com/zarr-developers/zarr-python/pull/2677)).
+* Quickstart guide alignment with V3 API ([#2697](https://github.com/zarr-developers/zarr-python/pull/2697)).
+* Fix doctest failures related to numcodecs 0.15 ([#2727](https://github.com/zarr-developers/zarr-python/pull/2727)).
+
+### Other
+
+* Removed some unnecessary files from the source distribution
+ to reduce its size. ([#2686](https://github.com/zarr-developers/zarr-python/pull/2686)).
+* Enable codecov in GitHub actions ([#2682](https://github.com/zarr-developers/zarr-python/pull/2682)).
+* Speed up hypothesis tests ([#2650](https://github.com/zarr-developers/zarr-python/pull/2650)).
+* Remove multiple imports for an import name ([#2723](https://github.com/zarr-developers/zarr-python/pull/2723)).
+
+## 3.0.0 (2025-01-09)
+
+3.0.0 is a new major release of Zarr-Python, with many breaking changes.
+See the [v3 migration guide](user-guide/v3_migration.md) for a listing of what's changed.
+
+Normal release note service will resume with further releases in the 3.0.0
+series.
+
+Release notes for the zarr-python 2.x and 1.x releases can be found here:
+https://zarr.readthedocs.io/en/support-v2/release.html
diff --git a/docs/release-notes.rst b/docs/release-notes.rst
deleted file mode 100644
index a89046dd6d..0000000000
--- a/docs/release-notes.rst
+++ /dev/null
@@ -1,269 +0,0 @@
-Release notes
-=============
-
-.. towncrier release notes start
-
-3.0.8 (2025-05-19)
-------------------
-
-.. warning::
-
- In versions 3.0.0 to 3.0.7 opening arrays or groups with ``mode='a'`` (the default for many builtin functions)
- would cause any existing paths in the store to be deleted. This is fixed in 3.0.8, and
- we recommend all users upgrade to avoid this bug that could cause unintentional data loss.
-
-Features
-~~~~~~~~
-
-- Added a `print_debug_info` function for bug reports. (:issue:`2913`)
-
-
-Bugfixes
-~~~~~~~~
-
-- Fix a bug that prevented the number of initialized chunks being counted properly. (:issue:`2862`)
-- Fixed sharding with GPU buffers. (:issue:`2978`)
-- Fix structured `dtype` fill value serialization for consolidated metadata (:issue:`2998`)
-- It is now possible to specify no compressor when creating a zarr format 2 array.
- This can be done by passing ``compressor=None`` to the various array creation routines.
-
- The default behaviour of automatically choosing a suitable default compressor remains if the compressor argument is not given.
- To reproduce the behaviour in previous zarr-python versions when ``compressor=None`` was passed, pass ``compressor='auto'`` instead. (:issue:`3039`)
-- Fixed the typing of ``dimension_names`` arguments throughout so that it now accepts iterables that contain `None` alongside `str`. (:issue:`3045`)
-- Using various functions to open data with ``mode='a'`` no longer deletes existing data in the store. (:issue:`3062`)
-- Internally use `typesize` constructor parameter for :class:`numcodecs.blosc.Blosc` to improve compression ratios back to the v2-package levels. (:issue:`2962`)
-- Specifying the memory order of Zarr format 2 arrays using the ``order`` keyword argument has been fixed. (:issue:`2950`)
-
-
-Misc
-~~~~
-
-- :issue:`2972`, :issue:`3027`, :issue:`3049`
-
-
-3.0.7 (2025-04-22)
-------------------
-
-Features
-~~~~~~~~
-
-- Add experimental ObjectStore storage class based on obstore. (:issue:`1661`)
-- Add ``zarr.from_array`` using concurrent streaming of source data (:issue:`2622`)
-
-
-Bugfixes
-~~~~~~~~
-
-- 0-dimensional arrays are now returning a scalar. Therefore, the return type of ``__getitem__`` changed
- to NDArrayLikeOrScalar. This change is to make the behavior of 0-dimensional arrays consistent with
- ``numpy`` scalars. (:issue:`2718`)
-- Fix `fill_value` serialization for `NaN` in `ArrayV2Metadata` and add property-based testing of round-trip serialization (:issue:`2802`)
-- Fixes `ConsolidatedMetadata` serialization of `nan`, `inf`, and `-inf` to be
- consistent with the behavior of `ArrayMetadata`. (:issue:`2996`)
-
-
-Improved Documentation
-~~~~~~~~~~~~~~~~~~~~~~
-
-- Updated the 3.0 migration guide to include the removal of "." syntax for getting group members. (:issue:`2991`, :issue:`2997`)
-
-
-Misc
-~~~~
-- Define a new versioning policy based on Effective Effort Versioning. This replaces the old Semantic
- Versioning-based policy. (:issue:`2924`, :issue:`2910`)
-- Make warning filters in the tests more specific, so warnings emitted by tests added in the future
- are more likely to be caught instead of ignored. (:issue:`2714`)
-- Avoid an unnecessary memory copy when writing Zarr to a local file (:issue:`2944`)
-
-
-3.0.6 (2025-03-20)
-------------------
-
-Bugfixes
-~~~~~~~~
-
-- Restore functionality of `del z.attrs['key']` to actually delete the key. (:issue:`2908`)
-
-
-3.0.5 (2025-03-07)
-------------------
-
-Bugfixes
-~~~~~~~~
-
-- Fixed a bug where ``StorePath`` creation would not apply standard path normalization to the ``path`` parameter,
- which led to the creation of arrays and groups with invalid keys. (:issue:`2850`)
-- Prevent update_attributes calls from deleting old attributes (:issue:`2870`)
-
-
-Misc
-~~~~
-
-- :issue:`2796`
-
-3.0.4 (2025-02-23)
-------------------
-
-Features
-~~~~~~~~
-
-- Adds functions for concurrently creating multiple arrays and groups. (:issue:`2665`)
-
-Bugfixes
-~~~~~~~~
-
-- Fixed a bug where ``ArrayV2Metadata`` could save ``filters`` as an empty array. (:issue:`2847`)
-- Fix a bug when setting values of a smaller last chunk. (:issue:`2851`)
-
-Misc
-~~~~
-
-- :issue:`2828`
-
-
-3.0.3 (2025-02-14)
-------------------
-
-Features
-~~~~~~~~
-
-- Improves performance of FsspecStore.delete_dir for remote filesystems supporting concurrent/batched deletes, e.g., s3fs. (:issue:`2661`)
-- Added :meth:`zarr.config.enable_gpu` to update Zarr's configuration to use GPUs. (:issue:`2751`)
-- Avoid reading chunks during writes where possible. :issue:`757` (:issue:`2784`)
-- :py:class:`LocalStore` learned to ``delete_dir``. This makes array and group deletes more efficient. (:issue:`2804`)
-- Add `zarr.testing.strategies.array_metadata` to generate ArrayV2Metadata and ArrayV3Metadata instances. (:issue:`2813`)
-- Add arbitrary `shards` to Hypothesis strategy for generating arrays. (:issue:`2822`)
-
-
-Bugfixes
-~~~~~~~~
-
-- Fixed bug with Zarr using device memory, instead of host memory, for storing metadata when using GPUs. (:issue:`2751`)
-- The array returned by ``zarr.empty`` and an empty ``zarr.core.buffer.cpu.NDBuffer`` will now be filled with the
- specified fill value, or with zeros if no fill value is provided.
- This fixes a bug where Zarr format 2 data with no fill value was written with un-predictable chunk sizes. (:issue:`2755`)
-- Fix zip-store path checking for stores with directories listed as files. (:issue:`2758`)
-- Use removeprefix rather than replace when removing filename prefixes in `FsspecStore.list` (:issue:`2778`)
-- Enable automatic removal of `needs release notes` with labeler action (:issue:`2781`)
-- Use the proper label config (:issue:`2785`)
-- Alters the behavior of ``create_array`` to ensure that any groups implied by the array's name are created if they do not already exist. Also simplifies the type signature for any function that takes an ArrayConfig-like object. (:issue:`2795`)
-- Enitialise empty chunks to the default fill value during writing and add default fill values for datetime, timedelta, structured, and other (void* fixed size) data types (:issue:`2799`)
-- Ensure utf8 compliant strings are used to construct numpy arrays in property-based tests (:issue:`2801`)
-- Fix pickling for ZipStore (:issue:`2807`)
-- Update numcodecs to not overwrite codec configuration ever. Closes :issue:`2800`. (:issue:`2811`)
-- Fix fancy indexing (e.g. arr[5, [0, 1]]) with the sharding codec (:issue:`2817`)
-
-
-Improved Documentation
-~~~~~~~~~~~~~~~~~~~~~~
-
-- Added new user guide on :ref:`user-guide-gpu`. (:issue:`2751`)
-
-
-3.0.2 (2025-01-31)
-------------------
-
-Features
-~~~~~~~~
-
-- Test ``getsize()`` and ``getsize_prefix()`` in ``StoreTests``. (:issue:`2693`)
-- Test that a ``ValueError`` is raised for invalid byte range syntax in ``StoreTests``. (:issue:`2693`)
-- Separate instantiating and opening a store in ``StoreTests``. (:issue:`2693`)
-- Add a test for using Stores as a context managers in ``StoreTests``. (:issue:`2693`)
-- Implemented ``LogingStore.open()``. (:issue:`2693`)
-- ``LoggingStore`` is now a generic class. (:issue:`2693`)
-- Change StoreTest's ``test_store_repr``, ``test_store_supports_writes``,
- ``test_store_supports_partial_writes``, and ``test_store_supports_listing``
- to to be implemented using ``@abstractmethod``, rather raising ``NotImplementedError``. (:issue:`2693`)
-- Test the error raised for invalid buffer arguments in ``StoreTests``. (:issue:`2693`)
-- Test that data can be written to a store that's not yet open using the store.set method in ``StoreTests``. (:issue:`2693`)
-- Adds a new function ``init_array`` for initializing an array in storage, and refactors ``create_array``
- to use ``init_array``. ``create_array`` takes two new parameters: ``data``, an optional array-like object, and ``write_data``, a bool which defaults to ``True``.
- If ``data`` is given to ``create_array``, then the ``dtype`` and ``shape`` attributes of ``data`` are used to define the
- corresponding attributes of the resulting Zarr array. Additionally, if ``data`` given and ``write_data`` is ``True``,
- then the values in ``data`` will be written to the newly created array. (:issue:`2761`)
-
-
-Bugfixes
-~~~~~~~~
-
-- Wrap sync fsspec filesystems with ``AsyncFileSystemWrapper``. (:issue:`2533`)
-- Added backwards compatibility for Zarr format 2 structured arrays. (:issue:`2681`)
-- Update equality for ``LoggingStore`` and ``WrapperStore`` such that 'other' must also be a ``LoggingStore`` or ``WrapperStore`` respectively, rather than only checking the types of the stores they wrap. (:issue:`2693`)
-- Ensure that ``ZipStore`` is open before getting or setting any values. (:issue:`2693`)
-- Use stdout rather than stderr as the default stream for ``LoggingStore``. (:issue:`2693`)
-- Match the errors raised by read only stores in ``StoreTests``. (:issue:`2693`)
-- Fixed ``ZipStore`` to make sure the correct attributes are saved when instances are pickled.
- This fixes a previous bug that prevent using ``ZipStore`` with a ``ProcessPoolExecutor``. (:issue:`2762`)
-- Updated the optional test dependencies to include ``botocore`` and ``fsspec``. (:issue:`2768`)
-- Fixed the fsspec tests to skip if ``botocore`` is not installed.
- Previously they would have failed with an import error. (:issue:`2768`)
-- Optimize full chunk writes. (:issue:`2782`)
-
-
-Improved Documentation
-~~~~~~~~~~~~~~~~~~~~~~
-
-- Changed the machinery for creating changelog entries.
- Now individual entries should be added as files to the `changes` directory in the `zarr-python` repository, instead of directly to the changelog file. (:issue:`2736`)
-
-Other
-~~~~~
-
-- Created a type alias ``ChunkKeyEncodingLike`` to model the union of ``ChunkKeyEncoding`` instances and the dict form of the
- parameters of those instances. ``ChunkKeyEncodingLike`` should be used by high-level functions to provide a convenient
- way for creating ``ChunkKeyEncoding`` objects. (:issue:`2763`)
-
-
-3.0.1 (Jan. 17, 2025)
----------------------
-
-* Implement ``zarr.from_array`` using concurrent streaming (:issue:`2622`).
-
-Bug fixes
-~~~~~~~~~
-* Fixes ``order`` argument for Zarr format 2 arrays (:issue:`2679`).
-
-* Fixes a bug that prevented reading Zarr format 2 data with consolidated
- metadata written using ``zarr-python`` version 2 (:issue:`2694`).
-
-* Ensure that compressor=None results in no compression when writing Zarr
- format 2 data (:issue:`2708`).
-
-* Fix for empty consolidated metadata dataset: backwards compatibility with
- Zarr-Python 2 (:issue:`2695`).
-
-Documentation
-~~~~~~~~~~~~~
-* Add v3.0.0 release announcement banner (:issue:`2677`).
-
-* Quickstart guide alignment with V3 API (:issue:`2697`).
-
-* Fix doctest failures related to numcodecs 0.15 (:issue:`2727`).
-
-Other
-~~~~~
-* Removed some unnecessary files from the source distribution
- to reduce its size. (:issue:`2686`).
-
-* Enable codecov in GitHub actions (:issue:`2682`).
-
-* Speed up hypothesis tests (:issue:`2650`).
-
-* Remove multiple imports for an import name (:issue:`2723`).
-
-
-.. _release_3.0.0:
-
-3.0.0 (Jan. 9, 2025)
---------------------
-
-3.0.0 is a new major release of Zarr-Python, with many breaking changes.
-See the :ref:`v3 migration guide` for a listing of what's changed.
-
-Normal release note service will resume with further releases in the 3.0.0
-series.
-
-Release notes for the zarr-python 2.x and 1.x releases can be found here:
-https://zarr.readthedocs.io/en/support-v2/release.html
diff --git a/docs/roadmap.md b/docs/roadmap.md
new file mode 100644
index 0000000000..4b5dbc4599
--- /dev/null
+++ b/docs/roadmap.md
@@ -0,0 +1,189 @@
+# Roadmap
+
+This page describes where Zarr-Python is headed: the goals for the next major
+cycle of work, the changes we intend to make, and how those changes will be
+released. It is a living document; discussion and counter-proposals are welcome
+on the
+[Zarr-Python issue tracker](https://github.com/zarr-developers/zarr-python/issues).
+
+*The history of this roadmap, including the detailed technical proposals it
+was distilled from, can be traced in the
+[zarr-python-planning](https://github.com/zarr-developers/zarr-python-planning)
+repository.*
+
+!!! note
+
+ This roadmap reflects the current thinking of the core developers. It is a
+ statement of direction, not a schedule. We don't know how long these changes
+ will take, only that we are committed to moving the project in the direction outlined
+ here.
+
+## Where we are
+
+The [3.0 release](https://github.com/zarr-developers/zarr-python/releases/tag/v3.0.0)
+was a total redesign of the library's internals, with three goals: full support
+for the Zarr V2 and V3 storage formats, storage APIs that are ergonomic for high-latency
+storage (such as cloud storage), and backwards compatibility with Zarr-Python 2.x where
+possible. Those goals were largely achieved! Going by the content of issues and pull requests
+submitted to the library, few users are grappling with 2.x → 3.x migration issues. Instead, we see
+users asking for things like better APIs, where "better" usually means faster.
+
+The 3.x redesign was carried out under hard backwards-compatibility
+constraints, and it inherited many structural patterns from the 2.x
+implementation it replaced. The library has never had a release cycle whose
+primary goal was the *shape* of the internals. The next body of work — which we
+call **"v4"** — is that overdue investment. We think iterating on the internals of
+the library will make it *much* easier to bring faster, more expressive APIs to Zarr-Python
+users.
+
+## Goals
+
+If the 3.0 goals could be sloganized as "migrate to Zarr V3, and improve cloud
+storage support", the slogan for the v4 goals is:
+**"a frictionless Zarr-based Python ecosystem for chunked arrays"**. Zarr-Python
+should be *foundational* for the growing number of Python packages that work
+with data in the Zarr format. Concretely, that means pushing in these
+directions:
+
+- Deliver excellent performance, out of the box, while retaining maintainability.
+- Make Zarr-Python APIs ergonomic and useful for developers.
+- Expand our scope to cover vital quality-of-life routines like data copying,
+ rechunking, and the like.
+- Ease the growth of Python tools across all levels of the Zarr stack.
+- Accelerate the implementation of new codecs, chunk grids, chunk key
+ encodings, etc.
+
+An important design input: [`zarrs`](https://github.com/zarrs/zarrs) (Rust) and
+[TensorStore](https://github.com/google/tensorstore) (C++) are two independent
+Zarr implementations that use architectural patterns we want to learn from.
+We see them as complementary rather than competitive.
+
+!!! note
+
+ Many of the features in this roadmap will not require breaking public 3.x APIs. We can and will
+ ship those features in 3.x releases; at the same time, we consider it clarifying to frame the
+ coherent development direction as vectored at a 4.0 milestone.
+
+## The Zarr stack
+
+Different applications need different levels of Zarr support: a convention
+validator only needs to read metadata documents; a visualization tool may only
+need read-only array access; other tools need everything. We think of this as a
+"Zarr stack", from most abstract to most concrete:
+
+1. **Conventions** — application and/or domain-specific schemas built on top of Zarr (OME-NGFF,
+ GeoZarr, anndata-zarr, multiscales).
+2. **Groups** — Zarr hierarchies, traversal, group-level attributes.
+3. **Arrays** — the user-facing array object, plus indexing and slicing.
+4. **Chunk decoding** — the codec pipeline.
+5. **Chunk addressing** — chunk grids and key encodings that map array
+ coordinates to store keys.
+6. **Stores** — the key-value layer.
+7. **Metadata** — pure data documents describing arrays and groups.
+
+Today, Zarr-Python is a monolith that serves every level: a consumer who only
+needs metadata handling has to install the full dependency footprint of the
+whole library, and a faster chunk-decoding implementation cannot plug in
+without re-implementing the layers above it. The v4 direction is to re-shape
+Zarr-Python around the stack, so that each level is something you can depend
+on, conform to, or replace, without buying every other level.
+
+We plan to "stackify" Zarr-Python by spinning core functionality out into separate Python packages, e.g. `zarr-metadata`, `zarr-indexing`, `zarr-storage`,
+`zarr-codec`, `zarr-dtype`, each with narrow scope, all composed in the `zarr` package. The Rust `zarrs` library
+successfully uses a structure like this, and we are keen to share the benefits of a more modular, maintainable codebase. Two of these subpackages,
+[`zarr-metadata`](https://zarr.readthedocs.io/projects/zarr-metadata/en/latest/) and [`zarr-indexing`](https://zarr.readthedocs.io/projects/zarr-indexing/en/latest/), are already
+published.
+
+## What we intend to change
+
+The following section details how we want to evolve the internal logic that drives Zarr-Python.
+
+### Foundation: swappable backends
+
+We propose to refactor Zarr-Python internals around a *swappable engine* — a protocol, or protocols,
+that define the core routines a Zarr implementation must support. Zarr-Python becomes one user-facing
+API that can be driven by multiple backends, including externally defined backends. We think this will allow users on many different platforms to get the best performance for their particular environment while retaining a familiar API.
+
+#### Rust bindings
+
+We want a Python backend (i.e., the status quo), but also a Rust-based backend, via bindings to the
+[`zarrs`](https://docs.rs/zarrs/latest/zarrs/) crate. The [zarrs-python](https://zarrs-python.readthedocs.io/en/latest/) project demonstrates that
+bridging `zarrs` and Zarr-Python buys a *lot* of performance in the specific case of chunk encoding. But zarrs-python is constrained today by limited
+modularity in Zarr-Python internals. Refactoring our internals around swappable backends should address this limitation.
+
+Any Python package that interfaces with `zarrs` will need Pythonic bindings to the Rust library. So we are *very* excited about the [zarrista](https://developmentseed.org/zarrista/latest/) package, which aims to provide complete Python bindings for `zarrs`.
+
+#### Sync / Async partitioning
+
+Internally we will branch over two kinds of backends: synchronous and asynchronous. The synchronous backend is suitable for arrays and groups persisted to low-latency storage like in-memory stores or local file systems, where async scheduling is pure friction. The asynchronous backend will use Python's `async` support and will provide concurrent APIs where it helps: for arrays and groups persisted to high-latency storage.
+
+### Lazy indexing
+
+The Zarr-Python Array API was initially designed to mirror NumPy, with eager
+array indexing syntax. `Array.__getitem__` performs IO eagerly and returns a NumPy array.
+That was helpful to the dominant use-case at the time of its creation, but it
+means deferred IO and computation currently require an external library
+such as Dask. It means there is no built-in support for representing multi-step
+reads as a single deferred plan. Further, it means that every chained
+selection round-trips to storage independently.
+
+We can fix this by introducing an API for lazy indexing. Under this model, an array indexing operation
+like `array[::2]` desugars to a declarative state like `(array, selection)`. Chained selections like
+`array[10:100][::2]` are fused immediately, and we defer actual IO for the time when the result of
+indexing is needed. [TensorStore](https://google.github.io/tensorstore/) is an excellent role model
+for Zarr-Python here, and we can deliver this functionality without breaking ordinary indexing behavior.
+See this [discussion](https://github.com/zarr-developers/zarr-python/discussions/1603) for more
+background.
+
+### Data types
+
+First-class support for ML-specific dtypes — `bfloat16`, the `float8`
+variants, packed `int4`/`uint4` — via
+[`ml_dtypes`](https://github.com/jax-ml/ml_dtypes). These data types have specifications written up in `zarr-extensions`, but there's no simple to get them integrated in Zarr-Python today.
+
+### Device-agnostic IO
+
+Make Zarr-Python's IO surfaces device-agnostic rather than adding GPU support
+as a bolted-on feature: stores and codecs grow APIs for writing into a
+caller-provided buffer (`read_into`, `decode_into`), and the `Array` facade
+returns array-like objects in the user's chosen Array API namespace. GPU
+support falls out once the assumption of CPU destinations is removed, and CPU
+paths get faster too, because pre-allocated output buffers eliminate per-chunk
+allocation.
+
+### Configuration, registries, and plugins
+
+Move configuration from "global mutable state read implicitly" to "typed data
+passed explicitly": a typed config object replacing the untyped global `donfig`
+dict, array-scoped runtime config passed at open time, a registry redesign that
+addresses implementations by stable identity and resolves plugin name-conflicts
+deliberately, and named profiles replacing global mutators.
+
+### Coordinated and distributed writes
+
+This area is actually an unfinished aspect of the 2.x → 3.0 migration: Zarr-Python 2.x supported
+synchronization logic via file-based locks, and we have not implemented equivalent functionality in
+3.x. We don't have *concrete* plans for closing this gap. Re-implementing simple object-based locking, for
+backends that support it, is a direct solution we should consider. But a transactional storage model,
+where a sequence of basic storage operations like reading and writing could be submitted in a batch and
+executed serially, with rollbacks under failure, is also quite appealing.
+As with array indexing, TensorStore is the trailblazer here, and we can learn from its example.
+
+We can also avoid the need for synchronization mechanisms entirely with better planning.
+Many users of the 2.x synchronization tooling needed to simply write values from one chunked source
+to another, without worrying about chunk alignment. This can be addressed e.g. by creating a write
+plan that partitions the input chunks into batches within which writes cannot race.
+
+## How to get involved
+
+- **Discuss the plans.** Comments and counter-proposals on any of the themes
+ above are welcome on the
+ [issue tracker](https://github.com/zarr-developers/zarr-python/issues) and in
+ the [developer chat](https://ossci.zulipchat.com/).
+- **Review in-flight work.** The `IndexTransform` algebra that lazy indexing is
+ built on is in review at
+ [#3906](https://github.com/zarr-developers/zarr-python/pull/3906).
+- **Weigh in as a downstream maintainer.** If your project's use of
+ Zarr-Python would be affected by the codec API rewrite, the stores rewrite,
+ or the lazy-indexing work, the planning phase is the time to surface
+ workloads or patterns that don't fit.
diff --git a/docs/subprojects.md b/docs/subprojects.md
new file mode 100644
index 0000000000..9f7951e836
--- /dev/null
+++ b/docs/subprojects.md
@@ -0,0 +1,45 @@
+# Subprojects
+
+Alongside `zarr` itself, the
+[zarr-python repository](https://github.com/zarr-developers/zarr-python) hosts a
+small number of companion packages. Each one is developed in the same repository
+but versioned, released, and documented independently, so you can depend on it
+without taking on `zarr` as a dependency.
+
+
+
+- [:material-code-json:{ .lg .middle } __zarr-metadata__](https://zarr.readthedocs.io/projects/zarr-metadata/)
+
+ ---
+
+ Spec-defined metadata types, models, and validators for Zarr v2 and v3, with
+ minimal dependencies. Useful if your software reads or writes Zarr metadata
+ documents but does not need a full Zarr implementation.
+
+ ```bash
+ pip install zarr-metadata
+ ```
+
+- [:material-vector-polyline:{ .lg .middle } __zarr-indexing__](https://zarr.readthedocs.io/projects/zarr-indexing/)
+
+ ---
+
+ Composable, lazy coordinate transforms for Zarr array indexing. Makes the
+ mapping from requested coordinates to stored coordinates a first-class,
+ composable value, and resolves which chunks a selection touches.
+
+ ```bash
+ pip install zarr-indexing
+ ```
+
+- [:material-server:{ .lg .middle } __zarr-http-server__](https://zarr.readthedocs.io/projects/zarr-http-server/)
+
+ ---
+
+ HTTP server for Zarr stores, arrays, and groups.
+
+ ```bash
+ pip install zarr-http-server
+ ```
+
+
diff --git a/docs/talks/scipy2019/submission.rst b/docs/talks/scipy2019/submission.rst
deleted file mode 100644
index 57fd925b1f..0000000000
--- a/docs/talks/scipy2019/submission.rst
+++ /dev/null
@@ -1,144 +0,0 @@
-Zarr - scalable storage of tensor data for use in parallel and distributed computing
-====================================================================================
-
-SciPy 2019 submission.
-
-
-Short summary
--------------
-
-Many scientific problems involve computing over large N-dimensional
-typed arrays of data, and reading or writing data is often the major
-bottleneck limiting speed or scalability. The Zarr project is
-developing a simple, scalable approach to storage of such data in a
-way that is compatible with a range of approaches to distributed and
-parallel computing. We describe the Zarr protocol and data storage
-format, and the current state of implementations for various
-programming languages including Python. We also describe current uses
-of Zarr in malaria genomics, the Human Cell Atlas, and the Pangeo
-project.
-
-
-Abstract
---------
-
-Background
-~~~~~~~~~~
-
-Across a broad range of scientific disciplines, data are naturally
-represented and stored as N-dimensional typed arrays, also known as
-tensors. The volume of data being generated is outstripping our
-ability to analyse it, and scientific communities are looking for ways
-to leverage modern multi-core CPUs and distributed computing
-platforms, including cloud computing. Retrieval and storage of data is
-often the major bottleneck, and new approaches to data storage are
-needed to accelerate distributed computations and enable them to scale
-on a variety of platforms.
-
-Methods
-~~~~~~~
-
-We have designed a new storage format and protocol for tensor data
-[1_], and have released an open source Python implementation [2_,
-3_]. Our approach builds on data storage concepts from HDF5 [4_],
-particularly chunking and compression, and hierarchical organisation
-of datasets. Key design goals include: a simple protocol and format
-that can be implemented in other programming languages; support for
-multiple concurrent readers or writers; support for a variety of
-parallel computing environments, from multi-threaded execution on a
-single CPU to multi-process execution across a multi-node cluster;
-pluggable storage subsystem with support for file systems, key-value
-databases and cloud object stores; pluggable encoding subsystem with
-support for a variety of modern compressors.
-
-Results
-~~~~~~~
-
-We illustrate the use of Zarr with examples from several scientific
-domains. Zarr is being used within the Pangeo project [5_], which is
-building a community platform for big data geoscience. The Pangeo
-community have converted a number of existing climate modelling and
-satellite observation datasets to Zarr [6_], and have demonstrated
-their use in computations using HPC and cloud computing
-environments. Within the MalariaGEN project [7_], Zarr is used to
-store genome variation data from next-generation sequencing of natural
-populations of malaria parasites and mosquitoes [8_] and these data
-are used as input to analyses of the evolution of these organisms in
-response to selective pressure from anti-malarial drugs and
-insecticides. Zarr is being used within the Human Cell Atlas (HCA)
-project [9_], which is building a reference atlas of healthy human
-cell types. This project hopes to leverage this information to better
-understand the dysregulation of cellular states that underly human
-disease. The Human Cell Atlas uses Zarr as the output data format
-because it enables the project to easily generate matrices containing
-user-selected subsets of cells.
-
-Conclusions
-~~~~~~~~~~~
-
-Zarr is generating interest across a range of scientific domains, and
-work is ongoing to establish a community process to support further
-development of the specifications and implementations in other
-programming languages [10_, 11_, 12_] and building interoperability
-with a similar project called N5 [13_]. Other packages within the
-PyData ecosystem, notably Dask [14_], Xarray [15_] and Intake [16_],
-have added capability to read and write Zarr, and together these
-packages provide a compelling solution for large scale data science
-using Python [17_]. Zarr has recently been presented in several
-venues, including a webinar for the ESIP Federation tech dive series
-[18_], and a talk at the AGU Fall Meeting 2018 [19_].
-
-
-References
-~~~~~~~~~~
-
-.. _1: https://zarr.readthedocs.io/en/stable/spec/v2.html
-.. _2: https://github.com/zarr-developers/zarr-python
-.. _3: https://github.com/zarr-developers/numcodecs
-.. _4: https://www.hdfgroup.org/solutions/hdf5/
-.. _5: https://pangeo.io/
-.. _6: https://pangeo.io/catalog.html
-.. _7: https://www.malariagen.net/
-.. _8: http://alimanfoo.github.io/2016/09/21/genotype-compression-benchmark.html
-.. _9: https://www.humancellatlas.org/
-.. _10: https://github.com/constantinpape/z5
-.. _11: https://github.com/lasersonlab/ndarray.scala
-.. _12: https://github.com/meggart/ZarrNative.jl
-.. _13: https://github.com/saalfeldlab/n5
-.. _14: http://docs.dask.org/en/latest/array-creation.html
-.. _15: http://xarray.pydata.org/en/stable/io.html
-.. _16: https://github.com/ContinuumIO/intake-xarray
-.. _17: http://matthewrocklin.com/blog/work/2018/01/22/pangeo-2
-.. _18: http://wiki.esipfed.org/index.php/Interoperability_and_Technology/Tech_Dive_Webinar_Series#8_March.2C_2018:_.22Zarr:_A_simple.2C_open.2C_scalable_solution_for_big_NetCDF.2FHDF_data_on_the_Cloud.22:_Alistair_Miles.2C_University_of_Oxford.
-.. _19: https://agu.confex.com/agu/fm18/meetingapp.cgi/Paper/390015
-
-
-Authors
--------
-
-Project contributors are listed in alphabetical order by surname.
-
-* `Ryan Abernathey `_, Columbia University
-* `Stephan Balmer `_, Meteotest
-* `Ambrose Carr `_, Chan Zuckerberg Initiative
-* `Tim Crone `_, Columbia University
-* `Martin Durant `_, Anaconda, inc.
-* `Jan Funke `_, HHMI Janelia
-* `Darren Gallagher `_, Satavia
-* `Fabian Gans `_, Max Planck Institute for Biogeochemistry
-* `Shikhar Goenka `_, Satavia
-* `Joe Hamman `_, NCAR
-* `Stephan Hoyer `_, Google
-* `Jerome Kelleher `_, University of Oxford
-* `John Kirkham `_, HHMI Janelia
-* `Alistair Miles `_, University of Oxford
-* `Josh Moore `_, University of Dundee
-* `Charles Noyes `_, University of Southern California
-* `Tarik Onalan `_
-* `Constantin Pape `_, University of Heidelberg
-* `Zain Patel `_, University of Cambridge
-* `Matthew Rocklin `_, NVIDIA
-* `Stephan Saafeld `_, HHMI Janelia
-* `Vincent Schut `_, Satelligence
-* `Justin Swaney `_, MIT
-* `Ryan Williams `_, Chan Zuckerberg Initiative
diff --git a/docs/user-guide/arrays.md b/docs/user-guide/arrays.md
new file mode 100644
index 0000000000..a192845f9e
--- /dev/null
+++ b/docs/user-guide/arrays.md
@@ -0,0 +1,767 @@
+# Working with arrays
+
+## Creating an array
+
+Zarr has several functions for creating arrays. For example:
+
+```python exec="true" session="arrays"
+import shutil
+shutil.rmtree('data', ignore_errors=True)
+import numpy as np
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+import zarr
+z = zarr.create_array(store="memory://arrays-demo", shape=(10000, 10000), chunks=(1000, 1000), dtype='int32')
+print(z)
+```
+
+The code above creates a 2-dimensional array of 32-bit integers with 10000 rows
+and 10000 columns, divided into chunks where each chunk has 1000 rows and 1000
+columns (and so there will be 100 chunks in total). The data is written to an
+in-memory store: when `fsspec` is installed, a `memory://` URL resolves to a
+[`zarr.storage.FsspecStore`][] backed by fsspec's in-memory filesystem; otherwise a
+[`zarr.storage.ManagedMemoryStore`][] is used. See the [Storage guide](storage.md)
+for more details on stores, and
+[Persistent arrays](#persistent-arrays) for details on storing arrays in other stores.
+See [Data types](data_types.md) for an in-depth look at the data types supported
+by Zarr, and [Chunk size and shape](performance.md#chunk-size-and-shape) in the
+performance guide for guidance on choosing chunk shapes.
+
+See the [`zarr.create_array`][] API documentation for more detailed information about
+creating arrays.
+
+## Reading and writing data
+
+Zarr arrays support a similar interface to [NumPy](https://numpy.org/doc/stable/)
+arrays for reading and writing data. For example, the entire array can be filled
+with a scalar value:
+
+```python exec="true" session="arrays" source="above"
+z[:] = 42
+```
+
+Regions of the array can also be written to, e.g.:
+
+```python exec="true" session="arrays" source="above"
+import numpy as np
+
+z[0, :] = np.arange(10000)
+z[:, 0] = np.arange(10000)
+```
+
+The contents of the array can be retrieved by slicing, which will load the
+requested region into memory as a NumPy array, e.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z[0, 0])
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z[-1, -1])
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z[0, :])
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z[:, 0])
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z[:])
+```
+
+More information about NumPy-style indexing can be found in the
+[NumPy documentation](https://numpy.org/doc/stable/user/basics.indexing.html).
+
+## Persistent arrays
+
+In the examples above, compressed data for each chunk of the array was stored in
+main memory. Zarr arrays can also be stored on a file system, enabling
+persistence of data between sessions. To do this, we can change the store
+argument to point to a filesystem path:
+
+```python exec="true" session="arrays" source="above"
+z1 = zarr.create_array(store='data/example-1.zarr', shape=(10000, 10000), chunks=(1000, 1000), dtype='int32')
+```
+
+The array above will store its configuration metadata and all compressed chunk
+data in a directory called `'data/example-1.zarr'` relative to the current working
+directory. The [`zarr.create_array`][] function provides a convenient way
+to create a new persistent array or continue working with an existing
+array. Note, there is no need to close an array: data are automatically
+flushed to disk, and files are automatically closed whenever an array is modified.
+
+Persistent arrays support the same interface for reading and writing data,
+e.g.:
+
+```python exec="true" session="arrays" source="above"
+z1[:] = 42
+z1[0, :] = np.arange(10000)
+z1[:, 0] = np.arange(10000)
+```
+
+Check that the data have been written and can be read again:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z2 = zarr.open_array('data/example-1.zarr', mode='r')
+print(np.all(z1[:] == z2[:]))
+```
+
+If you are just looking for a fast and convenient way to save NumPy arrays to
+disk then load back into memory later, the functions
+[`zarr.save`][] and [`zarr.load`][] may be
+useful. E.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+a = np.arange(10)
+zarr.save('data/example-2.zarr', a)
+print(zarr.load('data/example-2.zarr'))
+```
+
+Please note that there are a number of other options for persistent array
+storage, see the [Storage Guide](storage.md) for more details.
+
+## Resizing and appending
+
+A Zarr array can be resized, which means that any of its dimensions can be
+increased or decreased in length. For example:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z = zarr.create_array(store='data/example-3.zarr', shape=(10000, 10000), dtype='int32', chunks=(1000, 1000))
+z[:] = 42
+print(f"Original shape: {z.shape}")
+z.resize((20000, 10000))
+print(f"New shape: {z.shape}")
+```
+
+Note that when an array is resized, the underlying data are not rearranged in
+any way. If one or more dimensions are shrunk, any chunks falling outside the
+new array shape will be deleted from the underlying store.
+
+[`zarr.Array.append`][] is provided as a convenience function, which can be
+used to append data to any axis. E.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+a = np.arange(10000000, dtype='int32').reshape(10000, 1000)
+z = zarr.create_array(store='data/example-4.zarr', shape=a.shape, dtype=a.dtype, chunks=(1000, 100))
+z[:] = a
+print(f"Original shape: {z.shape}")
+z.append(a)
+print(f"Shape after first append: {z.shape}")
+z.append(np.vstack([a, a]), axis=1)
+print(f"Shape after second append: {z.shape}")
+```
+
+## Runtime configuration
+
+Zarr arrays are parametrized with a configuration that determines certain aspects of array behavior.
+
+We currently support five configuration options for arrays: `order`, `write_empty_chunks`, `read_missing_chunks`, `sharding_coalesce_max_gap_bytes`, and `sharding_coalesce_max_bytes`.
+
+| field | type | default | description |
+| - | - | - | - |
+| `order` | `Literal["C", "F"]` | `"C"` | The memory layout of arrays returned when reading data from the store.
+| `write_empty_chunks` | `bool` | `False` | Controls whether empty chunks are written to storage. See [Empty chunks](performance.md#empty-chunks).
+| `read_missing_chunks` | `bool` | `True` | Controls whether missing chunks are filled with the array's fill value on read. If `False`, reading missing chunks raises a [`ChunkNotFoundError`][zarr.errors.ChunkNotFoundError].
+| `sharding_coalesce_max_gap_bytes` | `int` | `1048576` (1 MiB) | When reading multiple chunks from the same shard, nearby byte ranges separated by no more than this many bytes are coalesced into a single request to the store.
+| `sharding_coalesce_max_bytes` | `int` | `16777216` (16 MiB) | Requests will not be coalesced if doing so would exceed this byte size.
+
+!!! info
+ The Zarr V3 spec states that readers should interpret an uninitialized chunk as containing the
+ array's `fill_value`. By default, Zarr-Python follows this behavior: a missing chunk is treated
+ as uninitialized and filled with the array's `fill_value`. However, if you know that all chunks
+ have been written (i.e., are initialized), you may want to treat a missing chunk as an error. Set
+ `read_missing_chunks=False` to raise a [`ChunkNotFoundError`][zarr.errors.ChunkNotFoundError] instead.
+
+!!! note
+ `write_empty_chunks=False` skips writing chunks that are entirely the array's fill value.
+ If `read_missing_chunks=False`, attempting to read these missing chunks will raise a [`ChunkNotFoundError`][zarr.errors.ChunkNotFoundError].
+
+You can specify the configuration when you create an array with the `config` keyword argument.
+`config` can be passed as either a `dict` or an `ArrayConfig` object.
+
+```python exec="true" session="arrays" source="above" result="ansi"
+arr = zarr.create_array({}, shape=(10,), dtype='int8', config={"write_empty_chunks": True})
+print(arr.config)
+```
+
+To get an array view with a different config, use the `with_config` method.
+
+```python exec="true" session="arrays" source="above" result="ansi"
+arr_f = arr.with_config({"order": "F"})
+print(arr_f.config)
+```
+
+## Compressors
+
+A number of different compressors can be used with Zarr. Zarr includes Blosc,
+Zstandard and Gzip compressors. Additional compressors are available through
+a separate package called [NumCodecs](https://numcodecs.readthedocs.io/en/stable/) which provides various
+compressor libraries including LZ4, Zlib, BZ2 and LZMA.
+Different compressors can be provided via the `compressors` keyword
+argument accepted by all array creation functions. For example:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+compressors = zarr.codecs.BloscCodec(cname='zstd', clevel=3, shuffle='bitshuffle')
+data = np.arange(100000000, dtype='int32').reshape(10000, 10000)
+z = zarr.create_array(store='data/example-5.zarr', shape=data.shape, dtype=data.dtype, chunks=(1000, 1000), compressors=compressors)
+z[:] = data
+print(z.compressors)
+```
+
+This array above will use Blosc as the primary compressor, using the Zstandard
+algorithm (compression level 3) internally within Blosc, and with the
+bit-shuffle filter applied.
+
+When using a compressor, it can be useful to get some diagnostics on the
+compression ratio. Zarr arrays provide the [`zarr.Array.info`][] property
+which can be used to print useful diagnostics, e.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.info)
+```
+
+The [`zarr.Array.info_complete`][] method inspects the underlying store and
+prints additional diagnostics, e.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.info_complete())
+```
+
+!!! note
+ [`zarr.Array.info_complete`][] will inspect the underlying store and may
+ be slow for large arrays. Use [`zarr.Array.info`][] if detailed storage
+ statistics are not needed.
+
+If you don't specify a compressor, by default Zarr uses the Zstandard
+compressor.
+
+To create an array without any compression, set `compressors=None`:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z_no_compress = zarr.create_array(store='data/example-uncompressed.zarr', shape=(10000, 10000), chunks=(1000, 1000), dtype='int32', compressors=None)
+print(f"Compressors: {z_no_compress.compressors}")
+```
+
+In addition to Blosc and Zstandard, other compression libraries can also be used. For example,
+here is an array using Gzip compression, level 1:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+data = np.arange(100000000, dtype='int32').reshape(10000, 10000)
+z = zarr.create_array(store='data/example-6.zarr', shape=data.shape, dtype=data.dtype, chunks=(1000, 1000), compressors=zarr.codecs.GzipCodec(level=1))
+z[:] = data
+print(f"Compressors: {z.compressors}")
+```
+
+Here is an example using LZMA from [NumCodecs](https://numcodecs.readthedocs.io/en/stable/) with a custom filter pipeline including LZMA's
+built-in delta filter:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+import lzma
+from zarr.codecs.numcodecs import LZMA
+
+lzma_filters = [dict(id=lzma.FILTER_DELTA, dist=4), dict(id=lzma.FILTER_LZMA2, preset=1)]
+compressors = LZMA(filters=lzma_filters)
+data = np.arange(100000000, dtype='int32').reshape(10000, 10000)
+z = zarr.create_array(store='data/example-7.zarr', shape=data.shape, dtype=data.dtype, chunks=(1000, 1000), compressors=compressors)
+print(f"Compressors: {z.compressors}")
+```
+
+## Filters
+
+In some cases, compression can be improved by transforming the data in some
+way. For example, if nearby values tend to be correlated, then shuffling the
+bytes within each numerical value or storing the difference between adjacent
+values may increase compression ratio. Some compressors provide built-in filters
+that apply transformations to the data prior to compression. For example, the
+Blosc compressor has built-in implementations of byte- and bit-shuffle filters,
+and the LZMA compressor has a built-in implementation of a delta
+filter. However, to provide additional flexibility for implementing and using
+filters in combination with different compressors, Zarr also provides a
+mechanism for configuring filters outside of the primary compressor.
+
+Here is an example using a delta filter with the Blosc compressor:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+from zarr.codecs.numcodecs import Delta
+
+filters = [Delta(dtype='int32')]
+compressors = zarr.codecs.BloscCodec(cname='zstd', clevel=1, shuffle='shuffle')
+data = np.arange(100000000, dtype='int32').reshape(10000, 10000)
+z = zarr.create_array(store='data/example-9.zarr', shape=data.shape, dtype=data.dtype, chunks=(1000, 1000), filters=filters, compressors=compressors)
+print(z.info_complete())
+```
+
+For more information about available filter codecs, see the [Numcodecs](https://numcodecs.readthedocs.io/en/stable/) documentation.
+
+## Advanced indexing
+
+Zarr arrays support several methods for advanced or "fancy"
+indexing, which enable a subset of data items to be extracted or updated in an
+array without loading the entire array into memory.
+
+Note that although this functionality is similar to some of the advanced
+indexing capabilities available on NumPy arrays and on h5py datasets, **the Zarr
+API for advanced indexing is different from both NumPy and h5py**, so please
+read this section carefully. For a complete description of the indexing API,
+see the documentation for the [`zarr.Array`][] class.
+
+### Indexing with coordinate arrays
+
+Items from a Zarr array can be extracted by providing an integer array of
+coordinates. E.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+data = np.arange(10) ** 2
+z = zarr.create_array(store='data/example-10.zarr', shape=data.shape, dtype=data.dtype)
+z[:] = data
+print(z[:])
+print(z.get_coordinate_selection([2, 5]))
+```
+
+Coordinate arrays can also be used to update data, e.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z.set_coordinate_selection([2, 5], [-1, -2])
+print(z[:])
+```
+
+For multidimensional arrays, coordinates must be provided for each dimension,
+e.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+data = np.arange(15).reshape(3, 5)
+z = zarr.create_array(store='data/example-11.zarr', shape=data.shape, dtype=data.dtype)
+z[:] = data
+print(z[:])
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.get_coordinate_selection(([0, 2], [1, 3])))
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z.set_coordinate_selection(([0, 2], [1, 3]), [-1, -2])
+print(z[:])
+```
+
+For convenience, coordinate indexing is also available via the `vindex`
+property, as well as the square bracket operator, e.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.vindex[[0, 2], [1, 3]])
+z.vindex[[0, 2], [1, 3]] = [-3, -4]
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z[:])
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z[[0, 2], [1, 3]])
+```
+
+When the indexing arrays have different shapes, they are broadcast together.
+That is, the following two calls are equivalent:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z[1, [1, 3]])
+print(z[[1, 1], [1, 3]])
+```
+
+### Indexing with a mask array
+
+Items can also be extracted by providing a Boolean mask. E.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+data = np.arange(10) ** 2
+z = zarr.create_array(store='data/example-12.zarr', shape=data.shape, dtype=data.dtype)
+z[:] = data
+print(z[:])
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+sel = np.zeros_like(z, dtype=bool)
+sel[2] = True
+sel[5] = True
+print(z.get_mask_selection(sel))
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z.set_mask_selection(sel, [-1, -2])
+print(z[:])
+```
+
+Here's a multidimensional example:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+data = np.arange(15).reshape(3, 5)
+z = zarr.create_array(store='data/example-13.zarr', shape=data.shape, dtype=data.dtype)
+z[:] = data
+print(z[:])
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+sel = np.zeros_like(z, dtype=bool)
+sel[0, 1] = True
+sel[2, 3] = True
+print(z.get_mask_selection(sel))
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z.set_mask_selection(sel, [-1, -2])
+print(z[:])
+```
+
+For convenience, mask indexing is also available via the `vindex` property,
+e.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.vindex[sel])
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+
+z.vindex[sel] = [-3, -4]
+print(z[:])
+```
+
+Mask indexing is conceptually the same as coordinate indexing, and is
+implemented internally via the same machinery. Both styles of indexing allow
+selecting arbitrary items from an array, also known as point selection.
+
+### Orthogonal indexing
+
+Zarr arrays also support methods for orthogonal indexing, which allows
+selections to be made along each dimension of an array independently. For
+example, this allows selecting a subset of rows and/or columns from a
+2-dimensional array. E.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+data = np.arange(15).reshape(3, 5)
+z = zarr.create_array(store='data/example-14.zarr', shape=data.shape, dtype=data.dtype)
+z[:] = data
+print(z[:])
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.get_orthogonal_selection(([0, 2], slice(None)))) # select first and third rows
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.get_orthogonal_selection((slice(None), [1, 3]))) # select second and fourth columns
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.get_orthogonal_selection(([0, 2], [1, 3]))) # select rows [0, 2] and columns [1, 3]
+```
+
+Data can also be modified, e.g.:
+
+```python exec="true" session="arrays" source="above"
+z.set_orthogonal_selection(([0, 2], [1, 3]), [[-1, -2], [-3, -4]])
+```
+
+For convenience, the orthogonal indexing functionality is also available via the
+`oindex` property, e.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+data = np.arange(15).reshape(3, 5)
+z = zarr.create_array(store='data/example-15.zarr', shape=data.shape, dtype=data.dtype)
+z[:] = data
+print(z.oindex[[0, 2], :]) # select first and third rows
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.oindex[:, [1, 3]]) # select second and fourth columns
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.oindex[[0, 2], [1, 3]]) # select rows [0, 2] and columns [1, 3]
+```
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z.oindex[[0, 2], [1, 3]] = [[-1, -2], [-3, -4]]
+print(z[:])
+```
+
+Any combination of integer, slice, 1D integer array and/or 1D Boolean array can
+be used for orthogonal indexing.
+
+If the index contains at most one iterable, and otherwise contains only slices and integers,
+orthogonal indexing is also available directly on the array:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+data = np.arange(15).reshape(3, 5)
+z = zarr.create_array(store='data/example-16.zarr', shape=data.shape, dtype=data.dtype)
+z[:] = data
+print(np.all(z.oindex[[0, 2], :] == z[[0, 2], :]))
+```
+
+### Block indexing
+
+Zarr also supports block indexing, which allows selections of whole chunks based on their
+logical indices along each dimension of an array. For example, this allows selecting
+a subset of chunk aligned rows and/or columns from a 2-dimensional array. E.g.:
+
+```python exec="true" session="arrays" source="above"
+data = np.arange(100).reshape(10, 10)
+z = zarr.create_array(store='data/example-17.zarr', shape=data.shape, dtype=data.dtype, chunks=(3, 3))
+z[:] = data
+```
+
+Retrieve items by specifying their block coordinates:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.get_block_selection(1))
+```
+
+Equivalent slicing:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z[3:6])
+```
+
+For convenience, the block selection functionality is also available via the
+`blocks` property, e.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.blocks[1])
+```
+
+Block index arrays may be multidimensional to index multidimensional arrays.
+For example:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.blocks[0, 1:3])
+```
+
+Data can also be modified. Let's start by a simple 2D array:
+
+```python exec="true" session="arrays" source="above"
+z = zarr.create_array(store='data/example-18.zarr', shape=(6, 6), dtype=int, chunks=(2, 2))
+```
+
+Set data for a selection of items:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z.set_block_selection((1, 0), 1)
+print(z[...])
+```
+
+For convenience, this functionality is also available via the `blocks` property.
+E.g.:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z.blocks[:, 2] = 7
+print(z[...])
+```
+
+Any combination of integer and slice can be used for block indexing:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.blocks[2, 1:3])
+```
+
+## Sharding
+
+Using small chunk shapes in very large arrays can lead to a very large number of chunks.
+This can become a performance issue for file systems and object storage.
+With Zarr format 3, a new sharding feature has been added to address this issue.
+
+With sharding, multiple chunks can be stored in a single storage object (e.g. a file).
+Within a shard, chunks are compressed and serialized separately.
+This allows individual chunks to be read independently.
+However, when writing data, a full shard must be written in one go for optimal
+performance and to avoid concurrency issues.
+That means that shards are the units of writing and chunks are the units of reading.
+Users need to configure the chunk and shard shapes accordingly. For guidance on
+choosing chunk and shard shapes, see [Sharding](performance.md#sharding) in the
+performance guide.
+
+Sharded arrays can be created by providing the `shards` parameter to [`zarr.create_array`][].
+
+```python exec="true" session="arrays" source="above" result="ansi"
+a = zarr.create_array('data/example-20.zarr', shape=(10000, 10000), shards=(1000, 1000), chunks=(100, 100), dtype='uint8')
+a[:] = (np.arange(10000 * 10000) % 256).astype('uint8').reshape(10000, 10000)
+print(a.info_complete())
+```
+
+In this example a shard shape of (1000, 1000) and a chunk shape of (100, 100) is used.
+This means that `10*10` chunks are stored in each shard, and there are `10*10` shards in total.
+Without the `shards` argument, there would be 10,000 chunks stored as individual files.
+
+## Rectilinear (variable) chunk grids
+
+!!! warning "Experimental"
+ Rectilinear chunk grids are an experimental feature and may change in
+ future releases. This feature is expected to stabilize in Zarr version 3.3.
+
+ Because the feature is still stabilizing, it is disabled by default and
+ must be explicitly enabled:
+
+ ```python exec="true" session="arrays" source="above"
+ import zarr
+ zarr.config.set({"array.rectilinear_chunks": True})
+ ```
+
+ Or via the environment variable `ZARR_ARRAY__RECTILINEAR_CHUNKS=True`.
+
+ The examples below assume this config has been set.
+
+By default, Zarr arrays use a regular chunk grid where every chunk along a
+given dimension has the same size (except possibly the final boundary chunk).
+Rectilinear chunk grids allow each chunk along a dimension to have a different
+size. This is useful when the natural partitioning of the data is not uniform —
+for example, satellite swaths of varying width, time series with irregular
+intervals, or spatial tiles of different extents.
+
+### Creating arrays with rectilinear chunks
+
+To create an array with rectilinear chunks, pass a nested list to the `chunks`
+parameter where each inner list gives the chunk sizes along one dimension:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z = zarr.create_array(
+ store=zarr.storage.MemoryStore(),
+ shape=(60, 100),
+ chunks=[[10, 20, 30], [50, 50]],
+ dtype='int32',
+)
+print(z.info)
+```
+
+In this example the first dimension is split into three chunks of sizes 10, 20,
+and 30, while the second dimension is split into two equal chunks of size 50.
+
+### Reading and writing data
+
+Rectilinear arrays support the same indexing interface as regular arrays.
+Reads and writes that cross chunk boundaries of different sizes are handled
+automatically:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+import numpy as np
+data = np.arange(60 * 100, dtype='int32').reshape(60, 100)
+z[:] = data
+# Read a slice that spans the first two chunks (sizes 10 and 20) along axis 0
+print(z[5:25, 0:5])
+```
+
+### Inspecting chunk sizes
+
+The `.write_chunk_sizes` property returns the actual data size of each storage
+chunk along every dimension. It works for both regular and rectilinear arrays
+and returns a tuple of tuples (matching the dask `Array.chunks` convention).
+When sharding is used, `.read_chunk_sizes` returns the inner chunk sizes instead:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+print(z.write_chunk_sizes)
+```
+
+For regular arrays, this includes the boundary chunk:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z_regular = zarr.create_array(
+ store=zarr.storage.MemoryStore(),
+ shape=(100, 80),
+ chunks=(30, 40),
+ dtype='int32',
+)
+print(z_regular.write_chunk_sizes)
+```
+
+Note that the `.chunks` property is only available for regular chunk grids. For
+rectilinear arrays, use `.write_chunk_sizes` (or `.read_chunk_sizes`) instead.
+
+### Resizing and appending
+
+Rectilinear arrays can be resized. When growing past the current edge sum, a
+new chunk is appended covering the additional extent. When shrinking, the chunk
+edges are preserved and the extent is re-bound (chunks beyond the new extent
+simply become inactive):
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z = zarr.create_array(
+ store=zarr.storage.MemoryStore(),
+ shape=(30,),
+ chunks=[[10, 20]],
+ dtype='float64',
+)
+z[:] = np.arange(30, dtype='float64')
+print(f"Before resize: chunk_sizes={z.write_chunk_sizes}")
+z.resize((50,))
+print(f"After resize: chunk_sizes={z.write_chunk_sizes}")
+```
+
+The `append` method also works with rectilinear arrays:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z.append(np.arange(10, dtype='float64'))
+print(f"After append: shape={z.shape}, chunk_sizes={z.write_chunk_sizes}")
+```
+
+### Compressors and filters
+
+Rectilinear arrays work with all codecs — compressors, filters, and checksums.
+Since each chunk may have a different size, the codec pipeline processes each
+chunk independently:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z = zarr.create_array(
+ store=zarr.storage.MemoryStore(),
+ shape=(60, 100),
+ chunks=[[10, 20, 30], [50, 50]],
+ dtype='float64',
+ filters=[zarr.codecs.TransposeCodec(order=(1, 0))],
+ compressors=[zarr.codecs.BloscCodec(cname='zstd', clevel=3)],
+)
+z[:] = np.arange(60 * 100, dtype='float64').reshape(60, 100)
+np.testing.assert_array_equal(z[:], np.arange(60 * 100, dtype='float64').reshape(60, 100))
+print("Roundtrip OK")
+```
+
+### Rectilinear shard boundaries
+
+Rectilinear chunk grids can also be used for shard boundaries when combined
+with sharding. In this case, the outer grid (shards) is rectilinear while the
+inner chunks remain regular. Each shard dimension must be divisible by the
+corresponding inner chunk size:
+
+```python exec="true" session="arrays" source="above" result="ansi"
+z = zarr.create_array(
+ store=zarr.storage.MemoryStore(),
+ shape=(120, 100),
+ chunks=(10, 10),
+ shards=[[60, 40, 20], [50, 50]],
+ dtype='int32',
+)
+z[:] = np.arange(120 * 100, dtype='int32').reshape(120, 100)
+print(z[50:70, 40:60])
+```
+
+Note that rectilinear inner chunks with sharding are not supported — only the
+shard boundaries can be rectilinear.
+
+### Metadata format
+
+Rectilinear chunk grid metadata uses run-length encoding (RLE) for compact
+serialization. When reading metadata, both bare integers and `[value, count]`
+pairs are accepted:
+
+- `[10, 20, 30]` — three chunks with explicit sizes
+- `[[10, 3]]` — three chunks of size 10 (RLE shorthand)
+- `[[10, 3], 5]` — three chunks of size 10, then one chunk of size 5
+
+When writing, Zarr automatically compresses repeated values into RLE format.
+
+## Features not yet ported to Zarr-Python 3
+
+The following Zarr-Python 2 features are not yet available in Zarr-Python 3.
+
+### Copying and migrating data
+
+See the Zarr-Python 2 documentation on [Copying and migrating data](https://zarr.readthedocs.io/en/support-v2/tutorial.html#copying-migrating-data) for more details.
diff --git a/docs/user-guide/arrays.rst b/docs/user-guide/arrays.rst
deleted file mode 100644
index baaf544e44..0000000000
--- a/docs/user-guide/arrays.rst
+++ /dev/null
@@ -1,636 +0,0 @@
-.. only:: doctest
-
- >>> import shutil
- >>> shutil.rmtree('data', ignore_errors=True)
-
-.. _user-guide-arrays:
-
-Working with arrays
-===================
-
-Creating an array
------------------
-
-Zarr has several functions for creating arrays. For example::
-
- >>> import zarr
- >>> store = zarr.storage.MemoryStore()
- >>> z = zarr.create_array(store=store, shape=(10000, 10000), chunks=(1000, 1000), dtype='int32')
- >>> z
-
-
-The code above creates a 2-dimensional array of 32-bit integers with 10000 rows
-and 10000 columns, divided into chunks where each chunk has 1000 rows and 1000
-columns (and so there will be 100 chunks in total). The data is written to a
-:class:`zarr.storage.MemoryStore` (e.g. an in-memory dict). See
-:ref:`user-guide-persist` for details on storing arrays in other stores, and see
-:ref:`user-guide-data-types` for an in-depth look at the data types supported by Zarr.
-
-For a complete list of array creation routines see the :mod:`zarr`
-module documentation.
-
-.. _user-guide-array:
-
-Reading and writing data
-------------------------
-
-Zarr arrays support a similar interface to `NumPy `_
-arrays for reading and writing data. For example, the entire array can be filled
-with a scalar value::
-
- >>> z[:] = 42
-
-Regions of the array can also be written to, e.g.::
-
- >>> import numpy as np
- >>>
- >>> z[0, :] = np.arange(10000)
- >>> z[:, 0] = np.arange(10000)
-
-The contents of the array can be retrieved by slicing, which will load the
-requested region into memory as a NumPy array, e.g.::
-
- >>> z[0, 0]
- array(0, dtype=int32)
- >>> z[-1, -1]
- array(42, dtype=int32)
- >>> z[0, :]
- array([ 0, 1, 2, ..., 9997, 9998, 9999],
- shape=(10000,), dtype=int32)
- >>> z[:, 0]
- array([ 0, 1, 2, ..., 9997, 9998, 9999],
- shape=(10000,), dtype=int32)
- >>> z[:]
- array([[ 0, 1, 2, ..., 9997, 9998, 9999],
- [ 1, 42, 42, ..., 42, 42, 42],
- [ 2, 42, 42, ..., 42, 42, 42],
- ...,
- [9997, 42, 42, ..., 42, 42, 42],
- [9998, 42, 42, ..., 42, 42, 42],
- [9999, 42, 42, ..., 42, 42, 42]],
- shape=(10000, 10000), dtype=int32)
-
-Read more about NumPy-style indexing can be found in the
-`NumPy documentation `_.
-
-.. _user-guide-persist:
-
-Persistent arrays
------------------
-
-In the examples above, compressed data for each chunk of the array was stored in
-main memory. Zarr arrays can also be stored on a file system, enabling
-persistence of data between sessions. To do this, we can change the store
-argument to point to a filesystem path::
-
- >>> z1 = zarr.create_array(store='data/example-1.zarr', shape=(10000, 10000), chunks=(1000, 1000), dtype='int32')
-
-The array above will store its configuration metadata and all compressed chunk
-data in a directory called ``'data/example-1.zarr'`` relative to the current working
-directory. The :func:`zarr.create_array` function provides a convenient way
-to create a new persistent array or continue working with an existing
-array. Note, there is no need to close an array: data are automatically
-flushed to disk, and files are automatically closed whenever an array is modified.
-
-Persistent arrays support the same interface for reading and writing data,
-e.g.::
-
- >>> z1[:] = 42
- >>> z1[0, :] = np.arange(10000)
- >>> z1[:, 0] = np.arange(10000)
-
-Check that the data have been written and can be read again::
-
- >>> z2 = zarr.open_array('data/example-1.zarr', mode='r')
- >>> np.all(z1[:] == z2[:])
- np.True_
-
-If you are just looking for a fast and convenient way to save NumPy arrays to
-disk then load back into memory later, the functions
-:func:`zarr.save` and :func:`zarr.load` may be
-useful. E.g.::
-
- >>> a = np.arange(10)
- >>> zarr.save('data/example-2.zarr', a)
- >>> zarr.load('data/example-2.zarr')
- array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
-
-Please note that there are a number of other options for persistent array
-storage, see the :ref:`Storage Guide ` guide for more details.
-
-.. _user-guide-resize:
-
-Resizing and appending
-----------------------
-
-A Zarr array can be resized, which means that any of its dimensions can be
-increased or decreased in length. For example::
-
- >>> z = zarr.create_array(store='data/example-3.zarr', shape=(10000, 10000), dtype='int32',chunks=(1000, 1000))
- >>> z[:] = 42
- >>> z.shape
- (10000, 10000)
- >>> z.resize((20000, 10000))
- >>> z.shape
- (20000, 10000)
-
-Note that when an array is resized, the underlying data are not rearranged in
-any way. If one or more dimensions are shrunk, any chunks falling outside the
-new array shape will be deleted from the underlying store.
-
-:func:`zarr.Array.append` is provided as a convenience function, which can be
-used to append data to any axis. E.g.::
-
- >>> a = np.arange(10000000, dtype='int32').reshape(10000, 1000)
- >>> z = zarr.create_array(store='data/example-4.zarr', shape=a.shape, dtype=a.dtype, chunks=(1000, 100))
- >>> z[:] = a
- >>> z.shape
- (10000, 1000)
- >>> z.append(a)
- (20000, 1000)
- >>> z.append(np.vstack([a, a]), axis=1)
- (20000, 2000)
- >>> z.shape
- (20000, 2000)
-
-.. _user-guide-compress:
-
-Compressors
------------
-
-A number of different compressors can be used with Zarr. Zarr includes Blosc,
-Zstandard and Gzip compressors. Additional compressors are available through
-a separate package called NumCodecs_ which provides various
-compressor libraries including LZ4, Zlib, BZ2 and LZMA.
-Different compressors can be provided via the ``compressors`` keyword
-argument accepted by all array creation functions. For example::
-
- >>> compressors = zarr.codecs.BloscCodec(cname='zstd', clevel=3, shuffle=zarr.codecs.BloscShuffle.bitshuffle)
- >>> data = np.arange(100000000, dtype='int32').reshape(10000, 10000)
- >>> z = zarr.create_array(store='data/example-5.zarr', shape=data.shape, dtype=data.dtype, chunks=(1000, 1000), compressors=compressors)
- >>> z[:] = data
- >>> z.compressors
- (BloscCodec(typesize=4, cname=, clevel=3, shuffle=, blocksize=0),)
-
-This array above will use Blosc as the primary compressor, using the Zstandard
-algorithm (compression level 3) internally within Blosc, and with the
-bit-shuffle filter applied.
-
-When using a compressor, it can be useful to get some diagnostics on the
-compression ratio. Zarr arrays provide the :attr:`zarr.Array.info` property
-which can be used to print useful diagnostics, e.g.::
-
- >>> z.info
- Type : Array
- Zarr format : 3
- Data type : Int32(endianness='little')
- Fill value : 0
- Shape : (10000, 10000)
- Chunk shape : (1000, 1000)
- Order : C
- Read-only : False
- Store type : LocalStore
- Filters : ()
- Serializer : BytesCodec(endian=)
- Compressors : (BloscCodec(typesize=4, cname=, clevel=3, shuffle=, blocksize=0),)
- No. bytes : 400000000 (381.5M)
-
-The :func:`zarr.Array.info_complete` method inspects the underlying store and
-prints additional diagnostics, e.g.::
-
- >>> z.info_complete()
- Type : Array
- Zarr format : 3
- Data type : Int32(endianness='little')
- Fill value : 0
- Shape : (10000, 10000)
- Chunk shape : (1000, 1000)
- Order : C
- Read-only : False
- Store type : LocalStore
- Filters : ()
- Serializer : BytesCodec(endian=)
- Compressors : (BloscCodec(typesize=4, cname=, clevel=3, shuffle=, blocksize=0),)
- No. bytes : 400000000 (381.5M)
- No. bytes stored : 3558573
- Storage ratio : 112.4
- Chunks Initialized : 100
-
-.. note::
- :func:`zarr.Array.info_complete` will inspect the underlying store and may
- be slow for large arrays. Use :attr:`zarr.Array.info` if detailed storage
- statistics are not needed.
-
-If you don't specify a compressor, by default Zarr uses the Zstandard
-compressor.
-
-In addition to Blosc and Zstandard, other compression libraries can also be used. For example,
-here is an array using Gzip compression, level 1::
-
- >>> data = np.arange(100000000, dtype='int32').reshape(10000, 10000)
- >>> z = zarr.create_array(store='data/example-6.zarr', shape=data.shape, dtype=data.dtype, chunks=(1000, 1000), compressors=zarr.codecs.GzipCodec(level=1))
- >>> z[:] = data
- >>> z.compressors
- (GzipCodec(level=1),)
-
-Here is an example using LZMA from NumCodecs_ with a custom filter pipeline including LZMA's
-built-in delta filter::
-
- >>> import lzma
- >>> from numcodecs.zarr3 import LZMA
- >>>
- >>> lzma_filters = [dict(id=lzma.FILTER_DELTA, dist=4), dict(id=lzma.FILTER_LZMA2, preset=1)]
- >>> compressors = LZMA(filters=lzma_filters)
- >>> data = np.arange(100000000, dtype='int32').reshape(10000, 10000)
- >>> z = zarr.create_array(store='data/example-7.zarr', shape=data.shape, dtype=data.dtype, chunks=(1000, 1000), compressors=compressors)
- >>> z.compressors
- (LZMA(codec_name='numcodecs.lzma', codec_config={'filters': [{'id': 3, 'dist': 4}, {'id': 33, 'preset': 1}]}),)
-
-The default compressor can be changed by setting the value of the using Zarr's
-:ref:`user-guide-config`, e.g.::
-
- >>> with zarr.config.set({'array.v2_default_compressor.default': {'id': 'blosc'}}):
- ... z = zarr.create_array(store={}, shape=(100000000,), chunks=(1000000,), dtype='int32', zarr_format=2)
- >>> z.filters
- ()
- >>> z.compressors
- (Blosc(cname='lz4', clevel=5, shuffle=SHUFFLE, blocksize=0),)
-
-To disable compression, set ``compressors=None`` when creating an array, e.g.::
-
- >>> z = zarr.create_array(store='data/example-8.zarr', shape=(100000000,), chunks=(1000000,), dtype='int32', compressors=None)
- >>> z.compressors
- ()
-
-.. _user-guide-filters:
-
-Filters
--------
-
-In some cases, compression can be improved by transforming the data in some
-way. For example, if nearby values tend to be correlated, then shuffling the
-bytes within each numerical value or storing the difference between adjacent
-values may increase compression ratio. Some compressors provide built-in filters
-that apply transformations to the data prior to compression. For example, the
-Blosc compressor has built-in implementations of byte- and bit-shuffle filters,
-and the LZMA compressor has a built-in implementation of a delta
-filter. However, to provide additional flexibility for implementing and using
-filters in combination with different compressors, Zarr also provides a
-mechanism for configuring filters outside of the primary compressor.
-
-Here is an example using a delta filter with the Blosc compressor::
-
- >>> from numcodecs.zarr3 import Delta
- >>>
- >>> filters = [Delta(dtype='int32')]
- >>> compressors = zarr.codecs.BloscCodec(cname='zstd', clevel=1, shuffle=zarr.codecs.BloscShuffle.shuffle)
- >>> data = np.arange(100000000, dtype='int32').reshape(10000, 10000)
- >>> z = zarr.create_array(store='data/example-9.zarr', shape=data.shape, dtype=data.dtype, chunks=(1000, 1000), filters=filters, compressors=compressors)
- >>> z.info
- Type : Array
- Zarr format : 3
- Data type : Int32(endianness='little')
- Fill value : 0
- Shape : (10000, 10000)
- Chunk shape : (1000, 1000)
- Order : C
- Read-only : False
- Store type : LocalStore
- Filters : (Delta(codec_name='numcodecs.delta', codec_config={'dtype': 'int32'}),)
- Serializer : BytesCodec(endian=)
- Compressors : (BloscCodec(typesize=4, cname=, clevel=1, shuffle=, blocksize=0),)
- No. bytes : 400000000 (381.5M)
-
-For more information about available filter codecs, see the `Numcodecs
-`_ documentation.
-
-.. _user-guide-indexing:
-
-Advanced indexing
------------------
-
-Zarr arrays support several methods for advanced or "fancy"
-indexing, which enable a subset of data items to be extracted or updated in an
-array without loading the entire array into memory.
-
-Note that although this functionality is similar to some of the advanced
-indexing capabilities available on NumPy arrays and on h5py datasets, **the Zarr
-API for advanced indexing is different from both NumPy and h5py**, so please
-read this section carefully. For a complete description of the indexing API,
-see the documentation for the :class:`zarr.Array` class.
-
-Indexing with coordinate arrays
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-Items from a Zarr array can be extracted by providing an integer array of
-coordinates. E.g.::
-
- >>> data = np.arange(10) ** 2
- >>> z = zarr.create_array(store='data/example-10.zarr', shape=data.shape, dtype=data.dtype)
- >>> z[:] = data
- >>> z[:]
- array([ 0, 1, 4, 9, 16, 25, 36, 49, 64, 81])
- >>> z.get_coordinate_selection([2, 5])
- array([ 4, 25])
-
-Coordinate arrays can also be used to update data, e.g.::
-
- >>> z.set_coordinate_selection([2, 5], [-1, -2])
- >>> z[:]
- array([ 0, 1, -1, 9, 16, -2, 36, 49, 64, 81])
-
-For multidimensional arrays, coordinates must be provided for each dimension,
-e.g.::
-
- >>> data = np.arange(15).reshape(3, 5)
- >>> z = zarr.create_array(store='data/example-11.zarr', shape=data.shape, dtype=data.dtype)
- >>> z[:] = data
- >>> z[:]
- array([[ 0, 1, 2, 3, 4],
- [ 5, 6, 7, 8, 9],
- [10, 11, 12, 13, 14]])
- >>> z.get_coordinate_selection(([0, 2], [1, 3]))
- array([ 1, 13])
- >>> z.set_coordinate_selection(([0, 2], [1, 3]), [-1, -2])
- >>> z[:]
- array([[ 0, -1, 2, 3, 4],
- [ 5, 6, 7, 8, 9],
- [10, 11, 12, -2, 14]])
-
-For convenience, coordinate indexing is also available via the ``vindex``
-property, as well as the square bracket operator, e.g.::
-
- >>> z.vindex[[0, 2], [1, 3]]
- array([-1, -2])
- >>> z.vindex[[0, 2], [1, 3]] = [-3, -4]
- >>> z[:]
- array([[ 0, -3, 2, 3, 4],
- [ 5, 6, 7, 8, 9],
- [10, 11, 12, -4, 14]])
- >>> z[[0, 2], [1, 3]]
- array([-3, -4])
-
-When the indexing arrays have different shapes, they are broadcast together.
-That is, the following two calls are equivalent::
-
- >>> z[1, [1, 3]]
- array([6, 8])
- >>> z[[1, 1], [1, 3]]
- array([6, 8])
-
-Indexing with a mask array
-~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-Items can also be extracted by providing a Boolean mask. E.g.::
-
- >>> data = np.arange(10) ** 2
- >>> z = zarr.create_array(store='data/example-12.zarr', shape=data.shape, dtype=data.dtype)
- >>> z[:] = data
- >>> z[:]
- array([ 0, 1, 4, 9, 16, 25, 36, 49, 64, 81])
- >>> sel = np.zeros_like(z, dtype=bool)
- >>> sel[2] = True
- >>> sel[5] = True
- >>> z.get_mask_selection(sel)
- array([ 4, 25])
- >>> z.set_mask_selection(sel, [-1, -2])
- >>> z[:]
- array([ 0, 1, -1, 9, 16, -2, 36, 49, 64, 81])
-
-Here's a multidimensional example::
-
- >>> data = np.arange(15).reshape(3, 5)
- >>> z = zarr.create_array(store='data/example-13.zarr', shape=data.shape, dtype=data.dtype)
- >>> z[:] = data
- >>> z[:]
- array([[ 0, 1, 2, 3, 4],
- [ 5, 6, 7, 8, 9],
- [10, 11, 12, 13, 14]])
- >>> sel = np.zeros_like(z, dtype=bool)
- >>> sel[0, 1] = True
- >>> sel[2, 3] = True
- >>> z.get_mask_selection(sel)
- array([ 1, 13])
- >>> z.set_mask_selection(sel, [-1, -2])
- >>> z[:]
- array([[ 0, -1, 2, 3, 4],
- [ 5, 6, 7, 8, 9],
- [10, 11, 12, -2, 14]])
-
-For convenience, mask indexing is also available via the ``vindex`` property,
-e.g.::
-
- >>> z.vindex[sel]
- array([-1, -2])
- >>> z.vindex[sel] = [-3, -4]
- >>> z[:]
- array([[ 0, -3, 2, 3, 4],
- [ 5, 6, 7, 8, 9],
- [10, 11, 12, -4, 14]])
-
-Mask indexing is conceptually the same as coordinate indexing, and is
-implemented internally via the same machinery. Both styles of indexing allow
-selecting arbitrary items from an array, also known as point selection.
-
-Orthogonal indexing
-~~~~~~~~~~~~~~~~~~~
-
-Zarr arrays also support methods for orthogonal indexing, which allows
-selections to be made along each dimension of an array independently. For
-example, this allows selecting a subset of rows and/or columns from a
-2-dimensional array. E.g.::
-
- >>> data = np.arange(15).reshape(3, 5)
- >>> z = zarr.create_array(store='data/example-14.zarr', shape=data.shape, dtype=data.dtype)
- >>> z[:] = data
- >>> z[:]
- array([[ 0, 1, 2, 3, 4],
- [ 5, 6, 7, 8, 9],
- [10, 11, 12, 13, 14]])
- >>> z.get_orthogonal_selection(([0, 2], slice(None))) # select first and third rows
- array([[ 0, 1, 2, 3, 4],
- [10, 11, 12, 13, 14]])
- >>> z.get_orthogonal_selection((slice(None), [1, 3])) # select second and fourth columns
- array([[ 1, 3],
- [ 6, 8],
- [11, 13]])
- >>> z.get_orthogonal_selection(([0, 2], [1, 3])) # select rows [0, 2] and columns [1, 4]
- array([[ 1, 3],
- [11, 13]])
-
-Data can also be modified, e.g.::
-
- >>> z.set_orthogonal_selection(([0, 2], [1, 3]), [[-1, -2], [-3, -4]])
-
-For convenience, the orthogonal indexing functionality is also available via the
-``oindex`` property, e.g.::
-
- >>> data = np.arange(15).reshape(3, 5)
- >>> z = zarr.create_array(store='data/example-15.zarr', shape=data.shape, dtype=data.dtype)
- >>> z[:] = data
- >>> z.oindex[[0, 2], :] # select first and third rows
- array([[ 0, 1, 2, 3, 4],
- [10, 11, 12, 13, 14]])
- >>> z.oindex[:, [1, 3]] # select second and fourth columns
- array([[ 1, 3],
- [ 6, 8],
- [11, 13]])
- >>> z.oindex[[0, 2], [1, 3]] # select rows [0, 2] and columns [1, 4]
- array([[ 1, 3],
- [11, 13]])
- >>> z.oindex[[0, 2], [1, 3]] = [[-1, -2], [-3, -4]]
- >>> z[:]
- array([[ 0, -1, 2, -2, 4],
- [ 5, 6, 7, 8, 9],
- [10, -3, 12, -4, 14]])
-
-Any combination of integer, slice, 1D integer array and/or 1D Boolean array can
-be used for orthogonal indexing.
-
-If the index contains at most one iterable, and otherwise contains only slices and integers,
-orthogonal indexing is also available directly on the array::
-
- >>> data = np.arange(15).reshape(3, 5)
- >>> z = zarr.create_array(store='data/example-16.zarr', shape=data.shape, dtype=data.dtype)
- >>> z[:] = data
- >>> np.all(z.oindex[[0, 2], :] == z[[0, 2], :])
- np.True_
-
-Block Indexing
-~~~~~~~~~~~~~~
-
-Zarr also support block indexing, which allows selections of whole chunks based on their
-logical indices along each dimension of an array. For example, this allows selecting
-a subset of chunk aligned rows and/or columns from a 2-dimensional array. E.g.::
-
- >>> data = np.arange(100).reshape(10, 10)
- >>> z = zarr.create_array(store='data/example-17.zarr', shape=data.shape, dtype=data.dtype, chunks=(3, 3))
- >>> z[:] = data
-
-Retrieve items by specifying their block coordinates::
-
- >>> z.get_block_selection(1)
- array([[30, 31, 32, 33, 34, 35, 36, 37, 38, 39],
- [40, 41, 42, 43, 44, 45, 46, 47, 48, 49],
- [50, 51, 52, 53, 54, 55, 56, 57, 58, 59]])
-
-Equivalent slicing::
-
- >>> z[3:6]
- array([[30, 31, 32, 33, 34, 35, 36, 37, 38, 39],
- [40, 41, 42, 43, 44, 45, 46, 47, 48, 49],
- [50, 51, 52, 53, 54, 55, 56, 57, 58, 59]])
-
-For convenience, the block selection functionality is also available via the
-`blocks` property, e.g.::
-
- >>> z.blocks[1]
- array([[30, 31, 32, 33, 34, 35, 36, 37, 38, 39],
- [40, 41, 42, 43, 44, 45, 46, 47, 48, 49],
- [50, 51, 52, 53, 54, 55, 56, 57, 58, 59]])
-
-Block index arrays may be multidimensional to index multidimensional arrays.
-For example::
-
- >>> z.blocks[0, 1:3]
- array([[ 3, 4, 5, 6, 7, 8],
- [13, 14, 15, 16, 17, 18],
- [23, 24, 25, 26, 27, 28]])
-
-Data can also be modified. Let's start by a simple 2D array::
-
- >>> z = zarr.create_array(store='data/example-18.zarr', shape=(6, 6), dtype=int, chunks=(2, 2))
-
-Set data for a selection of items::
-
- >>> z.set_block_selection((1, 0), 1)
- >>> z[...]
- array([[0, 0, 0, 0, 0, 0],
- [0, 0, 0, 0, 0, 0],
- [1, 1, 0, 0, 0, 0],
- [1, 1, 0, 0, 0, 0],
- [0, 0, 0, 0, 0, 0],
- [0, 0, 0, 0, 0, 0]])
-
-For convenience, this functionality is also available via the ``blocks`` property.
-E.g.::
-
- >>> z.blocks[:, 2] = 7
- >>> z[...]
- array([[0, 0, 0, 0, 7, 7],
- [0, 0, 0, 0, 7, 7],
- [1, 1, 0, 0, 7, 7],
- [1, 1, 0, 0, 7, 7],
- [0, 0, 0, 0, 7, 7],
- [0, 0, 0, 0, 7, 7]])
-
-Any combination of integer and slice can be used for block indexing::
-
- >>> z.blocks[2, 1:3]
- array([[0, 0, 7, 7],
- [0, 0, 7, 7]])
- >>>
- >>> root = zarr.create_group('data/example-19.zarr')
- >>> foo = root.create_array(name='foo', shape=(1000, 100), chunks=(10, 10), dtype='float32')
- >>> bar = root.create_array(name='foo/bar', shape=(100,), dtype='int32')
- >>> foo[:, :] = np.random.random((1000, 100))
- >>> bar[:] = np.arange(100)
- >>> root.tree()
- /
- └── foo (1000, 100) float32
-
-
-.. _user-guide-sharding:
-
-Sharding
---------
-
-Using small chunk shapes in very large arrays can lead to a very large number of chunks.
-This can become a performance issue for file systems and object storage.
-With Zarr format 3, a new sharding feature has been added to address this issue.
-
-With sharding, multiple chunks can be stored in a single storage object (e.g. a file).
-Within a shard, chunks are compressed and serialized separately.
-This allows individual chunks to be read independently.
-However, when writing data, a full shard must be written in one go for optimal
-performance and to avoid concurrency issues.
-That means that shards are the units of writing and chunks are the units of reading.
-Users need to configure the chunk and shard shapes accordingly.
-
-Sharded arrays can be created by providing the ``shards`` parameter to :func:`zarr.create_array`.
-
- >>> a = zarr.create_array('data/example-20.zarr', shape=(10000, 10000), shards=(1000, 1000), chunks=(100, 100), dtype='uint8')
- >>> a[:] = (np.arange(10000 * 10000) % 256).astype('uint8').reshape(10000, 10000)
- >>> a.info_complete()
- Type : Array
- Zarr format : 3
- Data type : UInt8()
- Fill value : 0
- Shape : (10000, 10000)
- Shard shape : (1000, 1000)
- Chunk shape : (100, 100)
- Order : C
- Read-only : False
- Store type : LocalStore
- Filters : ()
- Serializer : BytesCodec(endian=None)
- Compressors : (ZstdCodec(level=0, checksum=False),)
- No. bytes : 100000000 (95.4M)
- No. bytes stored : 3981473
- Storage ratio : 25.1
- Shards Initialized : 100
-
-In this example a shard shape of (1000, 1000) and a chunk shape of (100, 100) is used.
-This means that 10*10 chunks are stored in each shard, and there are 10*10 shards in total.
-Without the ``shards`` argument, there would be 10,000 chunks stored as individual files.
-
-Missing features in 3.0
------------------------
-
-
-The following features have not been ported to 3.0 yet.
-
-Copying and migrating data
-~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-See the Zarr-Python 2 documentation on `Copying and migrating data `_ for more details.
diff --git a/docs/user-guide/attributes.md b/docs/user-guide/attributes.md
new file mode 100644
index 0000000000..8c11c853f1
--- /dev/null
+++ b/docs/user-guide/attributes.md
@@ -0,0 +1,61 @@
+# Working with attributes
+
+Zarr arrays and groups support custom key/value attributes, which can be useful for
+storing application-specific metadata. For example:
+
+```python exec="true" session="attributes" source="above" result="ansi"
+import zarr
+root = zarr.create_group(store="memory://attributes-demo")
+root.attrs['foo'] = 'bar'
+z = root.create_array(name='zzz', shape=(10000, 10000), dtype='int32')
+z.attrs['baz'] = 42
+z.attrs['qux'] = [1, 4, 7, 12]
+print(sorted(root.attrs))
+```
+
+```python exec="true" session="attributes" source="above" result="ansi"
+print('foo' in root.attrs)
+```
+
+```python exec="true" session="attributes" source="above" result="ansi"
+print(root.attrs['foo'])
+```
+
+```python exec="true" session="attributes" source="above" result="ansi"
+print(sorted(z.attrs))
+```
+
+```python exec="true" session="attributes" source="above" result="ansi"
+print(z.attrs['baz'])
+```
+
+```python exec="true" session="attributes" source="above" result="ansi"
+print(z.attrs['qux'])
+```
+
+Attributes can be deleted with the `del` operator:
+
+```python exec="true" session="attributes" source="above" result="ansi"
+del z.attrs['baz']
+print(sorted(z.attrs))
+```
+
+Note that each attribute assignment or deletion writes the node's metadata
+document back to the store. To change several attributes in a single write,
+use [`zarr.Array.update_attributes`][] (or [`zarr.Group.update_attributes`][]
+for groups), which merges the given dict into the existing attributes and
+returns the updated array or group:
+
+```python exec="true" session="attributes" source="above" result="ansi"
+z = z.update_attributes({'baz': 43, 'quux': True})
+print(sorted(z.attrs))
+```
+
+Internally Zarr uses JSON to store array and group attributes, so attribute
+values must be JSON serializable.
+
+When working with hierarchies that contain many arrays and groups, reading the
+attributes of each node separately can be slow. See
+[Consolidated metadata](consolidated_metadata.md) for a way to store the
+metadata (including attributes) of all nodes in a hierarchy in a single
+document.
diff --git a/docs/user-guide/attributes.rst b/docs/user-guide/attributes.rst
deleted file mode 100644
index ed48623e29..0000000000
--- a/docs/user-guide/attributes.rst
+++ /dev/null
@@ -1,30 +0,0 @@
-.. _user-guide-attrs:
-
-Working with attributes
-=======================
-
-Zarr arrays and groups support custom key/value attributes, which can be useful for
-storing application-specific metadata. For example::
-
- >>> import zarr
- >>> store = zarr.storage.MemoryStore()
- >>> root = zarr.create_group(store=store)
- >>> root.attrs['foo'] = 'bar'
- >>> z = root.create_array(name='zzz', shape=(10000, 10000), dtype='int32')
- >>> z.attrs['baz'] = 42
- >>> z.attrs['qux'] = [1, 4, 7, 12]
- >>> sorted(root.attrs)
- ['foo']
- >>> 'foo' in root.attrs
- True
- >>> root.attrs['foo']
- 'bar'
- >>> sorted(z.attrs)
- ['baz', 'qux']
- >>> z.attrs['baz']
- 42
- >>> z.attrs['qux']
- [1, 4, 7, 12]
-
-Internally Zarr uses JSON to store array attributes, so attribute values must be
-JSON serializable.
diff --git a/docs/user-guide/cli.md b/docs/user-guide/cli.md
new file mode 100644
index 0000000000..77f50f5eaf
--- /dev/null
+++ b/docs/user-guide/cli.md
@@ -0,0 +1,133 @@
+# Command-line interface
+
+Zarr-Python provides a command-line interface that enables:
+
+- migration of Zarr v2 metadata to v3 (see the [3.0 Migration Guide](v3_migration.md) for
+ migrating your *code* from the Zarr-Python 2 API to the Zarr-Python 3 API)
+- removal of v2 or v3 metadata
+
+## Installation
+
+The command-line interface requires the `cli` optional dependencies. Install them with:
+
+```bash
+pip install "zarr[cli]"
+```
+
+Without this extra, running `zarr` in a terminal will fail with `ModuleNotFoundError`.
+
+## Getting help
+
+To see available commands run the following in a terminal:
+
+```bash
+zarr --help
+```
+
+or to get help on individual commands:
+
+```bash
+zarr migrate --help
+
+zarr remove-metadata --help
+```
+
+## Migrate metadata from v2 to v3
+
+### Migrate to a separate location
+
+To migrate a Zarr array/group's metadata from v2 to v3 run:
+
+```bash
+zarr migrate v3 path/to/input.zarr path/to/output.zarr
+```
+
+This will write new `zarr.json` files to `output.zarr`, leaving `input.zarr` un-touched.
+Note - this will migrate the entire Zarr hierarchy, so if `input.zarr` contains multiple groups/arrays,
+new `zarr.json` will be made for all of them.
+
+### Migrate in-place
+
+If you'd prefer to migrate the metadata in-place run:
+
+```bash
+zarr migrate v3 path/to/input.zarr
+```
+
+This will write new `zarr.json` files to `input.zarr`, leaving the existing v2 metadata un-touched.
+
+To open the array/group using the new metadata use:
+
+```python exec="true" session="cli-open" source="above"
+import zarr
+
+# create a small array to open (stands in for the migrated store)
+zarr.create_array("data/cli-demo.zarr", shape=(4, 4), chunks=(2, 2), dtype="i4", overwrite=True)
+
+zarr_with_v3_metadata = zarr.open("data/cli-demo.zarr", zarr_format=3)
+```
+
+Once you are happy with the conversion, you can run the following to remove the old v2 metadata:
+
+```bash
+zarr remove-metadata v2 path/to/input.zarr
+```
+
+Note there is also a shortcut to migrate and remove v2 metadata in one step:
+
+```bash
+zarr migrate v3 path/to/input.zarr --remove-v2-metadata
+```
+
+## Remove metadata
+
+Remove v2 metadata using:
+
+```bash
+zarr remove-metadata v2 path/to/input.zarr
+```
+
+or v3 with:
+
+```bash
+zarr remove-metadata v3 path/to/input.zarr
+```
+
+By default, this will only allow removal of metadata if a valid alternative exists. For example, you can't
+remove v2 metadata unless v3 metadata exists at that location.
+
+To override this behavior use `--force`:
+
+```bash
+zarr remove-metadata v3 path/to/input.zarr --force
+```
+
+## Dry run
+
+All commands provide a `--dry-run` option that will log changes that would be made on a real run, without creating
+or modifying any files.
+
+```bash
+zarr migrate v3 path/to/input.zarr --dry-run
+
+Dry run enabled - no new files will be created or changed. Log of files that would be created on a real run:
+Saving metadata to file://path/to/input.zarr/zarr.json
+```
+
+## Verbose
+
+You can also add `--verbose` **before** any command, to see a full log of its actions:
+
+```bash
+zarr --verbose migrate v3 path/to/input.zarr
+
+zarr --verbose remove-metadata v2 path/to/input.zarr
+```
+
+## Equivalent functions
+
+All features of the command-line interface are also available as functions in the
+`zarr.metadata.migrate_v3` module:
+[`migrate_v2_to_v3`][zarr.metadata.migrate_v3.migrate_v2_to_v3] and
+[`remove_metadata`][zarr.metadata.migrate_v3.remove_metadata].
+See the [`zarr.metadata` API reference](../api/zarr/metadata.md) for details.
diff --git a/docs/user-guide/config.md b/docs/user-guide/config.md
new file mode 100644
index 0000000000..d1a70a14b0
--- /dev/null
+++ b/docs/user-guide/config.md
@@ -0,0 +1,61 @@
+# Runtime configuration
+
+[`zarr.config`][] is responsible for managing the configuration of zarr and
+is based on the [donfig](https://github.com/pytroll/donfig) Python library.
+
+Configuration values can be set using code like the following:
+
+```python exec="true" session="config" source="above" result="ansi"
+import zarr
+
+zarr.config.set({'array.order': 'F'})
+
+print(zarr.config.get('array.order'))
+```
+
+`zarr.config.set` can also be used as a context manager, which restores the
+previous configuration on exit, and `zarr.config.reset` restores the default
+configuration:
+
+```python exec="true" session="config" source="above" result="ansi"
+zarr.config.reset()
+
+with zarr.config.set({'array.order': 'F'}):
+ print(zarr.config.get('array.order'))
+
+print(zarr.config.get('array.order'))
+```
+
+Alternatively, configuration values can be set using environment variables, e.g.
+`ZARR_ARRAY__ORDER=F`.
+
+The configuration can also be read from a YAML file in standard locations.
+For more information, see the
+[donfig documentation](https://donfig.readthedocs.io/en/latest/).
+
+Configuration options include the following:
+
+- Default Zarr format `default_zarr_format`
+- Default array order in memory `array.order`
+- Whether empty chunks are written to storage `array.write_empty_chunks`
+- Enable experimental rectilinear chunks `array.rectilinear_chunks`
+- Whether missing chunks are filled with the array's fill value on read `array.read_missing_chunks` (default `True`). Set to `False` to raise a [`ChunkNotFoundError`][zarr.errors.ChunkNotFoundError] instead.
+- Async and threading options, e.g. `async.concurrency` and `threading.max_workers`
+- Selections of implementations of codecs, codec pipelines and buffers
+- Enabling GPU support with `zarr.config.enable_gpu()`. See [GPU support](gpu.md) for more.
+- Control request merging when reading multiple chunks from the same shard with `array.sharding_coalesce_max_gap_bytes` and `array.sharding_coalesce_max_bytes`. Reads of nearby chunks are coalesced into a single request to the store when separated by at most `sharding_coalesce_max_gap_bytes` and the resulting merged read is no larger than `sharding_coalesce_max_bytes`.
+
+For selecting custom implementations of codecs, pipelines, buffers and ndbuffers,
+first register the implementations in the registry and then select them in the config.
+For example, an implementation of the bytes codec in a class `'custompackage.NewBytesCodec'`,
+requires the value of `codecs.bytes` to be `'custompackage.NewBytesCodec'`.
+
+This is the current default configuration:
+
+```python exec="true" session="config" source="above" result="ansi"
+from pprint import pprint
+import io
+output = io.StringIO()
+zarr.config.pprint(stream=output, width=60)
+print(output.getvalue())
+```
diff --git a/docs/user-guide/config.rst b/docs/user-guide/config.rst
deleted file mode 100644
index 5a9d26f2b9..0000000000
--- a/docs/user-guide/config.rst
+++ /dev/null
@@ -1,82 +0,0 @@
-.. _user-guide-config:
-
-Runtime configuration
-=====================
-
-``zarr.config`` is responsible for managing the configuration of zarr and
-is based on the `donfig `_ Python library.
-
-Configuration values can be set using code like the following::
-
- >>> import zarr
- >>>
- >>> zarr.config.set({'array.order': 'F'})
-
- >>>
- >>> # revert this change so it doesn't impact the rest of the docs
- >>> zarr.config.set({'array.order': 'C'})
-
-
-Alternatively, configuration values can be set using environment variables, e.g.
-``ZARR_ARRAY__ORDER=F``.
-
-The configuration can also be read from a YAML file in standard locations.
-For more information, see the
-`donfig documentation `_.
-
-Configuration options include the following:
-
-- Default Zarr format ``default_zarr_version``
-- Default array order in memory ``array.order``
-- Default filters, serializers and compressors, e.g. ``array.v3_default_filters``, ``array.v3_default_serializer``, ``array.v3_default_compressors``, ``array.v2_default_filters`` and ``array.v2_default_compressor``
-- Whether empty chunks are written to storage ``array.write_empty_chunks``
-- Async and threading options, e.g. ``async.concurrency`` and ``threading.max_workers``
-- Selections of implementations of codecs, codec pipelines and buffers
-- Enabling GPU support with ``zarr.config.enable_gpu()``. See :ref:`user-guide-gpu` for more.
-
-For selecting custom implementations of codecs, pipelines, buffers and ndbuffers,
-first register the implementations in the registry and then select them in the config.
-For example, an implementation of the bytes codec in a class ``'custompackage.NewBytesCodec'``,
-requires the value of ``codecs.bytes.name`` to be ``'custompackage.NewBytesCodec'``.
-
-This is the current default configuration::
-
- >>> zarr.config.pprint()
- {'array': {'order': 'C',
- 'v2_default_compressor': {'default': {'checksum': False,
- 'id': 'zstd',
- 'level': 0},
- 'variable-length-string': {'checksum': False,
- 'id': 'zstd',
- 'level': 0}},
- 'v2_default_filters': {'default': None,
- 'variable-length-string': [{'id': 'vlen-utf8'}]},
- 'v3_default_compressors': {'default': [{'configuration': {'checksum': False,
- 'level': 0},
- 'name': 'zstd'}],
- 'variable-length-string': [{'configuration': {'checksum': False,
- 'level': 0},
- 'name': 'zstd'}]},
- 'v3_default_filters': {'default': [], 'variable-length-string': []},
- 'v3_default_serializer': {'default': {'configuration': {'endian': 'little'},
- 'name': 'bytes'},
- 'variable-length-string': {'name': 'vlen-utf8'}},
- 'write_empty_chunks': False},
- 'async': {'concurrency': 10, 'timeout': None},
- 'buffer': 'zarr.buffer.cpu.Buffer',
- 'codec_pipeline': {'batch_size': 1,
- 'path': 'zarr.core.codec_pipeline.BatchedCodecPipeline'},
- 'codecs': {'blosc': 'zarr.codecs.blosc.BloscCodec',
- 'bytes': 'zarr.codecs.bytes.BytesCodec',
- 'crc32c': 'zarr.codecs.crc32c_.Crc32cCodec',
- 'endian': 'zarr.codecs.bytes.BytesCodec',
- 'gzip': 'zarr.codecs.gzip.GzipCodec',
- 'sharding_indexed': 'zarr.codecs.sharding.ShardingCodec',
- 'transpose': 'zarr.codecs.transpose.TransposeCodec',
- 'vlen-bytes': 'zarr.codecs.vlen_utf8.VLenBytesCodec',
- 'vlen-utf8': 'zarr.codecs.vlen_utf8.VLenUTF8Codec',
- 'zstd': 'zarr.codecs.zstd.ZstdCodec'},
- 'default_zarr_format': 3,
- 'json_indent': 2,
- 'ndbuffer': 'zarr.buffer.cpu.NDBuffer',
- 'threading': {'max_workers': None}}
diff --git a/docs/user-guide/consolidated_metadata.md b/docs/user-guide/consolidated_metadata.md
new file mode 100644
index 0000000000..9cb4d87c89
--- /dev/null
+++ b/docs/user-guide/consolidated_metadata.md
@@ -0,0 +1,139 @@
+# Consolidated metadata
+
+!!! warning
+ The Consolidated Metadata feature in Zarr-Python is considered experimental for v3
+ stores. [zarr-specs#309](https://github.com/zarr-developers/zarr-specs/pull/309)
+ has proposed a formal extension to the v3 specification to support consolidated metadata.
+
+Zarr-Python implements the Consolidated Metadata feature for both the v2 and v3 formats.
+Consolidated metadata can reduce the time needed to load the metadata for an
+entire hierarchy, especially when the metadata is being served over a network.
+Consolidated metadata essentially stores all the metadata for a hierarchy in the
+metadata of the root Group.
+
+## Usage
+
+If consolidated metadata is present in a Zarr Group's metadata then it is used
+by default. The initial read to open the group will need to communicate with
+the store (reading from a file for a [`zarr.storage.LocalStore`][], making a
+network request for a [`zarr.storage.FsspecStore`][]). After that, any subsequent
+metadata reads to get child Group or Array nodes will *not* require reads from the store.
+
+In Python, the consolidated metadata is available on the `.consolidated_metadata`
+attribute of the `GroupMetadata` object.
+
+```python exec="true" session="consolidated_metadata" source="above" result="ansi"
+import zarr
+import warnings
+
+warnings.filterwarnings("ignore", category=UserWarning)
+group = zarr.create_group(store="memory://consolidated-metadata-demo")
+print(group)
+array = group.create_array(shape=(1,), name='a', dtype='float64')
+print(array)
+```
+
+```python exec="true" session="consolidated_metadata" source="above" result="ansi"
+array = group.create_array(shape=(2, 2), name='b', dtype='float64')
+print(array)
+```
+
+```python exec="true" session="consolidated_metadata" source="above" result="ansi"
+array = group.create_array(shape=(3, 3, 3), name='c', dtype='float64')
+print(array)
+```
+
+```python exec="true" session="consolidated_metadata" source="above" result="ansi"
+result = zarr.consolidate_metadata("memory://consolidated-metadata-demo")
+print(result)
+```
+
+If we open that group, the Group's metadata includes a `ConsolidatedMetadata` object
+holding the metadata for every child node, which can be used:
+
+```python exec="true" session="consolidated_metadata" source="above" result="ansi"
+from pprint import pprint
+import io
+
+consolidated = zarr.open_group(store="memory://consolidated-metadata-demo")
+consolidated_metadata = consolidated.metadata.consolidated_metadata.metadata
+
+output = io.StringIO()
+pprint(dict(sorted(consolidated_metadata.items())), stream=output, width=60)
+print(output.getvalue())
+```
+
+Operations on the group to get children automatically use the consolidated metadata:
+
+```python exec="true" session="consolidated_metadata" source="above" result="ansi"
+print(consolidated['a']) # no read / HTTP request to the Store is required
+```
+
+With nested groups, the consolidated metadata is available on the children, recursively:
+
+```python exec="true" session="consolidated_metadata" source="above" result="ansi"
+child = group.create_group('child', attributes={'kind': 'child'})
+grandchild = child.create_group('grandchild', attributes={'kind': 'grandchild'})
+consolidated = zarr.consolidate_metadata("memory://consolidated-metadata-demo")
+
+output = io.StringIO()
+pprint(consolidated['child'].metadata.consolidated_metadata, stream=output, width=60)
+print(output.getvalue())
+```
+
+!!! info "Added in version 3.1.1"
+ The keys in the consolidated metadata are sorted prior to writing. Keys are
+ sorted in ascending order by path depth, where a path is defined as a sequence
+ of strings joined by `"/"`. For keys with the same path length, lexicographic
+ order is used to break the tie. This behavior ensures deterministic metadata
+ output for a given group.
+
+### Controlling the use of consolidated metadata
+
+By default, [`zarr.open_group`][] uses consolidated metadata if it is present, and
+falls back to reading metadata from the store otherwise. This behavior can be
+controlled with the `use_consolidated` keyword. Pass `use_consolidated=False` to
+ignore consolidated metadata and always read the metadata of child nodes directly
+from the store:
+
+```python exec="true" session="consolidated_metadata" source="above" result="ansi"
+group = zarr.open_group(store="memory://consolidated-metadata-demo", use_consolidated=False)
+print(group.metadata.consolidated_metadata)
+```
+
+Passing `use_consolidated=True` instead raises an error if consolidated metadata is
+not found, which is useful when reading over a network, where relying on many
+per-node metadata requests would be slow.
+
+## Synchronization and Concurrency
+
+Consolidated metadata is intended for read-heavy use cases on slowly changing
+hierarchies. For hierarchies where new nodes are constantly being added,
+removed, or modified, consolidated metadata may not be desirable.
+
+1. It will add some overhead to each update operation, since the metadata
+ would need to be re-consolidated to keep it in sync with the store.
+2. Readers using consolidated metadata will regularly see a "past" version
+ of the metadata, at the time they read the root node with its consolidated
+ metadata. Readers who need the latest view of a changing hierarchy can pass
+ `use_consolidated=False` to [`zarr.open_group`][] to always read child
+ metadata directly from the store.
+
+## Stores Without Support for Consolidated Metadata
+
+Some stores may want to opt out of the consolidated metadata mechanism. This
+may be for several reasons like:
+
+* They want to maintain read-write consistency, which is challenging with
+ consolidated metadata.
+* They have their own consolidated metadata mechanism.
+* They offer good enough performance without need for consolidation.
+
+This type of store can declare it doesn't want consolidation by implementing
+`Store.supports_consolidated_metadata` and returning `False`. For stores that don't support
+consolidation, Zarr will:
+
+* Raise an error on `consolidate_metadata` calls, maintaining the store in
+ its unconsolidated state.
+* Raise an error in `AsyncGroup.open(..., use_consolidated=True)`
+* Not use consolidated metadata in `AsyncGroup.open(..., use_consolidated=None)`
diff --git a/docs/user-guide/consolidated_metadata.rst b/docs/user-guide/consolidated_metadata.rst
deleted file mode 100644
index 4cd72dbc74..0000000000
--- a/docs/user-guide/consolidated_metadata.rst
+++ /dev/null
@@ -1,136 +0,0 @@
-.. _user-guide-consolidated-metadata:
-
-Consolidated metadata
-=====================
-
-.. warning::
- The Consolidated Metadata feature in Zarr-Python is considered experimental for v3
- stores. `zarr-specs#309 `_
- has proposed a formal extension to the v3 specification to support consolidated metadata.
-
-Zarr-Python implements the `Consolidated Metadata`_ for v2 and v3 stores.
-Consolidated metadata can reduce the time needed to load the metadata for an
-entire hierarchy, especially when the metadata is being served over a network.
-Consolidated metadata essentially stores all the metadata for a hierarchy in the
-metadata of the root Group.
-
-Usage
------
-
-If consolidated metadata is present in a Zarr Group's metadata then it is used
-by default. The initial read to open the group will need to communicate with
-the store (reading from a file for a :class:`zarr.storage.LocalStore`, making a
-network request for a :class:`zarr.storage.FsspecStore`). After that, any subsequent
-metadata reads get child Group or Array nodes will *not* require reads from the store.
-
-In Python, the consolidated metadata is available on the ``.consolidated_metadata``
-attribute of the ``GroupMetadata`` object.
-
- >>> import zarr
- >>>
- >>> store = zarr.storage.MemoryStore()
- >>> group = zarr.create_group(store=store)
- >>> group.create_array(shape=(1,), name='a', dtype='float64')
-
- >>> group.create_array(shape=(2, 2), name='b', dtype='float64')
-
- >>> group.create_array(shape=(3, 3, 3), name='c', dtype='float64')
-
- >>> zarr.consolidate_metadata(store)
-
-
-If we open that group, the Group's metadata has a :class:`zarr.core.group.ConsolidatedMetadata`
-that can be used.:
-
- >>> consolidated = zarr.open_group(store=store)
- >>> consolidated_metadata = consolidated.metadata.consolidated_metadata.metadata
- >>> from pprint import pprint
- >>> pprint(dict(sorted(consolidated_metadata.items())))
- {'a': ArrayV3Metadata(shape=(1,),
- data_type=Float64(endianness='little'),
- chunk_grid=RegularChunkGrid(chunk_shape=(1,)),
- chunk_key_encoding=DefaultChunkKeyEncoding(name='default',
- separator='/'),
- fill_value=np.float64(0.0),
- codecs=(BytesCodec(endian=),
- ZstdCodec(level=0, checksum=False)),
- attributes={},
- dimension_names=None,
- zarr_format=3,
- node_type='array',
- storage_transformers=()),
- 'b': ArrayV3Metadata(shape=(2, 2),
- data_type=Float64(endianness='little'),
- chunk_grid=RegularChunkGrid(chunk_shape=(2, 2)),
- chunk_key_encoding=DefaultChunkKeyEncoding(name='default',
- separator='/'),
- fill_value=np.float64(0.0),
- codecs=(BytesCodec(endian=),
- ZstdCodec(level=0, checksum=False)),
- attributes={},
- dimension_names=None,
- zarr_format=3,
- node_type='array',
- storage_transformers=()),
- 'c': ArrayV3Metadata(shape=(3, 3, 3),
- data_type=Float64(endianness='little'),
- chunk_grid=RegularChunkGrid(chunk_shape=(3, 3, 3)),
- chunk_key_encoding=DefaultChunkKeyEncoding(name='default',
- separator='/'),
- fill_value=np.float64(0.0),
- codecs=(BytesCodec(endian=),
- ZstdCodec(level=0, checksum=False)),
- attributes={},
- dimension_names=None,
- zarr_format=3,
- node_type='array',
- storage_transformers=())}
-
-Operations on the group to get children automatically use the consolidated metadata.:
-
- >>> consolidated['a'] # no read / HTTP request to the Store is required
-
-
-With nested groups, the consolidated metadata is available on the children, recursively.:
-
- >>> child = group.create_group('child', attributes={'kind': 'child'})
- >>> grandchild = child.create_group('child', attributes={'kind': 'grandchild'})
- >>> consolidated = zarr.consolidate_metadata(store)
- >>>
- >>> consolidated['child'].metadata.consolidated_metadata
- ConsolidatedMetadata(metadata={'child': GroupMetadata(attributes={'kind': 'grandchild'}, zarr_format=3, consolidated_metadata=ConsolidatedMetadata(metadata={}, kind='inline', must_understand=False), node_type='group')}, kind='inline', must_understand=False)
-
-Synchronization and Concurrency
--------------------------------
-
-Consolidated metadata is intended for read-heavy use cases on slowly changing
-hierarchies. For hierarchies where new nodes are constantly being added,
-removed, or modified, consolidated metadata may not be desirable.
-
-1. It will add some overhead to each update operation, since the metadata
- would need to be re-consolidated to keep it in sync with the store.
-2. Readers using consolidated metadata will regularly see a "past" version
- of the metadata, at the time they read the root node with its consolidated
- metadata.
-
-.. _Consolidated Metadata: https://github.com/zarr-developers/zarr-specs/pull/309
-
-Stores Without Support for Consolidated Metadata
-------------------------------------------------
-
-Some stores may want to opt out of the consolidated metadata mechanism. This
-may be for several reasons like:
-
-* They want to maintain read-write consistency, which is challenging with
- consolidated metadata.
-* They have their own consolidated metadata mechanism.
-* They offer good enough performance without need for consolidation.
-
-This type of store can declare it doesn't want consolidation by implementing
-`Store.supports_consolidated_metadata` and returning `False`. For stores that don't support
-consolidation, Zarr will:
-
-* Raise an error on `consolidate_metadata` calls, maintaining the store in
- its unconsolidated state.
-* Raise an error in `AsyncGroup.open(..., use_consolidated=True)`
-* Not use consolidated metadata in `AsyncGroup.open(..., use_consolidated=None)`
diff --git a/docs/user-guide/data_types.md b/docs/user-guide/data_types.md
new file mode 100644
index 0000000000..91f828a738
--- /dev/null
+++ b/docs/user-guide/data_types.md
@@ -0,0 +1,456 @@
+# Array data types
+
+## Zarr's data type model
+
+Zarr is designed for interoperability with NumPy, so if you are familiar with NumPy or any other
+N-dimensional array library, Zarr's model for array data types should seem familiar. However, Zarr
+data types have some unique features that are described in this document.
+
+Zarr arrays operate under an essential design constraint: unlike NumPy arrays, Zarr arrays
+are designed to be stored and accessed by other Zarr implementations. This means that, among other things,
+Zarr data types must be serializable to metadata documents in accordance with the Zarr specifications,
+which adds some unique aspects to the Zarr data type model.
+
+The following sections explain Zarr's data type model in greater detail and demonstrate the
+Zarr Python APIs for working with Zarr data types.
+
+### Array data types
+
+Every Zarr array has a data type, which defines the meaning of the array's elements. An array's data
+type is encoded in the JSON metadata for the array. This means that the data type of an array must be
+JSON-serializable.
+
+In Zarr V2, the data type of an array is stored in the `dtype` field in array metadata.
+Zarr V3 changed the name of this field to `data_type` and also defined new rules for the values
+that can be assigned to the `data_type` field.
+
+For example, in Zarr V2, the boolean array data type was represented in array metadata as the
+string `"|b1"`. In Zarr V3, the same type is represented as the string `"bool"`.
+
+### Scalars
+
+Zarr also specifies how array elements, i.e., scalars, are encoded in array metadata. This is necessary
+because Zarr uses a field in array metadata to define a default value for chunks that are not stored.
+This field, called `fill_value` in both Zarr V2 and Zarr V3 metadata documents, contains a
+JSON value that can be decoded to a scalar value compatible with the array's data type.
+
+For the boolean data type, the scalar encoding is simple—booleans are natively supported by
+JSON, so Zarr saves booleans as JSON booleans. Other scalars, like floats or raw bytes, have
+more elaborate encoding schemes, and in some cases, this scheme depends on the Zarr format version.
+
+## Data types in Zarr version 2
+
+Version 2 of the Zarr format defined its data types relative to
+[NumPy's data types](https://numpy.org/doc/stable/reference/arrays.dtypes.html#data-type-objects-dtype),
+and added a few non-NumPy data types as well. With one exception ([structured data types](#structured-data-type)), the Zarr
+V2 JSON identifier for a data type is just the NumPy `str` attribute of that data type:
+
+```python exec="true" session="data_types" source="above" result="ansi"
+import zarr
+import numpy as np
+import json
+
+store = {}
+np_dtype = np.dtype('int64')
+print(np_dtype.str)
+```
+
+```python exec="true" session="data_types" source="above" result="ansi"
+z = zarr.create_array(store=store, shape=(1,), dtype=np_dtype, zarr_format=2)
+dtype_meta = json.loads(store['.zarray'].to_bytes())["dtype"]
+print(dtype_meta)
+```
+
+!!! note
+
+ The `<` character in the data type metadata encodes the
+ [endianness](https://numpy.org/doc/stable/reference/generated/numpy.dtype.byteorder.html),
+ or "byte order," of the data type. As per the NumPy model,
+ in Zarr version 2 each data type has an endianness where applicable.
+ However, Zarr version 3 data types do not store endianness information.
+
+There are two special cases to consider: ["structured" data types](#structured-data-type), and
+["object"](#object-data-type) data types.
+
+### Structured data type
+
+NumPy allows the construction of a so-called "structured" data types comprised of ordered collections
+of named fields, where each field is itself a distinct NumPy data type. See the NumPy documentation
+[here](https://numpy.org/doc/stable/user/basics.rec.html).
+
+Crucially, NumPy does not use a special data type for structured data types—instead, NumPy
+implements structured data types as an optional feature of the so-called "Void" data type, which models
+arbitrary fixed-size byte strings. The `str` attribute of a regular NumPy void
+data type is the same as the `str` of a NumPy structured data type. This means that the `str`
+attribute does not convey information about the fields contained in a structured data type.
+For these reasons, Zarr V2 uses a special data type encoding for structured data types.
+They are stored in JSON as lists of pairs, where the first element is a string, and the second
+element is a Zarr V2 data type specification. This representation supports recursion.
+
+For example:
+
+```python exec="true" session="data_types" source="above" result="ansi"
+store = {}
+np_dtype = np.dtype([('field_a', '>i2'), ('field_b', [('subfield_c', '>f4'), ('subfield_d', 'i2')])])
+print(np_dtype.str)
+```
+
+```python exec="true" session="data_types" source="above" result="ansi"
+z = zarr.create_array(store=store, shape=(1,), dtype=np_dtype, zarr_format=2)
+dtype_meta = json.loads(store['.zarray'].to_bytes())["dtype"]
+print(dtype_meta)
+```
+
+### Object data type
+
+The NumPy "object" type is essentially an array of references to arbitrary Python objects.
+It can model arrays of variable-length UTF-8 strings, arrays of variable-length byte strings, or
+even arrays of variable-length arrays, each with a distinct data type. This makes the "object" data
+type expressive, but also complicated to store.
+
+Zarr Python cannot persistently store references to arbitrary Python objects. But if each of those Python
+objects has a consistent type, then we can use a special encoding procedure to store the array. This
+is how Zarr Python stores variable-length UTF-8 strings, or variable-length byte strings.
+
+Although these are separate data types in this library, they are both "object" arrays in NumPy, which means
+they have the *same* Zarr V2 string representation: `"|O"`.
+
+So for Zarr V2 we have to disambiguate different "object" data type arrays on the basis of their
+encoding procedure, i.e., the codecs declared in the `filters` and `compressor` attributes of array
+metadata.
+
+If an array with data type "object" used the `"vlen-utf8"` codec, then it was interpreted as an
+array of variable-length strings. If an array with data type "object" used the `"vlen-bytes"`
+codec, then it was interpreted as an array of variable-length byte strings.
+
+This all means that the `dtype` field alone does not fully specify a data type in Zarr V2.
+The name of the object codec used, if one was used, is also required.
+Although this fact can be ignored for many simple numeric data types, any comprehensive approach to
+Zarr V2 data types must either reject the "object" data types or include the "object codec"
+identifier in the JSON form of the basic data type model.
+
+## Data types in Zarr version 3
+
+The NumPy-based Zarr V2 data type representation was effective for simple data types but struggled
+with more complex data types, like "object" and "structured" data types. To address these limitations,
+Zarr V3 introduced several key changes to how data types are represented:
+
+- Instead of copying NumPy character codecs, Zarr V3 defines an identifier for each data type.
+ The basic data types are identified by strings like `"int8"`, `"int16"`, etc., and data types
+ that require a configuration can be identified by a JSON object.
+
+ For example, this JSON object declares a datetime data type:
+
+ ```json
+ {
+ "name": "numpy.datetime64",
+ "configuration": {
+ "unit": "s",
+ "scale_factor": 10
+ }
+ }
+ ```
+
+- Zarr V3 data types do not have endianness. This is a departure from Zarr V2, where multi-byte
+ data types are defined with endianness information. Instead, Zarr V3 requires that the endianness
+ of encoded array chunks is specified in the `codecs` attribute of array metadata. The Zarr
+ V3 specification leaves the in-memory endianness of decoded array chunks as an implementation detail.
+
+For more about data types in Zarr V3, see the
+[V3 specification](https://zarr-specs.readthedocs.io/en/latest/v3/data-types/index.html).
+
+## Data types in Zarr Python
+
+The two Zarr formats that Zarr Python supports specify data types in different ways: data types in
+Zarr version 2 are encoded as NumPy-compatible strings (or lists, in the case of structured data
+types), while data types in Zarr V3 are encoded as either strings or JSON objects. Zarr V3 data
+types do not have any associated endianness information, unlike Zarr V2 data types.
+
+Zarr Python needs to support both Zarr V2 and V3, which means we need to abstract over these differences.
+We do this with an abstract Zarr data type class: [ZDType][zarr.dtype.ZDType]
+which provides Zarr V2 and Zarr V3 compatibility routines for "native" data types.
+
+In this context, a "native" data type is a Python class, typically defined in another library, that
+models an array's data type. For example, [`numpy.dtypes.UInt8DType`][] is a native data type defined in NumPy.
+Zarr Python wraps the NumPy `uint8` with a [ZDType][zarr.dtype.ZDType] instance called
+[UInt8][zarr.dtype.UInt8].
+
+As of this writing, the only native data types Zarr Python supports are NumPy data types. We could
+avoid the "native data type" jargon and just say "NumPy data type," but we do not want to rule out the
+possibility of using non-NumPy array backends in the future.
+
+Each data type supported by Zarr Python is modeled by a [ZDType][zarr.dtype.ZDType] subclass, which provides an
+API for the following operations:
+
+- Encoding and decoding a native data type
+- Encoding and decoding a data type to and from Zarr V2 and Zarr V3 array metadata
+- Encoding and decoding a scalar value to and from Zarr V2 and Zarr V3 array metadata
+- Casting a Python object to a scalar value consistent with the data type
+
+### List of data types
+
+The following section lists the data types built in to Zarr Python. With a few exceptions, Zarr
+Python supports nearly all of the data types in NumPy. If you need a data type that is not listed
+here, it's possible to create it yourself: see [Adding new data types](#adding-new-data-types).
+
+#### Boolean
+
+- [Boolean][zarr.dtype.Bool]
+
+#### Integral
+
+- [Signed 8-bit integer][zarr.dtype.Int8]
+- [Signed 16-bit integer][zarr.dtype.Int16]
+- [Signed 32-bit integer][zarr.dtype.Int32]
+- [Signed 64-bit integer][zarr.dtype.Int64]
+- [Unsigned 8-bit integer][zarr.dtype.UInt8]
+- [Unsigned 16-bit integer][zarr.dtype.UInt16]
+- [Unsigned 32-bit integer][zarr.dtype.UInt32]
+- [Unsigned 64-bit integer][zarr.dtype.UInt64]
+
+#### Floating-point
+
+- [16-bit floating-point][zarr.dtype.Float16]
+- [32-bit floating-point][zarr.dtype.Float32]
+- [64-bit floating-point][zarr.dtype.Float64]
+- [64-bit complex floating-point][zarr.dtype.Complex64]
+- [128-bit complex floating-point][zarr.dtype.Complex128]
+
+#### String
+
+- [Fixed-length UTF-32 string][zarr.dtype.FixedLengthUTF32]
+- [Variable-length UTF-8 string][zarr.dtype.VariableLengthUTF8]
+
+#### Bytes
+
+- [Fixed-length null-terminated bytes][zarr.dtype.NullTerminatedBytes]
+- [Fixed-length raw bytes][zarr.dtype.RawBytes]
+- [Variable-length bytes][zarr.dtype.VariableLengthBytes]
+
+#### Temporal
+
+- [DateTime64][zarr.dtype.DateTime64]
+- [TimeDelta64][zarr.dtype.TimeDelta64]
+
+#### Struct-like
+
+- [Structured][zarr.dtype.Structured]
+
+!!! note "Zarr V3 Structured Data Types"
+
+ In Zarr V3, structured data types are specified using the `struct` extension defined in the
+ [zarr-extensions repository](https://github.com/zarr-developers/zarr-extensions/tree/main/data-types/struct).
+ The JSON representation uses an object format for fields:
+
+ ```json
+ {
+ "name": "struct",
+ "configuration": {
+ "fields": [
+ {"name": "x", "data_type": "float32"},
+ {"name": "y", "data_type": "int64"}
+ ]
+ }
+ }
+ ```
+
+ For backward compatibility, Zarr Python also accepts the legacy `structured` name with
+ tuple-format fields when reading existing data.
+
+ Fill values for structured types are represented as JSON objects mapping field names to values:
+
+ ```json
+ {"x": 1.5, "y": 42}
+ ```
+
+ When using structured types with multi-byte fields, the `bytes` codec must specify an
+ explicit `endian` parameter. If omitted, Zarr Python assumes little-endian for legacy
+ compatibility but emits a warning.
+
+### Example usage
+
+This section will demonstrate the basic usage of Zarr data types.
+
+Create a `ZDType` from a native data type:
+
+```python exec="true" session="data_types" source="above"
+from zarr.dtype import Int8
+import numpy as np
+int8 = Int8.from_native_dtype(np.dtype('int8'))
+```
+
+Convert back to a native data type:
+
+```python exec="true" session="data_types" source="above"
+native_dtype = int8.to_native_dtype()
+assert native_dtype == np.dtype('int8')
+```
+
+Get the default scalar value for the data type:
+
+```python exec="true" session="data_types" source="above"
+default_value = int8.default_scalar()
+assert default_value == np.int8(0)
+```
+
+Serialize to JSON for Zarr V2:
+
+```python exec="true" session="data_types" source="above" result="ansi"
+json_v2 = int8.to_json(zarr_format=2)
+print(json_v2)
+```
+
+!!! note
+
+ The representation returned by `to_json(zarr_format=2)` is more abstract than the literal contents
+ of Zarr V2 array metadata, because the JSON representation used by the `ZDType` classes must be
+ distinct across different data types. As noted [earlier](#object-data-type), Zarr V2 identifies
+ multiple distinct data types with the "object" data type identifier `"|O"`. Extra information
+ is needed to disambiguate these data types from one another. That's the reason for the
+ `object_codec_id` field you see here.
+
+And for V3:
+
+```python exec="true" session="data_types" source="above" result="ansi"
+json_v3 = int8.to_json(zarr_format=3)
+print(json_v3)
+```
+
+Serialize a scalar value to JSON:
+
+```python exec="true" session="data_types" source="above" result="ansi"
+json_value = int8.to_json_scalar(42, zarr_format=3)
+print(json_value)
+```
+
+Deserialize a scalar value from JSON:
+
+```python exec="true" session="data_types" source="above"
+scalar_value = int8.from_json_scalar(42, zarr_format=3)
+assert scalar_value == np.int8(42)
+```
+
+### Adding new data types
+
+Each Zarr data type is a separate Python class that inherits from
+[ZDType][zarr.dtype.ZDType]. You can define a custom data type by
+writing your own subclass of [ZDType][zarr.dtype.ZDType] and adding
+your data type to the data type registry. To see an executable demonstration
+of this process, see the [`custom_dtype` example](../user-guide/examples/custom_dtype.md).
+
+### Data type resolution
+
+Although Zarr Python uses a different data type model from NumPy, you can still define a Zarr array
+with a NumPy data type object:
+
+```python exec="true" session="data_types" source="above" result="ansi"
+from zarr import create_array
+import numpy as np
+a = create_array({}, shape=(10,), dtype=np.dtype('int'))
+print(a)
+```
+
+Or a string representation of a NumPy data type:
+
+```python exec="true" session="data_types" source="above" result="ansi"
+a = create_array({}, shape=(10,), dtype='
+```
+
+This example illustrates a general problem Zarr Python has to solve: how can we allow users to
+specify a data type as a string or a NumPy `dtype` object, and produce the right Zarr data type
+from that input? We call this process "data type resolution." Zarr Python also performs data type
+resolution when reading stored arrays, although in this case the input is a JSON value instead
+of a NumPy data type.
+
+For simple data types like `int`, the solution could be extremely simple: just
+maintain a lookup table that maps a NumPy data type to the Zarr data type equivalent. But not all
+data types are so simple. Consider this case:
+
+```python exec="true" session="data_types" source="above" result="ansi"
+from zarr import create_array
+import numpy as np
+a = create_array({}, shape=(10,), dtype=[('a', 'f8'), ('b', 'i8')])
+print(a.dtype) # this is the NumPy data type
+```
+
+```python exec="true" session="data_types" source="above" result="ansi"
+print(a.metadata.data_type) # this is the Zarr data type
+```
+
+In this example, we created a
+[NumPy structured data type](https://numpy.org/doc/stable/user/basics.rec.html#structured-datatypes).
+This data type is a container that can hold any NumPy data type, which makes it recursive. It is
+not possible to make a lookup table that relates all NumPy structured data types to their Zarr
+equivalents, as there is a nearly unbounded number of different structured data types. So instead of
+a static lookup table, Zarr Python relies on a dynamic approach to data type resolution.
+
+Zarr Python defines a collection of Zarr data types. This collection, called a "data type registry,"
+is essentially a dictionary where the keys are strings (a canonical name for each data type), and the
+values are the data type classes themselves. Dynamic data type resolution entails iterating over
+these data type classes, invoking that class' [from_native_dtype][zarr.dtype.ZDType.from_native_dtype]
+method, and returning a concrete data type instance if and only if exactly one of those constructor
+invocations is successful.
+
+In plain language, we take some user input, like a NumPy data type, offer it to all the
+known data type classes, and return an instance of the one data type class that can accept that user input.
+
+We want to avoid a situation where the same native data type matches multiple Zarr data types; that is,
+a NumPy data type should *uniquely* specify a single Zarr data type. But data type resolution is
+dynamic, so it's not possible to statically guarantee this uniqueness constraint. Therefore, we
+attempt data type resolution against *every* data type class, and if, for some reason, a native data
+type matches multiple Zarr data types, we treat this as an error and raise an exception.
+
+If you have a NumPy data type and you want to get the corresponding `ZDType` instance, you can use
+the `parse_dtype` function, which will use the dynamic resolution described above. `parse_dtype`
+handles a range of input types:
+
+- NumPy data types:
+
+ ```python exec="true" session="data_types" source="above" result="ansi"
+ import numpy as np
+ from zarr.dtype import parse_dtype
+ my_dtype = np.dtype('>M8[10s]')
+ print(parse_dtype(my_dtype, zarr_format=2))
+ ```
+
+- NumPy data type-compatible strings:
+
+ ```python exec="true" session="data_types" source="above" result="ansi"
+ dtype_str = '>M8[10s]'
+ print(parse_dtype(dtype_str, zarr_format=2))
+ ```
+
+- `ZDType` instances:
+
+ ```python exec="true" session="data_types" source="above" result="ansi"
+ from zarr.dtype import DateTime64
+ zdt = DateTime64(endianness='big', scale_factor=10, unit='s')
+ print(parse_dtype(zdt, zarr_format=2)) # Use a ZDType (this is a no-op)
+ ```
+
+- Python dictionaries (requires `zarr_format=3`). These dictionaries must be consistent with the
+ `JSON` form of the data type:
+
+ ```python exec="true" session="data_types" source="above" result="ansi"
+ dt_dict = {"name": "numpy.datetime64", "configuration": {"unit": "s", "scale_factor": 10}}
+ print(parse_dtype(dt_dict, zarr_format=3))
+ ```
+
+ ```python exec="true" session="data_types" source="above" result="ansi"
+ print(parse_dtype(dt_dict, zarr_format=3).to_json(zarr_format=3))
+ ```
diff --git a/docs/user-guide/data_types.rst b/docs/user-guide/data_types.rst
deleted file mode 100644
index dc29874b3b..0000000000
--- a/docs/user-guide/data_types.rst
+++ /dev/null
@@ -1,412 +0,0 @@
-.. _user-guide-data-types:
-
-Array data types
-================
-
-Zarr's Data Type Model
-----------------------
-
-Zarr is designed for interoperability with NumPy, so if you are familiar with NumPy or any other
-N-dimensional array library, Zarr's model for array data types should seem familiar. However, Zarr
-data types have some unique features that are described in this document.
-
-Zarr arrays operate under an essential design constraint: unlike NumPy arrays, Zarr arrays
-are designed to be stored and accessed by other Zarr implementations. This means that, among other things,
-Zarr data types must be serializable to metadata documents in accordance with the Zarr specifications,
-which adds some unique aspects to the Zarr data type model.
-
-The following sections explain Zarr's data type model in greater detail and demonstrate the
-Zarr Python APIs for working with Zarr data types.
-
-Array Data Types
-^^^^^^^^^^^^^^^^
-
-Every Zarr array has a data type, which defines the meaning of the array's elements. An array's data
-type is encoded in the JSON metadata for the array. This means that the data type of an array must be
-JSON-serializable.
-
-In Zarr V2, the data type of an array is stored in the ``dtype`` field in array metadata.
-Zarr V3 changed the name of this field to ``data_type`` and also defined new rules for the values
-that can be assigned to the ``data_type`` field.
-
-For example, in Zarr V2, the boolean array data type was represented in array metadata as the
-string ``"|b1"``. In Zarr V3, the same type is represented as the string ``"bool"``.
-
-Scalars
-^^^^^^^
-
-Zarr also specifies how array elements, i.e., scalars, are encoded in array metadata. This is necessary
-because Zarr uses a field in array metadata to define a default value for chunks that are not stored.
-This field, called ``fill_value`` in both Zarr V2 and Zarr V3 metadata documents, contains a
-JSON value that can be decoded to a scalar value compatible with the array's data type.
-
-For the boolean data type, the scalar encoding is simple—booleans are natively supported by
-JSON, so Zarr saves booleans as JSON booleans. Other scalars, like floats or raw bytes, have
-more elaborate encoding schemes, and in some cases, this scheme depends on the Zarr format version.
-
-Data Types in Zarr Version 2
-----------------------------
-
-Version 2 of the Zarr format defined its data types relative to
-`NumPy's data types `_,
-and added a few non-NumPy data types as well. With one exception (`structured data types <#structured-data-type>`_), the Zarr
-V2 JSON identifier for a data type is just the NumPy ``str`` attribute of that data type:
-
-.. code-block:: python
-
- >>> import zarr
- >>> import numpy as np
- >>> import json
- >>>
- >>> store = {}
- >>> np_dtype = np.dtype('int64')
- >>> np_dtype.str
- '>> z = zarr.create_array(store=store, shape=(1,), dtype=np_dtype, zarr_format=2)
- >>> dtype_meta = json.loads(store['.zarray'].to_bytes())["dtype"]
- >>> dtype_meta
- '`_,
- or "byte order," of the data type. As per the NumPy model,
- in Zarr version 2 each data type has an endianness where applicable.
- However, Zarr version 3 data types do not store endianness information.
-
-There are two special cases to consider: `"structured" data types <#structured-data-type>`_, and
-`"object" <#object-data-type>`_ data types.
-
-Structured Data Type
-^^^^^^^^^^^^^^^^^^^^
-
-NumPy allows the construction of a so-called "structured" data types comprised of ordered collections
-of named fields, where each field is itself a distinct NumPy data type. See the NumPy documentation
-`here `_.
-
-Crucially, NumPy does not use a special data type for structured data types—instead, NumPy
-implements structured data types as an optional feature of the so-called "Void" data type, which models
-arbitrary fixed-size byte strings. The ``str`` attribute of a regular NumPy void
-data type is the same as the ``str`` of a NumPy structured data type. This means that the ``str``
-attribute does not convey information about the fields contained in a structured data type.
-For these reasons, Zarr V2 uses a special data type encoding for structured data types.
-They are stored in JSON as lists of pairs, where the first element is a string, and the second
-element is a Zarr V2 data type specification. This representation supports recursion.
-
-For example:
-
-.. code-block:: python
-
- >>> store = {}
- >>> np_dtype = np.dtype([('field_a', '>i2'), ('field_b', [('subfield_c', '>f4'), ('subfield_d', 'i2')])])
- >>> np_dtype.str
- '|V8'
- >>> z = zarr.create_array(store=store, shape=(1,), dtype=np_dtype, zarr_format=2)
- >>> dtype_meta = json.loads(store['.zarray'].to_bytes())["dtype"]
- >>> dtype_meta
- [['field_a', '>i2'], ['field_b', [['subfield_c', '>f4'], ['subfield_d', '`_.
-
-Data Types in Zarr Python
--------------------------
-
-The two Zarr formats that Zarr Python supports specify data types in different ways: data types in
-Zarr version 2 are encoded as NumPy-compatible strings (or lists, in the case of structured data
-types), while data types in Zarr V3 are encoded as either strings or JSON objects. Zarr V3 data
-types do not have any associated endianness information, unlike Zarr V2 data types.
-
-Zarr Python needs to support both Zarr V2 and V3, which means we need to abstract over these differences.
-We do this with an abstract Zarr data type class: `ZDType <../api/zarr/dtype/index.html#zarr.dtype.ZDType>`_,
-which provides Zarr V2 and Zarr V3 compatibility routines for "native" data types.
-
-In this context, a "native" data type is a Python class, typically defined in another library, that
-models an array's data type. For example, ``np.dtypes.UInt8DType`` is a native data type defined in NumPy.
-Zarr Python wraps the NumPy ``uint8`` with a ``ZDType`` instance called
-`UInt8 <../api/zarr/dtype/index.html#zarr.dtype.ZDType>`_.
-
-As of this writing, the only native data types Zarr Python supports are NumPy data types. We could
-avoid the "native data type" jargon and just say "NumPy data type," but we do not want to rule out the
-possibility of using non-NumPy array backends in the future.
-
-Each data type supported by Zarr Python is modeled by a ``ZDType`` subclass, which provides an
-API for the following operations:
-
-- Encoding and decoding a native data type
-- Encoding and decoding a data type to and from Zarr V2 and Zarr V3 array metadata
-- Encoding and decoding a scalar value to and from Zarr V2 and Zarr V3 array metadata
-- Casting a Python object to a scalar value consistent with the data type
-
-List of data types
-^^^^^^^^^^^^^^^^^^
-
-The following section lists the data types built in to Zarr Python. With a few exceptions, Zarr
-Python supports nearly all of the data types in NumPy. If you need a data type that is not listed
-here, it's possible to create it yourself: see :ref:`adding-new-data-types`.
-
-Boolean
-"""""""
-- `Boolean <../api/zarr/dtype/index.html#zarr.dtype.Bool>`_
-
-Integral
-""""""""
-- `Signed 8-bit integer <../api/zarr/dtype/index.html#zarr.dtype.Int8>`_
-- `Signed 16-bit integer <../api/zarr/dtype/index.html#zarr.dtype.Int16>`_
-- `Signed 32-bit integer <../api/zarr/dtype/index.html#zarr.dtype.Int32>`_
-- `Signed 64-bit integer <../api/zarr/dtype/index.html#zarr.dtype.Int64>`_
-- `Unsigned 8-bit integer <../api/zarr/dtype/index.html#zarr.dtype.UInt8>`_
-- `Unsigned 16-bit integer <../api/zarr/dtype/index.html#zarr.dtype.UInt16>`_
-- `Unsigned 32-bit integer <../api/zarr/dtype/index.html#zarr.dtype.UInt32>`_
-- `Unsigned 64-bit integer <../api/zarr/dtype/index.html#zarr.dtype.UInt64>`_
-
-Floating-point
-""""""""""""""
-- `16-bit floating-point <../api/zarr/dtype/index.html#zarr.dtype.Float16>`_
-- `32-bit floating-point <../api/zarr/dtype/index.html#zarr.dtype.Float32>`_
-- `64-bit floating-point <../api/zarr/dtype/index.html#zarr.dtype.Float64>`_
-- `64-bit complex floating-point <../api/zarr/dtype/index.html#zarr.dtype.Complex64>`_
-- `128-bit complex floating-point <../api/zarr/dtype/index.html#zarr.dtype.Complex128>`_
-
-String
-""""""
-- `Fixed-length UTF-32 string <../api/zarr/dtype/index.html#zarr.dtype.FixedLengthUTF32>`_
-- `Variable-length UTF-8 string <../api/zarr/dtype/index.html#zarr.dtype.VariableLengthUTF8>`_
-
-Bytes
-"""""
-- `Fixed-length null-terminated bytes <../api/zarr/dtype/index.html#zarr.dtype.NullTerminatedBytes>`_
-- `Fixed-length raw bytes <../api/zarr/dtype/index.html#zarr.dtype.RawBytes>`_
-- `Variable-length bytes <../api/zarr/dtype/index.html#zarr.dtype.VariableLengthBytes>`_
-
-Temporal
-""""""""
-- `DateTime64 <../api/zarr/dtype/index.html#zarr.dtype.DateTime64>`_
-- `TimeDelta64 <../api/zarr/dtype/index.html#zarr.dtype.TimeDelta64>`_
-
-Struct-like
-"""""""""""
-- `Structured <../api/zarr/dtype/index.html#zarr.dtype.Structured>`_
-
-Example Usage
-^^^^^^^^^^^^^
-
-This section will demonstrates the basic usage of Zarr data types.
-
-Create a ``ZDType`` from a native data type:
-
-.. code-block:: python
-
- >>> from zarr.core.dtype import Int8
- >>> import numpy as np
- >>> int8 = Int8.from_native_dtype(np.dtype('int8'))
-
-Convert back to a native data type:
-
-.. code-block:: python
-
- >>> native_dtype = int8.to_native_dtype()
- >>> assert native_dtype == np.dtype('int8')
-
-Get the default scalar value for the data type:
-
-.. code-block:: python
-
- >>> default_value = int8.default_scalar()
- >>> assert default_value == np.int8(0)
-
-Serialize to JSON for Zarr V2:
-
-.. code-block:: python
-
- >>> json_v2 = int8.to_json(zarr_format=2)
- >>> json_v2
- {'name': '|i1', 'object_codec_id': None}
-
-.. note::
-
- The representation returned by ``to_json(zarr_format=2)`` is more abstract than the literal contents
- of Zarr V2 array metadata, because the JSON representation used by the ``ZDType`` classes must be
- distinct across different data types. As noted `earlier <#object-data-type>`_, Zarr V2 identifies
- multiple distinct data types with the "object" data type identifier ``"|O"``. Extra information
- is needed to disambiguate these data types from one another. That's the reason for the
- ``object_codec_id`` field you see here.
-
-And for V3:
-
-.. code-block:: python
-
- >>> json_v3 = int8.to_json(zarr_format=3)
- >>> json_v3
- 'int8'
-
-Serialize a scalar value to JSON:
-
-.. code-block:: python
-
- >>> json_value = int8.to_json_scalar(42, zarr_format=3)
- >>> json_value
- 42
-
-Deserialize a scalar value from JSON:
-
-.. code-block:: python
-
- >>> scalar_value = int8.from_json_scalar(42, zarr_format=3)
- >>> assert scalar_value == np.int8(42)
-
-.. _adding-new-data-types:
-
-Adding New Data Types
-^^^^^^^^^^^^^^^^^^^^^
-
-Each Zarr data type is a separate Python class that inherits from
-`ZDType <../api/zarr/dtype/index.html#zarr.dtype.ZDType>`_. You can define a custom data type by
-writing your own subclass of `ZDType <../api/zarr/dtype/index.html#zarr.dtype.ZDType>`_ and adding
-your data type to the data type registry. A complete example of this process is included below.
-
-The source code for this example can be found in the ``examples/custom_dtype.py`` file in the Zarr
-Python project directory.
-
-.. literalinclude:: ../../examples/custom_dtype.py
- :language: python
-
-Data Type Resolution
-^^^^^^^^^^^^^^^^^^^^
-
-Although Zarr Python uses a different data type model from NumPy, you can still define a Zarr array
-with a NumPy data type object:
-
-.. code-block:: python
-
- >>> from zarr import create_array
- >>> import numpy as np
- >>> a = create_array({}, shape=(10,), dtype=np.dtype('int'))
- >>> a
-
-
-Or a string representation of a NumPy data type:
-
-.. code-block:: python
-
- >>> a = create_array({}, shape=(10,), dtype='>> a
-
-
-The ``Array`` object presents itself like a NumPy array, including exposing a NumPy
-data type as its ``dtype`` attribute:
-
-.. code-block:: python
-
- >>> type(a.dtype)
-
-
-But if we inspect the metadata for the array, we can see the Zarr data type object:
-
-.. code-block:: python
-
- >>> type(a.metadata.data_type)
-
-
-This example illustrates a general problem Zarr Python has to solve: how can we allow users to
-specify a data type as a string or a NumPy ``dtype`` object, and produce the right Zarr data type
-from that input? We call this process "data type resolution." Zarr Python also performs data type
-resolution when reading stored arrays, although in this case the input is a JSON value instead
-of a NumPy data type.
-
-For simple data types like ``int``, the solution could be extremely simple: just
-maintain a lookup table that maps a NumPy data type to the Zarr data type equivalent. But not all
-data types are so simple. Consider this case:
-
-.. code-block:: python
-
- >>> from zarr import create_array
- >>> import warnings
- >>> import numpy as np
- >>> warnings.simplefilter("ignore", category=FutureWarning)
- >>> a = create_array({}, shape=(10,), dtype=[('a', 'f8'), ('b', 'i8')])
- >>> a.dtype # this is the NumPy data type
- dtype([('a', '>> a.metadata.data_type # this is the Zarr data type
- Structured(fields=(('a', Float64(endianness='little')), ('b', Int64(endianness='little'))))
-
-In this example, we created a
-`NumPy structured data type `_.
-This data type is a container that can hold any NumPy data type, which makes it recursive. It is
-not possible to make a lookup table that relates all NumPy structured data types to their Zarr
-equivalents, as there is a nearly unbounded number of different structured data types. So instead of
-a static lookup table, Zarr Python relies on a dynamic approach to data type resolution.
-
-Zarr Python defines a collection of Zarr data types. This collection, called a "data type registry,"
-is essentially a dictionary where the keys are strings (a canonical name for each data type), and the
-values are the data type classes themselves. Dynamic data type resolution entails iterating over
-these data type classes, invoking that class' `from_native_dtype <#api/dtype/ZDType.from_native_dtype>`_
-method, and returning a concrete data type instance if and only if exactly one of those constructor
-invocations is successful.
-
-In plain language, we take some user input, like a NumPy data type, offer it to all the
-known data type classes, and return an instance of the one data type class that can accept that user input.
-
-We want to avoid a situation where the same native data type matches multiple Zarr data types; that is,
-a NumPy data type should *uniquely* specify a single Zarr data type. But data type resolution is
-dynamic, so it's not possible to statically guarantee this uniqueness constraint. Therefore, we
-attempt data type resolution against *every* data type class, and if, for some reason, a native data
-type matches multiple Zarr data types, we treat this as an error and raise an exception.
\ No newline at end of file
diff --git a/docs/user-guide/examples/codec_pipeline_performance.md b/docs/user-guide/examples/codec_pipeline_performance.md
new file mode 100644
index 0000000000..f21e31636e
--- /dev/null
+++ b/docs/user-guide/examples/codec_pipeline_performance.md
@@ -0,0 +1,7 @@
+--8<-- "examples/codec_pipeline_performance/README.md"
+
+## Source Code
+
+```python exec="false" reason="pymdownx snippet include directive, not python source"
+--8<-- "examples/codec_pipeline_performance/codec_pipeline_performance.py"
+```
diff --git a/docs/user-guide/examples/custom_dtype.md b/docs/user-guide/examples/custom_dtype.md
new file mode 100644
index 0000000000..391407b822
--- /dev/null
+++ b/docs/user-guide/examples/custom_dtype.md
@@ -0,0 +1,7 @@
+--8<-- "examples/custom_dtype/README.md"
+
+## Source Code
+
+```python exec="false" reason="pymdownx snippet include directive, not python source"
+--8<-- "examples/custom_dtype/custom_dtype.py"
+```
diff --git a/docs/user-guide/examples/rectilinear_chunks.md b/docs/user-guide/examples/rectilinear_chunks.md
new file mode 100644
index 0000000000..098cdf1e2f
--- /dev/null
+++ b/docs/user-guide/examples/rectilinear_chunks.md
@@ -0,0 +1,173 @@
+# Rectilinear Chunk Grids
+
+This example demonstrates rectilinear (variable-sized) chunk grids, introduced in
+[#3802](https://github.com/zarr-developers/zarr-python/pull/3802). Rectilinear grids
+allow different chunk sizes along each dimension, which is useful for data that
+doesn't partition evenly — for example, sparse HEALPix cells grouped by parent tile,
+boundary-padded HPC arrays, or ingesting existing variable-chunked datasets via
+VirtualiZarr. See [Rectilinear (variable) chunk grids](../arrays.md#rectilinear-variable-chunk-grids)
+in the arrays guide for an introduction to the feature.
+
+The example chunks a HEALPix dataset by parent tile, writes it as a Zarr v3 array
+with a rectilinear chunk grid, and verifies the round trip through
+[Xarray](https://xarray.dev).
+
+!!! warning "Experimental"
+ Rectilinear chunk grids are an experimental feature and may change in future
+ releases. In addition, this example currently requires
+ [a fork of Xarray](https://github.com/maxrjones/xarray/tree/poc/unified-zarr-chunk-grid)
+ with rectilinear chunk grid support (this will ideally be incorporated into a
+ future Xarray release), as well as the `dask`, `healpix-geo`, and `obstore`
+ packages, and it reads an example dataset from a remote server. For these
+ reasons the code on this page is not executed when the documentation is built;
+ the outputs shown were captured from a live run.
+
+## Setup
+
+Rectilinear chunk grids are disabled by default and must be explicitly enabled via
+the `array.rectilinear_chunks` configuration option:
+
+```python exec="false" reason="requires an xarray fork with rectilinear chunk grid support and remote example data"
+import json
+import tempfile
+from pathlib import Path
+
+import numpy as np
+import xarray as xr
+from healpix_geo import nested
+from obstore.store import HTTPStore
+
+import zarr
+from zarr.storage import ObjectStore
+
+# Increase concurrency for better performance with obstore
+zarr.config.set({'async.concurrency': 128})
+# Opt in to rectilinear chunks
+zarr.config.set({'array.rectilinear_chunks': True})
+```
+
+## Inspect the HEALPix dataset
+
+Load the remote Zarr store to understand the data structure before chunking it:
+
+```python exec="false" reason="requires an xarray fork with rectilinear chunk grid support and remote example data"
+ob_store = HTTPStore.from_url("https://data-taos.ifremer.fr/GRID4EARTH/no_chunk_healpix.zarr")
+store = ObjectStore(ob_store)
+g = zarr.open_group(store, mode="r", zarr_format=2, use_consolidated=True)
+arr = g['da']
+
+print("Members:", list(g.members()))
+print("Attrs:", dict(g.attrs))
+print("Write chunk sizes:", arr.write_chunk_sizes)
+```
+
+```text
+Members: [('cell_ids', ), ('da', )]
+Attrs: {}
+Write chunk sizes: ((55611, 55611, 55611, 55609),)
+```
+
+## HEALPix-style variable chunking
+
+Inspired by [this use case](https://github.com/zarr-developers/zarr-python/pull/3534#issuecomment-3848669859):
+HEALPix grids where cells are grouped by parent tile at a coarser resolution level,
+producing variable-sized chunks along the cell dimension when accounting for sparsity.
+
+```python exec="false" reason="requires an xarray fork with rectilinear chunk grid support and remote example data"
+da = xr.open_zarr(
+ store,
+ zarr_format=2,
+ consolidated=True,
+)
+
+depth = da.cell_ids.attrs['level']
+new_depth = depth - 6
+parents = nested.zoom_to(da.cell_ids, depth=depth, new_depth=new_depth)
+_, chunk_sizes = np.unique(parents, return_counts=True)
+print(chunk_sizes)
+```
+
+```text
+[ 25 645 1510 2363 3203 74 769 3963 4096 233 1603 2450 4096 4096
+ 3327 4047 4096 4096 1278 2113 4096 3879 4096 3842 2173 983 4046 2187
+ 4095 1369 4096 4096 4096 4096 3515 1395 4096 3622 4096 4096 3875 4096
+ 4096 4096 4096 4096 2034 4096 358 3991 4096 4096 4096 4096 2714 1210
+ 4096 4096 4096 4096 92 3826 4096 2629 4096 1438 4096 353 4078 3410
+ 2407 226 132 2738 1223 23]
+```
+
+Rechunk the dataset with these variable-sized chunks:
+
+```python exec="false" reason="requires an xarray fork with rectilinear chunk grid support and remote example data"
+da = da.chunk({"cell_ids": tuple(chunk_sizes.tolist())})
+print(da.chunks)
+```
+
+```text
+Frozen({'cell_ids': (25, 645, 1510, 2363, 3203, 74, 769, 3963, 4096, 233, 1603, 2450, 4096, 4096, 3327, 4047, 4096, 4096, 1278, 2113, 4096, 3879, 4096, 3842, 2173, 983, 4046, 2187, 4095, 1369, 4096, 4096, 4096, 4096, 3515, 1395, 4096, 3622, 4096, 4096, 3875, 4096, 4096, 4096, 4096, 4096, 2034, 4096, 358, 3991, 4096, 4096, 4096, 4096, 2714, 1210, 4096, 4096, 4096, 4096, 92, 3826, 4096, 2629, 4096, 1438, 4096, 353, 4078, 3410, 2407, 226, 132, 2738, 1223, 23)})
+```
+
+## Write as rectilinear Zarr v3
+
+Write the variable-chunked dataset to a local Zarr v3 store with rectilinear chunk
+grids enabled:
+
+```python exec="false" reason="requires an xarray fork with rectilinear chunk grid support and remote example data"
+output_path = Path(tempfile.mkdtemp()) / "healpix_rectilinear.zarr"
+
+encoding = {
+ "da": {"chunks": [chunk_sizes.tolist()]},
+ "cell_ids": {"chunks": [chunk_sizes.tolist()]},
+}
+
+da.to_zarr(output_path, zarr_format=3, mode="w", encoding=encoding, consolidated=False)
+
+print(f"Written to: {output_path}")
+```
+
+```text
+Written to: /var/folders/.../T/tmp6dibcrho/healpix_rectilinear.zarr
+```
+
+## Verify the rectilinear metadata
+
+Inspect the output store to confirm the chunk grid is serialized as `"rectilinear"`
+in `zarr.json`, following the
+[rectilinear chunk grid extension spec](https://github.com/zarr-developers/zarr-extensions/tree/main/chunk-grids/rectilinear).
+
+Key things to look for in `chunk_grid`:
+
+- **`name`**: `"rectilinear"` (the extension identifier)
+- **`configuration.kind`**: `"inline"` (edge lengths stored directly in metadata)
+- **`configuration.chunk_shapes`**: one entry per dimension — here a single list for
+ the 1D `cell_ids` axis. Each element is either:
+ - a **bare integer** for a unique edge length (e.g., `25`, `645`)
+ - a **`[value, count]` array** using
+ [run-length encoding](https://github.com/zarr-developers/zarr-extensions/tree/main/chunk-grids/rectilinear#run-length-encoding)
+ for consecutive repeated sizes (e.g., `[4096, 4]` means four consecutive chunks
+ of size 4096)
+
+```python exec="false" reason="requires an xarray fork with rectilinear chunk grid support and remote example data"
+# Read the zarr.json for the 'da' array
+da_meta_path = output_path / "da" / "zarr.json"
+meta = json.loads(da_meta_path.read_text())
+print(meta['chunk_grid'])
+```
+
+```text
+{'name': 'rectilinear', 'configuration': {'kind': 'inline', 'chunk_shapes': [[25, 645, 1510, 2363, 3203, 74, 769, 3963, 4096, 233, 1603, 2450, [4096, 2], 3327, 4047, [4096, 2], 1278, 2113, 4096, 3879, 4096, 3842, 2173, 983, 4046, 2187, 4095, 1369, [4096, 4], 3515, 1395, 4096, 3622, [4096, 2], 3875, [4096, 5], 2034, 4096, 358, 3991, [4096, 4], 2714, 1210, [4096, 4], 92, 3826, 4096, 2629, 4096, 1438, 4096, 353, 4078, 3410, 2407, 226, 132, 2738, 1223, 23]]}}
+```
+
+## Round-trip verification
+
+Read the rectilinear store back and confirm the chunk sizes are preserved:
+
+```python exec="false" reason="requires an xarray fork with rectilinear chunk grid support and remote example data"
+roundtrip = xr.open_zarr(output_path, zarr_format=3, consolidated=False)
+
+print("Round-trip chunk sizes:", roundtrip.chunks)
+```
+
+```text
+Round-trip chunk sizes: Frozen({'cell_ids': (25, 645, 1510, 2363, 3203, 74, 769, 3963, 4096, 233, 1603, 2450, 4096, 4096, 3327, 4047, 4096, 4096, 1278, 2113, 4096, 3879, 4096, 3842, 2173, 983, 4046, 2187, 4095, 1369, 4096, 4096, 4096, 4096, 3515, 1395, 4096, 3622, 4096, 4096, 3875, 4096, 4096, 4096, 4096, 4096, 2034, 4096, 358, 3991, 4096, 4096, 4096, 4096, 2714, 1210, 4096, 4096, 4096, 4096, 92, 3826, 4096, 2629, 4096, 1438, 4096, 353, 4078, 3410, 2407, 226, 132, 2738, 1223, 23)})
+```
diff --git a/docs/user-guide/examples/sharding_coalescing.md b/docs/user-guide/examples/sharding_coalescing.md
new file mode 100644
index 0000000000..8b2e054af5
--- /dev/null
+++ b/docs/user-guide/examples/sharding_coalescing.md
@@ -0,0 +1,7 @@
+--8<-- "examples/sharding_coalescing/README.md"
+
+## Source Code
+
+```python exec="false" reason="pymdownx snippet include directive, not python source"
+--8<-- "examples/sharding_coalescing/sharding_coalescing.py"
+```
diff --git a/docs/user-guide/experimental.md b/docs/user-guide/experimental.md
new file mode 100644
index 0000000000..e14146610c
--- /dev/null
+++ b/docs/user-guide/experimental.md
@@ -0,0 +1,360 @@
+# Experimental features
+
+This section contains documentation for experimental Zarr Python features. The features described here are exciting and potentially useful, but also volatile -- we might change them at any time. Take this into account if you consider depending on these features. See the
+[experimental API policy](../contributing.md#experimental-api-policy) for the stability
+guarantees (or lack thereof) that apply to everything documented on this page.
+
+## `FusedCodecPipeline`
+
+A *codec pipeline* is the machinery that turns chunks of array data into stored bytes and back, by running the configured codecs (filters, serializer, compressors) and performing the storage IO.
+The default pipeline, `BatchedCodecPipeline`, schedules both the IO and codec work asynchronously -- roughly one coroutine per chunk operation.
+
+`FusedCodecPipeline` is an experimental alternative that runs codec compute and synchronous IO *synchronously*, avoiding that per-chunk async scheduling overhead and nasty [`asyncio.to_thread` overhead](https://github.com/python/cpython/issues/136084).
+On real workloads the scheduling cost can dominate the actual codec work, so removing it is a significant speedup -- especially for **sharded arrays**, where a single shard read or write involves many inner chunks.
+
+> **Note:** The win is *not* a faster compressor or a different on-disk format -- the bytes written are
+> identical. It is purely the removal of async scheduling overhead, plus a few vectorized fast paths
+> for dense, uncompressed shards i.e., removing compute where it is not needed.
+
+### When it helps
+
+There are two main benefits in this new pipeline:
+
+1. When storage IO is fast enough that the *scheduling* overhead, not the IO itself, is the bottleneck. That means **low-latency stores** that are themselves synchronous -- in particular [`zarr.storage.MemoryStore`][] and [`zarr.storage.LocalStore`][].
+
+2. Whenever codec work that is truly synchronous will not need the overhead of `async` scheduling i.e., inner-chunk codec work in sharding using something like `zstd`. We also now make use of `asyncio.as_completed` so that IO from asynchronous sources can begin decompression immediately.
+
+### Opting in
+
+`FusedCodecPipeline` is opt-in: the default pipeline is unchanged, so existing code behaves exactly as
+before. Select it through the [runtime configuration](config.md), by setting `codec_pipeline.path`:
+
+```python exec="true" session="experimental-fused" source="above" result="ansi"
+import zarr
+
+zarr.config.set(
+ {"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"}
+)
+```
+
+You can set this globally as above (affecting every array created or opened afterwards), or scope it to
+a block of code using `zarr.config.set` as a context manager:
+
+```python exec="true" session="experimental-fused" source="above"
+import numpy as np
+import zarr
+from zarr.storage import MemoryStore
+
+with zarr.config.set(
+ {"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"}
+):
+ # A sharded array on an in-memory store -- the low-latency case the
+ # synchronous pipeline targets.
+ arr = zarr.create_array(
+ store=MemoryStore(),
+ shape=(1000, 1000),
+ chunks=(100, 100),
+ shards=(1000, 1000),
+ dtype="float32",
+ )
+ arr[:] = np.random.random((1000, 1000)).astype("float32")
+ result = arr[:]
+
+print(result.shape)
+```
+
+To return to the default pipeline, set `codec_pipeline.path` back to the batched implementation:
+
+```python exec="true" session="experimental-fused" source="above"
+import zarr
+
+zarr.config.set(
+ {"codec_pipeline.path": "zarr.core.codec_pipeline.BatchedCodecPipeline"}
+)
+```
+
+### Threading
+
+By default the synchronous pipeline runs fully threaded i.e., `os.cpu_count()`.
+For memory-backed workflows, you may find that setting `max_workers` to 1 helps (since requests for data from the store are GIL-locked, unlike, say, file-backed i/o).
+
+```python exec="true" session="experimental-fused" source="above"
+import zarr
+
+# Use a fixed-size thread pool for codec compute.
+zarr.config.set({"codec_pipeline.max_workers": 8})
+
+# Or "auto", sized to the number of CPUs.
+zarr.config.set({"codec_pipeline.max_workers": None})
+```
+
+On many-core nodes a pool sized to `cpu_count` can oversubscribe workloads that already parallelize at a higher level (e.g. Dask).
+`codec_pipeline.max_workers` only affects `FusedCodecPipeline`; the default `BatchedCodecPipeline` ignores it.
+
+## `CacheStore`
+
+Zarr Python 3.1.4 adds [`zarr.experimental.cache_store.CacheStore`][], which provides a dual-store caching implementation
+that can be wrapped around any Zarr store to improve performance for repeated data access.
+This is particularly useful when working with remote stores (e.g., S3, HTTP) where network
+latency can significantly impact data access speed.
+
+The CacheStore implements a cache that uses a separate Store instance as the cache backend,
+providing persistent caching capabilities with time-based expiration, size-based eviction,
+and flexible cache storage options. It automatically evicts the least recently used items
+when the cache reaches its maximum size.
+
+Because the `CacheStore` uses an ordinary Zarr `Store` object as the caching layer, you can reuse the data stored in the cache later.
+
+!!! note
+ The CacheStore is a wrapper store that maintains compatibility with the full
+ `zarr.abc.store.Store` API while adding transparent caching functionality.
+
+### Basic Usage
+
+Creating a CacheStore requires both a source store and a cache store. The cache store
+can be any Store implementation, providing flexibility in cache persistence:
+
+```python exec="true" session="experimental" source="above"
+import zarr
+from zarr.storage import LocalStore
+import numpy as np
+from tempfile import mkdtemp
+from zarr.experimental.cache_store import CacheStore
+
+# Create a local store and a separate cache store
+local_store_path = mkdtemp(suffix='.zarr')
+source_store = LocalStore(local_store_path)
+cache_store = zarr.storage.MemoryStore() # In-memory cache
+cached_store = CacheStore(
+ store=source_store,
+ cache_store=cache_store,
+ max_size=256*1024*1024 # 256MB cache
+)
+
+# Create an array using the cached store
+zarr_array = zarr.zeros((100, 100), chunks=(10, 10), dtype='f8', store=cached_store, mode='w')
+
+# Write some data to force chunk creation
+zarr_array[:] = np.random.random((100, 100))
+```
+
+The dual-store architecture allows you to use different store types for source and cache,
+such as a remote store for source data and a local store for persistent caching.
+
+### Performance Benefits
+
+The CacheStore provides significant performance improvements for repeated data access:
+
+```python exec="true" session="experimental" source="above"
+import time
+
+# Benchmark reading with cache
+start = time.time()
+for _ in range(100):
+ _ = zarr_array[:]
+elapsed_cache = time.time() - start
+
+# Compare with direct store access (without cache)
+zarr_array_nocache = zarr.open(local_store_path, mode='r')
+start = time.time()
+for _ in range(100):
+ _ = zarr_array_nocache[:]
+elapsed_nocache = time.time() - start
+
+# Cache provides speedup for repeated access
+speedup = elapsed_nocache / elapsed_cache
+print(f"Speedup is {speedup}")
+```
+
+Cache effectiveness is particularly pronounced with repeated access to the same data chunks.
+
+### Cache Configuration
+
+The CacheStore can be configured with several parameters:
+
+**max_size**: Controls the maximum size of cached data in bytes. The
+[Basic Usage](#basic-usage) example above sets a 256MB limit with
+`max_size=256*1024*1024`:
+
+```python exec="true" session="experimental" source="above"
+# Unlimited cache size (use with caution)
+cache = CacheStore(
+ store=source_store,
+ cache_store=cache_store,
+ max_size=None
+)
+```
+
+**max_age_seconds**: Controls time-based cache expiration
+
+```python exec="true" session="experimental" source="above"
+# Cache expires after 1 hour
+cache = CacheStore(
+ store=source_store,
+ cache_store=cache_store,
+ max_age_seconds=3600
+)
+
+# Cache never expires
+cache = CacheStore(
+ store=source_store,
+ cache_store=cache_store,
+ max_age_seconds="infinity"
+)
+```
+
+**cache_set_data**: Controls whether written data is cached
+
+```python exec="true" session="experimental" source="above"
+# Cache data when writing (default)
+cache = CacheStore(
+ store=source_store,
+ cache_store=cache_store,
+ cache_set_data=True
+)
+
+# Don't cache written data (read-only cache)
+cache = CacheStore(
+ store=source_store,
+ cache_store=cache_store,
+ cache_set_data=False
+)
+```
+
+### Cache Statistics
+
+The CacheStore provides statistics to monitor cache performance and state:
+
+```python exec="true" session="experimental" source="above"
+# Access some data to generate cache activity
+# (these chunks were already cached by the reads above, so both accesses are cache hits)
+data = zarr_array[0:50, 0:50]
+data = zarr_array[0:50, 0:50]
+
+# Get comprehensive cache information
+info = cached_store.cache_info()
+print(info['cache_store_type']) # e.g., 'MemoryStore'
+print(info['max_age_seconds'])
+print(info['max_size'])
+print(info['current_size'])
+print(info['tracked_keys'])
+print(info['cached_keys'])
+print(info['cache_set_data'])
+```
+
+The `cache_info()` method returns a dictionary with detailed information about the cache state.
+
+### Cache Management
+
+The CacheStore provides methods for manual cache management:
+
+```python exec="true" session="experimental" source="above"
+# Clear all cached data and tracking information
+import asyncio
+asyncio.run(cached_store.clear_cache())
+
+# Check cache info after clearing
+info = cached_store.cache_info()
+assert info['tracked_keys'] == 0
+assert info['current_size'] == 0
+```
+
+The `clear_cache()` method is an async method that clears both the cache store
+(if it supports the `clear` method) and all internal tracking data.
+
+### Best Practices
+
+1. **Choose appropriate cache store**: Use MemoryStore for fast temporary caching or LocalStore for persistent caching
+2. **Size the cache appropriately**: Set `max_size` based on available storage and expected data access patterns
+3. **Use with remote stores**: The cache provides the most benefit when wrapping slow remote stores
+4. **Monitor cache statistics**: Use `cache_info()` to tune cache size and access patterns
+5. **Consider data locality**: Group related data accesses together to improve cache efficiency
+6. **Set appropriate expiration**: Use `max_age_seconds` for time-sensitive data or "infinity" for static data
+
+### Working with Different Store Types
+
+The CacheStore can wrap any store that implements the `zarr.abc.store.Store` interface
+and use any store type for the cache backend:
+
+#### Local Store with Memory Cache
+
+```python exec="true" session="experimental-memory-cache" source="above"
+from zarr.storage import LocalStore, MemoryStore
+from zarr.experimental.cache_store import CacheStore
+from tempfile import mkdtemp
+
+local_store_path = mkdtemp(suffix='.zarr')
+source_store = LocalStore(local_store_path)
+cache_store = MemoryStore()
+cached_store = CacheStore(
+ store=source_store,
+ cache_store=cache_store,
+ max_size=128*1024*1024
+)
+```
+
+#### Memory Store with Persistent Cache
+
+```python exec="true" session="experimental-local-cache" source="above"
+from tempfile import mkdtemp
+from zarr.storage import MemoryStore, LocalStore
+from zarr.experimental.cache_store import CacheStore
+
+memory_store = MemoryStore()
+local_store_path = mkdtemp(suffix='.zarr')
+persistent_cache = LocalStore(local_store_path)
+cached_store = CacheStore(
+ store=memory_store,
+ cache_store=persistent_cache,
+ max_size=256*1024*1024
+)
+```
+
+The dual-store architecture provides flexibility in choosing the best combination
+of source and cache stores for your specific use case.
+
+### Examples from Real Usage
+
+Here's a complete example demonstrating cache effectiveness:
+
+```python exec="true" session="experimental-final" source="above"
+import numpy as np
+import time
+from tempfile import mkdtemp
+import zarr
+import zarr.storage
+from zarr.experimental.cache_store import CacheStore
+
+# Create test data with dual-store cache
+local_store_path = mkdtemp(suffix='.zarr')
+source_store = zarr.storage.LocalStore(local_store_path)
+cache_store = zarr.storage.MemoryStore()
+cached_store = CacheStore(
+ store=source_store,
+ cache_store=cache_store,
+ max_size=256*1024*1024
+)
+zarr_array = zarr.zeros((100, 100), chunks=(10, 10), dtype='f8', store=cached_store, mode='w')
+zarr_array[:] = np.random.random((100, 100))
+
+# Demonstrate cache effectiveness with repeated access
+start = time.time()
+data = zarr_array[20:30, 20:30] # First access (cache miss)
+first_access = time.time() - start
+print(f"First access took {first_access}")
+
+start = time.time()
+data = zarr_array[20:30, 20:30] # Second access (cache hit)
+second_access = time.time() - start
+print(f"Second access took {second_access}")
+
+# Check cache statistics
+info = cached_store.cache_info()
+assert info['cached_keys'] > 0 # Should have cached keys
+assert info['current_size'] > 0 # Should have cached data
+print(f"Cache contains {info['cached_keys']} keys with {info['current_size']} bytes")
+```
+
+This example shows how the CacheStore can significantly reduce access times for repeated
+data reads, particularly important when working with remote data sources. The dual-store
+architecture allows for flexible cache persistence and management.
diff --git a/docs/user-guide/extending.md b/docs/user-guide/extending.md
new file mode 100644
index 0000000000..f852f9105e
--- /dev/null
+++ b/docs/user-guide/extending.md
@@ -0,0 +1,99 @@
+# Extending Zarr
+
+Zarr-Python 3 was designed to be extensible. This means that you can extend
+the library by writing custom classes and plugins. Currently, Zarr can be extended
+in the following ways:
+
+## Custom codecs
+
+!!! note
+ This section explains how custom codecs can be created for Zarr format 3 arrays. For Zarr
+ format 2, codecs should subclass the
+ [numcodecs.abc.Codec](https://numcodecs.readthedocs.io/en/stable/abc.html#numcodecs.abc.Codec)
+ base class and register through
+ [numcodecs.registry.register_codec](https://numcodecs.readthedocs.io/en/stable/registry.html#numcodecs.registry.register_codec).
+
+There are three types of codecs in Zarr:
+
+- array-to-array
+- array-to-bytes
+- bytes-to-bytes
+
+Array-to-array codecs are used to transform the array data before serializing
+to bytes. Examples include delta encoding or scaling codecs. Array-to-bytes codecs are used
+for serializing the array data to bytes. In Zarr, the main codec to use for numeric arrays
+is the [`zarr.codecs.BytesCodec`][]. Bytes-to-bytes codecs transform the serialized bytestreams
+of the array data. Examples include compression codecs, such as
+[`zarr.codecs.GzipCodec`][], [`zarr.codecs.BloscCodec`][] or
+[`zarr.codecs.ZstdCodec`][], and codecs that add a checksum to the bytestream, such as
+[`zarr.codecs.Crc32cCodec`][].
+
+Custom codecs for Zarr are implemented by subclassing the relevant base class, see
+[`zarr.abc.codec.ArrayArrayCodec`][], [`zarr.abc.codec.ArrayBytesCodec`][] and
+[`zarr.abc.codec.BytesBytesCodec`][]. Most custom codecs should implement the
+`_encode_single` and `_decode_single` methods. These methods operate on single chunks
+of the array data. Alternatively, custom codecs can implement the `encode` and `decode`
+methods, which operate on batches of chunks, in case the codec is intended to implement
+its own batch processing.
+
+Custom codecs should also implement the following methods:
+
+- `compute_encoded_size`, which returns the byte size of the encoded data given the byte
+ size of the original data. It should raise `NotImplementedError` for codecs with
+ variable-sized outputs, such as compression codecs.
+- `validate` (optional), which can be used to check that the codec metadata is compatible with the
+ array metadata. It should raise errors if not.
+- `resolve_metadata` (optional), which is important for codecs that change the shape,
+ dtype or fill value of a chunk.
+- `evolve_from_array_spec` (optional), which can be useful for automatically filling in
+ codec configuration metadata from the array metadata.
+
+To use custom codecs in Zarr, they need to be registered using the
+[entrypoint mechanism](https://packaging.python.org/en/latest/specifications/entry-points/).
+Commonly, entrypoints are declared in the `pyproject.toml` of your package under the
+`[project.entry-points."zarr.codecs"]` section. Zarr will automatically discover
+all codecs registered via the entrypoint mechanism in installed packages.
+
+```toml
+[project.entry-points."zarr.codecs"]
+"custompackage.fancy_codec" = "custompackage:FancyCodec"
+```
+
+New codecs need to have their own unique identifier. To avoid naming collisions, it is
+strongly recommended to prefix the codec identifier with a unique name. For example,
+the codecs from `numcodecs` are prefixed with `numcodecs.`, e.g. `numcodecs.delta`.
+
+!!! note
+ Note that the extension mechanism for the Zarr format 3 is still under development.
+ Requirements for custom codecs including the choice of codec identifiers might
+ change in the future.
+
+It is also possible to register codecs as replacements for existing codecs. This might be
+useful for providing specialized implementations, such as GPU-based codecs. In case of
+multiple codecs, the [`zarr.config`][] mechanism can be used to select the preferred
+implementation.
+
+## Custom stores
+
+Custom stores can be created by implementing the [`zarr.abc.store.Store`][] interface.
+See [developing custom stores](storage.md#developing-custom-stores) for more information.
+
+## Custom array buffers
+
+Zarr-python provides control over where and how arrays are stored in memory through
+[`zarr.abc.buffer.Buffer`][]. Currently both CPU (the default) and GPU implementations are
+provided (see [Using GPUs with Zarr](gpu.md) for more information). You can implement your own buffer
+classes by implementing the interface defined in [`zarr.abc.buffer.BufferPrototype`][].
+Like codecs, custom buffer implementations can be registered via entrypoints, using the
+`zarr.buffer` and `zarr.ndbuffer` entrypoint groups.
+
+## Custom data types
+
+Zarr supports user-defined data types. See the
+[data types documentation](data_types.md) for an explanation of how Zarr Python
+models data types and how to write your own, and the
+[custom data type example](examples/custom_dtype.md) for a complete worked example.
+
+## Other extensions
+
+In the future, Zarr will support writing custom chunk grids.
diff --git a/docs/user-guide/extending.rst b/docs/user-guide/extending.rst
deleted file mode 100644
index 4487e07ddf..0000000000
--- a/docs/user-guide/extending.rst
+++ /dev/null
@@ -1,94 +0,0 @@
-
-Extending Zarr
-==============
-
-Zarr-Python 3 was designed to be extensible. This means that you can extend
-the library by writing custom classes and plugins. Currently, Zarr can be extended
-in the following ways:
-
-Custom codecs
--------------
-
-.. note::
- This section explains how custom codecs can be created for Zarr format 3 arrays. For Zarr
- format 2, codecs should subclass the
- `numcodecs.abc.Codec `_
- base class and register through
- `numcodecs.registry.register_codec `_.
-
-There are three types of codecs in Zarr:
-- array-to-array
-- array-to-bytes
-- bytes-to-bytes
-
-Array-to-array codecs are used to transform the array data before serializing
-to bytes. Examples include delta encoding or scaling codecs. Array-to-bytes codecs are used
-for serializing the array data to bytes. In Zarr, the main codec to use for numeric arrays
-is the :class:`zarr.codecs.BytesCodec`. Bytes-to-bytes codecs transform the serialized bytestreams
-of the array data. Examples include compression codecs, such as
-:class:`zarr.codecs.GzipCodec`, :class:`zarr.codecs.BloscCodec` or
-:class:`zarr.codecs.ZstdCodec`, and codecs that add a checksum to the bytestream, such as
-:class:`zarr.codecs.Crc32cCodec`.
-
-Custom codecs for Zarr are implemented by subclassing the relevant base class, see
-:class:`zarr.abc.codec.ArrayArrayCodec`, :class:`zarr.abc.codec.ArrayBytesCodec` and
-:class:`zarr.abc.codec.BytesBytesCodec`. Most custom codecs should implemented the
-``_encode_single`` and ``_decode_single`` methods. These methods operate on single chunks
-of the array data. Alternatively, custom codecs can implement the ``encode`` and ``decode``
-methods, which operate on batches of chunks, in case the codec is intended to implement
-its own batch processing.
-
-Custom codecs should also implement the following methods:
-
-- ``compute_encoded_size``, which returns the byte size of the encoded data given the byte
- size of the original data. It should raise ``NotImplementedError`` for codecs with
- variable-sized outputs, such as compression codecs.
-- ``validate`` (optional), which can be used to check that the codec metadata is compatible with the
- array metadata. It should raise errors if not.
-- ``resolve_metadata`` (optional), which is important for codecs that change the shape,
- dtype or fill value of a chunk.
-- ``evolve_from_array_spec`` (optional), which can be useful for automatically filling in
- codec configuration metadata from the array metadata.
-
-To use custom codecs in Zarr, they need to be registered using the
-`entrypoint mechanism `_.
-Commonly, entrypoints are declared in the ``pyproject.toml`` of your package under the
-``[project.entry-points."zarr.codecs"]`` section. Zarr will automatically discover and
-load all codecs registered with the entrypoint mechanism from imported modules.
-
-.. code-block:: toml
-
- [project.entry-points."zarr.codecs"]
- "custompackage.fancy_codec" = "custompackage:FancyCodec"
-
-New codecs need to have their own unique identifier. To avoid naming collisions, it is
-strongly recommended to prefix the codec identifier with a unique name. For example,
-the codecs from ``numcodecs`` are prefixed with ``numcodecs.``, e.g. ``numcodecs.delta``.
-
-.. note::
- Note that the extension mechanism for the Zarr format 3 is still under development.
- Requirements for custom codecs including the choice of codec identifiers might
- change in the future.
-
-It is also possible to register codecs as replacements for existing codecs. This might be
-useful for providing specialized implementations, such as GPU-based codecs. In case of
-multiple codecs, the :mod:`zarr.core.config` mechanism can be used to select the preferred
-implementation.
-
-Custom stores
--------------
-
-Coming soon.
-
-Custom array buffers
---------------------
-
-Zarr-python provides control over where and how arrays stored in memory through
-:mod:`zarr.buffer`. Currently both CPU (the default) and GPU implementations are
-provided (see :ref:`user-guide-gpu` for more). You can implement your own buffer
-classes by implementing the interface defined in :mod:`zarr.abc.buffer`.
-
-Other extensions
-----------------
-
-In the future, Zarr will support writing custom custom data types and chunk grids.
diff --git a/docs/user-guide/glossary.md b/docs/user-guide/glossary.md
new file mode 100644
index 0000000000..dde08388a1
--- /dev/null
+++ b/docs/user-guide/glossary.md
@@ -0,0 +1,146 @@
+# Glossary
+
+This page defines key terms used throughout the zarr-python documentation and API.
+
+## Array Structure
+
+### Array
+
+An N-dimensional typed array stored in a Zarr [store](#store). An array's
+[metadata](#metadata) defines its shape, data type, chunk layout, and codecs.
+
+### Group
+
+A container for [arrays](#array) and other groups, enabling hierarchical
+organization of data — similar to directories in a file system, or groups in
+HDF5. Like arrays, each group has its own [metadata](#metadata) and
+[attributes](#attributes). See the [groups documentation](groups.md).
+
+### Chunk
+
+The fundamental unit of data in a Zarr array. An array is divided into chunks
+along each dimension according to the [chunk grid](#chunk-grid). Each chunk is
+independently compressed and encoded through the array's [codec](#codec) pipeline.
+
+When [sharding](#shard) is used, "chunk" refers to the inner chunks within each
+shard, because those are the compressible units. The chunks are the smallest units
+that can be read independently.
+
+!!! warning "Convention specific to zarr-python"
+ The use of "chunk" to mean the inner sub-chunk within a shard is a convention
+ adopted by zarr-python's `Array` API. In the Zarr V3 specification and in other
+ Zarr implementations, "chunk" may refer to the top-level grid cells (which
+ zarr-python calls "shards" when the sharding codec is used). Be aware of this
+ distinction when working across libraries.
+
+**API**: [`Array.chunks`][zarr.Array.chunks] returns the chunk shape. When
+sharding is used, this is the inner chunk shape.
+
+### Chunk Grid
+
+The partitioning of an array's elements into [chunks](#chunk). In Zarr V3, the
+chunk grid is defined in the array [metadata](#metadata) and determines the
+boundaries of each storage object.
+
+Zarr V3 supports two chunk grid types:
+
+- **Regular**: All chunks have the same shape (the last chunk along each
+ dimension may be smaller than the declared size).
+- **Rectilinear** *(experimental)*: Each dimension can have different chunk
+ sizes, specified as a list of edge lengths per dimension. Enable with
+ `zarr.config.set({'array.rectilinear_chunks': True})`.
+
+When sharding is used, the chunk grid defines the [shard](#shard) boundaries,
+not the inner chunk boundaries. The inner chunk shape is defined within the
+[sharding codec](#shard).
+
+**API**: The `chunk_grid` field in array metadata contains the storage-level
+grid. [`Array.chunks`][zarr.Array.chunks] returns the chunk shape for regular
+grids. For all grid types, `Array.read_chunk_sizes` and `Array.write_chunk_sizes`
+return the per-dimension chunk sizes in dask-style `tuple[tuple[int, ...], ...]`
+format. Note that while the chunk grid is a public concept of the Zarr format,
+the classes zarr-python uses to model chunk grids are currently private API.
+
+### Shard
+
+A storage object that contains one or more [chunks](#chunk). Sharding reduces the
+number of objects in a [store](#store) by grouping chunks together, which
+improves performance on file systems and object storage.
+
+Within each shard, chunks are compressed independently and can be read
+individually. However, writing requires updating the full shard for consistency,
+making shards the unit of writing and chunks the unit of reading.
+
+Sharding is implemented as a [codec](#codec) (the sharding indexed codec).
+When sharding is used:
+
+- The [chunk grid](#chunk-grid) in metadata defines the shard boundaries
+- The sharding codec's `chunk_shape` defines the inner chunk size
+- Each shard contains `shard_shape / chunk_shape` chunks per dimension
+
+**API**: [`Array.shards`][zarr.Array.shards] returns the shard shape, or `None`
+if sharding is not used. [`Array.chunks`][zarr.Array.chunks] returns the inner
+chunk shape.
+
+## Storage
+
+### Store
+
+A key-value storage backend that holds Zarr data and metadata. Stores implement
+the [`zarr.abc.store.Store`][] interface. Examples include local file systems,
+cloud object storage (S3, GCS, Azure), zip files, and in-memory dictionaries.
+
+Each [chunk](#chunk) or [shard](#shard) is stored as a single value (object or
+file) in the store, addressed by a key derived from its grid coordinates.
+
+### Metadata
+
+The JSON document that describes an [array](#array) or [group](#group). In Zarr
+format 3 this is a single `zarr.json` document; Zarr format 2 stores the
+equivalent information in separate `.zarray`, `.zgroup`, and `.zattrs` documents.
+For arrays, metadata includes the shape, data type, [chunk grid](#chunk-grid), fill
+value, and [codec](#codec) pipeline. Metadata is stored alongside the data in
+the [store](#store). Zarr-Python does not yet expose its internal metadata
+representation as part of its public API.
+
+### Attributes
+
+User-defined key-value pairs (any JSON-serializable values) attached to an
+[array](#array) or [group](#group). Attributes are stored in the
+[metadata](#metadata) document. See the
+[attributes documentation](attributes.md).
+
+### Consolidated Metadata
+
+A copy of the [metadata](#metadata) of every array and group in a hierarchy,
+stored in the metadata of the root group so that the entire hierarchy can be
+inspected with a single read from the [store](#store). See the
+[consolidated metadata documentation](consolidated_metadata.md).
+
+## Codecs
+
+### Codec
+
+A transformation applied to array data during reading and writing. Codecs are
+chained into a pipeline and come in three types:
+
+- **Array-to-array**: Transforms like transpose that rearrange array elements
+- **Array-to-bytes**: Serialization that converts an array to a byte sequence
+ (exactly one required)
+- **Bytes-to-bytes**: Compression or checksums applied to the serialized bytes
+
+The [sharding indexed codec](#shard) is a special array-to-bytes codec that
+groups multiple [chunks](#chunk) into a single storage object.
+
+## API Properties
+
+The following properties are available on [`zarr.Array`][]:
+
+| Property | Description |
+|----------|-------------|
+| `.chunks` | Chunk shape — the inner chunk shape when sharding is used. Raises for rectilinear grids |
+| `.shards` | Shard shape, or `None` if no sharding |
+| `.read_chunk_sizes` | Per-dimension chunk data sizes (`tuple[tuple[int, ...], ...]`). Works for all grid types |
+| `.write_chunk_sizes` | Per-dimension storage chunk sizes (`tuple[tuple[int, ...], ...]`). Works for all grid types |
+| `.nchunks` | Total number of independently compressible units across the array |
+| `.cdata_shape` | Number of independently compressible units per dimension |
diff --git a/docs/user-guide/gpu.md b/docs/user-guide/gpu.md
new file mode 100644
index 0000000000..26d1c114b0
--- /dev/null
+++ b/docs/user-guide/gpu.md
@@ -0,0 +1,60 @@
+# Using GPUs with Zarr
+
+Zarr can use GPUs to accelerate your workload by running `zarr.config.enable_gpu()`.
+
+!!! note
+ `zarr-python` currently supports reading the ndarray data into device (GPU)
+ memory as the final stage of the codec pipeline. Data will still be read into
+ or copied to host (CPU) memory for encoding and decoding.
+
+ In the future, codecs will be available for compressing and decompressing data on
+ the GPU, avoiding the need to move data between the host and device for
+ compression and decompression.
+
+## Installation
+
+Zarr's GPU support requires [CuPy](https://cupy.dev), which in turn requires a
+CUDA-compatible NVIDIA GPU. CuPy can be installed alongside Zarr with the `gpu`
+extra (see [Installation](installation.md) for the other optional dependency groups):
+
+```console
+pip install "zarr[gpu]"
+```
+
+This installs the `cupy-cuda12x` package. If you need a CuPy build for a different
+CUDA version, see the [CuPy installation guide](https://docs.cupy.dev/en/stable/install.html)
+and install the appropriate package yourself.
+
+## Reading data into device memory
+
+Calling `zarr.config.enable_gpu()` configures Zarr to use GPU memory for the data
+buffers used internally by Zarr:
+
+```python test="true" session="gpu-demo" markers="gpu" source="above"
+import zarr
+import cupy as cp
+
+zarr.config.enable_gpu()
+z = zarr.create_array(
+ store="memory://gpu-demo", shape=(100, 100), chunks=(10, 10), dtype="float32",
+)
+assert isinstance(z[:10, :10], cp.ndarray)
+```
+
+Note that the arrays returned by reads are of type `cupy.ndarray` rather than
+NumPy arrays.
+
+`zarr.config.enable_gpu()` returns a [donfig](https://donfig.readthedocs.io/en/latest/)
+`ConfigSet`, which can be used as a context manager to enable GPU support for a
+limited scope:
+
+```python test="true" session="gpu-demo" markers="gpu" source="above"
+with zarr.config.enable_gpu():
+ data = z[:10, :10]
+assert isinstance(data, cp.ndarray)
+```
+
+Under the hood, `enable_gpu()` selects the GPU-backed buffer classes
+`zarr.buffer.gpu.Buffer` and `zarr.buffer.gpu.NDBuffer` via the `buffer` and
+`ndbuffer` configuration keys. See [Custom array buffers](extending.md#custom-array-buffers)
+for more on Zarr's buffer classes, including how to implement your own.
diff --git a/docs/user-guide/gpu.rst b/docs/user-guide/gpu.rst
deleted file mode 100644
index 4d3492f8bd..0000000000
--- a/docs/user-guide/gpu.rst
+++ /dev/null
@@ -1,37 +0,0 @@
-.. _user-guide-gpu:
-
-Using GPUs with Zarr
-====================
-
-Zarr can use GPUs to accelerate your workload by running
-:meth:`zarr.config.enable_gpu`.
-
-.. note::
-
- `zarr-python` currently supports reading the ndarray data into device (GPU)
- memory as the final stage of the codec pipeline. Data will still be read into
- or copied to host (CPU) memory for encoding and decoding.
-
- In the future, codecs will be available compressing and decompressing data on
- the GPU, avoiding the need to move data between the host and device for
- compression and decompression.
-
-Reading data into device memory
--------------------------------
-
-:meth:`zarr.config.enable_gpu` configures Zarr to use GPU memory for the data
-buffers used internally by Zarr.
-
-.. code-block:: python
-
- >>> import zarr
- >>> import cupy as cp # doctest: +SKIP
- >>> zarr.config.enable_gpu() # doctest: +SKIP
- >>> store = zarr.storage.MemoryStore() # doctest: +SKIP
- >>> z = zarr.create_array( # doctest: +SKIP
- ... store=store, shape=(100, 100), chunks=(10, 10), dtype="float32",
- ... )
- >>> type(z[:10, :10]) # doctest: +SKIP
- cupy.ndarray
-
-Note that the output type is a ``cupy.ndarray`` rather than a NumPy array.
diff --git a/docs/user-guide/groups.md b/docs/user-guide/groups.md
new file mode 100644
index 0000000000..7429a03847
--- /dev/null
+++ b/docs/user-guide/groups.md
@@ -0,0 +1,199 @@
+# Working with groups
+
+Zarr supports hierarchical organization of arrays via groups. As with arrays,
+groups can be stored in memory, on disk, or via other storage systems that
+support a similar interface.
+
+To create a group, use the [`zarr.create_group`][] function:
+
+```python exec="true" session="groups" source="above" result="ansi"
+import zarr
+root = zarr.create_group(store="memory://groups-demo")
+print(root)
+```
+
+Zarr-Python provides three related functions for making groups:
+[`zarr.create_group`][] creates a new group; [`zarr.open_group`][] creates or
+re-opens a group depending on its `mode` argument (see below); and
+[`zarr.group`][], which is kept for compatibility with Zarr-Python 2, is
+equivalent to calling [`zarr.open_group`][] with `mode='a'` (or `mode='w'`
+when `overwrite=True`).
+
+Groups have a similar API to the Group class from [h5py](https://www.h5py.org/). For example, groups can contain other groups:
+
+```python exec="true" session="groups" source="above"
+foo = root.create_group('foo')
+bar = foo.create_group('bar')
+```
+
+Groups can also contain arrays, e.g.:
+
+```python exec="true" session="groups" source="above" result="ansi"
+z1 = bar.create_array(name='baz', shape=(10000, 10000), chunks=(1000, 1000), dtype='int32')
+print(z1)
+```
+
+Members of a group can be accessed with square-bracket item access, like a
+Python `dict`, e.g.:
+
+```python exec="true" session="groups" source="above" result="ansi"
+print(root['foo'])
+```
+
+The '/' character can be used to access multiple levels of the hierarchy in one
+call, e.g.:
+
+```python exec="true" session="groups" source="above" result="ansi"
+print(root['foo/bar'])
+```
+
+```python exec="true" session="groups" source="above" result="ansi"
+print(root['foo/bar/baz'])
+```
+
+Accessing a member with `[]` returns either an [`zarr.Array`][] or a [`zarr.Group`][], depending on
+what is stored at the given path. When you expect a node of a particular kind, use
+[`zarr.Group.get_array`][] or [`zarr.Group.get_group`][] instead. These methods accept the same
+paths as `[]`, but they have precise return types and raise an error if no node exists at the
+given path, or if the node is not of the expected kind:
+
+```python exec="true" session="groups" source="above" result="ansi"
+print(root.get_group('foo'))
+```
+
+```python exec="true" session="groups" source="above" result="ansi"
+print(root.get_array('foo/bar/baz'))
+```
+
+```python exec="true" session="groups" source="above" result="ansi"
+from zarr.errors import ContainsGroupError
+
+try:
+ root.get_array('foo')
+except ContainsGroupError as e:
+ print(e)
+```
+
+The [`zarr.Group.tree`][] method can be used to print a tree
+representation of the hierarchy, e.g.:
+
+```python exec="true" session="groups" source="above" result="ansi"
+print(root.tree())
+```
+
+The [`zarr.open_group`][] function provides a convenient way to create or
+re-open a group stored in a directory on the file-system, with sub-groups stored in
+sub-directories, e.g.:
+
+```python exec="true" session="groups" source="above" result="ansi"
+root = zarr.open_group('data/group.zarr', mode='w')
+print(root)
+```
+
+```python exec="true" session="groups" source="above" result="ansi"
+z = root.create_array(name='foo/bar/baz', shape=(10000, 10000), chunks=(1000, 1000), dtype='int32')
+print(z)
+```
+
+For more information on groups see the [`zarr.Group` API docs](../api/zarr/group.md).
+
+## Exploring group contents
+
+Groups also support a dict-like interface for enumerating their contents. The
+[`zarr.Group.keys`][] method iterates over member names, and the `in` operator
+tests for membership:
+
+```python exec="true" session="groups" source="above" result="ansi"
+print(list(root.keys()))
+print('foo' in root)
+```
+
+The [`zarr.Group.members`][] method returns `(name, member)` pairs for the
+arrays and groups contained in a group:
+
+```python exec="true" session="groups" source="above" result="ansi"
+for name, member in root.members():
+ print(name, member)
+```
+
+By default only immediate members are returned. Pass `max_depth=None` to
+recursively traverse the whole hierarchy below a group:
+
+```python exec="true" session="groups" source="above" result="ansi"
+for name, member in root.members(max_depth=None):
+ print(name, member)
+```
+
+Members can be deleted with the `del` operator, which removes the member's
+metadata and data from the store:
+
+```python exec="true" session="groups" source="above" result="ansi"
+del root['foo/bar/baz']
+for name, member in root.members(max_depth=None):
+ print(name, member)
+```
+
+## Batch group creation
+
+You can also create multiple groups concurrently with a single function call. [`zarr.create_hierarchy`][] takes
+a [`Store`](../api/zarr/storage.md) instance and a dict of `key : metadata` pairs, parses that dict, and
+writes metadata documents to storage:
+
+```python exec="true" session="groups" source="above" result="ansi"
+from zarr import create_hierarchy
+from zarr.core.group import GroupMetadata
+from zarr.storage import LocalStore
+
+from pprint import pprint
+import io
+
+node_spec = {'a/b/c': GroupMetadata()}
+nodes_created = dict(create_hierarchy(store=LocalStore(root='data'), nodes=node_spec))
+# Report nodes (pprint is used for cleaner rendering in the docs)
+output = io.StringIO()
+pprint(nodes_created, stream=output, width=60)
+print(output.getvalue())
+```
+
+Note that we only specified a single group named `a/b/c`, but 4 groups were created. These additional groups
+were created to ensure that the desired node `a/b/c` is connected to the root group `''` by a sequence
+of intermediate groups. [`zarr.create_hierarchy`][] normalizes the `nodes` keyword argument to
+ensure that the resulting hierarchy is complete, i.e. all groups or arrays are connected to the root
+of the hierarchy via intermediate groups.
+
+Because [`zarr.create_hierarchy`][] concurrently creates metadata documents, it's more efficient
+than repeated calls to [`create_group`][zarr.create_group] or [`create_array`][zarr.create_array], provided you can statically define
+the metadata for the groups and arrays you want to create.
+
+## Array and group diagnostics
+
+Diagnostic information about arrays and groups is available via the `info`
+property. E.g.:
+
+```python exec="true" session="groups" source="above" result="ansi"
+root = zarr.group(store="memory://diagnostics-demo")
+foo = root.create_group('foo')
+bar = foo.create_array(name='bar', shape=1000000, chunks=100000, dtype='int64')
+bar[:] = 42
+baz = foo.create_array(name='baz', shape=(1000, 1000), chunks=(100, 100), dtype='float32')
+baz[:] = 4.2
+print(root.info)
+```
+
+```python exec="true" session="groups" source="above" result="ansi"
+print(foo.info)
+```
+
+```python exec="true" session="groups" source="above" result="ansi"
+print(bar.info_complete())
+```
+
+```python exec="true" session="groups" source="above" result="ansi"
+print(baz.info)
+```
+
+Groups also have the [`zarr.Group.tree`][] method, e.g.:
+
+```python exec="true" session="groups" source="above" result="ansi"
+print(root.tree())
+```
diff --git a/docs/user-guide/groups.rst b/docs/user-guide/groups.rst
deleted file mode 100644
index 4237a9df50..0000000000
--- a/docs/user-guide/groups.rst
+++ /dev/null
@@ -1,172 +0,0 @@
-.. only:: doctest
-
- >>> import shutil
- >>> shutil.rmtree('data', ignore_errors=True)
-
-.. _user-guide-groups:
-
-Working with groups
-===================
-
-Zarr supports hierarchical organization of arrays via groups. As with arrays,
-groups can be stored in memory, on disk, or via other storage systems that
-support a similar interface.
-
-To create a group, use the :func:`zarr.group` function::
-
- >>> import zarr
- >>> store = zarr.storage.MemoryStore()
- >>> root = zarr.create_group(store=store)
- >>> root
-
-
-Groups have a similar API to the Group class from `h5py
-`_. For example, groups can contain other groups::
-
- >>> foo = root.create_group('foo')
- >>> bar = foo.create_group('bar')
-
-Groups can also contain arrays, e.g.::
-
- >>> z1 = bar.create_array(name='baz', shape=(10000, 10000), chunks=(1000, 1000), dtype='int32')
- >>> z1
-
-
-Members of a group can be accessed via the suffix notation, e.g.::
-
- >>> root['foo']
-
-
-The '/' character can be used to access multiple levels of the hierarchy in one
-call, e.g.::
-
- >>> root['foo/bar']
-
- >>> root['foo/bar/baz']
-
-
-The :func:`zarr.Group.tree` method can be used to print a tree
-representation of the hierarchy, e.g.::
-
- >>> root.tree()
- /
- └── foo
- └── bar
- └── baz (10000, 10000) int32
-
-
-The :func:`zarr.open_group` function provides a convenient way to create or
-re-open a group stored in a directory on the file-system, with sub-groups stored in
-sub-directories, e.g.::
-
- >>> root = zarr.open_group('data/group.zarr', mode='w')
- >>> root
-
- >>>
- >>> z = root.create_array(name='foo/bar/baz', shape=(10000, 10000), chunks=(1000, 1000), dtype='int32')
- >>> z
-
-
-.. TODO: uncomment after __enter__ and __exit__ are implemented
-.. Groups can be used as context managers (in a ``with`` statement).
-.. If the underlying store has a ``close`` method, it will be called on exit.
-
-For more information on groups see the :class:`zarr.Group` API docs.
-
-.. _user-guide-diagnostics:
-
-Batch Group Creation
---------------------
-
-You can also create multiple groups concurrently with a single function call. :func:`zarr.create_hierarchy` takes
-a :class:`zarr.storage.Store` instance and a dict of ``key : metadata`` pairs, parses that dict, and
-writes metadata documents to storage:
-
- >>> from zarr import create_hierarchy
- >>> from zarr.core.group import GroupMetadata
- >>> from zarr.storage import LocalStore
- >>> node_spec = {'a/b/c': GroupMetadata()}
- >>> nodes_created = dict(create_hierarchy(store=LocalStore(root='data'), nodes=node_spec))
- >>> print(sorted(nodes_created.items(), key=lambda kv: len(kv[0])))
- [('', ), ('a', ), ('a/b', ), ('a/b/c', )]
-
-Note that we only specified a single group named ``a/b/c``, but 4 groups were created. These additional groups
-were created to ensure that the desired node ``a/b/c`` is connected to the root group ``''`` by a sequence
-of intermediate groups. :func:`zarr.create_hierarchy` normalizes the ``nodes`` keyword argument to
-ensure that the resulting hierarchy is complete, i.e. all groups or arrays are connected to the root
-of the hierarchy via intermediate groups.
-
-Because :func:`zarr.create_hierarchy` concurrently creates metadata documents, it's more efficient
-than repeated calls to :func:`create_group` or :func:`create_array`, provided you can statically define
-the metadata for the groups and arrays you want to create.
-
-Array and group diagnostics
----------------------------
-
-Diagnostic information about arrays and groups is available via the ``info``
-property. E.g.::
-
- >>> store = zarr.storage.MemoryStore()
- >>> root = zarr.group(store=store)
- >>> foo = root.create_group('foo')
- >>> bar = foo.create_array(name='bar', shape=1000000, chunks=100000, dtype='int64')
- >>> bar[:] = 42
- >>> baz = foo.create_array(name='baz', shape=(1000, 1000), chunks=(100, 100), dtype='float32')
- >>> baz[:] = 4.2
- >>> root.info
- Name :
- Type : Group
- Zarr format : 3
- Read-only : False
- Store type : MemoryStore
- >>> foo.info
- Name : foo
- Type : Group
- Zarr format : 3
- Read-only : False
- Store type : MemoryStore
- >>> bar.info_complete()
- Type : Array
- Zarr format : 3
- Data type : Int64(endianness='little')
- Fill value : 0
- Shape : (1000000,)
- Chunk shape : (100000,)
- Order : C
- Read-only : False
- Store type : MemoryStore
- Filters : ()
- Serializer : BytesCodec(endian=)
- Compressors : (ZstdCodec(level=0, checksum=False),)
- No. bytes : 8000000 (7.6M)
- No. bytes stored : 1614
- Storage ratio : 4956.6
- Chunks Initialized : 10
- >>> baz.info
- Type : Array
- Zarr format : 3
- Data type : Float32(endianness='little')
- Fill value : 0.0
- Shape : (1000, 1000)
- Chunk shape : (100, 100)
- Order : C
- Read-only : False
- Store type : MemoryStore
- Filters : ()
- Serializer : BytesCodec(endian=)
- Compressors : (ZstdCodec(level=0, checksum=False),)
- No. bytes : 4000000 (3.8M)
-
-Groups also have the :func:`zarr.Group.tree` method, e.g.::
-
- >>> root.tree()
- /
- └── foo
- ├── bar (1000000,) int64
- └── baz (1000, 1000) float32
-
-
-.. note::
-
- :func:`zarr.Group.tree` requires the optional `rich `_
- dependency. It can be installed with the ``[tree]`` extra.
diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md
new file mode 100644
index 0000000000..6b9a547776
--- /dev/null
+++ b/docs/user-guide/index.md
@@ -0,0 +1,54 @@
+# User Guide
+
+Welcome to the user guide, where you can learn more about using Zarr-Python!
+
+## Getting Started
+
+New to Zarr-Python? Start here:
+
+- **[Installation](installation.md)** - Install Zarr-Python
+- **[Quick start](../quick-start.md)** - Quick overview of core functionality
+
+## Core Concepts
+
+Learn the essential building blocks:
+
+- **[Arrays](arrays.md)** - Learn the fundamentals of working with arrays
+- **[Groups](groups.md)** - Organize your data with groups
+- **[Attributes](attributes.md)** - Attach metadata to your arrays and groups
+- **[Storage](storage.md)** - Learn how data is stored and accessed
+
+## Configuration & Setup
+
+Customize your experience:
+
+- **[Runtime Configuration](config.md)** - Configure Zarr-Python for your needs
+- **[Command-Line Interface](cli.md)** - Migrate and manage Zarr metadata from the terminal
+- **[V3 Migration](v3_migration.md)** - Upgrading from version 2 to version 3
+
+## Advanced Topics
+
+Take your skills to the next level:
+
+- **[Data Types](data_types.md)** - Learn about supported and extensible data types
+- **[Performance](performance.md)** - Optimize for speed and efficiency
+- **[GPU](gpu.md)** - Leverage GPU acceleration
+- **[Extending](extending.md)** - Extend functionality with custom code
+- **[Consolidated Metadata](consolidated_metadata.md)** - Advanced metadata management
+- **[Experimental Features](experimental.md)** - Preview features that may change at any time
+
+## Examples
+
+Worked, end-to-end examples:
+
+- **[Custom Data Type](examples/custom_dtype.md)** - Extend Zarr-Python with a user-defined data type
+- **[Rectilinear Chunk Grids](examples/rectilinear_chunks.md)** - Use variable-sized chunks along each dimension
+
+## Reference
+
+- **[Glossary](glossary.md)** - Definitions of key terms (chunks, shards, codecs, etc.)
+
+## Need Help?
+
+- Browse the [API Reference](../api/zarr/index.md) for detailed function documentation
+- Report issues on [GitHub](https://github.com/zarr-developers/zarr-python/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen)
diff --git a/docs/user-guide/index.rst b/docs/user-guide/index.rst
deleted file mode 100644
index f92c576f32..0000000000
--- a/docs/user-guide/index.rst
+++ /dev/null
@@ -1,31 +0,0 @@
-.. _user-guide:
-
-User guide
-==========
-
-.. toctree::
- :maxdepth: 1
-
- installation
- arrays
- groups
- attributes
- storage
- config
- v3_migration
-
-Advanced Topics
----------------
-
-.. toctree::
- :maxdepth: 1
-
- data_types
- performance
- consolidated_metadata
- extending
- gpu
-
-
-.. Coming soon
- async
diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md
new file mode 100644
index 0000000000..a7487e83c8
--- /dev/null
+++ b/docs/user-guide/installation.md
@@ -0,0 +1,62 @@
+# Installation
+
+## Required dependencies
+
+Required dependencies include:
+
+- [Python](https://docs.python.org/3/) (3.12 or later)
+- [packaging](https://packaging.pypa.io/en/stable/) (22.0 or later)
+- [numpy](https://numpy.org) (2.0 or later)
+- [numcodecs](https://numcodecs.readthedocs.io/en/stable/) (0.14 or later)
+- [google-crc32c](https://github.com/googleapis/python-crc32c) (1.5 or later)
+- [typing_extensions](https://typing-extensions.readthedocs.io/en/latest/) (4.14 or later)
+- [donfig](https://donfig.readthedocs.io/en/latest/) (0.8 or later)
+
+## pip
+
+Zarr is available on [PyPI](https://pypi.org/project/zarr/). Install it using `pip`:
+
+```console
+pip install zarr
+```
+
+There are a number of optional dependency groups you can install for extra functionality.
+These can be installed using `pip install "zarr[]"`, e.g. `pip install "zarr[gpu]"`
+
+- `remote`: support for reading/writing to remote data stores (fsspec, obstore)
+- `gpu`: support for GPUs (cupy)
+- `cli`: support for the `zarr` [command-line interface](cli.md) (typer)
+- `optional`: support for path-like access to local and remote stores (universal-pathlib)
+- `cast-value-rs`: support for the `cast_value` codec (cast-value-rs)
+
+## conda
+
+Zarr is also published to [conda-forge](https://conda-forge.org). Install it using `conda`:
+
+```console
+conda install -c conda-forge zarr
+```
+
+Conda does not support optional dependencies, so you will have to manually install any packages
+needed to enable extra functionality.
+
+## Nightly wheels
+
+Development wheels are built nightly and published to the [scientific-python-nightly-wheels](https://anaconda.org/scientific-python-nightly-wheels) index. To install the latest nightly build:
+
+```console
+pip install --pre --extra-index-url https://pypi.anaconda.org/scientific-python-nightly-wheels/simple zarr
+```
+
+Note that nightly wheels may be unstable and are intended for testing purposes.
+
+## Dependency support
+
+Zarr has endorsed [Scientific-Python SPEC 0](https://scientific-python.org/specs/spec-0000/) and now follows the version support window as outlined below:
+
+- Python: 36 months after initial release
+- Core package dependencies (e.g. NumPy): 24 months after initial release
+
+## Development
+
+To install the latest development version of Zarr, see the [contributing guide](../contributing.md).
diff --git a/docs/user-guide/installation.rst b/docs/user-guide/installation.rst
deleted file mode 100644
index a79f0763cb..0000000000
--- a/docs/user-guide/installation.rst
+++ /dev/null
@@ -1,54 +0,0 @@
-Installation
-============
-
-Required dependencies
----------------------
-
-Required dependencies include:
-
-- `Python `_ (3.11 or later)
-- `packaging `_ (22.0 or later)
-- `numpy `_ (1.25 or later)
-- `numcodecs[crc32c] `_ (0.14 or later)
-- `typing_extensions `_ (4.9 or later)
-- `donfig `_ (0.8 or later)
-
-pip
----
-
-Zarr is available on `PyPI `_. Install it using ``pip``:
-
-.. code-block:: console
-
- $ pip install zarr
-
-There are a number of optional dependency groups you can install for extra functionality.
-These can be installed using ``pip install "zarr[]"``, e.g. ``pip install "zarr[gpu]"``
-
-- ``gpu``: support for GPUs
-- ``remote``: support for reading/writing to remote data stores
-
-Additional optional dependencies include ``rich``, ``universal_pathlib``. These must be installed separately.
-
-conda
------
-
-Zarr is also published to `conda-forge `_. Install it using ``conda``:
-
-.. code-block:: console
-
- $ conda install -c conda-forge zarr
-
-Conda does not support optional dependencies, so you will have to manually install any packages
-needed to enable extra functionality.
-
-Dependency support
-------------------
-Zarr has endorsed `Scientific-Python SPEC 0 `_ and now follows the version support window as outlined below:
-
-- Python: 36 months after initial release
-- Core package dependencies (e.g. NumPy): 24 months after initial release
-
-Development
------------
-To install the latest development version of Zarr, see the :ref:`contributing guide `.
diff --git a/docs/user-guide/performance.md b/docs/user-guide/performance.md
new file mode 100644
index 0000000000..52c1cf0d71
--- /dev/null
+++ b/docs/user-guide/performance.md
@@ -0,0 +1,333 @@
+# Optimizing performance
+
+## Chunk optimizations
+
+### Chunk size and shape
+
+In general, chunks of at least 1 megabyte (1M) uncompressed size seem to provide
+better performance, at least when using the Blosc compression library.
+
+The optimal chunk shape will depend on how you want to access the data. E.g.,
+for a 2-dimensional array, if you only ever take slices along the first
+dimension, then chunk across the second dimension. If you know you want to chunk
+across an entire dimension you can use the full size of that dimension within the
+`chunks` argument, e.g.:
+
+```python exec="true" session="performance" source="above" result="ansi"
+import zarr
+z1 = zarr.create_array(store={}, shape=(10000, 10000), chunks=(100, 10000), dtype='int32')
+print(z1.chunks)
+```
+
+Alternatively, if you only ever take slices along the second dimension, then
+chunk across the first dimension, e.g.:
+
+```python exec="true" session="performance" source="above" result="ansi"
+z2 = zarr.create_array(store={}, shape=(10000, 10000), chunks=(10000, 100), dtype='int32')
+print(z2.chunks)
+```
+
+If you require reasonable performance for both access patterns then you need to
+find a compromise, e.g.:
+
+```python exec="true" session="performance" source="above" result="ansi"
+z3 = zarr.create_array(store={}, shape=(10000, 10000), chunks=(1000, 1000), dtype='int32')
+print(z3.chunks)
+```
+
+If you are feeling lazy, you can let Zarr guess a chunk shape for your data by
+providing `chunks='auto'`, although please note that the algorithm for guessing
+a chunk shape is based on simple heuristics and may be far from optimal. E.g.:
+
+```python exec="true" session="performance" source="above" result="ansi"
+z4 = zarr.create_array(store={}, shape=(10000, 10000), chunks='auto', dtype='int32')
+print(z4.chunks)
+```
+
+If you know you are always going to be loading the entire array into memory, you
+can turn off chunks by providing `chunks` equal to `shape`, in which case there
+will be one single chunk for the array:
+
+```python exec="true" session="performance" source="above" result="ansi"
+z5 = zarr.create_array(store={}, shape=(10000, 10000), chunks=(10000, 10000), dtype='int32')
+print(z5.chunks)
+```
+
+### Sharding
+
+If you have large arrays but need small chunks to efficiently access the data, you can
+use sharding. Sharding provides a mechanism to store multiple chunks in a single
+storage object or file. This can be useful because traditional file systems and object
+storage systems may have performance issues storing and accessing many files.
+Additionally, small files can be inefficient to store if they are smaller than the
+block size of the file system.
+
+Picking a good combination of chunk shape and shard shape is important for performance.
+The chunk shape determines what unit of your data can be read independently, while the
+shard shape determines what unit of your data can be written efficiently.
+
+For an example, consider you have a 100 GB array and need to read small chunks of 1 MB.
+Without sharding, each chunk would be one file resulting in 100,000 files. That can
+already cause performance issues on some file systems.
+With sharding, you could use a shard size of 1 GB. This would result in 1000 chunks per
+file and 100 files in total, which seems manageable for most storage systems.
+You would still be able to read each 1 MB chunk independently, but you would need to
+write your data in 1 GB increments.
+
+To use sharding, you need to specify the `shards` parameter when creating the array.
+
+```python exec="true" session="performance" source="above" result="ansi"
+z6 = zarr.create_array(store={}, shape=(10000, 10000, 1000), shards=(1000, 1000, 1000), chunks=(100, 100, 100), dtype='uint8')
+print(z6.info)
+```
+
+`shards` can be `"auto"` as well, in which case Zarr chooses a shard shape for you.
+The `array.target_shard_size_bytes` configuration setting controls this choice: the
+cumulative uncompressed size of the chunks within each shard will be as close as
+possible to, without exceeding, that target. If the setting is `None` (the default),
+Zarr falls back to a built-in heuristic for choosing the shard shape.
+
+### Chunk memory layout
+
+The memory layout of the in-memory arrays that Zarr produces and consumes can be
+changed via the `order` config option, to use either C or Fortran layout. This can
+matter for performance when the data is passed to other libraries that expect a
+particular memory layout. E.g.:
+
+```python exec="true" session="performance" source="above" result="ansi"
+import numpy as np
+
+c = zarr.create_array(store={}, shape=(10000, 10000), chunks=(1000, 1000), dtype='int32', config={'order': 'C'})
+print(c[:100, :100].flags.c_contiguous)
+```
+
+```python exec="true" session="performance" source="above" result="ansi"
+with zarr.config.set({'array.order': 'F'}):
+ f = zarr.create_array(store={}, shape=(10000, 10000), chunks=(1000, 1000), dtype='int32')
+print(f[:100, :100].flags.f_contiguous)
+```
+
+Note that for Zarr format 3 arrays the `order` option only affects the in-memory
+layout: the bytes written to storage are identical for both settings. The layout of
+the serialized data is instead determined by the array's codecs (e.g. the transpose
+codec), which can change how well the data compresses depending on the correlation
+structure within the data and which compression filters (e.g., byte-shuffle) have
+been applied.
+
+### Subchunk memory layout
+
+The order of chunks **within each shard** can be changed via the `subchunk_write_order` parameter of the `ShardingCodec`. That parameter is a string which must be one of `["morton", "unordered", "lexicographic", "colexicographic"]`.
+
+By default [`morton`](https://en.wikipedia.org/wiki/Z-order_curve) order provides good spatial locality. [`lexicographic` (i.e., row-major)](https://en.wikipedia.org/wiki/Row-_and_column-major_order), for example, may be better suited to "batched" workflows where some form of sequential reading through a fixed number of outer dimensions is desired, and `colexicographic` is its reverse. `unordered` makes no guarantee about the order in which subchunks are laid out within a shard.
+
+### Empty chunks
+
+It is possible to configure how Zarr handles the storage of chunks that are "empty"
+(i.e., every element in the chunk is equal to the array's fill value). When creating
+an array with `write_empty_chunks=False`, Zarr will check whether a chunk is empty before compression and storage. If a chunk is empty,
+then Zarr does not store it, and instead deletes the chunk from storage
+if the chunk had been previously stored.
+
+This optimization prevents storing redundant objects and can speed up reads, but the cost is
+added computation during array writes, since the contents of
+each chunk must be compared to the fill value, and these advantages are contingent on the content of the array.
+If you know that your data will form chunks that are almost always non-empty, then there is no advantage to the optimization described above.
+In this case, creating an array with `write_empty_chunks=True` will instruct Zarr to write every chunk without checking for emptiness.
+
+The default value of `write_empty_chunks` is `False`:
+
+```python exec="true" session="performance" source="above" result="ansi"
+arr = zarr.create_array(store={}, shape=(1,), dtype='uint8')
+assert arr.config.write_empty_chunks == False
+```
+
+The following example illustrates the effect of the `write_empty_chunks` flag on
+the time required to write an array with different values:
+
+```python exec="true" session="performance" source="above" result="ansi"
+import zarr
+import numpy as np
+import time
+
+def timed_write(write_empty_chunks):
+ """
+ Measure the time required and number of objects created when writing
+ to a Zarr array with random ints or fill value.
+ """
+ chunks = (8192,)
+ shape = (chunks[0] * 1024,)
+ data = np.random.randint(0, 255, shape)
+ dtype = 'uint8'
+ arr = zarr.create_array(
+ f'data/example-{write_empty_chunks}.zarr',
+ shape=shape,
+ chunks=chunks,
+ dtype=dtype,
+ fill_value=0,
+ config={'write_empty_chunks': write_empty_chunks}
+ )
+ # initialize all chunks
+ arr[:] = 100
+ result = []
+ for value in (data, arr.fill_value):
+ start = time.time()
+ arr[:] = value
+ elapsed = time.time() - start
+ result.append((elapsed, arr.nchunks_initialized))
+ return result
+
+# log results
+for write_empty_chunks in (True, False):
+ full, empty = timed_write(write_empty_chunks)
+ print(f'\nwrite_empty_chunks={write_empty_chunks}:\n\tRandom Data: {full[0]:.4f}s, {full[1]} objects stored\n\t Empty Data: {empty[0]:.4f}s, {empty[1]} objects stored\n')
+```
+
+In this example, writing random data is slightly slower with `write_empty_chunks=False`,
+because every chunk must be checked for emptiness before it is stored. Writing empty
+data with `write_empty_chunks=False` is substantially faster, however, and stores no
+objects at all.
+
+### Changing chunk shapes (rechunking)
+
+Zarr-Python does not yet provide a built-in way to change the chunk shape of an
+existing array in place. Arrays can, however, be resized and appended to along any
+dimension — see [Resizing and appending](arrays.md#resizing-and-appending) — and data
+can be copied to a new array created with the desired chunk shape.
+
+## Parallel computing and synchronization
+
+Zarr is designed to support parallel computing and enables concurrent reads and writes to arrays.
+This section covers how to optimize Zarr's concurrency settings for different parallel computing
+scenarios.
+
+### Concurrent I/O operations
+
+Zarr uses asynchronous I/O internally to enable concurrent reads and writes across multiple chunks.
+The level of concurrency is controlled by the `async.concurrency` configuration setting, which
+determines the maximum number of concurrent I/O operations.
+
+The default value is 10, which is a conservative value. You may get improved performance by tuning
+the concurrency limit. You can adjust this value based on your specific needs:
+
+```python exec="true" session="perf-concurrency" source="above"
+import zarr
+
+# Set concurrency for the current session
+zarr.config.set({'async.concurrency': 128})
+
+# Or use environment variable
+# export ZARR_ASYNC_CONCURRENCY=128
+```
+
+Higher concurrency values can improve throughput when:
+
+- Working with remote storage (e.g., S3, GCS) where network latency is high
+- Reading/writing many small chunks in parallel
+- The storage backend can handle many concurrent requests
+
+Lower concurrency values may be beneficial when:
+
+- Working with local storage with limited I/O bandwidth
+- Memory is constrained (each concurrent operation requires buffer space)
+- Using Zarr within a parallel computing framework (see below)
+
+### Thread pool size (`threading.max_workers`)
+
+When synchronous Zarr code calls async operations internally, Zarr uses a
+`ThreadPoolExecutor` to run those coroutines. The `threading.max_workers`
+configuration option controls the maximum number of worker threads in that pool.
+By default it is `None`, which lets Python choose the pool size (typically
+`min(32, os.cpu_count() + 4)`).
+
+You can set it explicitly when you want more predictable resource usage:
+
+```python exec="true" session="perf-workers" source="above"
+import zarr
+
+zarr.config.set({'threading.max_workers': 8})
+```
+
+Reducing this value can help avoid overloading the event loop when Zarr is used
+inside a parallel computing framework such as Dask that already manages its own
+thread pool (see the Dask section below). Increasing it may improve throughput
+in CPU-bound workloads where many synchronous-to-async dispatches happen
+concurrently.
+
+### Using Zarr with Dask
+
+[Dask](https://www.dask.org/) is a popular parallel computing library that works well with Zarr for processing large arrays. When using Zarr with Dask, it's important to consider the interaction between Dask's thread pool and Zarr's concurrency settings.
+
+**Important**: When using many Dask threads, you may need to reduce both Zarr's `async.concurrency` and `threading.max_workers` settings to avoid creating too many concurrent operations. The total number of concurrent I/O operations can be roughly estimated as:
+
+```text
+total_concurrency ≈ dask_threads × zarr_async_concurrency
+```
+
+For example, if you're running Dask with 10 threads and Zarr's default concurrency of 10, you could potentially have up to 100 concurrent operations, which may overwhelm your storage system or cause memory issues.
+
+**Recommendation**: When using Dask with many threads, configure Zarr's concurrency settings:
+
+```python exec="false" reason="requires dask, which is not in the docs test environment"
+import zarr
+import dask.array as da
+
+# If using Dask with many threads (e.g., 8-16), reduce Zarr's concurrency settings
+zarr.config.set({
+ 'async.concurrency': 4, # Limit concurrent async operations
+ 'threading.max_workers': 4, # Limit Zarr's internal thread pool
+})
+
+# Open Zarr array
+z = zarr.open_array('data/large_array.zarr', mode='r')
+
+# Create Dask array from Zarr array
+arr = da.from_array(z, chunks=z.chunks)
+
+# Process with Dask
+result = arr.mean(axis=0).compute()
+```
+
+**Configuration guidelines for Dask workloads**:
+
+- `async.concurrency`: Controls the maximum number of concurrent async I/O operations. Start with a lower value (e.g., 4-8) when using many Dask threads.
+- `threading.max_workers`: Controls Zarr's internal thread pool size for blocking operations (defaults to `None`, letting Python choose the pool size). Reduce this to avoid thread contention with Dask's scheduler.
+
+You may need to experiment with different values to find the optimal balance for your workload. Monitor your system's resource usage and adjust these settings based on whether your storage system or CPU is the bottleneck.
+
+### Thread safety and process safety
+
+Zarr arrays are designed to be thread-safe for concurrent reads and writes from multiple threads within the same process. However, proper synchronization is required when writing to overlapping regions from multiple threads.
+
+For multi-process parallelism, Zarr provides safe concurrent writes as long as:
+
+- Different processes write to different chunks
+- The storage backend supports atomic writes (most do)
+
+When writing to the same chunks from multiple processes, you should use external synchronization mechanisms or ensure that writes are coordinated to avoid race conditions.
+
+## Pickle support
+
+Zarr arrays and groups can be pickled, as long as the underlying store object can be
+pickled. All of the storage classes provided in the `zarr.storage` module can be pickled.
+
+If an array or group is backed by a persistent store such as a `zarr.storage.LocalStore`,
+`zarr.storage.ZipStore` or `zarr.storage.FsspecStore` then the store data
+**are not** pickled. The only thing that is pickled is the necessary parameters to allow the store
+to re-open any underlying files or databases upon being unpickled.
+
+Note that pickling a `zarr.storage.MemoryStore` copies the data it holds into the
+pickle stream: unpickling produces an independent in-memory copy, so a `MemoryStore`
+cannot be used to share data between processes.
+
+E.g., pickle/unpickle a local store array:
+
+```python exec="true" session="performance" source="above" result="ansi"
+import pickle
+data = np.arange(100000)
+z1 = zarr.create_array(store='data/perf-example-2.zarr', shape=data.shape, chunks=data.shape, dtype=data.dtype)
+z1[:] = data
+s = pickle.dumps(z1)
+z2 = pickle.loads(s)
+assert z1 == z2
+print(np.all(z1[:] == z2[:]))
+```
diff --git a/docs/user-guide/performance.rst b/docs/user-guide/performance.rst
deleted file mode 100644
index 7d24c87373..0000000000
--- a/docs/user-guide/performance.rst
+++ /dev/null
@@ -1,278 +0,0 @@
-.. only:: doctest
-
- >>> import shutil
- >>> shutil.rmtree('data', ignore_errors=True)
-
-.. _user-guide-performance:
-
-Optimizing performance
-======================
-
-.. _user-guide-chunks:
-
-Chunk optimizations
--------------------
-
-.. _user-guide-chunks-shape:
-
-Chunk size and shape
-~~~~~~~~~~~~~~~~~~~~
-
-In general, chunks of at least 1 megabyte (1M) uncompressed size seem to provide
-better performance, at least when using the Blosc compression library.
-
-The optimal chunk shape will depend on how you want to access the data. E.g.,
-for a 2-dimensional array, if you only ever take slices along the first
-dimension, then chunk across the second dimension. If you know you want to chunk
-across an entire dimension you can use the full size of that dimension within the
-``chunks`` argument, e.g.::
-
- >>> import zarr
- >>> z1 = zarr.create_array(store={}, shape=(10000, 10000), chunks=(100, 10000), dtype='int32')
- >>> z1.chunks
- (100, 10000)
-
-Alternatively, if you only ever take slices along the second dimension, then
-chunk across the first dimension, e.g.::
-
- >>> z2 = zarr.create_array(store={}, shape=(10000, 10000), chunks=(10000, 100), dtype='int32')
- >>> z2.chunks
- (10000, 100)
-
-If you require reasonable performance for both access patterns then you need to
-find a compromise, e.g.::
-
- >>> z3 = zarr.create_array(store={}, shape=(10000, 10000), chunks=(1000, 1000), dtype='int32')
- >>> z3.chunks
- (1000, 1000)
-
-If you are feeling lazy, you can let Zarr guess a chunk shape for your data by
-providing ``chunks='auto'``, although please note that the algorithm for guessing
-a chunk shape is based on simple heuristics and may be far from optimal. E.g.::
-
- >>> z4 = zarr.create_array(store={}, shape=(10000, 10000), chunks='auto', dtype='int32')
- >>> z4.chunks
- (625, 625)
-
-If you know you are always going to be loading the entire array into memory, you
-can turn off chunks by providing ``chunks`` equal to ``shape``, in which case there
-will be one single chunk for the array::
-
- >>> z5 = zarr.create_array(store={}, shape=(10000, 10000), chunks=(10000, 10000), dtype='int32')
- >>> z5.chunks
- (10000, 10000)
-
-
-Sharding
-~~~~~~~~
-
-If you have large arrays but need small chunks to efficiently access the data, you can
-use sharding. Sharding provides a mechanism to store multiple chunks in a single
-storage object or file. This can be useful because traditional file systems and object
-storage systems may have performance issues storing and accessing many files.
-Additionally, small files can be inefficient to store if they are smaller than the
-block size of the file system.
-
-Picking a good combination of chunk shape and shard shape is important for performance.
-The chunk shape determines what unit of your data can be read independently, while the
-shard shape determines what unit of your data can be written efficiently.
-
-For an example, consider you have a 100 GB array and need to read small chunks of 1 MB.
-Without sharding, each chunk would be one file resulting in 100,000 files. That can
-already cause performance issues on some file systems.
-With sharding, you could use a shard size of 1 GB. This would result in 1000 chunks per
-file and 100 files in total, which seems manageable for most storage systems.
-You would still be able to read each 1 MB chunk independently, but you would need to
-write your data in 1 GB increments.
-
-To use sharding, you need to specify the ``shards`` parameter when creating the array.
-
- >>> z6 = zarr.create_array(store={}, shape=(10000, 10000, 1000), shards=(1000, 1000, 1000), chunks=(100, 100, 100), dtype='uint8')
- >>> z6.info
- Type : Array
- Zarr format : 3
- Data type : UInt8()
- Fill value : 0
- Shape : (10000, 10000, 1000)
- Shard shape : (1000, 1000, 1000)
- Chunk shape : (100, 100, 100)
- Order : C
- Read-only : False
- Store type : MemoryStore
- Filters : ()
- Serializer : BytesCodec(endian=None)
- Compressors : (ZstdCodec(level=0, checksum=False),)
- No. bytes : 100000000000 (93.1G)
-
-.. _user-guide-chunks-order:
-
-Chunk memory layout
-~~~~~~~~~~~~~~~~~~~
-
-The order of bytes **within each chunk** of an array can be changed via the
-``order`` config option, to use either C or Fortran layout. For
-multi-dimensional arrays, these two layouts may provide different compression
-ratios, depending on the correlation structure within the data. E.g.::
-
- >>> import numpy as np
- >>>
- >>> a = np.arange(100000000, dtype='int32').reshape(10000, 10000).T
- >>> c = zarr.create_array(store={}, shape=a.shape, chunks=(1000, 1000), dtype=a.dtype, config={'order': 'C'})
- >>> c[:] = a
- >>> c.info_complete()
- Type : Array
- Zarr format : 3
- Data type : Int32(endianness='little')
- Fill value : 0
- Shape : (10000, 10000)
- Chunk shape : (1000, 1000)
- Order : C
- Read-only : False
- Store type : MemoryStore
- Filters : ()
- Serializer : BytesCodec(endian=)
- Compressors : (ZstdCodec(level=0, checksum=False),)
- No. bytes : 400000000 (381.5M)
- No. bytes stored : 342588911
- Storage ratio : 1.2
- Chunks Initialized : 100
- >>> with zarr.config.set({'array.order': 'F'}):
- ... f = zarr.create_array(store={}, shape=a.shape, chunks=(1000, 1000), dtype=a.dtype)
- ... f[:] = a
- >>> f.info_complete()
- Type : Array
- Zarr format : 3
- Data type : Int32(endianness='little')
- Fill value : 0
- Shape : (10000, 10000)
- Chunk shape : (1000, 1000)
- Order : F
- Read-only : False
- Store type : MemoryStore
- Filters : ()
- Serializer : BytesCodec(endian=)
- Compressors : (ZstdCodec(level=0, checksum=False),)
- No. bytes : 400000000 (381.5M)
- No. bytes stored : 342588911
- Storage ratio : 1.2
- Chunks Initialized : 100
-
-In the above example, Fortran order gives a better compression ratio. This is an
-artificial example but illustrates the general point that changing the order of
-bytes within chunks of an array may improve the compression ratio, depending on
-the structure of the data, the compression algorithm used, and which compression
-filters (e.g., byte-shuffle) have been applied.
-
-.. _user-guide-chunks-empty-chunks:
-
-Empty chunks
-~~~~~~~~~~~~
-
-It is possible to configure how Zarr handles the storage of chunks that are "empty"
-(i.e., every element in the chunk is equal to the array's fill value). When creating
-an array with ``write_empty_chunks=False``, Zarr will check whether a chunk is empty before compression and storage. If a chunk is empty,
-then Zarr does not store it, and instead deletes the chunk from storage
-if the chunk had been previously stored.
-
-This optimization prevents storing redundant objects and can speed up reads, but the cost is
-added computation during array writes, since the contents of
-each chunk must be compared to the fill value, and these advantages are contingent on the content of the array.
-If you know that your data will form chunks that are almost always non-empty, then there is no advantage to the optimization described above.
-In this case, creating an array with ``write_empty_chunks=True`` (the default) will instruct Zarr to write every chunk without checking for emptiness.
-
-The following example illustrates the effect of the ``write_empty_chunks`` flag on
-the time required to write an array with different values.::
-
- >>> import zarr
- >>> import numpy as np
- >>> import time
- >>>
- >>> def timed_write(write_empty_chunks):
- ... """
- ... Measure the time required and number of objects created when writing
- ... to a Zarr array with random ints or fill value.
- ... """
- ... chunks = (8192,)
- ... shape = (chunks[0] * 1024,)
- ... data = np.random.randint(0, 255, shape)
- ... dtype = 'uint8'
- ... arr = zarr.create_array(
- ... f'data/example-{write_empty_chunks}.zarr',
- ... shape=shape,
- ... chunks=chunks,
- ... dtype=dtype,
- ... fill_value=0,
- ... config={'write_empty_chunks': write_empty_chunks}
- ... )
- ... # initialize all chunks
- ... arr[:] = 100
- ... result = []
- ... for value in (data, arr.fill_value):
- ... start = time.time()
- ... arr[:] = value
- ... elapsed = time.time() - start
- ... result.append((elapsed, arr.nchunks_initialized))
- ... return result
- ... # log results
- >>> for write_empty_chunks in (True, False):
- ... full, empty = timed_write(write_empty_chunks)
- ... print(f'\nwrite_empty_chunks={write_empty_chunks}:\n\tRandom Data: {full[0]:.4f}s, {full[1]} objects stored\n\t Empty Data: {empty[0]:.4f}s, {empty[1]} objects stored\n')
- write_empty_chunks=True:
- Random Data: ..., 1024 objects stored
- Empty Data: ...s, 1024 objects stored
-
- write_empty_chunks=False:
- Random Data: ...s, 1024 objects stored
- Empty Data: ...s, 0 objects stored
-
-
-In this example, writing random data is slightly slower with ``write_empty_chunks=True``,
-but writing empty data is substantially faster and generates far fewer objects in storage.
-
-.. _user-guide-rechunking:
-
-Changing chunk shapes (rechunking)
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
-Coming soon.
-
-.. _user-guide-sync:
-
-Parallel computing and synchronization
---------------------------------------
-
-Coming soon.
-
-.. _user-guide-pickle:
-
-Pickle support
---------------
-
-Zarr arrays and groups can be pickled, as long as the underlying store object can be
-pickled. With the exception of the :class:`zarr.storage.MemoryStore`, any of the
-storage classes provided in the :mod:`zarr.storage` module can be pickled.
-
-If an array or group is backed by a persistent store such as the a :class:`zarr.storage.LocalStore`,
-:class:`zarr.storage.ZipStore` or :class:`zarr.storage.FsspecStore` then the store data
-**are not** pickled. The only thing that is pickled is the necessary parameters to allow the store
-to re-open any underlying files or databases upon being unpickled.
-
-E.g., pickle/unpickle an local store array::
-
- >>> import pickle
- >>> data = np.arange(100000)
- >>> z1 = zarr.create_array(store='data/example-2.zarr', shape=data.shape, chunks=data.shape, dtype=data.dtype)
- >>> z1[:] = data
- >>> s = pickle.dumps(z1)
- >>> z2 = pickle.loads(s)
- >>> z1 == z2
- True
- >>> np.all(z1[:] == z2[:])
- np.True_
-
-.. _user-guide-tips-blosc:
-
-Configuring Blosc
------------------
-
-Coming soon.
diff --git a/docs/user-guide/storage.md b/docs/user-guide/storage.md
new file mode 100644
index 0000000000..a34e2e2874
--- /dev/null
+++ b/docs/user-guide/storage.md
@@ -0,0 +1,230 @@
+# Storage guide
+
+Zarr-Python supports multiple storage backends, including: local file systems,
+Zip files, remote stores via [fsspec](https://filesystem-spec.readthedocs.io/en/latest/) (S3, HTTP, etc.), and in-memory stores. In
+Zarr-Python 3, stores must implement the abstract store API from
+[`zarr.abc.store.Store`][].
+
+!!! note
+ Unlike Zarr-Python 2 where the store interface was built around a generic `MutableMapping`
+ API, Zarr-Python 3 utilizes a custom store API that utilizes Python's AsyncIO library.
+
+## Implicit Store Creation
+
+In most cases, it is not required to create a `Store` object explicitly. Passing a string
+(or other [StoreLike value](#user-guide-store-like)) to Zarr's top level API will result in the store
+being created automatically:
+
+```python exec="true" session="storage" source="above" result="ansi"
+import zarr
+
+# Implicitly creates a writable LocalStore
+group = zarr.create_group(store='data/foo/bar')
+print(group)
+```
+
+```python exec="true" session="storage" source="above" result="ansi"
+# Implicitly creates a read-only FsspecStore
+# Note: requires s3fs to be installed
+group = zarr.open_group(
+ store='s3://noaa-nwm-retro-v2-zarr-pds',
+ mode='r',
+ storage_options={'anon': True}
+)
+print(group)
+```
+
+```python exec="true" session="storage" source="above" result="ansi"
+# Implicitly creates a MemoryStore
+data = {}
+group = zarr.create_group(store=data)
+print(group)
+```
+
+### StoreLike {#user-guide-store-like}
+
+`StoreLike` values can be:
+
+- a `Path` or string indicating a location on the local file system.
+ This will create a [local store](#local-store):
+
+ ```python exec="true" session="storage" source="above" result="ansi"
+ group = zarr.open_group(store='data/foo/bar')
+ print(group)
+ ```
+
+ ```python exec="true" session="storage" source="above" result="ansi"
+ from pathlib import Path
+ group = zarr.open_group(store=Path('data/foo/bar'))
+ print(group)
+ ```
+
+- an FSSpec URI string, indicating a [remote store](#remote-store) location:
+
+ ```python exec="true" session="storage" source="above" result="ansi"
+ # Note: requires s3fs to be installed
+ group = zarr.open_group(
+ store='s3://noaa-nwm-retro-v2-zarr-pds',
+ mode='r',
+ storage_options={'anon': True}
+ )
+ print(group)
+ ```
+
+- an empty dictionary or None, which will create a new [memory store](#memory-store):
+
+ ```python exec="true" session="storage" source="above" result="ansi"
+ group = zarr.create_group(store={})
+ print(group)
+ ```
+
+ ```python exec="true" session="storage" source="above" result="ansi"
+ group = zarr.create_group(store=None)
+ print(group)
+ ```
+
+- a dictionary of string to [`Buffer`][zarr.abc.buffer.Buffer] mappings. This will
+ create a [memory store](#memory-store), using this dictionary as the
+ [`store_dict` argument][zarr.storage.MemoryStore].
+
+- an FSSpec [FSMap object](https://filesystem-spec.readthedocs.io/en/latest/api.html#fsspec.FSMap),
+ which will create an [FsspecStore](#remote-store).
+
+- a [universal-pathlib](https://github.com/fsspec/universal_pathlib) `UPath`, which will create an
+ [FsspecStore](#remote-store), or a [local store](#local-store) if the `UPath` is local. Put your
+ storage options on the `UPath` itself; passing a separate `storage_options` argument alongside
+ one raises `TypeError`.
+
+ ```python exec="false" reason="requires universal-pathlib, which is not in the docs environment"
+ from upath import UPath
+ group = zarr.open_group(UPath('s3://noaa-nwm-retro-v2-zarr-pds', anon=True), mode='r')
+ ```
+
+- a [`Store`][zarr.abc.store.Store] or [`StorePath`][zarr.storage.StorePath] -
+ see explicit store creation below.
+
+## Explicit Store Creation
+
+In some cases, it may be helpful to create a store instance directly. Zarr-Python offers
+built-in stores: [`zarr.storage.LocalStore`][], [`zarr.storage.FsspecStore`][],
+[`zarr.storage.ZipStore`][], [`zarr.storage.MemoryStore`][], and [`zarr.storage.ObjectStore`][].
+
+### Local Store
+
+The [`zarr.storage.LocalStore`][] stores data in a nested set of directories on a local
+filesystem:
+
+```python exec="true" session="storage" source="above" result="ansi"
+store = zarr.storage.LocalStore('data/foo/bar', read_only=True)
+group = zarr.open_group(store=store, mode='r')
+print(group)
+```
+
+### Zip Store
+
+The [`zarr.storage.ZipStore`][] stores the contents of a Zarr hierarchy in a single
+Zip file. The [Zip Store specification](https://github.com/zarr-developers/zarr-specs/pull/311) is currently in draft form:
+
+```python exec="true" session="storage" source="above" result="ansi"
+store = zarr.storage.ZipStore('data.zip', mode='w')
+array = zarr.create_array(store=store, shape=(2,), dtype='float64')
+print(array)
+```
+
+In place of a path, `ZipStore` also accepts an open binary file object (for
+example a file opened with `fsspec`, or an `obstore` reader), enabling zip
+archives on remote storage. The file must stay open for as long as the store
+is in use:
+
+```python exec="true" session="storage" source="above" result="ansi"
+store.close()
+f = open('data.zip', mode='rb') # must stay open while the store is used
+array = zarr.open_array(store=zarr.storage.ZipStore(f), mode='r')
+print(array[:])
+f.close()
+```
+
+### Remote Store
+
+The [`zarr.storage.FsspecStore`][] stores the contents of a Zarr hierarchy following the same
+logical layout as the [`LocalStore`][zarr.storage.LocalStore], except the store is assumed to be on a remote storage system
+such as cloud object storage (e.g. AWS S3, Google Cloud Storage, Azure Blob Store). The
+[`zarr.storage.FsspecStore`][] is backed by [fsspec](https://filesystem-spec.readthedocs.io/en/latest/) and can support any backend
+that implements the [AbstractFileSystem](https://filesystem-spec.readthedocs.io/en/stable/api.html#fsspec.spec.AbstractFileSystem)
+API. `storage_options` can be used to configure the fsspec backend:
+
+```python exec="true" session="storage" source="above" result="ansi"
+# Note: requires s3fs to be installed
+store = zarr.storage.FsspecStore.from_url(
+ 's3://noaa-nwm-retro-v2-zarr-pds',
+ read_only=True,
+ storage_options={'anon': True}
+)
+group = zarr.open_group(store=store, mode='r')
+print(group)
+```
+
+The type of filesystem (e.g. S3, https, etc..) is inferred from the scheme of the url (e.g. s3 for "**s3**://noaa-nwm-retro-v2-zarr-pds").
+In case a specific filesystem is needed, one can explicitly create it. For example to create an S3 filesystem
+(note that the filesystem must be created with `asynchronous=True`):
+
+```python exec="true" session="storage" source="above" result="ansi"
+# Note: requires s3fs to be installed
+import fsspec
+fs = fsspec.filesystem('s3', anon=True, asynchronous=True)
+store = zarr.storage.FsspecStore(fs, path='noaa-nwm-retro-v2-zarr-pds', read_only=True)
+print(store)
+```
+
+When using an S3-compatible service other than AWS, pass the service endpoint to the
+filesystem via `client_kwargs={'endpoint_url': 'https://...'}`.
+
+### Memory Store
+
+The [`zarr.storage.MemoryStore`][] stores Zarr data (metadata and chunks) in an
+in-memory dictionary:
+
+```python exec="true" session="storage" source="above" result="ansi"
+data = {}
+store = zarr.storage.MemoryStore(data)
+array = zarr.create_array(store=store, shape=(2,), dtype='float64')
+print(array)
+```
+
+### Object Store
+
+[`zarr.storage.ObjectStore`][] stores the contents of the Zarr hierarchy using any ObjectStore
+[storage implementation](https://developmentseed.org/obstore/latest/api/store/), including AWS S3 ([`obstore.store.S3Store`][]), Google Cloud Storage ([`obstore.store.GCSStore`][]), and Azure Blob Storage ([`obstore.store.AzureStore`][]). This store is backed by [obstore](https://developmentseed.org/obstore/latest/), which
+builds on the production quality Rust library [object_store](https://docs.rs/object_store/latest/object_store/).
+
+```python exec="true" session="storage" source="above" result="ansi"
+from zarr.storage import ObjectStore
+from obstore.store import MemoryStore
+
+store = ObjectStore(MemoryStore())
+array = zarr.create_array(store=store, shape=(2,), dtype='float64')
+print(array)
+```
+
+Here's an example of using ObjectStore for accessing remote data:
+
+```python exec="true" session="storage" source="above" result="ansi"
+from zarr.storage import ObjectStore
+from obstore.store import S3Store
+
+s3_store = S3Store('noaa-nwm-retro-v2-zarr-pds', skip_signature=True, region="us-west-2")
+store = zarr.storage.ObjectStore(store=s3_store, read_only=True)
+group = zarr.open_group(store=store, mode='r')
+print(group.info)
+```
+
+!!! warning
+ The [`zarr.storage.ObjectStore`][] class is experimental.
+
+## Developing custom stores
+
+Zarr-Python [`zarr.abc.store.Store`][] API is meant to be extended. The Store Abstract Base
+Class includes all of the methods needed to be a fully operational store in Zarr Python.
+Zarr also provides a test harness for custom stores: [`zarr.testing.store.StoreTests`][].
+See the [Custom stores](extending.md#custom-stores) section of the extending guide for
+more on implementing your own store.
diff --git a/docs/user-guide/storage.rst b/docs/user-guide/storage.rst
deleted file mode 100644
index 4215cbaf20..0000000000
--- a/docs/user-guide/storage.rst
+++ /dev/null
@@ -1,148 +0,0 @@
-.. only:: doctest
-
- >>> import shutil
- >>> shutil.rmtree('data', ignore_errors=True)
-
-.. _user-guide-storage:
-
-Storage guide
-=============
-
-Zarr-Python supports multiple storage backends, including: local file systems,
-Zip files, remote stores via fsspec_ (S3, HTTP, etc.), and in-memory stores. In
-Zarr-Python 3, stores must implement the abstract store API from
-:class:`zarr.abc.store.Store`.
-
-.. note::
- Unlike Zarr-Python 2 where the store interface was built around a generic ``MutableMapping``
- API, Zarr-Python 3 utilizes a custom store API that utilizes Python's AsyncIO library.
-
-Implicit Store Creation
------------------------
-
-In most cases, it is not required to create a ``Store`` object explicitly. Passing a string
-to Zarr's top level API will result in the store being created automatically.:
-
- >>> import zarr
- >>>
- >>> # Implicitly create a writable LocalStore
- >>> zarr.create_group(store='data/foo/bar')
-
- >>>
- >>> # Implicitly create a read-only FsspecStore
- >>> zarr.open_group(
- ... store='s3://noaa-nwm-retro-v2-zarr-pds',
- ... mode='r',
- ... storage_options={'anon': True}
- ... )
- >
- >>>
- >>> # Implicitly creates a MemoryStore
- >>> data = {}
- >>> zarr.create_group(store=data)
-
-
-Explicit Store Creation
------------------------
-
-In some cases, it may be helpful to create a store instance directly. Zarr-Python offers four
-built-in store: :class:`zarr.storage.LocalStore`, :class:`zarr.storage.FsspecStore`,
-:class:`zarr.storage.ZipStore`, :class:`zarr.storage.MemoryStore`, and :class:`zarr.storage.ObjectStore`.
-
-Local Store
-~~~~~~~~~~~
-
-The :class:`zarr.storage.LocalStore` stores data in a nested set of directories on a local
-filesystem.:
-
- >>> store = zarr.storage.LocalStore('data/foo/bar', read_only=True)
- >>> zarr.open_group(store=store, mode='r')
-
-
-Zip Store
-~~~~~~~~~
-
-The :class:`zarr.storage.ZipStore` stores the contents of a Zarr hierarchy in a single
-Zip file. The `Zip Store specification`_ is currently in draft form.:
-
- >>> store = zarr.storage.ZipStore('data.zip', mode='w')
- >>> zarr.create_array(store=store, shape=(2,), dtype='float64')
-
-
-Remote Store
-~~~~~~~~~~~~
-
-The :class:`zarr.storage.FsspecStore` stores the contents of a Zarr hierarchy in following the same
-logical layout as the ``LocalStore``, except the store is assumed to be on a remote storage system
-such as cloud object storage (e.g. AWS S3, Google Cloud Storage, Azure Blob Store). The
-:class:`zarr.storage.FsspecStore` is backed by `fsspec`_ and can support any backend
-that implements the `AbstractFileSystem `_
-API. ``storage_options`` can be used to configure the fsspec backend.:
-
- >>> store = zarr.storage.FsspecStore.from_url(
- ... 's3://noaa-nwm-retro-v2-zarr-pds',
- ... read_only=True,
- ... storage_options={'anon': True}
- ... )
- >>> zarr.open_group(store=store, mode='r')
- >
-
-Memory Store
-~~~~~~~~~~~~
-
-The :class:`zarr.storage.MemoryStore` a in-memory store that allows for serialization of
-Zarr data (metadata and chunks) to a dictionary.:
-
- >>> data = {}
- >>> store = zarr.storage.MemoryStore(data)
- >>> # TODO: replace with create_array after #2463
- >>> zarr.create_array(store=store, shape=(2,), dtype='float64')
-
-
-Object Store
-~~~~~~~~~~~~
-
-:class:`zarr.storage.ObjectStore` stores the contents of the Zarr hierarchy using any ObjectStore
-`storage implementation `_, including AWS S3 (:class:`obstore.store.S3Store`), Google Cloud Storage (:class:`obstore.store.GCSStore`), and Azure Blob Storage (:class:`obstore.store.AzureStore`). This store is backed by `obstore `_, which
-builds on the production quality Rust library `object_store `_.
-
-
- >>> from zarr.storage import ObjectStore
- >>> from obstore.store import MemoryStore
- >>>
- >>> store = ObjectStore(MemoryStore())
- >>> zarr.create_array(store=store, shape=(2,), dtype='float64')
-
-
-Here's an example of using ObjectStore for accessing remote data:
-
- >>> from zarr.storage import ObjectStore
- >>> from obstore.store import S3Store
- >>>
- >>> s3_store = S3Store('noaa-nwm-retro-v2-zarr-pds', skip_signature=True, region="us-west-2")
- >>> store = zarr.storage.ObjectStore(store=s3_store, read_only=True)
- >>> group = zarr.open_group(store=store, mode='r')
- >>> group.info
- Name :
- Type : Group
- Zarr format : 2
- Read-only : True
- Store type : ObjectStore
- No. members : 12
- No. arrays : 12
- No. groups : 0
-
-.. warning::
- The :class:`zarr.storage.ObjectStore` class is experimental.
-
-.. _user-guide-custom-stores:
-
-Developing custom stores
-------------------------
-
-Zarr-Python :class:`zarr.abc.store.Store` API is meant to be extended. The Store Abstract Base
-Class includes all of the methods needed to be a fully operational store in Zarr Python.
-Zarr also provides a test harness for custom stores: :class:`zarr.testing.store.StoreTests`.
-
-.. _Zip Store Specification: https://github.com/zarr-developers/zarr-specs/pull/311
-.. _fsspec: https://filesystem-spec.readthedocs.io
diff --git a/docs/user-guide/v3_migration.md b/docs/user-guide/v3_migration.md
new file mode 100644
index 0000000000..4d97963be2
--- /dev/null
+++ b/docs/user-guide/v3_migration.md
@@ -0,0 +1,244 @@
+# 3.0 Migration Guide
+
+Zarr-Python 3 represents a major refactor of the Zarr-Python codebase. Some of the
+goals motivating this refactor included:
+
+* adding support for the Zarr format 3 specification (along with the Zarr format 2 specification)
+* cleaning up internal and user facing APIs
+* improving performance (particularly in high latency storage environments like
+ cloud object stores)
+
+To accommodate this, Zarr-Python 3 introduces a number of changes to the API, including a number
+of significant breaking changes and deprecations.
+
+This page provides a guide explaining breaking changes and deprecations to help you
+migrate your code from version 2 to version 3. If we have missed anything, please
+open a [GitHub issue](https://github.com/zarr-developers/zarr-python/issues/new)
+so we can improve this guide.
+
+!!! tip
+ This page is about migrating your *code* from the Zarr-Python 2 API to the
+ Zarr-Python 3 API. If you want to migrate the *metadata* of stored data from
+ Zarr format 2 to Zarr format 3, see the `zarr migrate` command described in
+ the [command-line interface documentation](cli.md).
+
+## Compatibility target
+
+The goals described above necessitated some breaking changes to the API (hence the
+major version update), but where possible we have maintained backwards compatibility
+in the most widely used parts of the API. This includes the [`zarr.Array`][] and
+[`zarr.Group`][] classes and the "top-level API" (e.g. [`zarr.open_array`][] and
+[`zarr.open_group`][]).
+
+## Getting ready for 3.0
+
+Before migrating to Zarr-Python 3, we suggest projects that depend on Zarr-Python take
+the following actions in order:
+
+1. Pin the supported Zarr-Python version to `zarr>=2,<3`. This is a best practice
+ and will protect your users from any incompatibilities that may arise during the
+ release of Zarr-Python 3. This pin can be removed after migrating to Zarr-Python 3.
+2. Limit your imports from the Zarr-Python package. Most of the primary API `zarr.*`
+ will be compatible in Zarr-Python 3. However, the following breaking API changes are
+ planned:
+
+ - `numcodecs.*` will no longer be available in `zarr.*`. To migrate, import codecs
+ directly from `numcodecs`:
+
+ ```python exec="false" reason="intentionally shows the old/incorrect import for contrast"
+ from numcodecs import Blosc
+ # instead of:
+ # from zarr import Blosc
+ ```
+
+ - The `zarr.v3_api_available` feature flag is being removed. In Zarr-Python 3
+ the v3 API is always available, so you shouldn't need to use this flag.
+ - `zarr.errors` has been consolidated. Several exception classes from
+ Zarr-Python 2 (such as `zarr.errors.PathNotFoundError`) have been removed
+ or replaced. For example, missing nodes now raise `zarr.errors.NodeNotFoundError`
+ (which subclasses both `BaseZarrError` and `FileNotFoundError`) instead of
+ `zarr.errors.PathNotFoundError`. Review any code that catches exceptions
+ from `zarr.errors` after migrating.
+ - The following internal modules are being removed or significantly changed. If
+ your application relies on imports from any of the below modules, you will need
+ to either a) modify your application to no longer rely on these imports or b)
+ vendor the parts of the specific modules that you need.
+
+ * `zarr.attrs` has gone, with no replacement
+ * `zarr.codecs` has changed, see "Codecs" section below for more information
+ * `zarr.context` has gone, with no replacement
+ * `zarr.core` remains but should be considered private API
+ * `zarr.hierarchy` has gone, with no replacement (use `zarr.Group` in place of `zarr.hierarchy.Group`)
+ * `zarr.indexing` has gone, with no replacement
+ * `zarr.meta` has gone, with no replacement
+ * `zarr.meta_v1` has gone, with no replacement
+ * `zarr.sync` has gone, with no replacement
+ * `zarr.types` has gone, with no replacement
+ * `zarr.util` has gone, with no replacement
+ * `zarr.n5` has gone, see below for an alternative N5 option
+
+3. Test that your package works with version 3.
+4. Update the pin to include `zarr>=3,<4`.
+
+## Zarr-Python 2 support window
+
+Zarr-Python 2.x is still available, though we recommend migrating to Zarr-Python 3 for
+its performance improvements and new features. Security and bug fixes were made to
+the 2.x series for six months following the first Zarr-Python 3 release (January 2025);
+the 2.x series is no longer actively maintained.
+If you need to use the latest Zarr-Python 2 release, you can install it with:
+
+```console
+$ pip install "zarr==2.*"
+```
+
+!!! note
+ Development and maintenance of the 2.x release series has moved to the
+ [support/v2](https://github.com/zarr-developers/zarr-python/tree/support/v2) branch.
+ Issues and pull requests related to this branch are tagged with the
+ [V2](https://github.com/zarr-developers/zarr-python/labels/V2) label.
+
+## Migrating to Zarr-Python 3
+
+The following sections provide details on breaking changes in Zarr-Python 3.
+
+### The Array class
+
+1. Disallow direct construction - the signature for initializing the `Array` class has changed
+ significantly. Please use [`zarr.create_array`][] or [`zarr.open_array`][] instead of
+ directly constructing the [`zarr.Array`][] class.
+
+2. Defaulting to `zarr_format=3` - newly created arrays will use the version 3 of the
+ Zarr specification. To continue using version 2, set `zarr_format=2` when creating arrays
+ or set `default_zarr_format=2` in Zarr's runtime configuration.
+
+3. Function signature change to [`zarr.Array.resize`][] - the `resize` function now takes a
+ `zarr.core.common.ShapeLike` input rather than separate arguments for each dimension.
+ Use `resize((10,10))` in place of `resize(10,10)`.
+
+### The Group class
+
+1. Disallow direct construction - use [`zarr.open_group`][] or [`zarr.create_group`][]
+ instead of directly constructing the `zarr.Group` class.
+2. The h5py compatibility methods `create_dataset` and `require_dataset` have been removed.
+ Use the following replacements:
+
+ - [`zarr.Group.create_array`][] in place of `Group.create_dataset`
+ - [`zarr.Group.require_array`][] in place of `Group.require_dataset`
+
+3. Disallow "." syntax for getting group members. To get a member of a group named `foo`,
+ use `group["foo"]` in place of `group.foo`.
+4. The `zarr.storage.init_group` low-level helper function has been removed. Use
+ [`zarr.open_group`][] or [`zarr.create_group`][] instead:
+
+ ```diff
+ - from zarr.storage import init_group
+ - init_group(store, overwrite=True, path="my/path")
+ + import zarr
+ + zarr.open_group(store, mode="w", path="my/path")
+ ```
+
+### The Store class
+
+The Store API has changed significantly in Zarr-Python 3.
+
+#### The base store class
+
+The `MutableMapping` base class has been replaced in favor of a custom abstract base class ([`zarr.abc.store.Store`][]).
+An asynchronous interface is used for all store methods that use I/O.
+This change ensures that these store methods are non-blocking and are as performant as possible.
+
+#### Store implementations
+
+Store implementations have moved from the top-level module to `zarr.storage`:
+
+```diff title="Store import changes from v2 to v3"
+# Before (v2)
+- from zarr import MemoryStore
++ from zarr.storage import MemoryStore
+```
+
+The following stores have been renamed or changed:
+
+| v2 | v3 |
+|------------------------|------------------------------------|
+| `DirectoryStore` | [`zarr.storage.LocalStore`][] |
+| `FSStore` | [`zarr.storage.FsspecStore`][] |
+| `TempStore` | Use [`tempfile.TemporaryDirectory`][] with [`LocalStore`][zarr.storage.LocalStore] |
+
+A number of deprecated stores were also removed.
+See [issue #1274](https://github.com/zarr-developers/zarr-python/issues/1274) for more details on the removal of these stores.
+
+- `N5Store` - see https://github.com/zarr-developers/n5py for an alternative interface to
+ N5 formatted data.
+- `ABSStore` - use the [`zarr.storage.FsspecStore`][] instead along with fsspec's
+ [adlfs backend](https://github.com/fsspec/adlfs).
+- `DBMStore`
+- `LMDBStore`
+- `SQLiteStore`
+- `MongoDBStore`
+- `RedisStore`
+
+The latter five stores in this list do not have an equivalent in Zarr-Python 3.
+If you are interested in developing a custom store that targets these backends, see
+[developing custom stores](storage.md#developing-custom-stores) or open an
+[issue](https://github.com/zarr-developers/zarr-python/issues) to discuss your use case.
+
+### Codecs
+
+Codecs defined in ``numcodecs`` (and also imported into the ``zarr.codecs`` namespace in Zarr-Python 2)
+should still be used when creating Zarr format 2 arrays.
+
+Codecs for creating Zarr format 3 arrays are available in two locations:
+
+- `zarr.codecs` contains Zarr format 3 codecs that are defined in the [codecs section of the Zarr format 3 specification](https://zarr-specs.readthedocs.io/en/latest/v3/codecs/index.html).
+- `numcodecs.zarr3` contains codecs from `numcodecs` that can be used to create Zarr format 3 arrays, but are not necessarily part of the Zarr format 3 specification.
+
+### Dependencies
+
+When installing using `pip`:
+
+- The new `remote` dependency group can be used to install a supported version of
+ `fsspec`, required for remote data access.
+- The new `gpu` dependency group can be used to install a supported version of
+ `cuda`, required for GPU functionality.
+- The `jupyter` optional dependency group has been removed, since v3 contains no
+ jupyter specific functionality.
+
+### Miscellaneous
+
+- The keyword argument `zarr_version` in most creation functions in `zarr`
+ (e.g. [`zarr.create`][], [`zarr.open`][], [`zarr.group`][], [`zarr.array`][]) has
+ been removed. Use `zarr_format` instead.
+
+## Unimplemented Zarr-Python 2 features
+
+A few features of Zarr-Python 2 remain unimplemented in Zarr-Python 3.
+If any of the features listed below are important to your use case
+of Zarr-Python, please open (or comment on) a
+[GitHub issue](https://github.com/zarr-developers/zarr-python/issues/new).
+
+The following functions / methods have not been ported to Zarr-Python 3:
+
+- `zarr.copy` ([issue #2407](https://github.com/zarr-developers/zarr-python/issues/2407))
+- `zarr.copy_all` ([issue #2407](https://github.com/zarr-developers/zarr-python/issues/2407))
+- `zarr.copy_store` ([issue #2407](https://github.com/zarr-developers/zarr-python/issues/2407))
+
+The following features (corresponding to function arguments to functions in
+ `zarr`) have not been ported to Zarr-Python 3. Using these features
+ will raise a warning or a `NotImplementedError`:
+
+- `cache_attrs`
+- `cache_metadata`
+- `chunk_store` ([issue #2495](https://github.com/zarr-developers/zarr-python/issues/2495))
+- `meta_array`
+- `object_codec` ([issue #2617](https://github.com/zarr-developers/zarr-python/issues/2617))
+- `synchronizer` ([issue #1596](https://github.com/zarr-developers/zarr-python/issues/1596))
+
+The following features that were supported by Zarr-Python 2 have not been ported
+ to Zarr-Python 3:
+
+- Object dtypes ([issue #2616](https://github.com/zarr-developers/zarr-python/issues/2616))
+- Ragged arrays ([issue #2618](https://github.com/zarr-developers/zarr-python/issues/2618))
+- Groups and Arrays do not implement `__enter__` and `__exit__` protocols ([issue #2619](https://github.com/zarr-developers/zarr-python/issues/2619))
+- Default filters for object dtypes for Zarr format 2 arrays ([issue #2627](https://github.com/zarr-developers/zarr-python/issues/2627))
diff --git a/docs/user-guide/v3_migration.rst b/docs/user-guide/v3_migration.rst
deleted file mode 100644
index a6258534e4..0000000000
--- a/docs/user-guide/v3_migration.rst
+++ /dev/null
@@ -1,238 +0,0 @@
-.. _v3 migration guide:
-
-3.0 Migration Guide
-===================
-
-Zarr-Python 3 represents a major refactor of the Zarr-Python codebase. Some of the
-goals motivating this refactor included:
-
-* adding support for the Zarr format 3 specification (along with the Zarr format 2 specification)
-* cleaning up internal and user facing APIs
-* improving performance (particularly in high latency storage environments like
- cloud object stores)
-
-To accommodate this, Zarr-Python 3 introduces a number of changes to the API, including a number
-of significant breaking changes and deprecations.
-
-This page provides a guide explaining breaking changes and deprecations to help you
-migrate your code from version 2 to version 3. If we have missed anything, please
-open a `GitHub issue `_
-so we can improve this guide.
-
-Compatibility target
---------------------
-
-The goals described above necessitated some breaking changes to the API (hence the
-major version update), but where possible we have maintained backwards compatibility
-in the most widely used parts of the API. This in the :class:`zarr.Array` and
-:class:`zarr.Group` classes and the "top-level API" (e.g. :func:`zarr.open_array` and
-:func:`zarr.open_group`).
-
-Getting ready for 3.0
----------------------
-
-Before migrating to Zarr-Python 3, we suggest projects that depend on Zarr-Python take
-the following actions in order:
-
-1. Pin the supported Zarr-Python version to ``zarr>=2,<3``. This is a best practice
- and will protect your users from any incompatibilities that may arise during the
- release of Zarr-Python 3. This pin can be removed after migrating to Zarr-Python 3.
-2. Limit your imports from the Zarr-Python package. Most of the primary API ``zarr.*``
- will be compatible in Zarr-Python 3. However, the following breaking API changes are
- planned:
-
- - ``numcodecs.*`` will no longer be available in ``zarr.*``. To migrate, import codecs
- directly from ``numcodecs``:
-
- .. code-block:: python
-
- from numcodecs import Blosc
- # instead of:
- # from zarr import Blosc
-
- - The ``zarr.v3_api_available`` feature flag is being removed. In Zarr-Python 3
- the v3 API is always available, so you shouldn't need to use this flag.
- - The following internal modules are being removed or significantly changed. If
- your application relies on imports from any of the below modules, you will need
- to either a) modify your application to no longer rely on these imports or b)
- vendor the parts of the specific modules that you need.
-
- * ``zarr.attrs`` has gone, with no replacement
- * ``zarr.codecs`` has gone, use ``numcodecs`` instead
- * ``zarr.context`` has gone, with no replacement
- * ``zarr.core`` remains but should be considered private API
- * ``zarr.hierarchy`` has gone, with no replacement (use ``zarr.Group`` inplace of ``zarr.hierarchy.Group``)
- * ``zarr.indexing`` has gone, with no replacement
- * ``zarr.meta`` has gone, with no replacement
- * ``zarr.meta_v1`` has gone, with no replacement
- * ``zarr.sync`` has gone, with no replacement
- * ``zarr.types`` has gone, with no replacement
- * ``zarr.util`` has gone, with no replacement
- * ``zarr.n5`` has gone, see below for an alternative N5 options
-
-3. Test that your package works with version 3.
-4. Update the pin to include ``zarr>=3,<4``.
-
-Zarr-Python 2 support window
-----------------------------
-
-Zarr-Python 2.x is still available, though we recommend migrating to Zarr-Python 3 for
-its performance improvements and new features. Security and bug fixes will be made to
-the 2.x series for at least six months following the first Zarr-Python 3 release.
-If you need to use the latest Zarr-Python 2 release, you can install it with:
-
-.. code-block:: console
-
- $ pip install "zarr==2.*"
-
-.. note::
- Development and maintenance of the 2.x release series has moved to the
- `support/v2 `_ branch.
- Issues and pull requests related to this branch are tagged with the
- `V2 `_ label.
-
-Migrating to Zarr-Python 3
---------------------------
-
-The following sections provide details on breaking changes in Zarr-Python 3.
-
-The Array class
-~~~~~~~~~~~~~~~
-
-1. Disallow direct construction - the signature for initializing the ``Array`` class has changed
- significantly. Please use :func:`zarr.create_array` or :func:`zarr.open_array` instead of
- directly constructing the :class:`zarr.Array` class.
-
-2. Defaulting to ``zarr_format=3`` - newly created arrays will use the version 3 of the
- Zarr specification. To continue using version 2, set ``zarr_format=2`` when creating arrays
- or set ``default_zarr_version=2`` in Zarr's :ref:`runtime configuration `.
-
-The Group class
-~~~~~~~~~~~~~~~
-
-1. Disallow direct construction - use :func:`zarr.open_group` or :func:`zarr.create_group`
- instead of directly constructing the :class:`zarr.Group` class.
-2. Most of the h5py compatibility methods are deprecated and will issue warnings if used.
- The following functions are drop in replacements that have the same signature and functionality:
-
- - Use :func:`zarr.Group.create_array` in place of :func:`zarr.Group.create_dataset`
- - Use :func:`zarr.Group.require_array` in place of :func:`zarr.Group.require_dataset`
-3. Disallow "." syntax for getting group members. To get a member of a group named ``foo``,
- use ``group["foo"]`` in place of ``group.foo``.
-
-The Store class
-~~~~~~~~~~~~~~~
-
-The Store API has changed significant in Zarr-Python 3. The most notable changes to the
-Store API are:
-
-Store Import Paths
-^^^^^^^^^^^^^^^^^^
-Several store implementations have moved from the top-level module to ``zarr.storage``:
-
-.. code-block:: diff
- :caption: Store import changes from v2 to v3
-
- # Before (v2)
- - from zarr import MemoryStore, DirectoryStore
- + from zarr.storage import MemoryStore, LocalStore # LocalStore replaces DirectoryStore
-
-Common replacements:
-
-+-------------------------+------------------------------------+
-| v2 Import | v3 Import |
-+=========================+====================================+
-| ``zarr.MemoryStore`` | ``zarr.storage.MemoryStore`` |
-+-------------------------+------------------------------------+
-| ``zarr.DirectoryStore`` | ``zarr.storage.LocalStore`` |
-+-------------------------+------------------------------------+
-| ``zarr.TempStore`` | Use ``tempfile.TemporaryDirectory``|
-| | with ``LocalStore`` |
-+-------------------------+------------------------------------+
-
-1. Replaced the ``MutableMapping`` base class in favor of a custom abstract base class
- (:class:`zarr.abc.store.Store`).
-2. Switched to an asynchronous interface for all store methods that result in IO. This
- change ensures that all store methods are non-blocking and are as performant as
- possible.
-
-Beyond the changes store interface, a number of deprecated stores were also removed in
-Zarr-Python 3. See :issue:`1274` for more details on the removal of these stores.
-
-- ``N5Store`` - see https://github.com/zarr-developers/n5py for an alternative interface to
- N5 formatted data.
-- ``ABSStore`` - use the :class:`zarr.storage.FsspecStore` instead along with fsspec's
- `adlfs backend `_.
-
-The following stores have been removed altogether. Users who need these stores will have to
-implement their own version in zarr-python v3.
-
-- ``DBMStore``
-- ``LMDBStore``
-- ``SQLiteStore``
-- ``MongoDBStore``
-- ``RedisStore``
-
-At present, the latter five stores in this list do not have an equivalent in Zarr-Python 3.
-If you are interested in developing a custom store that targets these backends, see
-:ref:`developing custom stores ` or open an
-`issue `_ to discuss your use case.
-
-Dependencies
-~~~~~~~~~~~~
-
-When installing using ``pip``:
-
-- The new ``remote`` dependency group can be used to install a supported version of
- ``fsspec``, required for remote data access.
-- The new ``gpu`` dependency group can be used to install a supported version of
- ``cuda``, required for GPU functionality.
-- The ``jupyter`` optional dependency group has been removed, since v3 contains no
- jupyter specific functionality.
-
-Miscellaneous
-~~~~~~~~~~~~~
-
-- The keyword argument ``zarr_version`` available in most creation functions in :mod:`zarr`
- (e.g. :func:`zarr.create`, :func:`zarr.open`, :func:`zarr.group`, :func:`zarr.array`) has
- been deprecated in favor of ``zarr_format``.
-
-🚧 Work in Progress 🚧
-----------------------
-
-Zarr-Python 3 is still under active development, and is not yet fully complete.
-The following list summarizes areas of the codebase that we expect to build out
-after the 3.0.0 release. If features listed below are important to your use case
-of Zarr-Python, please open (or comment on) a
-`GitHub issue `_.
-
-- The following functions / methods have not been ported to Zarr-Python 3 yet:
-
- * :func:`zarr.copy` (:issue:`2407`)
- * :func:`zarr.copy_all` (:issue:`2407`)
- * :func:`zarr.copy_store` (:issue:`2407`)
- * :func:`zarr.Group.move` (:issue:`2108`)
-
-- The following features (corresponding to function arguments to functions in
- :mod:`zarr`) have not been ported to Zarr-Python 3 yet. Using these features
- will raise a warning or a ``NotImplementedError``:
-
- * ``cache_attrs``
- * ``cache_metadata``
- * ``chunk_store`` (:issue:`2495`)
- * ``meta_array``
- * ``object_codec`` (:issue:`2617`)
- * ``synchronizer`` (:issue:`1596`)
- * ``dimension_separator``
-
-- The following features that were supported by Zarr-Python 2 have not been ported
- to Zarr-Python 3 yet:
-
- * Structured arrays / dtypes (:issue:`2134`)
- * Fixed-length string dtypes (:issue:`2347`)
- * Datetime and timedelta dtypes (:issue:`2616`)
- * Object dtypes (:issue:`2617`)
- * Ragged arrays (:issue:`2618`)
- * Groups and Arrays do not implement ``__enter__`` and ``__exit__`` protocols (:issue:`2619`)
- * Big Endian dtypes (:issue:`2324`)
- * Default filters for object dtypes for Zarr format 2 arrays (:issue:`2627`)
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000000..a6b5fa2179
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,44 @@
+# Zarr Python Examples
+
+This directory contains complete, runnable examples demonstrating various features and use cases of Zarr Python.
+
+## Directory Structure
+
+Each example is organized in its own subdirectory with the following structure:
+
+```
+examples/
+├── example_name/
+│ ├── README.md # Documentation for the example
+│ └── example_name.py # Python source code
+└── ...
+```
+
+## Adding New Examples
+
+To add a new example:
+
+1. Create a new subdirectory: `examples/my_example/`
+2. Add your Python code: `examples/my_example/my_example.py`
+3. Create documentation: `examples/my_example/README.md`
+4. Create a documentation page at `docs/user-guide/examples/my_example.md`. The documentation page should simply link to the `README.md` and the source code, e.g.:
+
+ ````
+ # docs/user-guide/examples/my_example.md
+ --8<-- "examples/my_example/README.md"
+
+ ## Source Code
+
+ ```python
+ --8<-- "examples/my_example/my_example.py"
+ ```
+ ````
+5. Update `mkdocs.yml` to include the new example in the navigation.
+
+### Example README.md Format
+
+Your README.md should include:
+
+- A title (`# Example Name`)
+- Description of what the example demonstrates
+- Instructions for running the example
diff --git a/examples/codec_pipeline_performance/README.md b/examples/codec_pipeline_performance/README.md
new file mode 100644
index 0000000000..5d85412c29
--- /dev/null
+++ b/examples/codec_pipeline_performance/README.md
@@ -0,0 +1,59 @@
+# Codec Pipeline Performance
+
+This example compares the default `BatchedCodecPipeline` against the opt-in
+`FusedCodecPipeline` on a sharded array, across two stores (memory and local)
+and two codec regimes (uncompressed and gzip), at one worker and at `cpu_count`.
+
+A *codec pipeline* turns chunks of array data into stored bytes and back, running
+the configured codecs and performing the storage IO. The default
+`BatchedCodecPipeline` schedules both asynchronously -- roughly one coroutine per
+chunk operation. That model pays off for high-latency stores, where there is
+useful work to do while waiting on IO. For low-latency stores (in-process memory,
+the local filesystem) the IO completes too quickly for the overlap to be worth
+its cost, and the async scheduling becomes pure overhead.
+
+`FusedCodecPipeline` runs codec compute and synchronous IO synchronously,
+removing that overhead. It is
+[experimental](https://zarr.readthedocs.io/en/stable/user-guide/experimental/)
+and opt-in; the default pipeline is unchanged.
+
+## What it shows
+
+- How to select a pipeline with `zarr.config.set`, and why the array must be
+ *created* inside the config block: the pipeline class is resolved at array
+ construction time and then travels with the array.
+- That the benefit depends strongly on layout and on whether compression is in
+ play. Some configurations are slower under the fused pipeline -- the script
+ reports speedups below 1.00x rather than hiding them.
+- That `codec_pipeline.max_workers` is read only by `FusedCodecPipeline`; the
+ default pipeline ignores it entirely.
+
+## Running
+
+```bash
+uv run codec_pipeline_performance.py
+```
+
+The script has no arguments and writes only to an in-memory store.
+
+## Interpreting the output
+
+The numbers are specific to your CPU, your Python build, and the workload chosen
+here. They are a measurement of your machine, not a published benchmark -- treat
+a single run as indicative and re-measure against your own data and store before
+switching pipelines in production.
+
+Two effects are worth watching for:
+
+- **The two codec regimes tell opposite stories about `max_workers`.**
+ Uncompressed IO is dominated by per-chunk *scheduling*, so `Fused (1 worker)`
+ is already fastest and a thread pool only adds overhead. gzip is genuinely
+ CPU-bound: a single worker compresses chunk after chunk sequentially and can
+ be *slower than the default*, while a thread pool spreads that compression
+ over cores and reclaims the win. That flip is why the fused pipeline is
+ threaded by default, and why pinning `max_workers=1` is worth it for
+ memory-backed uncompressed data.
+- **Chunk size decides whether threading can help at all.** The 64×64 inner
+ chunks here are small enough that per-chunk scheduling dominates uncompressed
+ IO, yet large enough that per-chunk gzip is real work to parallelize. Much
+ coarser chunks leave the pool with too few items to spread.
diff --git a/examples/codec_pipeline_performance/codec_pipeline_performance.py b/examples/codec_pipeline_performance/codec_pipeline_performance.py
new file mode 100644
index 0000000000..b821f3e6a7
--- /dev/null
+++ b/examples/codec_pipeline_performance/codec_pipeline_performance.py
@@ -0,0 +1,214 @@
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+# "zarr @ git+https://github.com/zarr-developers/zarr-python.git@main",
+# "numpy",
+# ]
+# ///
+
+"""
+Compare the `BatchedCodecPipeline` and the `FusedCodecPipeline`.
+
+The default `BatchedCodecPipeline` schedules storage IO and codec compute
+asynchronously -- roughly one coroutine per chunk operation. For a *sharded*
+array that means one coroutine per inner chunk inside every shard. That is the
+right model for high-latency stores, where there is useful work to do while
+waiting for IO. For low-latency stores (in-process memory, the local
+filesystem) the IO completes too quickly for the overlap to pay for itself, and
+the scheduling becomes pure overhead.
+
+The `FusedCodecPipeline` runs codec compute and synchronous IO synchronously,
+removing that overhead. Whether it wins, and whether its thread pool helps,
+depends on which resource is actually scarce:
+
+ * Uncompressed IO is dominated by per-chunk *scheduling*, not compute. There
+ is nothing for a thread pool to parallelize, so a single worker is already
+ fastest and extra workers only add overhead.
+ * gzip is genuinely CPU-bound. A single worker compresses every chunk
+ sequentially and can be *slower than the default*, while a thread pool
+ spreads that compression across cores and reclaims the win. This is when
+ `max_workers > 1` earns its keep.
+
+Run it with:
+
+ uv run codec_pipeline_performance.py
+
+Numbers are hardware-, layout-, and codec-dependent. Treat the output as a
+measurement of *your* machine, not as a published benchmark.
+"""
+
+from __future__ import annotations
+
+import operator
+import os
+import statistics
+import tempfile
+import timeit
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+import zarr
+from zarr.storage import LocalStore, MemoryStore
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from zarr.abc.store import Store
+
+BATCHED = "zarr.core.codec_pipeline.BatchedCodecPipeline"
+FUSED = "zarr.core.codec_pipeline.FusedCodecPipeline"
+
+# gzip is CPU-bound to encode, which is exactly the regime where the thread
+# pool matters. Level 6 is gzip's own default.
+GZIP = {"name": "gzip", "configuration": {"level": 6}}
+
+# 4096x4096 int32 = 64 MiB, split into 16 shards of 1024x1024, each holding
+# 16x16 = 256 inner chunks of 64x64 -> 4096 inner chunks in total. The chunks
+# are small enough that per-chunk coroutine scheduling dominates uncompressed
+# IO, yet large enough that per-chunk gzip is real work to spread over cores.
+SHAPE = (4096, 4096)
+SHARDS = (1024, 1024)
+CHUNKS = (64, 64)
+DTYPE = "int32"
+
+CONFIGS: tuple[tuple[str, dict[str, object]], ...] = (
+ ("Batched (default)", {"codec_pipeline.path": BATCHED}),
+ ("Fused (1 worker)", {"codec_pipeline.path": FUSED, "codec_pipeline.max_workers": 1}),
+ ("Fused (cpu_count)", {"codec_pipeline.path": FUSED, "codec_pipeline.max_workers": None}),
+)
+
+
+def time_call(fn: Callable[[], object], repeat: int = 3) -> float:
+ """Median wall-clock seconds for one call to `fn`.
+
+ `timeit.Timer` supplies `perf_counter` and disables the cyclic garbage
+ collector during each run, so a collection triggered by earlier work cannot
+ land inside a measurement. `number=1` because a single call here already
+ moves 64 MiB -- the per-call overhead `timeit` amortizes is irrelevant at
+ this scale.
+ """
+ return statistics.median(timeit.Timer(fn).repeat(repeat=repeat, number=1))
+
+
+def measure(
+ settings: dict[str, object],
+ store: Store,
+ data: np.ndarray,
+ compressors: object,
+) -> tuple[float, float]:
+ """Time one full write and one full read of `data` under `settings`.
+
+ The whole operation runs inside `zarr.config.set`, not just the array
+ construction. The pipeline class is resolved when the array is built, but
+ `codec_pipeline.max_workers` is read *per operation*, so a timed call made
+ outside the config block would silently use whatever worker count was
+ globally in effect -- which makes every configuration look identical.
+ """
+ everything = slice(None)
+
+ def write_once() -> None:
+ with zarr.config.set(settings):
+ array = zarr.create_array(
+ store=store,
+ shape=SHAPE,
+ chunks=CHUNKS,
+ shards=SHARDS,
+ dtype=DTYPE,
+ compressors=compressors,
+ fill_value=0,
+ overwrite=True,
+ )
+ operator.setitem(array, everything, data)
+
+ write = time_call(write_once)
+
+ # The bytes on disk are identical whichever pipeline wrote them, so reading
+ # back what we just wrote isolates read performance on the same data.
+ def read_once() -> object:
+ with zarr.config.set(settings):
+ return zarr.open_array(store=store, mode="r")[everything]
+
+ read = time_call(read_once)
+
+ if not np.array_equal(read_once(), data):
+ raise AssertionError("round trip mismatch")
+ return write, read
+
+
+def make_store(kind: str, tmp: Path) -> Store:
+ if kind == "memory":
+ return MemoryStore()
+ return LocalStore(tmp / f"demo_{kind}_{os.getpid()}.zarr")
+
+
+def main() -> None:
+ n_cpu = os.cpu_count() or 1
+
+ # Each regime gets the data that actually exercises it. `arange` is
+ # trivially compressible, which is fine when nothing compresses it, but it
+ # would make gzip finish almost instantly and hide the CPU-bound behavior
+ # this example is about. The noisy array keeps gzip genuinely busy.
+ n = int(np.prod(SHAPE))
+ plain_data = np.arange(n, dtype=DTYPE).reshape(SHAPE)
+ noisy_data = np.random.default_rng(0).integers(0, 2**24, size=SHAPE, dtype=DTYPE)
+
+ n_shards = int(np.prod([s // c for s, c in zip(SHAPE, SHARDS, strict=True)]))
+ per_shard = int(np.prod([s // c for s, c in zip(SHARDS, CHUNKS, strict=True)]))
+ print(f"zarr {zarr.__version__} | {n_cpu} CPUs")
+ print(
+ f"array {SHAPE} {DTYPE} = {plain_data.nbytes / 2**20:.0f} MiB | "
+ f"{n_shards} shards x {per_shard} inner chunks = {n_shards * per_shard} chunks\n"
+ )
+
+ with tempfile.TemporaryDirectory() as tmp:
+ for store_kind in ("memory", "local"):
+ for codec_label, compressors, data in (
+ ("uncompressed", None, plain_data),
+ ("gzip-6 (CPU-bound)", GZIP, noisy_data),
+ ):
+ print(f"=== {store_kind} store / {codec_label} ===")
+ print(
+ f"{'pipeline':<22}{'write (s)':>11}{'vs base':>10}"
+ f"{'read (s)':>12}{'vs base':>10}"
+ )
+ results: dict[str, tuple[float, float]] = {}
+ for label, settings in CONFIGS:
+ store = make_store(store_kind, Path(tmp))
+ results[label] = measure(settings, store, data, compressors)
+
+ base_write, base_read = results[CONFIGS[0][0]]
+ for label, (write, read) in results.items():
+ print(
+ f"{label:<22}{write:>10.3f}{base_write / write:>9.1f}x"
+ f"{read:>11.3f}{base_read / read:>9.1f}x"
+ )
+
+ # The headline comparison: does the thread pool earn its keep?
+ single_write, single_read = results["Fused (1 worker)"]
+ pool_write, pool_read = results["Fused (cpu_count)"]
+ print(
+ f" workers (cpu_count vs 1 worker): "
+ f"write {single_write / pool_write:.1f}x "
+ f"read {single_read / pool_read:.1f}x"
+ )
+ print()
+
+ print(
+ "Reading it:\n"
+ " * Uncompressed IO is scheduling-bound, so Fused (1 worker) is already\n"
+ " fastest -- a thread pool has nothing to parallelize and only adds\n"
+ " overhead.\n"
+ " * gzip is CPU-bound, so Fused (1 worker) can be *slower* than the\n"
+ " default, while Fused (cpu_count) spreads compression across cores\n"
+ " and reclaims the win. That flip is why the fused pipeline is\n"
+ " threaded by default, and why pinning max_workers=1 is worth it for\n"
+ " memory-backed uncompressed data.\n"
+ " * `codec_pipeline.max_workers` is read only by the FusedCodecPipeline;\n"
+ " the default BatchedCodecPipeline ignores it."
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/custom_dtype/README.md b/examples/custom_dtype/README.md
new file mode 100644
index 0000000000..266f398ca8
--- /dev/null
+++ b/examples/custom_dtype/README.md
@@ -0,0 +1,27 @@
+# Custom Data Type
+
+This example demonstrates how to extend Zarr Python by defining a new data type.
+
+The example shows how to:
+
+- Define a custom `ZDType` class for the `int2` data type from [`ml_dtypes`](https://pypi.org/project/ml-dtypes/)
+- Implement all required methods for serialization and deserialization
+- Register the custom data type with Zarr's registry
+- Create and use arrays with the custom data type in both Zarr v2 and v3 formats
+
+## Running the Example
+
+The script declares its dependencies inline
+([PEP 723](https://peps.python.org/pep-0723/)), so the easiest way to run it is
+with [uv](https://docs.astral.sh/uv/), which installs them automatically:
+
+```bash
+uv run examples/custom_dtype/custom_dtype.py
+```
+
+Alternatively, run it with plain Python, in which case you must first install
+`zarr`, `ml_dtypes`, and `pytest` yourself:
+
+```bash
+python examples/custom_dtype/custom_dtype.py
+```
diff --git a/examples/custom_dtype.py b/examples/custom_dtype/custom_dtype.py
similarity index 88%
rename from examples/custom_dtype.py
rename to examples/custom_dtype/custom_dtype.py
index a98f3414f6..53acb70f52 100644
--- a/examples/custom_dtype.py
+++ b/examples/custom_dtype/custom_dtype.py
@@ -1,8 +1,8 @@
# /// script
-# requires-python = ">=3.11"
+# requires-python = ">=3.12"
# dependencies = [
# "zarr @ git+https://github.com/zarr-developers/zarr-python.git@main",
-# "ml_dtypes==0.5.1",
+# "ml_dtypes==0.5.4",
# "pytest==8.4.1"
# ]
# ///
@@ -22,14 +22,9 @@
import pytest
import zarr
-from zarr.core.common import JSON, ZarrFormat
-from zarr.core.dtype import ZDType, data_type_registry
-from zarr.core.dtype.common import (
- DataTypeValidationError,
- DTypeConfig_V2,
- DTypeJSON,
- check_dtype_spec_v2,
-)
+from zarr.dtype import ZDType, check_dtype_spec_v2, data_type_registry
+from zarr.errors import DataTypeValidationError
+from zarr.types import JSON, DTypeConfig_V2, DTypeJSON, ZarrFormat
# This is the int2 array data type
int2_dtype_cls = type(np.dtype("int2"))
@@ -120,7 +115,7 @@ def _from_json_v3(cls: type[Self], data: DTypeJSON) -> Self:
msg = f"Invalid JSON representation of {cls.__name__}. Got {data!r}, expected the string {cls._zarr_v3_name!r}"
raise DataTypeValidationError(msg)
- @overload # type: ignore[override]
+ @overload
def to_json(self, zarr_format: Literal[2]) -> DTypeConfig_V2[Literal["int2"], None]: ...
@overload
@@ -145,7 +140,7 @@ def to_json(
"""
if zarr_format == 2:
return {"name": "int2", "object_codec_id": None}
- elif zarr_format == 3:
+ if zarr_format == 3:
return self._zarr_v3_name
raise ValueError(f"zarr_format must be 2 or 3, got {zarr_format}") # pragma: no cover
@@ -191,9 +186,11 @@ def to_json_scalar(self, data: object, *, zarr_format: ZarrFormat) -> int:
"""
# We could add a type check here, but we don't need to for this example
val: int = int(data) # type: ignore[call-overload]
- if val not in (-2, -1, 0, 1):
- raise ValueError("Invalid value. Expected -2, -1, 0, or 1.")
- return val
+
+ if val in {-2, -1, 0, 1}:
+ return val
+
+ raise ValueError("Invalid value. Expected -2, -1, 0, or 1.")
def from_json_scalar(self, data: JSON, *, zarr_format: ZarrFormat) -> ml_dtypes.int2:
"""
@@ -217,10 +214,14 @@ def from_json_scalar(self, data: JSON, *, zarr_format: ZarrFormat) -> ml_dtypes.
# this parametrized function will create arrays in zarr v2 and v3 using our new data type
@pytest.mark.parametrize("zarr_format", [2, 3])
-def test_custom_dtype(tmp_path: Path, zarr_format: Literal[2, 3]) -> None:
+def test_custom_dtype(tmp_path: Path, zarr_format: ZarrFormat) -> None:
# create array and write values
z_w = zarr.create_array(
- store=tmp_path, shape=(4,), dtype="int2", zarr_format=zarr_format, compressors=None
+ store=tmp_path,
+ shape=(4,),
+ dtype="int2",
+ zarr_format=zarr_format,
+ compressors=None,
)
z_w[:] = [-1, -2, 0, 1]
@@ -230,10 +231,7 @@ def test_custom_dtype(tmp_path: Path, zarr_format: Literal[2, 3]) -> None:
print(z_r.info_complete())
# look at the array metadata
- if zarr_format == 2:
- meta_file = tmp_path / ".zarray"
- else:
- meta_file = tmp_path / "zarr.json"
+ meta_file = tmp_path / (".zarray" if zarr_format == 2 else "zarr.json")
print(json.dumps(json.loads(meta_file.read_text()), indent=2))
@@ -242,4 +240,16 @@ def test_custom_dtype(tmp_path: Path, zarr_format: Literal[2, 3]) -> None:
# Without the dummy configuration file, at test time pytest will attempt to use the
# configuration file in the project root, which will error because Zarr is using some
# plugins that are not installed in this example.
- sys.exit(pytest.main(["-s", __file__, f"-c {__file__}"]))
+ sys.exit(
+ pytest.main(
+ [
+ "-s",
+ __file__,
+ f"-c {__file__}",
+ # Suppress: "PytestAssertRewriteWarning: Module already imported so
+ # cannot be rewritten; zarr"
+ "-W",
+ "ignore::pytest.PytestAssertRewriteWarning",
+ ]
+ )
+ )
diff --git a/examples/sharding_coalescing/README.md b/examples/sharding_coalescing/README.md
new file mode 100644
index 0000000000..29ba08c9ce
--- /dev/null
+++ b/examples/sharding_coalescing/README.md
@@ -0,0 +1,63 @@
+# Sharded Read Coalescing
+
+This example demonstrates byte-range coalescing for partial reads of sharded
+arrays, a performance optimization added in Zarr-Python 3.3.0 and enabled by
+default.
+
+A shard is one stored object containing many inner chunks, each occupying its own
+byte range. Reading N inner chunks could mean N separate byte-range requests.
+Because byte ranges are intervals, nearby ranges can be merged: `[a, b)` and
+`[b, c)` together cover `[a, c)`, so a single request can serve both. Merging
+trades reading some bytes you did not ask for against issuing fewer requests --
+worthwhile whenever a request is expensive, as with object storage.
+
+## What it shows
+
+- Reading scattered inner chunks from one shard with coalescing **off** issues
+ one store request per inner chunk; with the **default** settings the same read
+ collapses to a single request.
+- The resulting wall-clock difference against a store with simulated latency.
+- That coalescing changes only *how* data is fetched, never *what* is returned --
+ the script asserts both configurations produce identical arrays.
+- A case where coalescing changes nothing: a contiguous selection already has
+ adjacent byte ranges, so it merges under any setting.
+
+## Running
+
+```bash
+uv run sharding_coalescing.py
+```
+
+## How the comparison is set up
+
+Two details make the effect observable, and both are worth understanding if you
+adapt this script:
+
+- **The selection must have gaps.** A contiguous read produces adjacent byte
+ ranges that merge regardless of configuration. The strided selections skip
+ inner chunks, creating the gaps that the `sharding_coalesce_max_gap_bytes`
+ budget decides whether to bridge.
+- **Latency must be charged per merged fetch.** The example defines a small
+ `WrapperStore` subclass that sleeps in `get`. It deliberately does *not* keep
+ `WrapperStore.get_ranges`, which forwards straight to the wrapped store and
+ would bypass the latency entirely; inheriting the `Store` ABC's `get_ranges`
+ instead runs the coalescer over its own `get`, so each merged fetch pays once.
+
+ `zarr.testing.store` ships a ready-made `LatencyStore`, but importing it pulls
+ in `pytest`. Defining the wrapper inline keeps the example runnable with only
+ `zarr` and `numpy` installed.
+
+## Configuration
+
+Two settings control the behavior, both settable globally via `zarr.config` or
+per array via `config=` on `zarr.create_array` / `Array.with_config`:
+
+| Setting | Default | Meaning |
+| --- | --- | --- |
+| `sharding_coalesce_max_gap_bytes` | 1 MiB | Merge two ranges only if the gap between them is no larger than this |
+| `sharding_coalesce_max_bytes` | 16 MiB | Never let a merged read exceed this size |
+
+Setting the gap to `0` merges only exactly-adjacent ranges, which approximates
+the pre-3.3.0 behavior; that is how the example emulates the old path. Raising
+the gap reads more unwanted bytes in exchange for fewer round trips -- the right
+value depends on how expensive a request is against how fast your link is.
diff --git a/examples/sharding_coalescing/sharding_coalescing.py b/examples/sharding_coalescing/sharding_coalescing.py
new file mode 100644
index 0000000000..5da62ed806
--- /dev/null
+++ b/examples/sharding_coalescing/sharding_coalescing.py
@@ -0,0 +1,226 @@
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+# "zarr @ git+https://github.com/zarr-developers/zarr-python.git@main",
+# "numpy",
+# ]
+# ///
+
+"""
+Demonstrate byte-range coalescing for partial reads of sharded arrays.
+
+A shard is a single stored object holding many inner chunks, each occupying
+its own byte range. Reading N inner chunks could mean issuing N separate
+byte-range requests to the store. Because byte ranges are intervals, a reader
+can instead merge nearby ranges: `[a, b)` and `[b, c)` together cover
+`[a, c)`, so one request can serve both. Merging trades reading some bytes you
+did not ask for against issuing fewer requests -- a good trade whenever a
+request is expensive, which is the normal case for object storage.
+
+Zarr-Python 3.3.0 does this automatically. Two settings control it:
+
+ * `sharding_coalesce_max_gap_bytes` (default 1 MiB) -- merge two ranges only
+ if the gap between them is no larger than this.
+ * `sharding_coalesce_max_bytes` (default 16 MiB) -- never let a merged read
+ exceed this size.
+
+Setting the gap to 0 disables merging of non-adjacent ranges, which
+approximates the pre-3.3.0 behavior. This script compares the two, counting
+store requests and measuring wall-clock time against a store with simulated
+latency.
+
+Run it with:
+
+ uv run sharding_coalescing.py
+"""
+
+from __future__ import annotations
+
+import asyncio
+import operator
+import statistics
+import timeit
+from contextlib import contextmanager
+from functools import partial
+from typing import TYPE_CHECKING
+
+import numpy as np
+
+import zarr
+import zarr.core._coalesce as coalesce_module
+from zarr.abc.store import ByteRequest, RangeByteRequest, Store
+from zarr.storage import MemoryStore, WrapperStore
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterator, Sequence
+
+ from zarr.core.buffer import Buffer, BufferPrototype
+
+# Emulates the pre-3.3.0 behavior: with a zero gap budget, only ranges that are
+# exactly adjacent get merged, so scattered inner chunks are fetched one by one.
+NO_COALESCING = {"sharding_coalesce_max_gap_bytes": 0}
+
+# The shipped defaults. Spelled out here so the comparison is explicit rather
+# than relying on whatever the global config happens to be.
+DEFAULT_COALESCING = {
+ "sharding_coalesce_max_gap_bytes": 1 << 20, # 1 MiB
+ "sharding_coalesce_max_bytes": 16 << 20, # 16 MiB
+}
+
+GET_LATENCY_S = 0.005 # 5 ms per request, a modest stand-in for object storage
+
+
+class PerRequestLatencyStore(WrapperStore[Store]):
+ """Wraps a store, charging a fixed latency per byte-range fetch.
+
+ `zarr.testing.store` ships a `LatencyStore`, but importing it pulls in
+ `pytest`; defining the wrapper here keeps this example runnable with only
+ zarr and numpy installed.
+
+ Two details matter for the measurement:
+
+ * The latency is applied in `get`, which is what an individual fetch costs.
+ * `get_ranges` is explicitly *not* overridden to forward to the wrapped
+ store. `WrapperStore.get_ranges` does forward, which would skip this
+ class's `get` entirely and make every configuration look identical.
+ Inheriting the `Store` ABC's implementation instead runs the coalescer
+ over `self.get`, so each *merged* fetch pays the latency once -- which is
+ exactly the cost coalescing exists to reduce.
+ """
+
+ get_ranges = Store.get_ranges
+
+ def __init__(self, store: Store, *, get_latency: float) -> None:
+ super().__init__(store)
+ self.get_latency = get_latency
+
+ def _with_store(self, store: Store) -> PerRequestLatencyStore:
+ # `WrapperStore` rebuilds the wrapper when opening read-only, so the
+ # latency setting has to be carried across.
+ return type(self)(store, get_latency=self.get_latency)
+
+ async def get(
+ self,
+ key: str,
+ prototype: BufferPrototype,
+ byte_range: ByteRequest | None = None,
+ ) -> Buffer | None:
+ await asyncio.sleep(self.get_latency)
+ return await self._store.get(key, prototype, byte_range)
+
+
+@contextmanager
+def counting_requests() -> Iterator[Callable[[], int]]:
+ """Count the store fetches issued inside the block.
+
+ Wraps the coalescing planner rather than the store: every merged group it
+ returns, plus every range it declined to merge, becomes exactly one fetch.
+ Counting here rather than at the store means the number reported is the
+ planner's decision, which is precisely what the settings control.
+ """
+ original = coalesce_module.coalesce_ranges
+ total = 0
+
+ def counting_coalesce_ranges(
+ byte_ranges: Sequence[ByteRequest | None],
+ *,
+ max_gap_bytes: int,
+ max_coalesced_bytes: int,
+ ) -> tuple[
+ list[list[tuple[int, RangeByteRequest]]],
+ list[tuple[int, ByteRequest | None]],
+ ]:
+ nonlocal total
+ groups, uncoalescable = original(
+ byte_ranges,
+ max_gap_bytes=max_gap_bytes,
+ max_coalesced_bytes=max_coalesced_bytes,
+ )
+ total += len(groups) + len(uncoalescable)
+ return groups, uncoalescable
+
+ coalesce_module.coalesce_ranges = counting_coalesce_ranges
+ try:
+ yield lambda: total
+ finally:
+ coalesce_module.coalesce_ranges = original
+
+
+def measure_read(array: zarr.Array, selection: slice) -> tuple[int, float]:
+ """Return (store fetches, median seconds) for reading `selection`."""
+ read = partial(operator.getitem, array, selection)
+
+ with counting_requests() as fetches:
+ result = read()
+ requests = fetches()
+
+ # `timeit.Timer` supplies the loop, `perf_counter`, and GC handling. The
+ # median of several runs keeps one unlucky run from dominating.
+ elapsed = statistics.median(timeit.Timer(read).repeat(repeat=5, number=1))
+
+ assert result.size > 0 # a read that returned nothing would time as "fast"
+ return requests, elapsed
+
+
+def main() -> None:
+ n = 8192
+ chunk = 64
+ inner_chunks = n // chunk
+
+ base = MemoryStore()
+ source = (np.arange(n, dtype="uint64") % 251).astype("uint8")
+
+ # One shard holding every inner chunk, uncompressed so inner-chunk byte
+ # offsets stay predictable and the demonstration is easy to reason about.
+ writable = zarr.create_array(
+ store=base, shape=(n,), chunks=(chunk,), shards=(n,), dtype="uint8", compressors=None
+ )
+ writable[:] = source
+
+ store = PerRequestLatencyStore(base, get_latency=GET_LATENCY_S)
+
+ print(f"zarr {zarr.__version__}")
+ print(f"array: {n} uint8 values, {inner_chunks} inner chunks of {chunk} in a single shard")
+ print(f"store: MemoryStore wrapped with {GET_LATENCY_S * 1000:.0f} ms of latency per request\n")
+
+ # A strided selection touches inner chunks with unread chunks in between,
+ # so there are real gaps for the coalescer to bridge. A contiguous
+ # selection would merge under any setting, since its ranges are adjacent.
+ selections = {
+ "every 2nd inner chunk": slice(None, None, chunk * 2),
+ "every 4th inner chunk": slice(None, None, chunk * 4),
+ "contiguous quarter": slice(0, n // 4),
+ }
+
+ header = f"{'selection':<24} {'coalescing':<12} {'requests':>9} {'time':>10}"
+ print(header)
+ print("-" * len(header))
+
+ for label, selection in selections.items():
+ results = {}
+ for mode, config in (("off", NO_COALESCING), ("default", DEFAULT_COALESCING)):
+ array = zarr.open_array(store=store, mode="r").with_config(config)
+ requests, elapsed = measure_read(array, selection)
+ results[mode] = (requests, elapsed)
+ print(f"{label:<24} {mode:<12} {requests:>9} {elapsed * 1000:>9.1f}ms")
+
+ off_requests, off_time = results["off"]
+ on_requests, on_time = results["default"]
+ if on_requests < off_requests:
+ print(
+ f"{'':<24} {'->':<12} "
+ f"{off_requests // on_requests:>8}x fewer {off_time / on_time:>9.1f}x faster"
+ )
+ else:
+ print(f"{'':<24} {'->':<12} {'no change (ranges already adjacent)':>30}")
+ print()
+
+ # Correctness is the point: coalescing must not change what you read back.
+ for mode, config in (("off", NO_COALESCING), ("default", DEFAULT_COALESCING)):
+ array = zarr.open_array(store=store, mode="r").with_config(config)
+ assert np.array_equal(array[::128], source[::128]), mode
+ print("Both configurations return identical data; coalescing only changes how it is fetched.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/lychee.toml b/lychee.toml
new file mode 100644
index 0000000000..54a5b49b8d
--- /dev/null
+++ b/lychee.toml
@@ -0,0 +1,25 @@
+# Configuration for the lychee link checker (https://lychee.cli.rs/).
+# Auto-discovered as ./lychee.toml by the lychee GitHub Action.
+
+# Treat redirect status codes as success rather than failures.
+accept = ["200..=299"]
+
+# Files lychee should not scan for links.
+exclude_path = [
+ # mkdocs-material theme overrides: hrefs are Jinja expressions like
+ # `{{ '../' ~ base_url }}`, not real URLs, so lychee cannot resolve them.
+ "docs/overrides",
+ # Design notes: working records that point at transient artifacts (commits,
+ # fork branches, compare URLs) which are expected to disappear over time.
+ "design",
+]
+
+# URL patterns to ignore (regex, matched against the full URL).
+exclude = [
+ # Local docs preview server shown in the contributing guide ("hatch run serve"),
+ # documentation of a command rather than a reachable link.
+ '^https?://0\.0\.0\.0',
+ '^https?://(localhost|127\.0\.0\.1)(:\d+)?',
+ # SPEC 0 page times out but is valid.
+ '^https://scientific-python\.org/specs/spec-0000',
+]
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000000..4d06701a87
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,301 @@
+# Based on https://github.com/developmentseed/obspec/blob/main/mkdocs.yml
+site_name: zarr-python
+repo_name: zarr-developers/zarr-python
+repo_url: https://github.com/zarr-developers/zarr-python
+site_description: An implementation of chunked, compressed, N-dimensional arrays for Python.
+site_author: Alistair Miles
+site_url: !ENV [READTHEDOCS_CANONICAL_URL, 'https://zarr.readthedocs.io/']
+docs_dir: docs
+use_directory_urls: true
+
+nav:
+ - "index.md"
+ - "quick-start.md"
+ - User Guide:
+ - user-guide/index.md
+ - user-guide/installation.md
+ - user-guide/arrays.md
+ - user-guide/groups.md
+ - user-guide/attributes.md
+ - user-guide/data_types.md
+ - user-guide/storage.md
+ - user-guide/config.md
+ - user-guide/performance.md
+ - user-guide/cli.md
+ - user-guide/extending.md
+ - user-guide/gpu.md
+ - user-guide/consolidated_metadata.md
+ - user-guide/experimental.md
+ - user-guide/v3_migration.md
+ - user-guide/glossary.md
+ - Examples:
+ - user-guide/examples/custom_dtype.md
+ - user-guide/examples/rectilinear_chunks.md
+ - user-guide/examples/codec_pipeline_performance.md
+ - user-guide/examples/sharding_coalescing.md
+ - subprojects.md
+ - API Reference:
+ - api/zarr/index.md
+ - 'zarr.abc':
+ - api/zarr/abc/index.md
+ - 'zarr.abc.buffer': api/zarr/abc/buffer.md
+ - 'zarr.abc.codec': api/zarr/abc/codec.md
+ - 'zarr.abc.metadata': api/zarr/abc/metadata.md
+ - 'zarr.abc.numcodec': api/zarr/abc/numcodec.md
+ - 'zarr.abc.store': api/zarr/abc/store.md
+ - 'zarr.api':
+ - api/zarr/api/index.md
+ - 'zarr.api.asynchronous': api/zarr/api/asynchronous.md
+ - 'zarr.api.synchronous': api/zarr/api/synchronous.md
+ - 'zarr.Array': api/zarr/array.md
+ - 'zarr.array': api/zarr/functions/array.md
+ - 'zarr.buffer':
+ - api/zarr/buffer/index.md
+ - 'zarr.buffer.cpu': api/zarr/buffer/cpu.md
+ - 'zarr.buffer.gpu': api/zarr/buffer/gpu.md
+ - 'zarr.codecs': api/zarr/codecs.md
+ - 'zarr.codecs.numcodecs': api/zarr/codecs/numcodecs.md
+ - 'zarr.config': api/zarr/config.md
+ - 'zarr.consolidate_metadata': api/zarr/functions/consolidate_metadata.md
+ - 'zarr.create': api/zarr/functions/create.md
+ - 'zarr.create_array': api/zarr/functions/create_array.md
+ - 'zarr.create_group': api/zarr/functions/create_group.md
+ - 'zarr.create_hierarchy': api/zarr/functions/create_hierarchy.md
+ - 'zarr.dtype': api/zarr/dtype.md
+ - 'zarr.empty': api/zarr/functions/empty.md
+ - 'zarr.empty_like': api/zarr/functions/empty_like.md
+ - 'zarr.errors': api/zarr/errors.md
+ - 'zarr.experimental': api/zarr/experimental.md
+ - 'zarr.from_array': api/zarr/functions/from_array.md
+ - 'zarr.full': api/zarr/functions/full.md
+ - 'zarr.full_like': api/zarr/functions/full_like.md
+ - 'zarr.Group': api/zarr/group.md
+ - 'zarr.group': api/zarr/functions/group.md
+ - 'zarr.load': api/zarr/functions/load.md
+ - 'zarr.metadata': api/zarr/metadata.md
+ - 'zarr.ones': api/zarr/functions/ones.md
+ - 'zarr.ones_like': api/zarr/functions/ones_like.md
+ - 'zarr.open': api/zarr/functions/open.md
+ - 'zarr.open_array': api/zarr/functions/open_array.md
+ - 'zarr.open_consolidated': api/zarr/functions/open_consolidated.md
+ - 'zarr.open_group': api/zarr/functions/open_group.md
+ - 'zarr.open_like': api/zarr/functions/open_like.md
+ - 'zarr.print_debug_info': api/zarr/functions/print_debug_info.md
+ - 'zarr.registry': api/zarr/registry.md
+ - 'zarr.save': api/zarr/functions/save.md
+ - 'zarr.save_array': api/zarr/functions/save_array.md
+ - 'zarr.save_group': api/zarr/functions/save_group.md
+ - 'zarr.storage': api/zarr/storage.md
+ - 'zarr.testing':
+ - api/zarr/testing/index.md
+ - 'zarr.testing.buffer': api/zarr/testing/buffer.md
+ - 'zarr.testing.stateful': api/zarr/testing/stateful.md
+ - 'zarr.testing.store': api/zarr/testing/store.md
+ - 'zarr.testing.strategies': api/zarr/testing/strategies.md
+ - 'zarr.testing.utils': api/zarr/testing/utils.md
+ - 'zarr.zeros': api/zarr/functions/zeros.md
+ - 'zarr.zeros_like': api/zarr/functions/zeros_like.md
+ # The companion packages are Read the Docs subprojects of this one; link
+ # to the /projects/ paths Read the Docs advertises as canonical rather
+ # than to their standalone *.readthedocs.io domains, so following one
+ # keeps the reader on this site's domain.
+ - 'zarr-metadata ↪': https://zarr.readthedocs.io/projects/zarr-metadata/
+ - 'zarr-indexing ↪': https://zarr.readthedocs.io/projects/zarr-indexing/
+ - release-notes.md
+ - roadmap.md
+ - contributing.md
+ - Blog:
+ - blog/index.md
+hooks:
+ - mkdocs_hooks.py
+
+watch:
+ - src/zarr
+ - docs
+
+theme:
+ language: en
+ name: material
+ custom_dir: docs/overrides
+ logo: _static/logo_bw.png
+ favicon: _static/favicon-96x96.png
+
+ palette:
+ # Light mode
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ primary: custom
+ accent: custom
+ toggle:
+ icon: material/brightness-7
+ name: Switch to dark mode
+
+ # Dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ primary: custom
+ accent: custom
+ toggle:
+ icon: material/brightness-4
+ name: Switch to light mode
+
+ font:
+ text: Roboto
+ code: Roboto Mono
+
+ features:
+ - content.code.annotate
+ - content.code.copy
+ - navigation.indexes
+ - navigation.instant
+ - navigation.tracking
+ - search.suggest
+ - search.share
+
+extra:
+ social:
+ - icon: fontawesome/brands/mastodon
+ link: https://fosstodon.org/@zarr
+ - icon: fontawesome/brands/bluesky
+ link: https://bsky.app/profile/zarr.dev
+
+extra_css:
+ - overrides/stylesheets/extra.css
+
+plugins:
+ - autorefs
+ - blog:
+ blog_dir: blog
+ post_dir: "{blog}/posts"
+ post_url_format: "{slug}"
+ # The blog is a simple reverse-chronological list of posts; the archive
+ # and category indexes add navigation we don't have the volume to justify.
+ archive: false
+ categories: false
+ - search
+ - markdown-exec
+ - mkdocstrings:
+ enable_inventory: true
+ handlers:
+ python:
+ paths: [src/zarr]
+ options:
+ allow_inspection: true
+ docstring_section_style: list
+ docstring_style: numpy
+ inherited_members: true
+ line_length: 60
+ separate_signature: true
+ show_root_heading: true
+ show_signature_annotations: true
+ show_source: true
+ show_symbol_type_toc: true
+ signature_crossrefs: true
+ show_if_no_docstring: true
+ extensions:
+ - griffe_inherited_docstrings
+
+ inventories:
+ - https://docs.python.org/3/objects.inv
+ - https://docs.xarray.dev/en/stable/objects.inv
+ - https://numpy.org/doc/stable/objects.inv
+ - https://numcodecs.readthedocs.io/en/stable/objects.inv
+ - https://msgspec.dev/objects.inv
+ - https://developmentseed.org/obstore/latest/objects.inv
+ - https://filesystem-spec.readthedocs.io/en/latest/objects.inv
+ - https://requests.readthedocs.io/en/latest/objects.inv
+ - https://docs.aiohttp.org/en/stable/objects.inv
+ - https://s3fs.readthedocs.io/en/latest/objects.inv
+ - https://docs.h5py.org/en/stable/objects.inv
+ - https://icechunk.io/en/stable/objects.inv
+ - https://lithops-cloud.github.io/docs/objects.inv
+ - https://docs.dask.org/en/stable/objects.inv
+ - redirects:
+ redirect_maps:
+ 'spec/index.md': 'https://zarr-specs.readthedocs.io'
+ 'spec/v1.md': 'https://zarr-specs.readthedocs.io/en/latest/v1/v1.0.html'
+ 'spec/v2.md': 'https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html'
+ 'spec/v3.md': 'https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html'
+ 'license.md': 'https://github.com/zarr-developers/zarr-python/blob/main/LICENSE.txt'
+ 'genindex.html.md': 'index.md'
+ 'py-modindex.html.md': 'index.md'
+ 'search.html.md': 'index.md'
+ 'tutorial.md': 'user-guide/installation.md'
+ 'getting-started.md': 'quick-start.md'
+ 'installation.md': 'user-guide/installation.md'
+ 'release.md': 'release-notes.md'
+ 'about.html.md': 'index.md'
+ 'arrays.html.md': 'user-guide/arrays.md'
+ 'attributes.html.md': 'user-guide/attributes.md'
+ 'cli.html.md': 'user-guide/cli.md'
+ 'config.html.md': 'user-guide/config.md'
+ 'consolidated_metadata.html.md': 'user-guide/consolidated_metadata.md'
+ 'data_types.html.md': 'user-guide/data_types.md'
+ 'extending.html.md': 'user-guide/extending.md'
+ 'gpu.html.md': 'user-guide/gpu.md'
+ 'groups.html.md': 'user-guide/groups.md'
+ 'installation.html.md': 'user-guide/installation.md'
+ 'performance.html.md': 'user-guide/performance.md'
+ 'quickstart.html.md': 'quick-start.md'
+ 'release-notes.html.md': 'release-notes.md'
+ 'storage.html.md': 'user-guide/storage.md'
+ 'v3_migration.html.md': 'user-guide/v3_migration.md'
+ 'user-guide/arrays.html.md': 'user-guide/arrays.md'
+ 'user-guide/attributes.html.md': 'user-guide/attributes.md'
+ 'user-guide/cli.html.md': 'user-guide/cli.md'
+ 'user-guide/config.html.md': 'user-guide/config.md'
+ 'user-guide/consolidated_metadata.html.md': 'user-guide/consolidated_metadata.md'
+ 'user-guide/data_types.html.md': 'user-guide/data_types.md'
+ 'user-guide/extending.html.md': 'user-guide/extending.md'
+ 'user-guide/gpu.html.md': 'user-guide/gpu.md'
+ 'user-guide/groups.html.md': 'user-guide/groups.md'
+ 'user-guide/installation.html.md': 'user-guide/installation.md'
+ 'user-guide/performance.html.md': 'user-guide/performance.md'
+ 'user-guide/storage.html.md': 'user-guide/storage.md'
+ 'user-guide/v3_migration.html.md': 'user-guide/v3_migration.md'
+ 'developers/contributing.html.md': 'contributing.md'
+ 'developers/index.html.md': 'contributing.md'
+ 'developers/roadmap.html.md': 'https://zarr.readthedocs.io/en/v3.0.8/developers/roadmap.html'
+ 'api.md': 'api/zarr/index.md'
+ 'api/zarr/create.md': 'api/zarr/functions/create.md'
+ 'api/zarr/open.md': 'api/zarr/functions/open.md'
+ 'api/zarr/save.md': 'api/zarr/functions/save.md'
+ 'api/zarr/load.md': 'api/zarr/functions/load.md'
+ 'api/zarr/metadata/migrate_v3.md': 'api/zarr/metadata.md'
+
+# Based on https://github.com/developmentseed/titiler/blob/50934c929cca2fa8d3c408d239015f8da429c6a8/docs/mkdocs.yml#L115-L140
+markdown_extensions:
+ - admonition
+ - attr_list
+ - codehilite:
+ guess_lang: false
+ - def_list
+ - footnotes
+ - md_in_html
+ - pymdownx.arithmatex
+ - pymdownx.betterem
+ - pymdownx.caret:
+ insert: false
+ - pymdownx.details
+ - pymdownx.escapeall:
+ hardbreak: true
+ nbsp: true
+ - pymdownx.magiclink:
+ hide_protocol: true
+ repo_url_shortener: true
+ - pymdownx.smartsymbols
+ - pymdownx.superfences
+ - pymdownx.tasklist:
+ custom_checkbox: true
+ - pymdownx.tilde
+ - pymdownx.emoji:
+ emoji_index: !!python/name:material.extensions.emoji.twemoji
+ emoji_generator: !!python/name:material.extensions.emoji.to_svg
+ - toc:
+ permalink: true
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ - pymdownx.inlinehilite
+ - pymdownx.snippets
diff --git a/mkdocs_hooks.py b/mkdocs_hooks.py
new file mode 100644
index 0000000000..88368926b9
--- /dev/null
+++ b/mkdocs_hooks.py
@@ -0,0 +1,82 @@
+"""MkDocs hook that renders validation-marked code fences as ordinary code blocks.
+
+The docs validation convention (see ``tests/test_docs.py`` and the contributing
+guide) requires every python fence to carry ``exec="true"``, ``test="true"``, or
+``exec="false" reason="..."``. Markdown Exec's superfences fence only claims
+``exec="true"`` blocks; without this hook the remaining marked fences fail
+superfences validation and their contents spill into the page as raw markdown
+(e.g. the PEP 723 header of the custom dtype example rendered as headings).
+
+This hook registers a second ``python`` fence, tried when Markdown Exec's
+declines, that strips the validation attributes and delegates to the standard
+superfences highlighter so the block renders exactly like a plain code fence.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from markdown import Markdown
+ from mkdocs.config.defaults import MkDocsConfig
+
+# Mirrors markdown_exec's _to_bool: everything but these means "true".
+_FALSY = {"", "no", "off", "false", "0"}
+
+
+def _validator(
+ language: str,
+ inputs: dict[str, str],
+ options: dict[str, Any],
+ attrs: dict[str, Any],
+ md: Markdown,
+) -> bool:
+ """Claim fences marked test="true" or exec="false"; leave the rest alone."""
+ if "exec" not in inputs and "test" not in inputs:
+ # Plain fence: let the default superfences pathway highlight it.
+ return False
+ if str(inputs.get("exec", "false")).lower() not in _FALSY:
+ # Executable fence: Markdown Exec's own custom fence handles it.
+ return False
+ # Consume the validation attributes so they don't leak into the output.
+ inputs.clear()
+ return True
+
+
+def _formatter(
+ source: str,
+ language: str,
+ css_class: str,
+ options: dict[str, Any],
+ md: Markdown,
+ classes: list[str] | None = None,
+ id_value: str = "",
+ attrs: dict[str, Any] | None = None,
+ **kwargs: Any,
+) -> str:
+ """Render with the same highlighter superfences uses for plain fences."""
+ fenced = md.preprocessors["fenced_code_block"]
+ fenced.get_hl_settings()
+ return fenced.highlight(
+ src=source,
+ language=language,
+ options={},
+ md=md,
+ classes=classes,
+ id_value=id_value,
+ attrs=attrs or {},
+ )
+
+
+def on_config(config: MkDocsConfig) -> MkDocsConfig:
+ superfences = config.setdefault("mdx_configs", {}).setdefault("pymdownx.superfences", {})
+ custom_fences = superfences.setdefault("custom_fences", [])
+ custom_fences.append(
+ {
+ "name": "python",
+ "class": "python",
+ "validator": _validator,
+ "format": _formatter,
+ }
+ )
+ return config
diff --git a/packages/zarr-http-server/.readthedocs.yaml b/packages/zarr-http-server/.readthedocs.yaml
new file mode 100644
index 0000000000..efbda6852d
--- /dev/null
+++ b/packages/zarr-http-server/.readthedocs.yaml
@@ -0,0 +1,43 @@
+# Read the Docs configuration for the zarr-http-server docs site, separate from
+# the zarr-python site configured by the repo-root .readthedocs.yaml. The RTD
+# project for zarr-http-server must set its configuration-file path to
+# packages/zarr-http-server/.readthedocs.yaml.
+version: 2
+
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.12"
+ jobs:
+ post_checkout:
+ # Cancel pull request builds that do not touch this package. Exit code
+ # 183 cancels the build and reports success to the Git provider. Scoped
+ # to PR builds ("external" versions) because origin/main is only a
+ # meaningful diff base there.
+ - |
+ if [ "$READTHEDOCS_VERSION_TYPE" = "external" ] && git diff --quiet origin/main -- packages/zarr-http-server;
+ then
+ exit 183;
+ fi
+ install:
+ - pip install --upgrade pip
+ - pip install ./packages/zarr-http-server --group packages/zarr-http-server/pyproject.toml:docs
+ build:
+ html:
+ # Build from inside the package rather than pointing `-f` at its config
+ # from the repo root. mkdocs resolves some settings relative to the
+ # current working directory rather than to the config file, so building
+ # from elsewhere looks for them in the wrong place -- and silently, since
+ # the paths are valid, just wrong. zarr-indexing hit this: with
+ # `pymdownx.snippets` and a relative `base_path`, its snippets were
+ # searched for under the repo-root docs/ and the build failed with
+ # SnippetMissingError, while `just docs-check` passed because it runs
+ # from here. Building from the package directory makes this identical to
+ # the local and CI invocations, so a green build there means a green
+ # build here.
+ #
+ # $READTHEDOCS_OUTPUT is absolute, so the cd does not affect it.
+ - cd packages/zarr-http-server && mkdocs build --strict --site-dir $READTHEDOCS_OUTPUT/html
+
+mkdocs:
+ configuration: packages/zarr-http-server/mkdocs.yml
diff --git a/packages/zarr-http-server/CHANGELOG.md b/packages/zarr-http-server/CHANGELOG.md
new file mode 100644
index 0000000000..7c4bc92cad
--- /dev/null
+++ b/packages/zarr-http-server/CHANGELOG.md
@@ -0,0 +1,3 @@
+# Release notes
+
+
diff --git a/packages/zarr-http-server/LICENSE.txt b/packages/zarr-http-server/LICENSE.txt
new file mode 100644
index 0000000000..1e8da4d242
--- /dev/null
+++ b/packages/zarr-http-server/LICENSE.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015-2025 Zarr Developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/zarr-http-server/README.md b/packages/zarr-http-server/README.md
new file mode 100644
index 0000000000..53df0d2f50
--- /dev/null
+++ b/packages/zarr-http-server/README.md
@@ -0,0 +1,71 @@
+# zarr-http-server
+
+HTTP server for Zarr stores, arrays, and groups.
+
+Documentation:
+
+`zarr-http-server` exposes a Zarr `Store`, `Array`, or `Group` over HTTP via an
+ASGI app, so any HTTP-capable client — including zarr-python itself, via
+`FsspecStore` or `ObjectStore` — can read the data. The app is built on
+[Starlette](https://www.starlette.io/) and can be run with any ASGI server;
+the `serve` / `serve_background` helpers run it with
+[Uvicorn](https://uvicorn.dev/).
+
+> [!WARNING]
+> This package is experimental. Its API may change or be removed at any point.
+
+## Installation
+
+```bash
+pip install zarr-http-server
+```
+
+## Quick start
+
+```python
+import zarr
+from zarr_http_server import node_app, serve_background
+
+store = zarr.storage.MemoryStore()
+array = zarr.create_array(store, shape=(100,), chunks=(10,), dtype="float64")
+
+with serve_background(node_app(array)) as server:
+ print(server.url) # e.g. http://127.0.0.1:8000
+```
+
+Building an app and running it are separate steps, and either app works with
+either runner:
+
+- **Build** with `store_app` to serve every key in a store, or `node_app` to
+ serve only the keys belonging to one `Array` or `Group` — requests for keys
+ outside that node return 404 even when those keys exist in the underlying
+ store.
+- **Run** with `serve`, which blocks, or `serve_background`, which returns a
+ handle you can shut down later.
+
+Reads are all that is enabled by default: `GET` and `HEAD` are served, and
+`PUT`, `POST`, `DELETE` and `PATCH` are answered with 405.
+
+> [!CAUTION]
+> `store_app` applies no per-key filtering. Only point it at a store whose full
+> contents are safe to serve, and note that enabling `PUT` grants write access
+> to everything the store contains. The
+> [user guide](https://zarr-http-server.readthedocs.io/en/latest/guide/#read-only-serving)
+> covers the read-only guarantees available.
+
+The [user guide](https://zarr-http-server.readthedocs.io/en/latest/guide/)
+covers byte ranges, CORS, writes, serving several nodes, running from a
+notebook, and Uvicorn configuration.
+
+## Examples
+
+[`examples/`](examples/) holds a runnable script and a notebook, both executed
+by the test suite so neither can drift from the code:
+
+```bash
+uv run examples/serve.py
+```
+
+## License
+
+MIT — see [LICENSE.txt](LICENSE.txt).
diff --git a/packages/zarr-http-server/changes/3732.feature.md b/packages/zarr-http-server/changes/3732.feature.md
new file mode 100644
index 0000000000..accd91ab81
--- /dev/null
+++ b/packages/zarr-http-server/changes/3732.feature.md
@@ -0,0 +1,3 @@
+Initial release of `zarr-http-server`: an HTTP server exposing Zarr stores,
+arrays, and groups over an ASGI app, extracted from the
+`zarr.experimental.serve` prototype in zarr-python.
diff --git a/packages/zarr-http-server/changes/README.md b/packages/zarr-http-server/changes/README.md
new file mode 100644
index 0000000000..80f0be782c
--- /dev/null
+++ b/packages/zarr-http-server/changes/README.md
@@ -0,0 +1,25 @@
+Writing a changelog entry for `zarr-http-server`
+---------------------------------------------
+
+Fragments in **this** directory are released notes for the `zarr-http-server`
+package only — kept separate from the parent zarr-python `changes/`
+directory so a PR touching only `packages/zarr-http-server/` produces a
+release note for this package only.
+
+Please put a new file in this directory named `xxxx..md`, where
+
+- `xxxx` is the pull request number associated with this entry
+- `` is one of:
+ - feature
+ - bugfix
+ - doc
+ - removal
+ - misc
+
+Inside the file, please write a short description of what you have
+changed, and how it impacts users of `zarr-http-server`.
+
+A `zarr-http-server` release runs `towncrier build` in `packages/zarr-http-server/`,
+which consumes the fragments here and updates `CHANGELOG.md`. Fragments
+that describe parent zarr-python changes (not the server package)
+belong in the top-level `changes/` directory, not here.
diff --git a/packages/zarr-http-server/docs/_static/favicon-96x96.png b/packages/zarr-http-server/docs/_static/favicon-96x96.png
new file mode 100644
index 0000000000..e77977ccf4
Binary files /dev/null and b/packages/zarr-http-server/docs/_static/favicon-96x96.png differ
diff --git a/packages/zarr-http-server/docs/_static/logo_bw.png b/packages/zarr-http-server/docs/_static/logo_bw.png
new file mode 100644
index 0000000000..df1979d3cc
Binary files /dev/null and b/packages/zarr-http-server/docs/_static/logo_bw.png differ
diff --git a/packages/zarr-http-server/docs/api/index.md b/packages/zarr-http-server/docs/api/index.md
new file mode 100644
index 0000000000..ce5d748b26
--- /dev/null
+++ b/packages/zarr-http-server/docs/api/index.md
@@ -0,0 +1,41 @@
+---
+title: API reference
+---
+
+# API reference
+
+Everything public is re-exported from the top-level `zarr_http_server`
+namespace; the private `_serve` and `_keys` modules are implementation detail
+and carry no compatibility guarantee.
+
+## Building an app
+
+::: zarr_http_server.store_app
+
+::: zarr_http_server.node_app
+
+## Running a server
+
+::: zarr_http_server.serve
+
+::: zarr_http_server.serve_background
+
+::: zarr_http_server.BackgroundServer
+
+## Configuration
+
+::: zarr_http_server.CorsOptions
+
+::: zarr_http_server.HTTPMethod
+
+::: zarr_http_server.ReadOnlyHTTPMethod
+
+::: zarr_http_server.READ_ONLY_HTTP_METHODS
+
+::: zarr_http_server.READ_WRITE_HTTP_METHODS
+
+::: zarr_http_server.AUTO_PORT
+
+::: zarr_http_server.DEFAULT_PORT
+
+::: zarr_http_server.DEFAULT_MAX_BODY_SIZE
diff --git a/packages/zarr-http-server/docs/guide.md b/packages/zarr-http-server/docs/guide.md
new file mode 100644
index 0000000000..d52d5b882d
--- /dev/null
+++ b/packages/zarr-http-server/docs/guide.md
@@ -0,0 +1,393 @@
+---
+title: User guide
+---
+
+# User guide
+
+## Building an ASGI app
+
+[`store_app`][zarr_http_server.store_app] creates an ASGI app that exposes
+every key in a store. Only point it at a store whose full contents are safe to
+serve publicly — it grants read (and, if `PUT` is enabled, write) access to
+everything the store contains, with no per-key filtering:
+
+```python
+import zarr
+from zarr_http_server import store_app
+
+store = zarr.storage.MemoryStore()
+zarr.create_array(store, shape=(100, 100), chunks=(10, 10), dtype="float64")
+
+app = store_app(store)
+
+# Run with any ASGI server, e.g. Uvicorn:
+# uvicorn my_module:app --host 0.0.0.0 --port 8000
+```
+
+[`node_app`][zarr_http_server.node_app] creates an ASGI app that only serves
+keys belonging to a specific `Array` or `Group`. Requests for keys outside the
+node receive a 404, even if those keys exist in the underlying store:
+
+```python
+import zarr
+from zarr_http_server import node_app
+
+store = zarr.storage.MemoryStore()
+root = zarr.open_group(store)
+root.create_array("a", shape=(10,), dtype="int32")
+root.create_array("b", shape=(20,), dtype="float64")
+
+# Only serve the array at "a" — requests for "b" will return 404.
+app = node_app(root["a"])
+```
+
+## Running the server
+
+Build an app, then run it. Either app works with either runner.
+
+[`serve`][zarr_http_server.serve] blocks until the server is stopped, which is
+the shape for a script or a container entrypoint:
+
+```python
+from zarr_http_server import serve, store_app
+
+serve(store_app(store), host="127.0.0.1", port=8000)
+```
+
+[`serve_background`][zarr_http_server.serve_background] instead starts the
+server in a daemon thread and returns a
+[`BackgroundServer`][zarr_http_server.BackgroundServer] as soon as the socket
+is listening, so the caller can carry on. These are two functions rather than
+one with a flag, because they differ in the only thing that matters at a call
+site: whether control comes back.
+
+### Choosing a port
+
+Both default to `port="auto"`, which prefers port 8000 but falls back to any
+free port if it is taken, reporting the result through `server.url` and
+Uvicorn's startup line. An **explicit** port means the opposite — bind exactly
+that or fail — because a caller who names one usually has a proxy or a
+container port mapping expecting the server there, and silently moving would
+break it while looking healthy. `port=0` keeps its usual meaning of "any free
+port, no preference".
+
+### Reading back with a zarr client
+
+`BackgroundServer` is a context manager, so the server stops when the block
+exits. The example below also *reads back* over HTTP, which is a client-side
+concern: `zarr.open_array(server.url)` goes through `FsspecStore`, which needs
+an HTTP-capable fsspec that `zarr-http-server` does not pull in
+(`pip install "fsspec[http]"`).
+
+```python
+import numpy as np
+import zarr
+from zarr.storage import MemoryStore
+
+from zarr_http_server import node_app, serve_background
+
+store = MemoryStore()
+arr = zarr.create_array(store, shape=(100,), chunks=(10,), dtype="float64")
+arr[:] = np.arange(100, dtype="float64")
+
+with serve_background(node_app(arr), host="127.0.0.1") as server:
+ remote = zarr.open_array(server.url, mode="r")
+ np.testing.assert_array_equal(remote[:], arr[:])
+# Server is shut down automatically when the block exits.
+```
+
+### Shutting down
+
+`BackgroundServer.shutdown()` — and leaving the `with` block — waits up to
+`shutdown_timeout` seconds, 5 by default, for in-flight requests to finish
+before forcing the server closed. It raises if the thread will not stop, so a
+silent failure cannot leave you believing the port is free when it is not.
+
+```python
+with serve_background(node_app(arr), shutdown_timeout=30) as server:
+ ...
+```
+
+## Serving several nodes
+
+Serving two arrays does not mean running two servers. Which approach fits
+depends on where the arrays live.
+
+If they share a parent group, serve the parent — `node_app` recurses through
+its members, so both are reachable under one port and node scoping still
+applies to everything outside it:
+
+```python
+server = serve_background(node_app(root))
+# -> /a/zarr.json, /a/c/0, /b/zarr.json, ...
+```
+
+If everything in the store is safe to expose, `store_app(store)` does the same
+for the whole key space.
+
+Otherwise — arrays in *different* stores, or nodes that are not siblings —
+`store_app` and `node_app` return plain Starlette apps, so mount them and run
+the result:
+
+```python
+from starlette.applications import Starlette
+from starlette.routing import Mount
+
+from zarr_http_server import node_app, serve_background
+
+app = Starlette(routes=[
+ Mount("/first", app=node_app(one)),
+ Mount("/second", app=node_app(other)),
+])
+server = serve_background(app)
+```
+
+Each mount keeps its own validation, so a request under one cannot reach
+another's data — `/first/../second/zarr.json` and its percent-encoded spellings
+all return 404.
+
+Both runners take any ASGI app, so the split is clean: what an app *serves*
+(`methods`, `cors_options`, `max_body_size`) is settled when the app is built,
+while `serve` / `serve_background` only decide how it runs (`host`, `port`,
+`shutdown_timeout`, `uvicorn_options`).
+
+## Serving from a notebook
+
+A notebook needs a server that outlives the cell that started it, so the `with`
+form above is the wrong shape — it shuts the server down as soon as the block
+ends. Start it, keep the handle, and stop it later:
+
+```python
+# cell 1 — start
+server = serve_background(node_app(array), host="127.0.0.1")
+print(server.url) # e.g. http://127.0.0.1:54635
+
+# cell 2..n — use it, across as many cells as you like
+httpx.get(f"{server.url}/zarr.json")
+
+# last cell — stop
+server.shutdown()
+```
+
+Two things make this comfortable in a kernel you re-run. `serve_background`
+runs Uvicorn in a daemon thread with its own event loop, so it never touches
+the kernel's loop and cannot block it. And its default `port="auto"` falls back
+to a free port when 8000 is taken — re-running a start cell without stopping
+the previous server is the classic notebook mistake, and a fixed port fails
+there with *address already in use*. `server.url` reports the port actually
+bound.
+
+If you forget to stop one, the thread is a daemon, so restarting the kernel
+always clears it — and since each start takes a fresh port, a forgotten server
+does not block the next one.
+
+[`examples/serve_notebook.ipynb`](https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-http-server/examples/serve_notebook.ipynb)
+is a runnable version of this. It is executed by the test suite, so it cannot
+drift from the code.
+
+## Uvicorn configuration
+
+`serve` and `serve_background` name the options most callers need — `host`,
+`port`, `shutdown_timeout` — and forward anything else to `uvicorn.Config`
+through `uvicorn_options`, so nothing Uvicorn can do is out of reach:
+
+```python
+server = serve_background(
+ store_app(store),
+ host="0.0.0.0",
+ port=8443,
+ uvicorn_options={
+ "ssl_keyfile": "key.pem",
+ "ssl_certfile": "cert.pem",
+ "proxy_headers": True,
+ "forwarded_allow_ips": "10.0.0.0/8",
+ "log_level": "warning",
+ },
+)
+```
+
+Keys you pass are merged over the ones set for you, so they win. `server.url`
+reflects the scheme actually in use (`https` when TLS is configured) and is
+`None` when the server is not bound to a TCP host and port — a `uds` or `fd`
+bind has no URL to report.
+
+## CORS
+
+Both app builders accept a [`CorsOptions`][zarr_http_server.CorsOptions]
+parameter to enable
+[CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) middleware for
+browser-based clients:
+
+```python
+from zarr_http_server import CorsOptions, store_app
+
+app = store_app(
+ store,
+ cors_options=CorsOptions(
+ allow_origins=["*"],
+ allow_methods=["GET"],
+ ),
+)
+```
+
+`CorsOptions` carries every parameter Starlette's `CORSMiddleware` accepts —
+`allow_headers`, `allow_credentials`, `allow_origin_regex`,
+`allow_private_network`, `expose_headers` and `max_age` as well as the two
+above — so configuring CORS never means reaching around this package. All keys
+are optional.
+
+Two defaults differ from Starlette's, because the server knows things the
+caller should not have to. It emits `Content-Range` on every ranged response,
+which is *not* a CORS-safelisted response header, so `expose_headers` defaults
+to `["Content-Range"]` — otherwise a browser client can read the bytes but not
+learn which bytes it got. And it accepts a `Range` request header, so
+`allow_headers` defaults to `["Range"]` — otherwise a preflight naming `Range`
+is rejected. A key you supply replaces the default outright, so
+`expose_headers=[]` means "expose nothing".
+
+`allow_methods` is checked against what the app actually serves: advertising a
+method the route rejects raises `ValueError`, and `"*"` expands to what is
+served rather than to every verb Starlette knows.
+
+## HTTP range requests
+
+The server supports the standard `Range` header for partial reads. The three
+forms defined by [RFC 9110](https://httpwg.org/specs/rfc9110.html#field.range)
+are supported:
+
+| Header | Meaning |
+| ------------ | ------------------------ |
+| `bytes=0-99` | First 100 bytes |
+| `bytes=100-` | Everything from byte 100 |
+| `bytes=-50` | Last 50 bytes |
+
+A successful range request returns HTTP 206 (Partial Content) with a
+`Content-Range` header, including for suffix ranges — the server resolves
+`bytes=-50` against the object's size so the response says which bytes it
+carries.
+
+A range that is well-formed but names nothing readable — one lying wholly
+beyond the end of the object, an inverted one such as `bytes=5-2`, or
+`bytes=-0` — returns 416 (Range Not Satisfiable). A last-byte-position past the
+end of the object is *not* in that category: per RFC 9110 §14.1.2 it is
+clamped, so `bytes=0-999999` on a short object returns the whole thing.
+
+A `Range` header the server cannot use is **ignored** rather than refused, per
+RFC 9110 §14.2: an unrecognized unit (`chars=0-7`), a multi-range request
+(`bytes=0-7, 10-20`, which this server does not build multipart responses for),
+or malformed syntax all return 200 with the full representation.
+
+## Read-only serving
+
+Read-only is the default. `store_app(store)` and `node_app(node)` accept `GET`
+and `HEAD` and answer **405** to `PUT`, `POST`, `DELETE` and `PATCH` — no
+argument is needed to get there. `HEAD` is served wherever `GET` is, as RFC
+9110 §9.3.2 asks of every origin server, and is answered from the value's size
+without transferring it.
+
+`POST` is not merely unrouted, it is unconfigurable: the accepted methods are
+`GET`, `HEAD` and `PUT`, and asking for anything else raises `ValueError` when
+the app is built.
+
+Two named sets let a call site state which it is, instead of leaving it to the
+presence or absence of an argument:
+
+```python
+from zarr_http_server import READ_ONLY_HTTP_METHODS, READ_WRITE_HTTP_METHODS, store_app
+
+app = store_app(store, methods=READ_ONLY_HTTP_METHODS) # GET, HEAD
+app = store_app(store, methods=READ_WRITE_HTTP_METHODS) # GET, HEAD, PUT
+```
+
+The distinction also exists in the type domain.
+[`ReadOnlyHTTPMethod`][zarr_http_server.ReadOnlyHTTPMethod] is a
+`Literal["GET", "HEAD"]`, so a read-only set can be *declared* rather than
+merely configured — a `frozenset[ReadOnlyHTTPMethod]` containing `"PUT"` is a
+type error, not a runtime surprise:
+
+```python
+from zarr_http_server import ReadOnlyHTTPMethod
+
+reads: frozenset[ReadOnlyHTTPMethod] = frozenset({"GET", "HEAD"}) # ok
+reads = frozenset({"GET", "PUT"}) # type error
+```
+
+Both constants are derived from those Literals, so the runtime sets and the
+static types cannot disagree about what the server serves.
+
+`READ_ONLY_HTTP_METHODS` is exactly the default, so passing it changes nothing
+except that the intent is written down. The practical value is the other
+direction: a writable app *must* name a method set, so `grep -r 'methods='`
+finds every place that opts into writes.
+
+For a guarantee that does not depend on getting `methods` right, make the
+*store* read-only. The store refuses writes itself, so no routing mistake — now
+or in a later edit — can produce one:
+
+```python
+app = store_app(store.with_read_only(True))
+
+# For a node, open it read-only and node_app inherits that store:
+app = node_app(zarr.open_array(store, mode="r"))
+```
+
+These two layers are independent, and the store is the stronger one: it holds
+even if the HTTP layer is misconfigured. Asking for both at once —
+`READ_WRITE_HTTP_METHODS` on a read-only store — is a contradiction that can
+never succeed, so it raises `ValueError` at construction rather than turning
+into a 403 for whichever client tries to write first.
+
+## Writes
+
+To enable writes, name a method set that includes `PUT`:
+
+```python
+from zarr_http_server import READ_WRITE_HTTP_METHODS, store_app
+
+app = store_app(store, methods=READ_WRITE_HTTP_METHODS)
+```
+
+A `PUT` stores the request body at the given path and returns 204 (No Content).
+Bodies are capped at
+[`DEFAULT_MAX_BODY_SIZE`][zarr_http_server.DEFAULT_MAX_BODY_SIZE] (256 MiB) and
+a larger one returns 413 (Content Too Large) — `Store.set` takes a whole
+buffer, so an accepted body is held in memory in full. Raise or remove the cap
+with `max_body_size`:
+
+```python
+app = store_app(store, methods=READ_WRITE_HTTP_METHODS, max_body_size=None)
+```
+
+!!! danger "Writes through `store_app` are unvalidated"
+
+ `store_app` exposes every key in the store, so `PUT` grants unrestricted
+ write access to all of it. It also does not *validate* keys, because it
+ proxies the raw key space and has no array semantics to check against: a
+ client that misspells a chunk key — `c/00/00` where zarr writes `c/0/0` —
+ gets a successful write to a key no reader will ever consult, so the data
+ is stored but invisible to anyone opening the array.
+
+ `node_app` rejects such a key with 404, since it knows which node it is
+ serving and therefore which keys are real. If you are serving a zarr
+ hierarchy to clients you do not control and writes are enabled, prefer
+ `node_app`.
+
+ Note also that a client that can write a node's metadata can change what
+ that node contains, and so what it will serve.
+
+## Examples
+
+Both live in
+[`examples/`](https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-http-server/examples)
+and are executed by the test suite, so neither can drift from the code.
+
+`serve.py` creates an in-memory Zarr array, serves it, and fetches the
+`zarr.json` metadata document and a raw chunk with `httpx`. It declares its own
+dependencies inline, so uv installs them for you:
+
+```bash
+uv run examples/serve.py
+```
+
+`serve_notebook.ipynb` is the notebook equivalent, showing how to start a
+server in one cell and stop it in another.
diff --git a/packages/zarr-http-server/docs/index.md b/packages/zarr-http-server/docs/index.md
new file mode 100644
index 0000000000..271de6445e
--- /dev/null
+++ b/packages/zarr-http-server/docs/index.md
@@ -0,0 +1,71 @@
+# zarr-http-server
+
+HTTP server for Zarr stores, arrays, and groups.
+
+`zarr-http-server` is developed in the
+[zarr-python repository](https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-http-server)
+and released independently of `zarr` itself. Install it with:
+
+```
+pip install zarr-http-server
+```
+
+!!! warning "Experimental"
+
+ This package is experimental. Its API may change or be removed at any
+ point.
+
+## What this is
+
+`zarr-http-server` exposes a Zarr `Store`, `Array`, or `Group` over HTTP via an
+ASGI app, so any HTTP-capable client — including zarr-python itself, via
+`FsspecStore` or `ObjectStore` — can read the data. The app is built on
+[Starlette](https://www.starlette.io/) and can be run with any ASGI server;
+the `serve` / `serve_background` helpers run it with
+[Uvicorn](https://uvicorn.dev/).
+
+Building an app and running it are separate steps, and either app works with
+either runner:
+
+- **Build** with [`store_app`][zarr_http_server.store_app] to serve every key
+ in a store, or [`node_app`][zarr_http_server.node_app] to serve only the keys
+ belonging to one `Array` or `Group` — requests for keys outside that node
+ return 404 even when those keys exist in the underlying store.
+- **Run** with [`serve`][zarr_http_server.serve], which blocks, or
+ [`serve_background`][zarr_http_server.serve_background], which returns a
+ handle you can shut down later.
+
+Byte-range reads, configurable CORS headers, and a configurable set of allowed
+HTTP methods are handled by the app.
+
+## Quick start
+
+```python
+import zarr
+from zarr_http_server import node_app, serve_background
+
+store = zarr.storage.MemoryStore()
+array = zarr.create_array(store, shape=(100,), chunks=(10,), dtype="float64")
+
+with serve_background(node_app(array)) as server:
+ print(server.url) # e.g. http://127.0.0.1:8000
+```
+
+Reads are all that is enabled by default: `GET` and `HEAD` are served, and
+`PUT`, `POST`, `DELETE` and `PATCH` are answered with 405.
+
+!!! danger "Serving a whole store grants access to all of it"
+
+ `store_app` applies no per-key filtering. Only point it at a store whose
+ full contents are safe to serve, and note that enabling `PUT` grants write
+ access to everything the store contains. See
+ [read-only serving](guide.md#read-only-serving) for the guarantees
+ available.
+
+## Next steps
+
+- [User guide](guide.md) — building apps, running them, byte ranges, CORS,
+ writes, notebooks, and Uvicorn configuration
+- [API reference](api/index.md)
+- [Changelog](https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-http-server/CHANGELOG.md)
+- [License (MIT)](https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-http-server/LICENSE.txt)
diff --git a/packages/zarr-http-server/examples/serve.py b/packages/zarr-http-server/examples/serve.py
new file mode 100644
index 0000000000..45caa9ea67
--- /dev/null
+++ b/packages/zarr-http-server/examples/serve.py
@@ -0,0 +1,45 @@
+# /// script
+# requires-python = ">=3.12"
+# dependencies = [
+# "zarr-http-server @ git+https://github.com/zarr-developers/zarr-python.git@main#subdirectory=packages/zarr-http-server",
+# "httpx",
+# ]
+# ///
+"""
+Serve a Zarr array over HTTP and fetch its metadata and chunks.
+
+This example creates an in-memory array, serves it in a background thread,
+then uses ``httpx`` to request the ``zarr.json`` metadata document and a raw
+chunk.
+"""
+
+import json
+
+import httpx
+import numpy as np
+import zarr
+from zarr.storage import MemoryStore
+
+from zarr_http_server import node_app, serve_background
+
+# -- create an array --------------------------------------------------------
+store = MemoryStore()
+data = np.arange(1000, dtype="uint8").reshape(10, 10, 10)
+# no compression
+arr = zarr.create_array(store, data=data, chunks=(5, 5, 5), write_data=True, compressors=None)
+
+# -- serve it in the background ---------------------------------------------
+# port=0 asks the OS for a free port, so running this twice -- or running it
+# while something else holds 8000 -- works. `server.url` reports what it bound.
+with serve_background(node_app(arr), host="127.0.0.1") as server:
+ # -- fetch metadata ------------------------------------------------------
+ resp = httpx.get(f"{server.url}/zarr.json")
+ assert resp.status_code == 200
+ meta = resp.json()
+ print("zarr.json:")
+ print(json.dumps(meta, indent=2))
+
+ # -- fetch a raw chunk ---------------------------------------------------
+ resp = httpx.get(f"{server.url}/c/0/0/0")
+ assert resp.status_code == 200
+ print(f"\nchunk c/0/0/0: {len(resp.content)} bytes")
diff --git a/packages/zarr-http-server/examples/serve_notebook.ipynb b/packages/zarr-http-server/examples/serve_notebook.ipynb
new file mode 100644
index 0000000000..e751a526c1
--- /dev/null
+++ b/packages/zarr-http-server/examples/serve_notebook.ipynb
@@ -0,0 +1,213 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "6cb56289",
+ "metadata": {},
+ "source": [
+ "# Serving a Zarr array from a notebook\n",
+ "\n",
+ "A notebook needs a server that outlives the cell that started it, which is the\n",
+ "one thing the `with serve_background(...)` form in the README cannot give\n",
+ "that shuts the server down as soon as the block ends.\n",
+ "\n",
+ "The notebook pattern is instead:\n",
+ "\n",
+ "1. start with `serve_background` and keep the handle,\n",
+ "2. use the server across as many cells as you like,\n",
+ "3. `shutdown()` when you are done.\n",
+ "\n",
+ "Two details make this comfortable in a kernel you re-run:\n",
+ "\n",
+ "- **`serve_background`** runs uvicorn in a daemon thread with its own event\n",
+ " loop, so it never touches the kernel's loop and cannot block it.\n",
+ "- **`port=\"auto\"`**, its default, prefers port 8000 but takes a free one if\n",
+ " it is busy. Re-running a start cell without stopping the previous server is\n",
+ " the classic notebook mistake, and a fixed port fails there with *address\n",
+ " already in use*. `server.url` reports the port actually bound."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "e2e217fa",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import numpy as np\n",
+ "import zarr\n",
+ "from zarr.storage import MemoryStore\n",
+ "\n",
+ "from zarr_http_server import node_app, serve_background\n",
+ "\n",
+ "store = MemoryStore()\n",
+ "array = zarr.create_array(\n",
+ " store,\n",
+ " data=np.arange(1000, dtype=\"uint8\").reshape(10, 10, 10),\n",
+ " chunks=(5, 5, 5),\n",
+ " compressors=None,\n",
+ " write_data=True,\n",
+ ")\n",
+ "array.info"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "35752272",
+ "metadata": {},
+ "source": [
+ "## Start\n",
+ "\n",
+ "`serve_background` returns a `BackgroundServer` as soon as the socket is\n",
+ "so the next cell can use it immediately."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "b5010cdb",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "server = serve_background(node_app(array), host=\"127.0.0.1\")\n",
+ "\n",
+ "print(f\"serving at {server.url}\")\n",
+ "assert server.url is not None"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d4e1f8d6",
+ "metadata": {},
+ "source": [
+ "## Use it\n",
+ "\n",
+ "The server is alive across cells now. Anything that speaks HTTP can read from\n",
+ "it -- here `httpx`, but a browser or another zarr client works the same way.\n",
+ "\n",
+ "(With `fsspec[http]` installed you can also do\n",
+ "`zarr.open_array(server.url, mode=\"r\")` to read the array back through zarr\n",
+ "itself.)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5ac501fa",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import httpx\n",
+ "\n",
+ "metadata = httpx.get(f\"{server.url}/zarr.json\", timeout=30)\n",
+ "print(metadata.status_code, metadata.headers[\"content-type\"])\n",
+ "assert metadata.status_code == 200\n",
+ "assert metadata.json()[\"shape\"] == [10, 10, 10]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "c7a94eff",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "chunk = httpx.get(f\"{server.url}/c/0/0/0\", timeout=30)\n",
+ "print(f\"chunk c/0/0/0: {len(chunk.content)} bytes\")\n",
+ "assert chunk.status_code == 200\n",
+ "\n",
+ "# Byte ranges work too, and say which bytes came back.\n",
+ "part = httpx.get(f\"{server.url}/c/0/0/0\", headers={\"Range\": \"bytes=0-9\"}, timeout=30)\n",
+ "print(part.status_code, part.headers[\"content-range\"], part.content)\n",
+ "assert part.status_code == 206\n",
+ "assert part.content == chunk.content[:10]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c8fd3ea2",
+ "metadata": {},
+ "source": [
+ "## Writes are off unless you ask\n",
+ "\n",
+ "The default is read-only, so a stray `PUT` from a notebook cell -- or from\n",
+ "anyone else who can reach the port -- is refused."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "2f6bee45",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "refused = httpx.put(f\"{server.url}/c/0/0/0\", content=b\"nope\", timeout=30)\n",
+ "print(\"PUT ->\", refused.status_code)\n",
+ "assert refused.status_code == 405"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "74f4bebc",
+ "metadata": {},
+ "source": [
+ "## Stop\n",
+ "\n",
+ "`shutdown()` waits for in-flight requests, then forces the server closed. It\n",
+ "raises if the thread will not stop, so a silent failure cannot leave you\n",
+ "believing the port is free when it is not."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "53a3e8d4",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "server.shutdown()\n",
+ "\n",
+ "# The port really is closed now.\n",
+ "try:\n",
+ " httpx.get(f\"{server.url}/zarr.json\", timeout=5)\n",
+ "except httpx.HTTPError as exc:\n",
+ " print(f\"as expected, no longer serving: {type(exc).__name__}\")\n",
+ "else:\n",
+ " raise AssertionError(\"server still responding after shutdown\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8a7742e0",
+ "metadata": {},
+ "source": [
+ "## If you forget to stop one\n",
+ "\n",
+ "The server thread is a daemon, so it dies with the kernel -- restarting the\n",
+ "kernel always clears it. Because `port=0` picks a fresh port each time, a\n",
+ "forgotten server does not block the next one either; it just holds a port\n",
+ "until the kernel exits.\n",
+ "\n",
+ "If you want the shutdown tied to a block rather than a cell, the context\n",
+ "manager form still works inside a single cell:\n",
+ "\n",
+ "```python\n",
+ "with serve_background(node_app(array)) as server:\n",
+ " ... # everything must happen in this cell\n",
+ "```"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "name": "python"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/packages/zarr-http-server/justfile b/packages/zarr-http-server/justfile
new file mode 100644
index 0000000000..d65b1b53b7
--- /dev/null
+++ b/packages/zarr-http-server/justfile
@@ -0,0 +1,68 @@
+# Development verbs for the zarr-http-server package. Recipes run with this
+# directory as the working directory regardless of where `just` is invoked.
+#
+# CI calls these recipes rather than repeating their commands, so a green run
+# in .github/workflows/zarr-http-server.yml means the same thing as a green
+# `just check` here.
+
+# Pinned to the ruff that .pre-commit-config.yaml uses, so this and the
+# pre-commit gate enforce one standard. An unpinned `uvx ruff` floats to the
+# newest release: when ruff 0.16 began selecting BLE001 under the root
+# config's `B` prefix, this job failed on rules the pinned ruff never enforced,
+# with no code change to blame. Bump alongside the pre-commit rev.
+ruff_version := "0.16.0"
+
+# List available recipes
+default:
+ @just --list
+
+# The `examples` group carries the deps the README examples need, so the test
+# that reads a served array back with a zarr client runs here instead of
+# silently skipping.
+# Run the test suite; extra args are passed to pytest
+test *args:
+ uv run --group test --group examples pytest tests {{ args }}
+
+# Lint the package sources and tests
+lint:
+ uvx ruff@{{ ruff_version }} check .
+
+# This package type-checks with mypy (see [tool.mypy] in pyproject.toml)
+# rather than the pyright used by the other packages under packages/.
+# Type-check the package sources
+typecheck:
+ uv run --group test --with mypy mypy src
+
+# Run everything CI runs for this package
+check: lint typecheck test docs-check
+
+# Preview the changelog that the next release would generate
+changelog-draft:
+ uvx towncrier build --draft --version Unreleased
+
+# Build this package's documentation site, warnings as errors
+docs-check:
+ env DISABLE_MKDOCS_2_WARNING=true uv run --group docs mkdocs build --strict
+
+# With no argument, uses port 8000 if free, otherwise an ephemeral free port;
+# an explicitly requested port is used as-is so a conflict fails loudly.
+# Serve this package's documentation site
+docs-serve port="":
+ #!/usr/bin/env bash
+ set -euo pipefail
+ port="{{ port }}"
+ if [ -z "$port" ]; then
+ port=$(uv run --group docs python -c '
+ import socket
+ s = socket.socket()
+ try:
+ s.bind(("127.0.0.1", 8000))
+ except OSError:
+ s.close()
+ s = socket.socket()
+ s.bind(("127.0.0.1", 0))
+ print(s.getsockname()[1])
+ s.close()
+ ')
+ fi
+ exec env DISABLE_MKDOCS_2_WARNING=true uv run --group docs mkdocs serve -a "localhost:$port"
diff --git a/packages/zarr-http-server/mkdocs.yml b/packages/zarr-http-server/mkdocs.yml
new file mode 100644
index 0000000000..7ebfe8c017
--- /dev/null
+++ b/packages/zarr-http-server/mkdocs.yml
@@ -0,0 +1,100 @@
+site_name: zarr-http-server
+# The package lives in the zarr-python monorepo; point the header source
+# widget at the package directory rather than the repository root.
+repo_name: zarr-python/packages/zarr-http-server
+repo_url: https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-http-server
+# Absolute because mkdocs would otherwise append this to repo_url's subpath.
+edit_uri: https://github.com/zarr-developers/zarr-python/edit/main/packages/zarr-http-server/docs/
+site_description: HTTP server for Zarr stores, arrays, and groups.
+site_author: Davis Bennett
+site_url: !ENV [READTHEDOCS_CANONICAL_URL, 'https://zarr-http-server.readthedocs.io/']
+docs_dir: docs
+use_directory_urls: true
+
+nav:
+ - index.md
+ - guide.md
+ - API Reference:
+ - api/index.md
+ - Changelog: https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-http-server/CHANGELOG.md
+
+watch:
+ - src
+
+theme:
+ language: en
+ name: material
+ logo: _static/logo_bw.png
+ favicon: _static/favicon-96x96.png
+
+ palette:
+ # Light mode
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ toggle:
+ icon: material/brightness-7
+ name: Switch to dark mode
+
+ # Dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ toggle:
+ icon: material/brightness-4
+ name: Switch to light mode
+
+ font:
+ text: Roboto
+ code: Roboto Mono
+
+ features:
+ - content.code.annotate
+ - content.code.copy
+ - navigation.indexes
+ - navigation.instant
+ - navigation.tracking
+ - search.suggest
+ - search.share
+
+plugins:
+ - autorefs
+ - search
+ - mkdocstrings:
+ enable_inventory: true
+ handlers:
+ python:
+ paths: [src]
+ options:
+ allow_inspection: true
+ docstring_section_style: list
+ docstring_style: numpy
+ inherited_members: true
+ line_length: 60
+ separate_signature: true
+ show_root_heading: true
+ show_signature_annotations: true
+ show_source: true
+ show_symbol_type_toc: true
+ signature_crossrefs: true
+ show_if_no_docstring: true
+ extensions:
+ - griffe_inherited_docstrings
+
+ inventories:
+ - https://docs.python.org/3/objects.inv
+ - https://zarr.readthedocs.io/en/stable/objects.inv
+
+markdown_extensions:
+ - admonition
+ - attr_list
+ - def_list
+ - footnotes
+ - md_in_html
+ - pymdownx.details
+ - pymdownx.superfences
+ - toc:
+ permalink: true
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ - pymdownx.inlinehilite
diff --git a/packages/zarr-http-server/pyproject.toml b/packages/zarr-http-server/pyproject.toml
new file mode 100644
index 0000000000..10db7ae496
--- /dev/null
+++ b/packages/zarr-http-server/pyproject.toml
@@ -0,0 +1,163 @@
+[build-system]
+requires = ["hatchling>=1.29.0", "hatch-vcs"]
+build-backend = "hatchling.build"
+
+[project]
+name = "zarr-http-server"
+dynamic = ["version"]
+description = "HTTP server for Zarr stores, arrays, and groups."
+readme = "README.md"
+requires-python = ">=3.12"
+license = "MIT"
+license-files = ["LICENSE.txt"]
+authors = [
+ { name = "Davis Bennett", email = "davis.v.bennett@gmail.com" },
+]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "Intended Audience :: Information Technology",
+ "Intended Audience :: Science/Research",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Topic :: Scientific/Engineering",
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
+ "Typing :: Typed",
+]
+keywords = ["zarr", "http", "server", "asgi"]
+dependencies = [
+ "zarr>=3.1",
+ "starlette>=1.0",
+ "uvicorn>=0.29",
+]
+
+[project.urls]
+Homepage = "https://github.com/zarr-developers/zarr-python"
+Source = "https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-http-server"
+Issues = "https://github.com/zarr-developers/zarr-python/issues"
+Changelog = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-http-server/CHANGELOG.md"
+Documentation = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-http-server/README.md"
+
+[dependency-groups]
+test = [
+ "pytest",
+ "httpx",
+ "httpx2",
+ "hypothesis",
+ # Executing examples/serve_notebook.ipynb in the suite: nbformat reads it,
+ # nbclient runs it, ipykernel is the kernel it runs in.
+ "nbformat",
+ "nbclient",
+ "ipykernel",
+]
+examples = [
+ # Optional dependencies to run the README examples. Reading a served
+ # array back with `zarr.open_array(url)` goes through FsspecStore, which
+ # needs an HTTP-capable fsspec; `examples/serve.py` uses httpx.
+ "fsspec[http]",
+ "httpx",
+]
+docs = [
+ # Pins match the zarr-python docs environment in the repo-root
+ # pyproject.toml so the two sites render with the same toolchain.
+ "mkdocs-material==9.7.7",
+ "mkdocs==1.6.1",
+ "mkdocstrings==1.0.6",
+ "mkdocstrings-python==2.0.5",
+ "griffe-inherited-docstrings==1.1.3",
+ # mkdocstrings uses ruff to format rendered signatures
+ "ruff==0.16.0",
+]
+
+# Dev-only: resolve zarr from the repo root so package tests run against
+# in-repo zarr. Affects uv resolution only, not published metadata.
+[tool.uv.sources]
+zarr = { path = "../..", editable = true }
+
+[tool.hatch.version]
+source = "vcs"
+tag-pattern = '^zarr_http_server-v(?P.+)$'
+# `git_describe_command` ensures we get the zarr_http_server tags instead of latest.
+# `local_scheme` strips the git commit info so the appending info is just a counter from latest tag.
+# test-pypi doesn't accept git commit info in tags, and the count should be enough to distinguish unique runs.
+raw-options = { root = "../..", git_describe_command = "git describe --dirty --tags --long --match zarr_http_server-v*", local_scheme = "no-local-version" }
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/zarr_http_server"]
+
+# An allowlist, so nothing that merely happens to sit in the package directory
+# — a scratch script, a stray notebook — can ride along in a release. With no
+# sdist section at all hatchling defaults to "everything not gitignored", which
+# is a blocklist by another name. The list keeps an sdist self-testing:
+# `tests/test_examples.py` runs `examples/serve.py` and executes every cell of
+# `examples/serve_notebook.ipynb`, so those are part of the suite rather than
+# decoration, and `/docs` plus `/mkdocs.yml` are a self-contained site.
+# `changes/` and `.readthedocs.yaml` are deliberately absent: towncrier
+# fragments are consumed into `CHANGELOG.md` at release time, and the RTD
+# config only means anything in the repository. `pyproject.toml`, `README.md`
+# and `LICENSE.txt` are added by hatchling itself.
+[tool.hatch.build.targets.sdist]
+include = [
+ "/src",
+ "/tests",
+ "/docs",
+ "/examples",
+ "/mkdocs.yml",
+ "/justfile",
+ "/CHANGELOG.md",
+]
+
+[tool.ruff]
+extend = "../../pyproject.toml"
+target-version = "py312"
+
+[tool.pytest.ini_options]
+minversion = "7"
+testpaths = ["tests"]
+xfail_strict = true
+addopts = ["-ra", "--strict-config", "--strict-markers"]
+filterwarnings = [
+ "error",
+]
+
+[tool.mypy]
+files = ["src"]
+python_version = "3.12"
+ignore_missing_imports = true
+namespace_packages = false
+pretty = true
+show_error_code_links = true
+show_error_context = true
+strict = true
+warn_unreachable = true
+enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool", "truthy-iterable"]
+
+[tool.numpydoc_validation]
+# Mirrors the root zarr-python config so moved docstrings (written for that
+# config) don't trip stricter defaults just because this package has its own
+# pyproject.toml. See https://numpydoc.readthedocs.io/en/latest/validation.html#built-in-validation-checks
+checks = [
+ "GL10",
+ "SS04",
+ "PR02",
+ "PR03",
+ "PR05",
+ "PR06",
+]
+
+[tool.towncrier]
+# Fragments for this package live alongside the package source, separate
+# from the parent zarr-python `changes/` directory, so a PR touching only
+# `packages/zarr-http-server/` produces a release note for this package only.
+directory = "changes"
+filename = "CHANGELOG.md"
+package = "zarr_http_server"
+underlines = ["", "", ""]
+title_format = "## {version} ({project_date})"
+issue_format = "[#{issue}](https://github.com/zarr-developers/zarr-python/issues/{issue})"
+start_string = "\n"
diff --git a/packages/zarr-http-server/src/zarr_http_server/__init__.py b/packages/zarr-http-server/src/zarr_http_server/__init__.py
new file mode 100644
index 0000000000..4f4fbd22f8
--- /dev/null
+++ b/packages/zarr-http-server/src/zarr_http_server/__init__.py
@@ -0,0 +1,38 @@
+"""Zarr-http-server: HTTP server for Zarr stores, arrays, and groups."""
+
+from importlib.metadata import version
+
+from zarr_http_server._serve import (
+ AUTO_PORT,
+ DEFAULT_MAX_BODY_SIZE,
+ DEFAULT_PORT,
+ READ_ONLY_HTTP_METHODS,
+ READ_WRITE_HTTP_METHODS,
+ BackgroundServer,
+ CorsOptions,
+ HTTPMethod,
+ ReadOnlyHTTPMethod,
+ node_app,
+ serve,
+ serve_background,
+ store_app,
+)
+
+__version__ = version("zarr-http-server")
+
+__all__ = [
+ "AUTO_PORT",
+ "DEFAULT_MAX_BODY_SIZE",
+ "DEFAULT_PORT",
+ "READ_ONLY_HTTP_METHODS",
+ "READ_WRITE_HTTP_METHODS",
+ "BackgroundServer",
+ "CorsOptions",
+ "HTTPMethod",
+ "ReadOnlyHTTPMethod",
+ "__version__",
+ "node_app",
+ "serve",
+ "serve_background",
+ "store_app",
+]
diff --git a/packages/zarr-http-server/src/zarr_http_server/_keys.py b/packages/zarr-http-server/src/zarr_http_server/_keys.py
new file mode 100644
index 0000000000..e468cd066d
--- /dev/null
+++ b/packages/zarr-http-server/src/zarr_http_server/_keys.py
@@ -0,0 +1,218 @@
+"""Utilities for determining the set of valid store keys for zarr nodes.
+
+A zarr node (array or group) implicitly defines a subset of keys in the
+underlying store. For an **array** the valid keys are:
+
+* metadata documents (``zarr.json`` for v3, ``.zarray`` / ``.zattrs`` for v2)
+* chunk (or shard) keys whose decoded coordinates fall within the storage grid
+
+For a **group** the valid keys are:
+
+* its own metadata documents
+* any path ``/`` where ```` is a direct member and
+ ```` is recursively valid for that child
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+ZARR_JSON = "zarr.json"
+ZARRAY_JSON = ".zarray"
+ZGROUP_JSON = ".zgroup"
+ZATTRS_JSON = ".zattrs"
+ZMETADATA_V2_JSON = ".zmetadata"
+
+if TYPE_CHECKING:
+ from zarr import Array, Group
+
+_ARRAY_METADATA_KEYS_V3 = frozenset({ZARR_JSON})
+_ARRAY_METADATA_KEYS_V2 = frozenset({ZARRAY_JSON, ZATTRS_JSON})
+_GROUP_METADATA_KEYS_V3 = frozenset({ZARR_JSON})
+_GROUP_METADATA_KEYS_V2 = frozenset({ZGROUP_JSON, ZATTRS_JSON, ZMETADATA_V2_JSON})
+
+
+def array_metadata_keys(zarr_format: int) -> frozenset[str]:
+ """Return the metadata key basenames an array owns, for a zarr format.
+
+ Parameters
+ ----------
+ zarr_format : int
+ The zarr format version (2 or 3).
+
+ Returns
+ -------
+ frozenset of str
+ """
+ if zarr_format == 3:
+ return _ARRAY_METADATA_KEYS_V3
+ return _ARRAY_METADATA_KEYS_V2
+
+
+def group_metadata_keys(zarr_format: int) -> frozenset[str]:
+ """Return the metadata key basenames a group owns, for a zarr format.
+
+ Parameters
+ ----------
+ zarr_format : int
+ The zarr format version (2 or 3).
+
+ Returns
+ -------
+ frozenset of str
+ """
+ if zarr_format == 3:
+ return _GROUP_METADATA_KEYS_V3
+ return _GROUP_METADATA_KEYS_V2
+
+
+def decode_chunk_key(array: Array[Any], key: str) -> tuple[int, ...] | None:
+ """Try to decode *key* into chunk coordinates for *array*.
+
+ Parameters
+ ----------
+ array : Array
+ The array whose chunk key encoding should be used.
+ key : str
+ The candidate chunk key string.
+
+ Returns
+ -------
+ tuple of int, or None
+ The decoded coordinates, or ``None`` if *key* is not a valid chunk key.
+ """
+ try:
+ if array.metadata.zarr_format == 2:
+ coords = tuple(int(p) for p in key.split(array.metadata.dimension_separator))
+ # A 0-d v2 array holds its single chunk under "0", which decodes
+ # to a 1-tuple that no 0-d grid could match.
+ if len(array.shape) == 0:
+ return () if coords == (0,) else None
+ return coords
+
+ # Ask zarr rather than predicting it: the encoding owns its own
+ # grammar, so a new or third-party chunk key encoding decodes here
+ # without this package knowing anything about it.
+ return array.metadata.chunk_key_encoding.decode_chunk_key(key)
+ except (ValueError, TypeError, NotImplementedError):
+ return None
+
+
+def _shard_grid_shape(array: Array[Any]) -> tuple[int, ...]:
+ """Shape of the shard grid, falling back to the chunk grid when unsharded."""
+ shard_shape = array.shards if array.shards is not None else array.chunks
+ return tuple(-(-s // c) for s, c in zip(array.shape, shard_shape, strict=True))
+
+
+def is_valid_chunk_key(array: Array[Any], key: str) -> bool:
+ """Check whether *key* is a valid chunk key for *array*.
+
+ Decodes the key, checks that the resulting coordinates fall within the
+ storage grid (shard grid if sharding is used, chunk grid otherwise), and
+ requires the key to be spelled exactly as zarr itself would spell it.
+
+ That last check is what makes the accepted key set equal to the set of
+ keys zarr can actually read. Decoding alone is lenient -- `int` accepts
+ leading zeros, a leading `+`/`-`, surrounding whitespace, underscore
+ separators, and non-ASCII decimal digits -- so `c/00/00` and `c/0/0`
+ decode to the same coordinates while naming *different* store keys. A
+ write to the non-canonical spelling would be stored under a key no reader
+ ever looks up: the client sees success and the data is invisible.
+
+ Parameters
+ ----------
+ array : Array
+ The array to validate against.
+ key : str
+ The candidate chunk key string.
+
+ Returns
+ -------
+ bool
+ """
+ coords = decode_chunk_key(array, key)
+ if coords is None:
+ return False
+ grid = _shard_grid_shape(array)
+ if len(coords) != len(grid):
+ return False
+ if not all(0 <= c < g for c, g in zip(coords, grid, strict=True)):
+ return False
+ return array.metadata.encode_chunk_key(coords) == key
+
+
+def is_valid_array_key(array: Array[Any], key: str) -> bool:
+ """Check whether *key* is a valid store key for *array*.
+
+ Valid keys are metadata documents and chunk keys.
+
+ Parameters
+ ----------
+ array : Array
+ The array to validate against.
+ key : str
+ The candidate key, relative to the array's root.
+
+ Returns
+ -------
+ bool
+ """
+ if key in array_metadata_keys(array.metadata.zarr_format):
+ return True
+ return is_valid_chunk_key(array, key)
+
+
+def is_valid_node_key(node: Array[Any] | Group, key: str) -> bool:
+ """Check whether *key* is a valid store key relative to *node*.
+
+ For an ``Array``, valid keys are metadata documents and chunk keys.
+
+ For a ``Group``, valid keys are the group's own metadata documents, or
+ a path of the form ``/`` where ```` is a direct
+ member and ```` is recursively valid for that child.
+
+ Parameters
+ ----------
+ node : Array or Group
+ The zarr node to validate against.
+ key : str
+ The candidate key, relative to the node's root.
+
+ Returns
+ -------
+ bool
+ """
+ from zarr import Array
+
+ if isinstance(node, Array):
+ return is_valid_array_key(node, key)
+
+ # Group
+ if key in group_metadata_keys(node.metadata.zarr_format):
+ return True
+
+ # Try to match the first path component against a child member.
+ if "/" in key:
+ child_name, remainder = key.split("/", 1)
+ else:
+ # A bare name with no slash can't be a valid group-level key —
+ # groups contain children (which have subkeys), not bare keys.
+ return False
+
+ try:
+ child = node[child_name]
+ except KeyError:
+ # There is no such member, so no key beneath it can be valid.
+ #
+ # Only a missing name is caught here. Anything else -- an I/O error
+ # reading the child's metadata, unparsable JSON, a codec from a
+ # plugin this process lacks -- means the key could not be *judged*,
+ # which is not the same as judging it absent. Reporting those as 404
+ # would be a lie with teeth: under the v3 spec an absent chunk is an
+ # uninitialized one, so a correct reader answers a 404 by silently
+ # substituting the array's fill value over data that exists. Letting
+ # them propagate surfaces a 500, which is the honest answer and the
+ # one a client cannot mistake for data.
+ return False
+
+ return is_valid_node_key(child, remainder)
diff --git a/packages/zarr-http-server/src/zarr_http_server/_serve.py b/packages/zarr-http-server/src/zarr_http_server/_serve.py
new file mode 100644
index 0000000000..dce1df6b12
--- /dev/null
+++ b/packages/zarr-http-server/src/zarr_http_server/_serve.py
@@ -0,0 +1,1170 @@
+from __future__ import annotations
+
+import asyncio
+import errno
+import logging
+import ntpath
+import socket
+import sys
+import threading
+import time
+from enum import Enum, auto
+from functools import partial
+from typing import TYPE_CHECKING, Any, Literal, Self, TypedDict, cast, get_args
+
+from zarr.abc.store import OffsetByteRequest, RangeByteRequest, SuffixByteRequest
+from zarr.buffer import cpu
+
+from zarr_http_server._keys import array_metadata_keys, group_metadata_keys, is_valid_node_key
+
+if TYPE_CHECKING:
+ from collections.abc import Mapping
+ from collections.abc import Set as AbstractSet
+
+ import uvicorn
+ from starlette.applications import Starlette
+ from starlette.requests import Request
+ from starlette.responses import Response
+ from zarr import Array, Group
+ from zarr.abc.store import ByteRequest, Store
+
+__all__ = [
+ "AUTO_PORT",
+ "DEFAULT_MAX_BODY_SIZE",
+ "DEFAULT_PORT",
+ "READ_ONLY_HTTP_METHODS",
+ "READ_WRITE_HTTP_METHODS",
+ "BackgroundServer",
+ "CorsOptions",
+ "HTTPMethod",
+ "ReadOnlyHTTPMethod",
+ "node_app",
+ "serve",
+ "serve_background",
+ "store_app",
+]
+
+
+class CorsOptions(TypedDict, total=False):
+ """Options forwarded to Starlette's `CORSMiddleware`.
+
+ Every parameter the middleware accepts appears here, so configuring CORS
+ never requires reaching around this package. Keys left out fall back to
+ the defaults described below; a key that is present is used verbatim,
+ including an empty list.
+
+ Two defaults differ from Starlette's own, because this server knows
+ something its caller should not have to. It emits `Content-Range` on
+ every ranged response, which is *not* a CORS-safelisted response header --
+ with Starlette's empty `expose_headers` a browser client can read the
+ bytes but not learn which bytes it got. And it accepts a `Range` request
+ header, which Starlette's empty `allow_headers` would reject at preflight.
+
+ * `expose_headers` defaults to `["Content-Range"]`
+ * `allow_headers` defaults to `["Range"]`
+
+ Everything else defaults to the middleware's own value: no origins, `GET`
+ only, no credentials, no origin regex, no private-network access, and a
+ 600-second preflight cache.
+ """
+
+ allow_origins: list[str]
+ allow_methods: list[str]
+ allow_headers: list[str]
+ allow_credentials: bool
+ allow_origin_regex: str | None
+ allow_private_network: bool
+ expose_headers: list[str]
+ max_age: int
+
+
+_CORS_DEFAULTS: CorsOptions = {
+ "expose_headers": ["Content-Range"],
+ "allow_headers": ["Range"],
+}
+
+
+ReadOnlyHTTPMethod = Literal["GET", "HEAD"]
+"""An HTTP method that cannot modify the store.
+
+Distinguished from `HTTPMethod` in the type domain, not only at runtime, so a
+read-only interface can be *declared* rather than merely configured: a
+parameter annotated `AbstractSet[ReadOnlyHTTPMethod]` cannot be handed `"PUT"`
+without a type error, whatever the value turns out to be at runtime.
+
+`HEAD` belongs here because Starlette routes it wherever `GET` goes, which is
+what RFC 9110 §9.3.2 asks of an origin server. It is answered from the value's
+size rather than by building and discarding a body.
+"""
+
+_WriteHTTPMethod = Literal["PUT"]
+"""An HTTP method that modifies the store.
+
+Private because nothing needs to name "the write methods" on its own -- it
+exists so `HTTPMethod` can be defined as the union rather than as a third
+hand-written list of the same strings.
+"""
+
+HTTPMethod = ReadOnlyHTTPMethod | _WriteHTTPMethod
+"""An HTTP method this server implements.
+
+`GET` and `HEAD` read a key; `PUT` writes one. Other verbs are not accepted:
+the handler has no behavior for them, so serving them would silently answer
+as if they were `GET`.
+"""
+
+# Derived from the types above rather than restated, so the runtime sets and
+# the static types cannot disagree about what this server serves. Adding a
+# method to a Literal is then the only edit needed.
+READ_ONLY_HTTP_METHODS: frozenset[ReadOnlyHTTPMethod] = frozenset(get_args(ReadOnlyHTTPMethod))
+"""Methods that only read. The default for every app in this package.
+
+Naming the set makes a read-only deployment say so at the call site, rather
+than being the absence of an argument:
+
+ store_app(store, methods=READ_ONLY_HTTP_METHODS)
+
+Typed as a set of `ReadOnlyHTTPMethod`, so a caller building on it keeps the
+static guarantee: adding `"PUT"` to a `frozenset[ReadOnlyHTTPMethod]` is a
+type error, not a runtime surprise.
+
+The stronger guarantee is a read-only store, which holds however `methods` is
+configured -- see `store.with_read_only(True)`.
+"""
+
+READ_WRITE_HTTP_METHODS: frozenset[HTTPMethod] = frozenset(
+ get_args(ReadOnlyHTTPMethod) + get_args(_WriteHTTPMethod)
+)
+"""Methods that read and write. Serving these grants clients write access.
+
+Every writable app must name a method set, so this constant is also what makes
+writable deployments findable: grepping for `READ_WRITE_HTTP_METHODS` (or for
+`methods=` generally) turns up every place that opts in.
+"""
+
+_SUPPORTED_METHODS: frozenset[str] = READ_WRITE_HTTP_METHODS
+
+_LOGGER = logging.getLogger("uvicorn.error")
+"""uvicorn's own logger, so a port fallback appears alongside its startup lines."""
+
+DEFAULT_PORT = 8000
+"""Port tried first when `port="auto"`."""
+
+AUTO_PORT: Literal["auto"] = "auto"
+"""Sentinel for `port`: prefer `DEFAULT_PORT`, but settle for any free port.
+
+An explicit port is a requirement -- it binds that port or fails -- because a
+caller who names one usually has something else expecting the server there.
+`"auto"` says the opposite: no particular port is needed, so a collision
+should not stop the server from starting. `port=0` keeps its usual meaning of
+"any free port", with no preference.
+"""
+
+_STARTUP_TIMEOUT = 5.0
+"""Seconds to wait for a background server to report that it is listening."""
+
+_STARTUP_ABANDON_TIMEOUT = 5.0
+"""Seconds to wait for a server that failed to start to stop again."""
+
+_SHUTDOWN_JOIN_MARGIN = 1.0
+"""Seconds to wait beyond uvicorn's graceful bound before forcing shutdown.
+
+uvicorn spends a fixed ~0.2s tearing down (a 0.1s loop tick plus a 0.1s
+sleep) before its own `timeout_graceful_shutdown` wait begins, so a join that
+merely equals that bound is guaranteed to expire first and escalate to
+`force_exit` -- which makes uvicorn skip ASGI lifespan shutdown.
+"""
+
+DEFAULT_MAX_BODY_SIZE = 256 * 1024 * 1024
+"""Default cap on a `PUT` body, in bytes.
+
+`Store.set` takes a whole `Buffer`, so an accepted body is held in memory in
+full; the body is read incrementally and abandoned once it passes this cap,
+so one request cannot size the server's memory use. Pass
+`max_body_size=None` to lift the cap and read the body whole.
+"""
+
+
+class BackgroundServer:
+ """A running background HTTP server that can be used as a context manager.
+
+ Wraps a ``uvicorn.Server`` running in a daemon thread. When used as a
+ context manager the server is shut down automatically on exit.
+
+ Parameters
+ ----------
+ server : uvicorn.Server
+ The running uvicorn server instance.
+ thread : threading.Thread
+ The daemon thread running the server.
+ host : str or None
+ The host the server was asked to bind, or ``None`` when it is not
+ listening on a TCP socket.
+ port : int or None
+ The port actually bound, or ``None`` when the server is not listening
+ on a TCP socket.
+ scheme : str, optional
+ URL scheme the server is reachable over. Defaults to ``"http"``.
+ shutdown_timeout : int, optional
+ Seconds to wait for in-flight requests to finish gracefully during
+ :meth:`shutdown` before forcing the server closed. Defaults to ``5``.
+
+ Examples
+ --------
+ >>> with serve_background(node_app(arr)) as server: # doctest: +SKIP
+ ... print(f"Listening on {server.host}:{server.port}")
+ ... # server is shut down when the block exits
+ """
+
+ def __init__(
+ self,
+ server: uvicorn.Server,
+ thread: threading.Thread,
+ *,
+ host: str | None,
+ port: int | None,
+ scheme: str = "http",
+ shutdown_timeout: int = 5,
+ ) -> None:
+ self._server = server
+ self._thread = thread
+ self.host = host
+ self.port = port
+ self.scheme = scheme
+ self._shutdown_timeout = shutdown_timeout
+
+ @property
+ def url(self) -> str | None:
+ """The base URL of the running server.
+
+ ``None`` when the server is not listening on a TCP socket -- a unix
+ socket or an inherited file descriptor has no host and port, and
+ inventing one would be a URL that connects to nothing.
+ """
+ if self.host is None or self.port is None:
+ return None
+ return f"{self.scheme}://{self.host}:{self.port}"
+
+ def shutdown(self) -> None:
+ """Signal the server to shut down and wait for it to stop.
+
+ Waits for the server thread to exit on its own, then escalates to
+ ``force_exit`` if it has not, so a request wedged outside uvicorn's
+ loop cannot block here forever.
+
+ Raises
+ ------
+ RuntimeError
+ If the thread is still running after both waits. Returning
+ normally would report success for a server that is still bound to
+ its port and still serving, which the caller cannot detect any
+ other way.
+ """
+ self._server.should_exit = True
+ # Outlast uvicorn's own graceful wait rather than matching it. uvicorn
+ # spends roughly 0.2s on teardown (a 0.1s loop tick plus a 0.1s sleep)
+ # *before* its `timeout_graceful_shutdown` wait even begins, so an
+ # equal bound here always expires first -- escalating to force_exit on
+ # the path that is supposed to be the orderly one, which makes uvicorn
+ # skip ASGI lifespan shutdown entirely.
+ self._thread.join(timeout=self._graceful_timeout + _SHUTDOWN_JOIN_MARGIN)
+ if self._thread.is_alive():
+ self._server.force_exit = True
+ self._thread.join(timeout=self._shutdown_timeout)
+
+ if self._thread.is_alive():
+ raise RuntimeError(
+ "Server thread did not stop within "
+ f"{self._graceful_timeout + _SHUTDOWN_JOIN_MARGIN + self._shutdown_timeout:.1f}s, "
+ "even after force_exit. The server may still be serving and "
+ "holding its port; a request blocked in a store call cannot be "
+ "cancelled from here."
+ )
+
+ @property
+ def _graceful_timeout(self) -> float:
+ """uvicorn's own graceful-shutdown bound, whoever configured it."""
+ configured = self._server.config.timeout_graceful_shutdown
+ return float(configured) if configured is not None else float(self._shutdown_timeout)
+
+ def __enter__(self) -> Self:
+ return self
+
+ def __exit__(self, *args: object) -> None:
+ self.shutdown()
+
+
+class _RangeVerdict(Enum):
+ """The outcome of a Range header that does not name a readable range."""
+
+ IGNORE = auto()
+ """Serve the full representation with 200, as if no Range had been sent."""
+
+ UNSATISFIABLE = auto()
+ """Answer 416: the range is well-formed but names nothing readable."""
+
+
+_MAX_BYTE_POS = sys.maxsize
+"""Largest byte position this server will pass to a store.
+
+Range bounds arrive as arbitrary-precision Python integers, but a store
+ultimately turns them into an index-sized `seek`/`read`. Feeding an oversized
+value through raises `OverflowError`/`ValueError` from deep inside the store
+rather than producing a response, so bounds are clamped or rejected here.
+"""
+
+
+def _parse_int(text: str) -> int | None:
+ """Parse a byte position, accepting only the canonical spelling of one.
+
+ `int` is lenient in ways an HTTP byte position is not -- it accepts
+ surrounding whitespace, a leading `+`/`-`, underscore separators, and
+ non-ASCII decimal digits -- so `bytes=+0-1` and `bytes=0_0-1` would parse.
+ RFC 9110 defines a byte position as 1*DIGIT.
+ """
+ if not text.isascii() or not text.isdigit():
+ return None
+ return int(text)
+
+
+def _parse_range_header(range_header: str) -> ByteRequest | _RangeVerdict:
+ """Parse an HTTP Range header into a ByteRequest.
+
+ A header this server cannot turn into a single read is *ignored* rather
+ than rejected. RFC 9110 §14.2 requires a server to ignore a Range whose
+ unit it does not recognize, and permits ignoring one it cannot parse; in
+ both cases the correct answer is the full representation, not 416.
+ Answering 416 would tell a client the object is unreadable when it is
+ merely the request that was unsupported -- and a client coalescing two
+ chunk reads into one multi-range request would take that at face value.
+
+ 416 is reserved for a well-formed range that genuinely names nothing.
+
+ Parameters
+ ----------
+ range_header : str
+ The value of the Range header, e.g. ``"bytes=0-99"`` or ``"bytes=-100"``.
+
+ Returns
+ -------
+ ByteRequest or _RangeVerdict
+ A ``RangeByteRequest``, ``OffsetByteRequest``, or ``SuffixByteRequest``
+ for a readable range, otherwise the verdict to apply.
+ """
+ if not range_header.startswith("bytes="):
+ # An unrecognized range unit; RFC 9110 §14.2 says MUST ignore.
+ return _RangeVerdict.IGNORE
+ range_spec = range_header[len("bytes=") :]
+ if "," in range_spec:
+ # A multipart range. Legal to send, and legal to answer with the whole
+ # representation; this server does not build multipart/byteranges.
+ return _RangeVerdict.IGNORE
+
+ if range_spec.startswith("-"):
+ # suffix request: bytes=-N
+ suffix = _parse_int(range_spec[1:])
+ if suffix is None:
+ return _RangeVerdict.IGNORE
+ if suffix == 0:
+ # "the last zero bytes" names nothing.
+ return _RangeVerdict.UNSATISFIABLE
+ return SuffixByteRequest(suffix=min(suffix, _MAX_BYTE_POS))
+
+ parts = range_spec.split("-", 1)
+ if len(parts) != 2:
+ return _RangeVerdict.IGNORE
+ start_str, end_str = parts
+ start = _parse_int(start_str)
+ if start is None:
+ return _RangeVerdict.IGNORE
+ if start > _MAX_BYTE_POS:
+ # No object can be this long, so the range starts past every end.
+ return _RangeVerdict.UNSATISFIABLE
+ if end_str == "":
+ # offset request: bytes=N-
+ return OffsetByteRequest(offset=start)
+ end_pos = _parse_int(end_str)
+ if end_pos is None:
+ return _RangeVerdict.IGNORE
+ # HTTP end is inclusive, ByteRequest end is exclusive. A last-byte-pos at
+ # or past the end of the object is satisfiable -- RFC 9110 §14.1.2 says to
+ # clamp it -- so an oversized bound is capped rather than refused.
+ end = min(end_pos, _MAX_BYTE_POS - 1) + 1
+ if start >= end:
+ # An inverted range like "bytes=5-2" is unsatisfiable, not a read
+ # of negative length.
+ return _RangeVerdict.UNSATISFIABLE
+ return RangeByteRequest(start=start, end=end)
+
+
+def _is_drive_qualified(path: str) -> bool:
+ """Whether *path* carries a drive or UNC prefix that would discard a store root.
+
+ A drive-qualified key such as `C:/Windows`, or the drive-relative `a:b`,
+ replaces the root it is joined to rather than extending it. This is
+ rejected on every platform, not just Windows: the check is a string-level
+ gate in front of an arbitrary `Store`, and this package cannot know how a
+ given implementation resolves keys.
+
+ The cost is that a node whose *first* path segment looks like `:...`
+ is unreachable -- `ntpath` treats any single character before a colon as
+ a drive, so there is no safe subset to admit. Such names are legal but
+ rare, and later segments are unaffected (`sub/a:b` is served normally).
+
+ Parameters
+ ----------
+ path : str
+ The candidate key, with separators already folded to `/`.
+
+ Returns
+ -------
+ bool
+ """
+ return ntpath.splitdrive(path)[0] != ""
+
+
+def _names_nothing(exc: OSError) -> bool:
+ """Whether an `OSError` answers about the *name*, rather than reporting failure.
+
+ `ENAMETOOLONG` is the store saying no such name is expressible here. That
+ is an answer about the key -- nothing can be stored under it, so a miss is
+ honest -- and it is unreachable for real data, because `encode_chunk_key`
+ never produces a segment near a filesystem's length limit. Answering 404
+ therefore cannot make a reader substitute fill values over a chunk that
+ exists, and it keeps a client from turning a freely chosen key into a 5xx.
+
+ Every other `errno` describes a failure to complete the operation and must
+ surface as one. `EINVAL` was accepted here and was the dangerous case: it
+ is POSIX's catch-all, reachable on a perfectly ordinary short key through
+ a bad seek or an unsupported filesystem feature. Under the v3 spec an
+ absent chunk is an uninitialized one, so reporting such a failure as 404
+ has a correct reader write fill values over data that is merely
+ unreadable. When in doubt the store's own signal is the one to trust:
+ `None` means absent, a raised error means the request could not be
+ answered.
+
+ Parameters
+ ----------
+ exc : OSError
+ The error raised by the store.
+
+ Returns
+ -------
+ bool
+ """
+ return exc.errno == errno.ENAMETOOLONG
+
+
+def _content_range(byte_range: RangeByteRequest | OffsetByteRequest, length: int) -> str:
+ """Build a `Content-Range` value for a 206 response.
+
+ Parameters
+ ----------
+ byte_range : RangeByteRequest or OffsetByteRequest
+ The range that was served. A suffix request is resolved to an absolute
+ range before reaching here, because RFC 9110 §15.3.7 requires every
+ single-part 206 to carry a `Content-Range` and a suffix's first-byte
+ position is not knowable without the object's size.
+ length : int
+ The number of bytes actually returned.
+
+ Returns
+ -------
+ str
+ A `bytes -/*` value.
+ """
+ start = byte_range.start if isinstance(byte_range, RangeByteRequest) else byte_range.offset
+ # The total length is unknown here; RFC 9110 permits "*" in its place.
+ return f"bytes {start}-{start + length - 1}/*"
+
+
+async def _resolve_suffix(
+ store: Store, path: str, byte_range: SuffixByteRequest
+) -> RangeByteRequest | _RangeVerdict:
+ """Turn a suffix request into an absolute range using the object's size.
+
+ A suffix range names its bytes relative to an end this server does not
+ otherwise need to know. Resolving it here is what lets the 206 carry a
+ `Content-Range`, which RFC 9110 requires and which a caller reading a
+ shard index needs in order to locate what it was given.
+ """
+ try:
+ size = await store.getsize(path)
+ except FileNotFoundError:
+ return _RangeVerdict.UNSATISFIABLE
+ if size == 0:
+ return _RangeVerdict.UNSATISFIABLE
+ # A suffix longer than the object is satisfiable and yields the whole
+ # object, per RFC 9110 §14.1.2.
+ return RangeByteRequest(start=max(0, size - byte_range.suffix), end=size)
+
+
+_JSON_BASENAMES = (
+ array_metadata_keys(2)
+ | array_metadata_keys(3)
+ | group_metadata_keys(2)
+ | group_metadata_keys(3)
+)
+"""Metadata documents that are JSON, for every zarr format.
+
+Derived from the same tables that decide which keys a node owns, so a v2
+array's `.zarray` is typed as JSON rather than as opaque bytes -- and adding a
+document in one place cannot leave the media type behind in the other.
+"""
+
+
+def content_type_for(path: str) -> str:
+ """Media type for a store key, chosen by its basename."""
+ if path.rsplit("/", 1)[-1] in _JSON_BASENAMES:
+ return "application/json"
+ return "application/octet-stream"
+
+
+async def _head_response(store: Store, path: str, content_type: str) -> Response:
+ """Answer a HEAD without transferring the value.
+
+ A HEAD body is discarded at the wire, so routing HEAD through the GET
+ handler reads the whole object -- megabytes of chunk or shard -- to report
+ a length. `Store.getsize` is a `stat` on a filesystem store and an
+ info/HEAD call on a remote one.
+ """
+ from starlette.responses import Response
+
+ try:
+ size = await store.getsize(path)
+ except FileNotFoundError:
+ return Response(status_code=404)
+ except OSError as exc:
+ if not _names_nothing(exc):
+ raise
+ return Response(status_code=404)
+ return Response(status_code=200, media_type=content_type, headers={"Content-Length": str(size)})
+
+
+async def _get_response(
+ store: Store,
+ path: str,
+ byte_range: RangeByteRequest | OffsetByteRequest | None = None,
+) -> Response:
+ """Fetch a key from the store and return an HTTP response."""
+ from starlette.responses import Response
+
+ proto = cpu.buffer_prototype
+ content_type = content_type_for(path)
+
+ try:
+ buf = await store.get(path, proto, byte_range=byte_range)
+ except (MemoryError, OverflowError):
+ # The client's last-byte-pos is wider than the store can materialize
+ # -- it sizes its read from the range, not from the object. RFC 9110
+ # §14.1.2 makes a last-byte-pos at or past the end of the object
+ # satisfiable and clamps it to the end, so re-read from the same start
+ # to EOF, which is what the clamped range denotes. Refusing with 416
+ # would deny a request that is merely over-wide, and a client asking
+ # for "from here to well past the end" is asking a normal question.
+ if not isinstance(byte_range, RangeByteRequest):
+ raise
+ byte_range = OffsetByteRequest(offset=byte_range.start)
+ buf = await store.get(path, proto, byte_range=byte_range)
+ except OSError as exc:
+ if not _names_nothing(exc):
+ # A real I/O failure, which must not be reported as a miss. Under
+ # the v3 spec an absent chunk is an uninitialized one, and a
+ # reader is right to substitute the array's fill value for it --
+ # so 404 asserts something about the store's contents. An
+ # unreadable chunk is not an uninitialized chunk, and answering
+ # 404 would have a correct client silently materialize fill
+ # values over data that exists.
+ raise
+ return Response(status_code=404)
+ if buf is None:
+ return Response(status_code=404)
+
+ if byte_range is None:
+ return Response(content=buf.to_bytes(), status_code=200, media_type=content_type)
+
+ body = buf.to_bytes()
+ if len(body) == 0:
+ # The range lies wholly beyond the end of the object.
+ return Response(status_code=416)
+
+ headers = {"Content-Range": _content_range(byte_range, len(body))}
+ return Response(content=body, status_code=206, media_type=content_type, headers=headers)
+
+
+async def _handle_request(request: Request) -> Response:
+ """Handle a request, optionally filtering by node validity."""
+ from starlette.responses import Response
+
+ store: Store = request.app.state.store
+ node: Array[Any] | Group | None = request.app.state.node
+ prefix: str = request.app.state.prefix
+ path = request.path_params.get("path", "")
+
+ # Reject non-canonical / traversal-prone keys before touching the store.
+ # Starlette percent-decodes path params, so "..%2f" arrives as a literal
+ # ".." segment and "%2f"-encoded leading slashes arrive as an empty leading
+ # segment (making the key absolute, which escapes a filesystem store root).
+ # Backslashes are separators on Windows and drive-qualified or
+ # root-relative keys discard a filesystem store's root entirely, so fold
+ # separators before checking segments -- mirroring zarr's normalize_path
+ # (src/zarr/storage/_utils.py) plus a drive check it doesn't need. Legitimate
+ # zarr keys never contain empty, ".", or ".." segments, or a drive letter.
+ segments = path.replace("\\", "/").split("/")
+ if any(segment in ("", ".", "..") for segment in segments) or _is_drive_qualified(path):
+ return Response(status_code=404)
+
+ # A NUL can never appear in a store key, and reaches the filesystem layer
+ # as a raised error rather than a miss. Rejecting it here keeps that out
+ # of the store, so the store's own errors always mean real I/O trouble.
+ if "\x00" in path:
+ return Response(status_code=404)
+
+ # If serving a node, validate the key before touching the store. Group
+ # validation opens children through zarr's synchronous API, which drives
+ # the store to completion and would otherwise block the event loop for
+ # the duration -- serializing every concurrent request behind it, and
+ # outlasting the shutdown timeout.
+ if node is not None and not await asyncio.to_thread(is_valid_node_key, node, path):
+ return Response(status_code=404)
+
+ # Resolve the full store key by prepending the node's prefix.
+ store_key = f"{prefix}/{path}" if prefix else path
+
+ if request.method == "PUT":
+ if store.read_only:
+ # The store will refuse this with a ValueError from deep inside
+ # `set`, which is a 500 -- a server fault. Refusing to write to a
+ # read-only store is not a fault, it is the answer.
+ return Response(status_code=403)
+
+ max_body_size: int | None = request.app.state.max_body_size
+ if max_body_size is None:
+ body = await request.body()
+ else:
+ declared = request.headers.get("content-length")
+ if declared is not None and declared.isdigit() and int(declared) > max_body_size:
+ return Response(status_code=413)
+
+ # Read incrementally and stop at the cap. `request.body()` would
+ # buffer the whole body first, which a chunked request can use to
+ # exceed the cap by any amount before it is ever checked.
+ chunks: list[bytes] = []
+ received = 0
+ async for chunk in request.stream():
+ received += len(chunk)
+ if received > max_body_size:
+ return Response(status_code=413)
+ chunks.append(chunk)
+ body = b"".join(chunks)
+
+ buf = cpu.buffer_prototype.buffer.from_bytes(body)
+ try:
+ await store.set(store_key, buf)
+ except OSError as exc:
+ if not _names_nothing(exc):
+ # A real write failure -- a full disk, a read-only mount, a
+ # permissions problem. Reporting it as 404 would tell the
+ # client the write is pointless rather than failed.
+ raise
+ return Response(status_code=404)
+ return Response(status_code=204)
+
+ if request.method == "HEAD":
+ # A HEAD body is discarded at the wire, so reading the value to build
+ # one transfers the whole object to answer a question about its size.
+ # `getsize` is a stat on a filesystem store and a HEAD/info call on a
+ # remote one.
+ return await _head_response(store, store_key, content_type_for(path))
+
+ range_header = request.headers.get("range")
+ byte_range: RangeByteRequest | OffsetByteRequest | None = None
+ if range_header is not None:
+ parsed = _parse_range_header(range_header)
+ if parsed is _RangeVerdict.UNSATISFIABLE:
+ return Response(status_code=416)
+ if isinstance(parsed, SuffixByteRequest):
+ parsed = await _resolve_suffix(store, store_key, parsed)
+ if parsed is _RangeVerdict.UNSATISFIABLE:
+ return Response(status_code=416)
+ # _RangeVerdict.IGNORE falls through with byte_range still None, which
+ # serves the full representation with 200.
+ if not isinstance(parsed, _RangeVerdict):
+ byte_range = parsed
+
+ return await _get_response(store, store_key, byte_range)
+
+
+def _make_starlette_app(
+ *,
+ methods: AbstractSet[HTTPMethod] | None = None,
+ cors_options: CorsOptions | None = None,
+) -> Starlette:
+ """Create a Starlette app with the request handler.
+
+ Raises
+ ------
+ ValueError
+ If `methods` contains anything outside `GET`, `PUT`, and `HEAD`.
+ """
+ from starlette.applications import Starlette
+ from starlette.middleware.cors import CORSMiddleware
+ from starlette.routing import Route
+
+ if methods is None:
+ methods = READ_ONLY_HTTP_METHODS
+
+ # An empty set must not reach Starlette: `Route` treats a falsy `methods`
+ # as "match every method", so asking for no methods would serve them all.
+ if not methods:
+ raise ValueError(
+ "methods must name at least one HTTP method; "
+ f"accepted methods are {', '.join(sorted(_SUPPORTED_METHODS))}."
+ )
+
+ unsupported = sorted(set(methods) - _SUPPORTED_METHODS)
+ if unsupported:
+ raise ValueError(
+ f"Unsupported HTTP method(s): {', '.join(unsupported)}. "
+ f"Accepted methods are {', '.join(sorted(_SUPPORTED_METHODS))}."
+ )
+
+ app = Starlette(
+ routes=[Route("/{path:path}", _handle_request, methods=list(methods))],
+ )
+
+ if cors_options is not None:
+ # Typed loosely on purpose: unpacking a merged TypedDict loses the
+ # per-key types, so the looseness is contained to this block rather
+ # than spread across casts at each use.
+ merged: dict[str, Any] = {**_CORS_DEFAULTS, **cors_options}
+ if "allow_methods" in merged:
+ # Only when the caller said something. Absent, Starlette's own
+ # `GET`-only default stands: widening it to everything served
+ # would newly advertise `PUT` cross-origin on a write-enabled app
+ # that never asked for it.
+ merged["allow_methods"] = _reconcile_allow_methods(
+ merged["allow_methods"], served=_served_methods(methods)
+ )
+ app.add_middleware(
+ CORSMiddleware,
+ # Our defaults first, so a key the caller supplied wins outright
+ # rather than being merged into -- an explicit `expose_headers: []`
+ # means "expose nothing", not "expose our default".
+ **merged,
+ )
+ return app
+
+
+def _reject_writes_to_a_read_only_store(
+ store: Store, methods: AbstractSet[HTTPMethod] | None
+) -> None:
+ """Refuse a configuration whose writes can never succeed.
+
+ A store's `read_only` is fixed when it is built, so asking to serve `PUT`
+ from one is a contradiction that would only reveal itself as a 403 on the
+ first write a client attempts -- possibly long after deployment, and to
+ the client rather than to whoever misconfigured it. Saying so at
+ construction matches how unsupported `methods` and contradictory
+ `cors_options` are already handled.
+
+ Raises
+ ------
+ ValueError
+ If `methods` asks for `PUT` on a read-only store.
+ """
+ if methods is not None and "PUT" in methods and store.read_only:
+ raise ValueError(
+ "methods asks for PUT, but the store is read-only, so no write "
+ "could ever succeed. Drop PUT to serve reads, or pass a writable "
+ "store (`store.with_read_only(False)`)."
+ )
+
+
+def _served_methods(methods: AbstractSet[HTTPMethod]) -> frozenset[str]:
+ """The methods the route will actually answer.
+
+ Starlette adds `HEAD` to any route that serves `GET`, which RFC 9110
+ §9.3.2 asks of every origin server, so `HEAD` is served whenever `GET` is
+ whether or not it was named.
+ """
+ served = set(methods)
+ if "GET" in served:
+ served.add("HEAD")
+ return frozenset(served)
+
+
+def _reconcile_allow_methods(allow_methods: list[str], *, served: frozenset[str]) -> list[str]:
+ """Check `cors_options["allow_methods"]` against what the route serves.
+
+ An advertised method the route rejects is a promise the server cannot
+ keep: a browser caches the preflight and every later cross-origin call
+ fails with 405 after a successful handshake. The reverse is worse -- the
+ same silence lets `allow_methods=["*"]` on a write-enabled app hand every
+ origin on the internet write access, which is exactly the footgun the
+ `methods` validation above exists to prevent.
+
+ `"*"` expands to what is actually served rather than being rejected: it
+ is the idiomatic spelling of "everything this app does", and the app
+ cannot do more than it serves.
+ """
+ if "*" in allow_methods:
+ return sorted(served)
+
+ unserved = sorted(set(allow_methods) - served)
+ if unserved:
+ raise ValueError(
+ f"cors_options['allow_methods'] advertises {', '.join(unserved)}, "
+ f"which this app does not serve (it serves {', '.join(sorted(served))}). "
+ "A browser would cache that preflight and every such request would "
+ "then fail with 405."
+ )
+ return list(allow_methods)
+
+
+def _build_server(
+ app: Starlette,
+ *,
+ host: str,
+ port: int | Literal["auto"],
+ shutdown_timeout: int,
+ uvicorn_options: Mapping[str, object] | None,
+) -> tuple[uvicorn.Server, dict[str, object], socket.socket | None]:
+ """Configure a `uvicorn.Server` for *app*, and report how it will bind.
+
+ Returns the options actually used -- a key from `uvicorn_options` may have
+ replaced one passed here -- and, for `port="auto"`, the socket already
+ bound on the caller's behalf, which must be handed to `Server.run`.
+ """
+ import uvicorn
+
+ options: dict[str, object] = {
+ "host": host,
+ "port": port,
+ "timeout_graceful_shutdown": shutdown_timeout,
+ }
+ if uvicorn_options is not None:
+ options.update(uvicorn_options)
+
+ sock: socket.socket | None = None
+ if options.get("port") == AUTO_PORT:
+ if _binds_without_a_port(options):
+ # A uds or fd bind ignores host and port; leave a valid int in
+ # place of the sentinel so `Config` still type-checks.
+ options["port"] = DEFAULT_PORT
+ else:
+ # Bind here rather than probing and handing uvicorn a port number:
+ # probing would release the port before uvicorn claimed it, which
+ # is the bind-then-close race that makes "find a free port" helpers
+ # flaky. Holding the socket means nothing can take it in between.
+ sock = _bind_preferred_or_free(str(options["host"]), DEFAULT_PORT)
+ # Keep Config agreeing with reality, so uvicorn's own "running on
+ # ..." line names the port it is really serving.
+ options["port"] = sock.getsockname()[1]
+
+ # uvicorn.Config's parameters are individually typed and there are ~50 of
+ # them; `Mapping[str, object]` is the honest type for the public argument,
+ # so the cast is confined to the call itself.
+ return uvicorn.Server(uvicorn.Config(app, **cast("dict[str, Any]", options))), options, sock
+
+
+def _binds_without_a_port(options: Mapping[str, object]) -> bool:
+ """Whether these options bind something other than a TCP host and port."""
+ return options.get("uds") is not None or options.get("fd") is not None
+
+
+def _bind_preferred_or_free(host: str, preferred: int) -> socket.socket:
+ """Bind *preferred* on *host* if it is free, otherwise any free port.
+
+ The address family comes from `getaddrinfo` rather than being assumed:
+ hard-coding `AF_INET` would fail for an IPv6 host such as ``"::1"``.
+ """
+ for candidate in (preferred, 0):
+ family, socktype, proto, _, sockaddr = socket.getaddrinfo(
+ host, candidate, type=socket.SOCK_STREAM
+ )[0]
+ sock = socket.socket(family, socktype, proto)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.bind(sockaddr)
+ except OSError:
+ sock.close()
+ if candidate == 0:
+ # Nothing is free, which is a real failure rather than a
+ # reason to keep looking.
+ raise
+ _LOGGER.info(
+ "port %d is in use; binding a free port instead. Pass an "
+ "explicit port to require a particular one.",
+ preferred,
+ )
+ continue
+ return sock
+ raise AssertionError("unreachable") # pragma: no cover
+
+
+def serve(
+ app: Starlette,
+ *,
+ host: str = "127.0.0.1",
+ port: int | Literal["auto"] = AUTO_PORT,
+ shutdown_timeout: int = 5,
+ uvicorn_options: Mapping[str, object] | None = None,
+) -> None:
+ """Run an ASGI app under Uvicorn, blocking until it is stopped.
+
+ Returns only when the server stops, so this is the shape for a process
+ whose job is to serve -- a script, a container entrypoint. Use
+ :func:`serve_background` when the caller has more to do.
+
+ Build the app first with :func:`store_app` or :func:`node_app`, or compose
+ several of them; what an app serves is settled when it is built, and this
+ only decides how it runs.
+
+ .. code-block:: python
+
+ serve(store_app(store), host="0.0.0.0", port=8000)
+
+ Parameters
+ ----------
+ app : Starlette
+ The ASGI app to run.
+ host : str, optional
+ The host to bind to. Defaults to ``"127.0.0.1"``.
+ port : int, optional
+ The port to bind to. Defaults to ``8000``.
+ shutdown_timeout : int, optional
+ Seconds to wait for in-flight requests to finish gracefully when the
+ server is shut down, via uvicorn's ``timeout_graceful_shutdown``.
+ Defaults to ``5``.
+ uvicorn_options : Mapping[str, object], optional
+ Extra options passed straight to `uvicorn.Config`, merged over the
+ ones set here (`host`, `port`, `timeout_graceful_shutdown`), so a
+ caller key wins. This is the escape hatch for anything uvicorn can do
+ that this signature does not name -- TLS via `ssl_keyfile` /
+ `ssl_certfile`, `proxy_headers` and `forwarded_allow_ips` behind a
+ reverse proxy, `root_path` when mounted under a prefix, `log_level`,
+ `limit_concurrency`, or a `uds` / `fd` bind.
+ """
+ server, _, sock = _build_server(
+ app,
+ host=host,
+ port=port,
+ shutdown_timeout=shutdown_timeout,
+ uvicorn_options=uvicorn_options,
+ )
+ server.run(sockets=[sock] if sock is not None else None)
+
+
+def serve_background(
+ app: Starlette,
+ *,
+ host: str = "127.0.0.1",
+ port: int | Literal["auto"] = AUTO_PORT,
+ shutdown_timeout: int = 5,
+ uvicorn_options: Mapping[str, object] | None = None,
+) -> BackgroundServer:
+ """Start an ASGI app under Uvicorn in a daemon thread and return at once.
+
+ Returns once the socket is listening, so the next statement can use the
+ server. The returned handle is also a context manager:
+
+ .. code-block:: python
+
+ with serve_background(node_app(array)) as server:
+ httpx.get(f"{server.url}/zarr.json")
+
+ In a notebook, where the server must outlive the cell that started it,
+ keep the handle instead and call :meth:`BackgroundServer.shutdown` later.
+
+ Parameters
+ ----------
+ app : Starlette
+ The ASGI app to run.
+ host : str, optional
+ The host to bind to. Defaults to ``"127.0.0.1"``.
+ port : int, optional
+ The port to bind to. Defaults to ``0``, which asks the OS for a free
+ one -- unlike :func:`serve`, whose caller usually needs a port others
+ already know. A background server is normally reached through
+ :attr:`BackgroundServer.url`, and a fixed default would make starting
+ a second one, or re-running a notebook cell, fail on a port collision.
+ shutdown_timeout : int, optional
+ Seconds to wait for in-flight requests to finish gracefully, both for
+ uvicorn's ``timeout_graceful_shutdown`` and for the wait
+ :meth:`BackgroundServer.shutdown` uses before forcing the thread
+ closed. Defaults to ``5``.
+ uvicorn_options : Mapping[str, object], optional
+ Extra options passed straight to `uvicorn.Config`, merged over the
+ ones set here so a caller key wins. See :func:`serve`. Binding
+ somewhere other than a TCP host and port leaves
+ :attr:`BackgroundServer.url` as ``None``.
+
+ Returns
+ -------
+ BackgroundServer
+ A handle for the running server.
+
+ Raises
+ ------
+ RuntimeError
+ If the server does not start -- most often because the port is
+ already in use.
+ """
+ server, options, sock = _build_server(
+ app,
+ host=host,
+ port=port,
+ shutdown_timeout=shutdown_timeout,
+ uvicorn_options=uvicorn_options,
+ )
+
+ # uvicorn skips signal-handler installation off the main thread
+ # (Server.capture_signals), so no workaround is needed here.
+ thread = threading.Thread(
+ target=partial(server.run, sockets=[sock] if sock is not None else None), daemon=True
+ )
+ thread.start()
+
+ deadline = time.monotonic() + _STARTUP_TIMEOUT
+ while not server.started:
+ if not thread.is_alive():
+ # uvicorn logs the underlying error and calls sys.exit, which in a
+ # thread ends it without surfacing anything to the caller. The
+ # overwhelmingly common cause is a port already in use.
+ raise RuntimeError(
+ f"Server thread exited before startup completed; {host}:{port} "
+ "may already be in use. See the server log for the cause."
+ )
+ if time.monotonic() > deadline:
+ # The thread is still alive here, unlike the branch above, and it
+ # is about to finish starting. Raising without stopping it would
+ # leave a server bound to the port with no handle to shut it down
+ # -- a daemon thread serving for the rest of the process, and a
+ # retry on the same port failing with the other error above.
+ server.should_exit = True
+ server.force_exit = True
+ thread.join(timeout=_STARTUP_ABANDON_TIMEOUT)
+ raise RuntimeError(
+ f"Server failed to start within {_STARTUP_TIMEOUT:g} seconds; "
+ "it has been signalled to stop."
+ )
+ time.sleep(0.01)
+
+ # Report the port the socket actually bound rather than the one asked for,
+ # so `port=0` ("pick a free port") yields a usable `url`. A unix-socket or
+ # file-descriptor bind has no host and port at all, so both stay None and
+ # `url` reports None rather than naming an address nothing is listening on.
+ bound_port: int | None = None
+ for bound in server.servers:
+ for sock in bound.sockets:
+ sockname = sock.getsockname()
+ if isinstance(sockname, tuple) and len(sockname) >= 2:
+ bound_port = int(sockname[1])
+ break
+ break
+
+ # The host is taken from the request rather than the socket: a wildcard
+ # bind reports "0.0.0.0", which is not an address a client can connect to.
+ requested_host = options.get("host")
+ bound_host = (
+ str(requested_host) if bound_port is not None and requested_host is not None else None
+ )
+
+ return BackgroundServer(
+ server,
+ thread,
+ host=bound_host,
+ port=bound_port,
+ scheme="https" if server.config.is_ssl else "http",
+ shutdown_timeout=shutdown_timeout,
+ )
+
+
+def store_app(
+ store: Store,
+ *,
+ methods: AbstractSet[HTTPMethod] | None = None,
+ cors_options: CorsOptions | None = None,
+ max_body_size: int | None = DEFAULT_MAX_BODY_SIZE,
+) -> Starlette:
+ """Create a Starlette ASGI app that serves every key in a zarr ``Store``.
+
+ Parameters
+ ----------
+ store : Store
+ The zarr store to serve.
+ methods : set of HTTPMethod, optional
+ The HTTP methods to accept: any of `"GET"`, `"HEAD"`, and `"PUT"`.
+ Defaults to `{"GET"}`, which also serves `HEAD`. Passing any other
+ method raises `ValueError`.
+ cors_options : CorsOptions, optional
+ If provided, CORS middleware will be added with the given options.
+ max_body_size : int or None, optional
+ Largest `PUT` body to accept, in bytes; larger requests get a 413.
+ `Store.set` takes a whole `Buffer`, so bodies cannot be streamed and
+ are held in memory in full. Defaults to `DEFAULT_MAX_BODY_SIZE`;
+ pass `None` to lift the cap.
+
+ Returns
+ -------
+ Starlette
+ An ASGI application.
+ """
+ _reject_writes_to_a_read_only_store(store, methods)
+ app = _make_starlette_app(methods=methods, cors_options=cors_options)
+ app.state.store = store
+ app.state.node = None
+ app.state.prefix = ""
+ app.state.max_body_size = max_body_size
+ return app
+
+
+def node_app(
+ node: Array[Any] | Group,
+ *,
+ methods: AbstractSet[HTTPMethod] | None = None,
+ cors_options: CorsOptions | None = None,
+ max_body_size: int | None = DEFAULT_MAX_BODY_SIZE,
+) -> Starlette:
+ """Create a Starlette ASGI app that serves only the keys belonging to a
+ zarr ``Array`` or ``Group``.
+
+ For an ``Array``, the served keys are the metadata document(s) and all
+ chunk (or shard) keys whose coordinates fall within the array's grid.
+
+ For a ``Group``, the served keys are the group's own metadata plus any
+ path that resolves through the group's members to a valid array metadata
+ document or chunk key.
+
+ Requests for keys outside this set receive a 404 response, even if the
+ underlying store contains data at that path.
+
+ Parameters
+ ----------
+ node : Array or Group
+ The zarr array or group to serve.
+ methods : set of HTTPMethod, optional
+ The HTTP methods to accept: any of `"GET"`, `"HEAD"`, and `"PUT"`.
+ Defaults to `{"GET"}`, which also serves `HEAD`. Passing any other
+ method raises `ValueError`.
+ cors_options : CorsOptions, optional
+ If provided, CORS middleware will be added with the given options.
+ max_body_size : int or None, optional
+ Largest `PUT` body to accept, in bytes; larger requests get a 413.
+ `Store.set` takes a whole `Buffer`, so bodies cannot be streamed and
+ are held in memory in full. Defaults to `DEFAULT_MAX_BODY_SIZE`;
+ pass `None` to lift the cap.
+
+ Returns
+ -------
+ Starlette
+ An ASGI application.
+ """
+ _reject_writes_to_a_read_only_store(node.store_path.store, methods)
+ app = _make_starlette_app(methods=methods, cors_options=cors_options)
+ app.state.store = node.store_path.store
+ app.state.node = node
+ app.state.prefix = node.store_path.path
+ app.state.max_body_size = max_body_size
+ return app
diff --git a/packages/zarr-http-server/src/zarr_http_server/py.typed b/packages/zarr-http-server/src/zarr_http_server/py.typed
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/zarr-http-server/tests/conftest.py b/packages/zarr-http-server/tests/conftest.py
new file mode 100644
index 0000000000..84694b42ba
--- /dev/null
+++ b/packages/zarr-http-server/tests/conftest.py
@@ -0,0 +1,30 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Literal
+
+import pytest
+from zarr.storage import MemoryStore
+
+if TYPE_CHECKING:
+ from zarr.abc.store import Store
+
+ZarrFormat = Literal[2, 3]
+
+
+@pytest.fixture
+def store(request: pytest.FixtureRequest) -> Store:
+ """Store fixture resolved via indirect parametrization."""
+ if request.param != "memory":
+ raise ValueError(f"unsupported store param: {request.param!r}")
+ return MemoryStore()
+
+
+@pytest.fixture(params=(2, 3), ids=["zarr2", "zarr3"])
+def zarr_format(request: pytest.FixtureRequest) -> ZarrFormat:
+ """Zarr format version fixture, parametrized over v2 and v3."""
+ if request.param == 2:
+ return 2
+ elif request.param == 3:
+ return 3
+ msg = f"Invalid zarr format requested. Got {request.param}, expected one of (2, 3)."
+ raise ValueError(msg)
diff --git a/packages/zarr-http-server/tests/test_examples.py b/packages/zarr-http-server/tests/test_examples.py
new file mode 100644
index 0000000000..20a5d5b1b0
--- /dev/null
+++ b/packages/zarr-http-server/tests/test_examples.py
@@ -0,0 +1,60 @@
+"""The shipped examples must keep working.
+
+An example that has quietly rotted is worse than no example: it is the first
+thing a new user copies. These run the real files rather than a paraphrase of
+them, so a signature change or a behavior change fails here rather than in
+someone's notebook.
+"""
+
+from __future__ import annotations
+
+import pathlib
+import runpy
+
+import pytest
+
+EXAMPLES = pathlib.Path(__file__).resolve().parent.parent / "examples"
+NOTEBOOK = EXAMPLES / "serve_notebook.ipynb"
+SCRIPT = EXAMPLES / "serve.py"
+
+
+@pytest.mark.parametrize("path", [NOTEBOOK, SCRIPT], ids=["notebook", "script"])
+def test_example_exists(path: pathlib.Path) -> None:
+ """Guards the paths above: a renamed or moved example would otherwise turn
+ its execution test into a skip, or a no-op, that nobody notices."""
+ assert path.is_file(), f"missing example at {path}"
+
+
+def test_serve_script_runs() -> None:
+ """Run examples/serve.py top to bottom.
+
+ In-process rather than as a subprocess: the script's inline uv metadata
+ resolves `zarr-http-server` from git, so `uv run` on it would test whatever
+ is on main instead of the working tree.
+ """
+ runpy.run_path(str(SCRIPT), run_name="__main__")
+
+
+def test_serve_notebook_executes() -> None:
+ """Run every cell in a real kernel.
+
+ The notebook asserts its own expectations -- status codes, byte ranges,
+ that a PUT is refused, that the port is closed after `shutdown()` -- so
+ this is not merely a check that nothing raised. `NotebookClient.execute`
+ defaults to ``allow_errors=False``, so any failed cell raises
+ `CellExecutionError` and fails this test with that cell's traceback.
+ """
+ nbformat = pytest.importorskip("nbformat")
+ nbclient = pytest.importorskip("nbclient")
+ pytest.importorskip("ipykernel", reason="a kernel is needed to execute the notebook")
+
+ notebook = nbformat.read(NOTEBOOK, as_version=4)
+
+ # Run with the notebook's own directory as cwd, so any relative path it
+ # uses means the same thing as when a reader opens it.
+ nbclient.NotebookClient(
+ notebook,
+ timeout=300,
+ kernel_name="python3",
+ resources={"metadata": {"path": str(EXAMPLES)}},
+ ).execute()
diff --git a/packages/zarr-http-server/tests/test_properties.py b/packages/zarr-http-server/tests/test_properties.py
new file mode 100644
index 0000000000..ff54392dfc
--- /dev/null
+++ b/packages/zarr-http-server/tests/test_properties.py
@@ -0,0 +1,580 @@
+"""Property-based tests driven against a real HTTP endpoint.
+
+Every test here talks to an actual uvicorn server over a socket, not to an
+in-process ASGI client, so the properties cover the parts of the stack that
+only exist on the wire: header parsing, method dispatch, and status codes.
+
+Each property checks **two** things after a request -- the response, and the
+state of the backing store. That pairing is the point. A server can answer
+correctly and corrupt the store, or refuse a request and write anyway, and a
+response-only assertion sees neither. The bug that motivated this module did
+exactly that: a `PUT` to a non-canonically spelled chunk key ("c/00/00"
+instead of "c/0/0") answered `204 No Content` and stored the body under a key
+no reader ever looks up, so the client saw success and the data was invisible.
+
+The vocabulary below splits keys into two families:
+
+* **in-band** -- keys the served node genuinely owns: its metadata documents
+ and the canonical spelling of each chunk key in its grid. These must be
+ served, and writes to them must be visible to a zarr reader.
+* **out-of-band** -- everything else: non-canonical spellings of a valid
+ chunk key, coordinates outside the grid, traversal attempts, and keys
+ belonging to a sibling node. These must be refused *and* must leave the
+ store byte-for-byte unchanged.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Literal
+from urllib.parse import quote
+
+import httpx
+import numpy as np
+import pytest
+import zarr
+from hypothesis import assume, given, settings
+from hypothesis import strategies as st
+from zarr.buffer import cpu
+from zarr.core.sync import sync
+
+from zarr_http_server import node_app, serve_background, store_app
+from zarr_http_server._keys import _shard_grid_shape
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator
+
+ from zarr import Array
+ from zarr.abc.store import Store
+
+# Real HTTP round trips are slower and jumpier than hypothesis' default
+# deadline allows, and a CI runner under load makes that worse.
+_HTTP = settings(deadline=None, max_examples=50)
+
+# An uncompressed array so a chunk's bytes are exactly its raw values: a PUT
+# body of the right length is a legitimate chunk, which lets these tests
+# assert that a write is readable through a zarr client rather than merely
+# present in the store.
+_SHAPE = (6, 4)
+_CHUNKS = (2, 2)
+_DTYPE = "int32"
+
+
+@dataclass(frozen=True)
+class Served:
+ """A running server plus the handles needed to reason about its keys."""
+
+ url: str
+ store: Store
+ array: Array[Any]
+ kind: Literal["store", "node"]
+
+ http_prefix: str
+ """Prepended to an array-relative key to form the request path."""
+
+ store_prefix: str
+ """Prepended to an array-relative key to form the backing store key."""
+
+ def path(self, array_relative_key: str) -> str:
+ return f"{self.http_prefix}{array_relative_key}"
+
+ def store_key(self, array_relative_key: str) -> str:
+ return f"{self.store_prefix}{array_relative_key}"
+
+
+def _snapshot(store: Store) -> dict[str, bytes]:
+ """Every key in *store* mapped to its bytes.
+
+ Comparing two snapshots is how these tests assert that a rejected request
+ changed nothing -- not just that it added no key, but that it modified
+ none either.
+ """
+
+ async def _read() -> dict[str, bytes]:
+ out: dict[str, bytes] = {}
+ async for key in store.list():
+ buf = await store.get(key, cpu.buffer_prototype)
+ if buf is not None:
+ out[key] = buf.to_bytes()
+ return out
+
+ return sync(_read())
+
+
+@pytest.fixture(
+ scope="module",
+ params=[
+ ("store", 2, "memory"),
+ ("store", 3, "memory"),
+ ("node", 2, "memory"),
+ ("node", 3, "memory"),
+ # LocalStore reads through the filesystem, which sizes its read from
+ # the range rather than from the object and so raises on an over-wide
+ # one. MemoryStore just slices a `bytes` and is happy with any bound,
+ # so a memory-only matrix cannot tell a clamped range from a refused
+ # one -- the store backend is part of what these properties test.
+ ("store", 3, "local"),
+ ("node", 3, "local"),
+ ],
+ ids=["store-v2", "store-v3", "node-v2", "node-v3", "store-v3-local", "node-v3-local"],
+)
+def served(
+ request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory
+) -> Iterator[Served]:
+ """One real server per (app kind, zarr format, store backend).
+
+ Module-scoped because hypothesis drives hundreds of requests per test and
+ a server per example would dominate the runtime. `port=0` lets the OS
+ choose the port and `server.url` reports what it actually bound, which
+ avoids the bind-then-close race of picking a port up front.
+ """
+ kind, zarr_format, backend = request.param
+
+ store: Store
+ if backend == "local":
+ root_dir = tmp_path_factory.mktemp(f"{kind}-v{zarr_format}")
+ store = zarr.storage.LocalStore(root_dir)
+ else:
+ store = zarr.storage.MemoryStore()
+ root = zarr.open_group(store, mode="w", zarr_format=zarr_format)
+ array = root.create_array(
+ "inside", shape=_SHAPE, chunks=_CHUNKS, dtype=_DTYPE, compressors=None
+ )
+ array[:] = np.arange(int(np.prod(_SHAPE)), dtype=_DTYPE).reshape(_SHAPE)
+ # A sibling the node_app must never serve, and whose keys therefore make
+ # good out-of-band probes.
+ sibling = root.create_array(
+ "outside", shape=_CHUNKS, chunks=_CHUNKS, dtype=_DTYPE, compressors=None
+ )
+ sibling[:] = 1
+
+ if kind == "node":
+ server = serve_background(node_app(array, methods={"GET", "PUT"}), host="127.0.0.1", port=0)
+ http_prefix = ""
+ else:
+ server = serve_background(
+ store_app(store, methods={"GET", "PUT"}), host="127.0.0.1", port=0
+ )
+ http_prefix = "inside/"
+ assert server is not None
+
+ try:
+ yield Served(
+ url=server.url,
+ store=store,
+ array=array,
+ kind=kind,
+ http_prefix=http_prefix,
+ store_prefix="inside/",
+ )
+ finally:
+ server.shutdown()
+
+
+def _grid(array: Array[Any]) -> tuple[int, ...]:
+ return _shard_grid_shape(array)
+
+
+@st.composite
+def chunk_coords(draw: st.DrawFn, array: Array[Any]) -> tuple[int, ...]:
+ """Coordinates of a chunk that exists in *array*'s storage grid."""
+ return tuple(draw(st.integers(min_value=0, max_value=g - 1)) for g in _grid(array))
+
+
+@st.composite
+def out_of_grid_coords(draw: st.DrawFn, array: Array[Any]) -> tuple[int, ...]:
+ """Coordinates outside the grid, so the key is well-formed but names nothing."""
+ grid = _grid(array)
+ axis = draw(st.integers(min_value=0, max_value=len(grid) - 1))
+ coords = list(draw(chunk_coords(array)))
+ coords[axis] = draw(st.integers(min_value=grid[axis], max_value=grid[axis] + 50))
+ return tuple(coords)
+
+
+# The confusability with ASCII digits is the point: `int()` accepts these as
+# decimal digits, so they name a different store key while decoding to the
+# same coordinate. That is the defect these strategies probe for.
+_ARABIC_INDIC = "٠١٢٣٤٥٦٧٨٩"
+_FULLWIDTH = "0123456789" # noqa: RUF001
+
+
+def _respellings(digits: str) -> list[str]:
+ """Strings `int()` maps to the same value as *digits*, spelled differently.
+
+ These are exactly the spellings that make decode-only validation unsafe:
+ each names a *different* store key while decoding to the same coordinate.
+ """
+ value = int(digits)
+ return [
+ f"0{digits}",
+ f"00{digits}",
+ f"+{digits}",
+ f" {digits}",
+ f"{digits} ",
+ f"\t{digits}",
+ "".join(_ARABIC_INDIC[int(d)] for d in digits),
+ "".join(_FULLWIDTH[int(d)] for d in digits),
+ *([f"-{digits}"] if value == 0 else []),
+ ]
+
+
+@st.composite
+def non_canonical_chunk_keys(draw: st.DrawFn, array: Array[Any]) -> str:
+ """A chunk key that decodes into the grid but is not zarr's own spelling.
+
+ Built by taking the canonical key and re-spelling one of its digit runs,
+ which keeps this strategy independent of the chunk key encoding's grammar.
+ """
+ coords = draw(chunk_coords(array))
+ canonical = array.metadata.encode_chunk_key(coords)
+ runs = list(re.finditer(r"\d+", canonical))
+ assume(runs)
+ run = draw(st.sampled_from(runs))
+ replacement = draw(st.sampled_from(_respellings(run.group())))
+ key = canonical[: run.start()] + replacement + canonical[run.end() :]
+ assume(key != canonical)
+ return key
+
+
+TRAVERSAL_KEYS = [
+ # Percent-encoded on purpose. An HTTP client resolves dot-segments before
+ # it sends -- httpx turns "../secret" into "/secret" and ".." into "/" per
+ # RFC 3986 §5.2.4 -- so a literal "../" probe never reaches the server as
+ # traversal and asserts nothing. Encoded, it survives the client intact
+ # and Starlette decodes it back into a real ".." segment on arrival, which
+ # is the form the server's own guard has to catch.
+ "..%2Fsecret",
+ "%2e%2e%2Fsecret",
+ "%2e%2e%2F%2e%2e%2Fetc%2Fpasswd",
+ "%2e%2e",
+ "%2e",
+ "%2e%2Fzarr.json",
+ "a%2F..%2F..%2Fb",
+ "%2Fabsolute",
+ "sub%2F%2Fempty",
+ "C%3A%2Fwindows",
+ "..%5Cwindows",
+ "a%5C..%5C..%5Cb",
+ "%00nul",
+]
+
+
+def _chunk_payload(array: Array[Any]) -> bytes:
+ """Bytes of a full, uncompressed chunk for *array*."""
+ return np.zeros(_CHUNKS, dtype=array.dtype).tobytes()
+
+
+def _url(served: Served, array_relative_key: str) -> str:
+ """Request URL for a key, percent-encoded so it survives the wire verbatim.
+
+ Generated keys contain characters a URL cannot carry literally -- a tab
+ makes httpx raise `InvalidURL`, and a space or a non-ASCII digit would be
+ re-encoded on the way out anyway. Encoding here means Starlette decodes
+ the path param back to exactly the key the strategy produced, so the
+ property really is "for any key K, a request for K is refused" rather than
+ "for any key the URL parser happened to leave alone".
+ """
+ return f"{served.url}/{quote(served.path(array_relative_key), safe='/')}"
+
+
+def _require_node_scope(served: Served) -> None:
+ """Skip a property that only a node-scoped app can satisfy.
+
+ `store_app` proxies the store's raw key space and has no array semantics
+ to validate against, so every syntactically acceptable key is in-band for
+ it by contract -- including a non-canonical chunk spelling. Only
+ `node_app` claims to serve exactly one node's keys, so only `node_app` can
+ be held to what that set contains.
+ """
+ if served.kind != "node":
+ pytest.skip("store_app serves the raw key space; node scoping does not apply")
+
+
+class TestInBandRequests:
+ """Keys the node owns are served, and writes to them are visible."""
+
+ @given(data=st.data())
+ @_HTTP
+ def test_get_of_a_canonical_key_returns_the_stored_bytes(
+ self, served: Served, data: st.DataObject
+ ) -> None:
+ """A GET of an in-band key returns exactly what the store holds, and
+ reading never changes the store."""
+ coords = data.draw(chunk_coords(served.array))
+ relative = served.array.metadata.encode_chunk_key(coords)
+
+ before = _snapshot(served.store)
+ response = httpx.get(_url(served, relative), timeout=30)
+
+ expected = before.get(served.store_key(relative))
+ if expected is None:
+ assert response.status_code == 404
+ else:
+ assert response.status_code == 200
+ assert response.content == expected
+ assert _snapshot(served.store) == before
+
+ @given(data=st.data())
+ @_HTTP
+ def test_put_of_a_canonical_key_is_stored_and_readable(
+ self, served: Served, data: st.DataObject
+ ) -> None:
+ """The headline property: a PUT that reports success must be visible.
+
+ Success means three things at once -- a 2xx, the bytes landing under
+ the key the client named, and a zarr client subsequently reading back
+ the values that were written. The original defect satisfied the first
+ and failed the other two.
+ """
+ coords = data.draw(chunk_coords(served.array))
+ fill = data.draw(st.integers(min_value=-(2**31), max_value=2**31 - 1))
+ relative = served.array.metadata.encode_chunk_key(coords)
+ payload = np.full(_CHUNKS, fill, dtype=served.array.dtype).tobytes()
+
+ before = _snapshot(served.store)
+ response = httpx.put(_url(served, relative), content=payload, timeout=30)
+ assert response.status_code == 204
+
+ after = _snapshot(served.store)
+ key = served.store_key(relative)
+ assert after[key] == payload, "the body did not land under the key the client named"
+ assert set(after) - set(before) <= {key}, "the write touched a key the client did not name"
+
+ # The write is not merely present, it is legible: reopen the array and
+ # read the chunk the coordinates address.
+ reread = zarr.open_array(served.store, path="inside")
+ block = tuple(slice(c * s, (c + 1) * s) for c, s in zip(coords, _CHUNKS, strict=True))
+ assert np.array_equal(reread[block], np.full(_CHUNKS, fill, dtype=served.array.dtype))
+
+ @given(data=st.data())
+ @_HTTP
+ def test_range_of_a_stored_key_returns_the_matching_slice(
+ self, served: Served, data: st.DataObject
+ ) -> None:
+ """A satisfiable range returns exactly the bytes it names, and says so
+ in Content-Range."""
+ relative = served.array.metadata.encode_chunk_key(tuple(0 for _ in _grid(served.array)))
+ key = served.store_key(relative)
+ before = _snapshot(served.store)
+ assume(key in before)
+ body = before[key]
+
+ start = data.draw(st.integers(min_value=0, max_value=len(body) - 1))
+ end = data.draw(st.integers(min_value=start, max_value=len(body) - 1))
+
+ response = httpx.get(
+ _url(served, relative), headers={"Range": f"bytes={start}-{end}"}, timeout=30
+ )
+
+ assert response.status_code == 206
+ assert response.content == body[start : end + 1]
+ # RFC 9110 §15.3.7: a single-part 206 must carry Content-Range, and it
+ # must describe the bytes actually returned.
+ content_range = response.headers["content-range"]
+ assert content_range.startswith(f"bytes {start}-{start + len(response.content) - 1}/")
+ assert _snapshot(served.store) == before
+
+ @given(suffix=st.integers(min_value=1, max_value=200))
+ @_HTTP
+ def test_suffix_range_returns_the_tail_and_locates_it(
+ self, served: Served, suffix: int
+ ) -> None:
+ """A suffix range must report where in the object its bytes came from.
+
+ Zarr's sharding codec reads a shard index this way, so a 206 without
+ Content-Range leaves the reader unable to tell a clamped whole-object
+ read from the tail it asked for.
+ """
+ relative = served.array.metadata.encode_chunk_key(tuple(0 for _ in _grid(served.array)))
+ key = served.store_key(relative)
+ before = _snapshot(served.store)
+ assume(key in before)
+ body = before[key]
+
+ response = httpx.get(
+ _url(served, relative), headers={"Range": f"bytes=-{suffix}"}, timeout=30
+ )
+
+ assert response.status_code == 206
+ expected = body[-suffix:] if suffix <= len(body) else body
+ assert response.content == expected
+ first = len(body) - len(expected)
+ assert response.headers["content-range"].startswith(f"bytes {first}-{len(body) - 1}/")
+ assert _snapshot(served.store) == before
+
+
+class TestOutOfBandRequests:
+ """Keys the node does not own are refused, and change nothing."""
+
+ @given(data=st.data())
+ @_HTTP
+ def test_non_canonical_chunk_key_is_refused_and_writes_nothing(
+ self, served: Served, data: st.DataObject
+ ) -> None:
+ """The regression that motivated this module.
+
+ A key that decodes into the grid but is spelled differently from
+ zarr's own rendering names a store key no reader consults. Accepting a
+ write to it reports success and loses the data, so it must be refused
+ and the store must be untouched.
+ """
+ _require_node_scope(served)
+ relative = data.draw(non_canonical_chunk_keys(served.array))
+ payload = _chunk_payload(served.array)
+
+ before = _snapshot(served.store)
+ put = httpx.put(_url(served, relative), content=payload, timeout=30)
+ assert put.status_code == 404
+ assert _snapshot(served.store) == before, "a refused write still modified the store"
+
+ get = httpx.get(_url(served, relative), timeout=30)
+ assert get.status_code == 404
+ assert _snapshot(served.store) == before
+
+ @given(data=st.data())
+ @_HTTP
+ def test_out_of_grid_chunk_key_is_refused_and_writes_nothing(
+ self, served: Served, data: st.DataObject
+ ) -> None:
+ """Coordinates past the end of the grid address no chunk of this array."""
+ _require_node_scope(served)
+ coords = data.draw(out_of_grid_coords(served.array))
+ relative = served.array.metadata.encode_chunk_key(coords)
+
+ before = _snapshot(served.store)
+ put = httpx.put(_url(served, relative), content=_chunk_payload(served.array), timeout=30)
+ get = httpx.get(_url(served, relative), timeout=30)
+
+ assert put.status_code == 404
+ assert get.status_code == 404
+ assert _snapshot(served.store) == before
+
+ @given(key=st.sampled_from(TRAVERSAL_KEYS))
+ @_HTTP
+ def test_traversal_key_is_refused_and_writes_nothing(self, served: Served, key: str) -> None:
+ """Nothing that tries to leave the served scope may be served or written."""
+ before = _snapshot(served.store)
+ put = httpx.put(f"{served.url}/{key}", content=b"payload", timeout=30)
+ get = httpx.get(f"{served.url}/{key}", timeout=30)
+
+ assert put.status_code in (403, 404, 405), f"{key!r} was accepted for writing"
+ assert get.status_code in (403, 404, 405), f"{key!r} was served"
+ assert _snapshot(served.store) == before
+
+ @given(data=st.data())
+ @_HTTP
+ def test_node_app_never_serves_a_sibling(self, served: Served, data: st.DataObject) -> None:
+ """A node_app is scoped to one node, so a sibling's keys are invisible
+ even though they exist in the same store."""
+ if served.kind != "node":
+ pytest.skip("store_app deliberately serves the whole store")
+
+ coords = data.draw(chunk_coords(served.array))
+ chunk = served.array.metadata.encode_chunk_key(coords)
+ relative = data.draw(
+ st.sampled_from(
+ [
+ f"outside/{chunk}",
+ "outside/zarr.json",
+ "outside/.zarray",
+ f"../outside/{chunk}",
+ ]
+ )
+ )
+
+ before = _snapshot(served.store)
+ get = httpx.get(f"{served.url}/{relative}", timeout=30)
+ put = httpx.put(f"{served.url}/{relative}", content=b"payload", timeout=30)
+
+ assert get.status_code == 404
+ assert put.status_code == 404
+ assert _snapshot(served.store) == before
+
+
+class TestMethodsAndRanges:
+ """Transport-level invariants that hold for every key."""
+
+ @given(
+ method=st.sampled_from(["DELETE", "POST", "PATCH", "OPTIONS"]),
+ data=st.data(),
+ )
+ @_HTTP
+ def test_unconfigured_method_is_refused_and_writes_nothing(
+ self, served: Served, method: str, data: st.DataObject
+ ) -> None:
+ """Only the methods the app was configured with may reach the store."""
+ coords = data.draw(chunk_coords(served.array))
+ relative = served.array.metadata.encode_chunk_key(coords)
+
+ before = _snapshot(served.store)
+ response = httpx.request(method, _url(served, relative), content=b"payload", timeout=30)
+
+ assert response.status_code == 405
+ assert _snapshot(served.store) == before
+
+ @given(
+ header=st.sampled_from(
+ [
+ "bytes=abc-def",
+ "bytes=0-1,4-5",
+ "chars=0-7",
+ "bytes=",
+ "nonsense",
+ "bytes=+0-1",
+ ]
+ )
+ )
+ @_HTTP
+ def test_unusable_range_serves_the_whole_object(self, served: Served, header: str) -> None:
+ """RFC 9110 §14.2: a Range the server cannot use is ignored, not refused."""
+ relative = served.array.metadata.encode_chunk_key(tuple(0 for _ in _grid(served.array)))
+ before = _snapshot(served.store)
+ key = served.store_key(relative)
+ assume(key in before)
+
+ response = httpx.get(_url(served, relative), headers={"Range": header}, timeout=30)
+
+ assert response.status_code == 200
+ assert response.content == before[key]
+ assert "content-range" not in response.headers
+
+ @given(
+ header=st.sampled_from(
+ [
+ "bytes=5-2",
+ "bytes=-0",
+ "bytes=99999999999999999999-",
+ "bytes=100000-100001",
+ ]
+ )
+ )
+ @_HTTP
+ def test_unsatisfiable_range_is_refused(self, served: Served, header: str) -> None:
+ """A well-formed range that names nothing readable is a 416."""
+ relative = served.array.metadata.encode_chunk_key(tuple(0 for _ in _grid(served.array)))
+ before = _snapshot(served.store)
+ assume(served.store_key(relative) in before)
+
+ response = httpx.get(_url(served, relative), headers={"Range": header}, timeout=30)
+
+ assert response.status_code == 416
+ assert _snapshot(served.store) == before
+
+ @given(end=st.integers(min_value=10**19, max_value=10**30))
+ @_HTTP
+ def test_absurdly_wide_range_is_clamped_not_refused(self, served: Served, end: int) -> None:
+ """RFC 9110 §14.1.2 clamps a last-byte-pos past the end of the object,
+ so an over-wide range reads to EOF rather than erroring."""
+ relative = served.array.metadata.encode_chunk_key(tuple(0 for _ in _grid(served.array)))
+ key = served.store_key(relative)
+ before = _snapshot(served.store)
+ assume(key in before)
+
+ response = httpx.get(
+ _url(served, relative), headers={"Range": f"bytes=0-{end}"}, timeout=30
+ )
+
+ assert response.status_code == 206
+ assert response.content == before[key]
+ assert _snapshot(served.store) == before
diff --git a/packages/zarr-http-server/tests/test_serve.py b/packages/zarr-http-server/tests/test_serve.py
new file mode 100644
index 0000000000..8db6752ba1
--- /dev/null
+++ b/packages/zarr-http-server/tests/test_serve.py
@@ -0,0 +1,1831 @@
+from __future__ import annotations
+
+import asyncio
+import errno
+import os
+import socket
+from typing import TYPE_CHECKING, Any, Literal, get_args
+
+import numpy as np
+import pytest
+import zarr
+from starlette.applications import Starlette
+from starlette.routing import Mount
+from starlette.testclient import TestClient
+from zarr.buffer import cpu
+from zarr.storage import LocalStore, MemoryStore
+
+from zarr_http_server._serve import (
+ _SHUTDOWN_JOIN_MARGIN,
+ READ_ONLY_HTTP_METHODS,
+ READ_WRITE_HTTP_METHODS,
+ CorsOptions,
+ ReadOnlyHTTPMethod,
+ _bind_preferred_or_free,
+ _parse_range_header,
+ _RangeVerdict,
+ node_app,
+ serve_background,
+ store_app,
+)
+
+if TYPE_CHECKING:
+ import pathlib
+ from collections.abc import Coroutine, Iterator
+
+ from zarr.abc.store import Store
+
+ZarrFormat = Literal[2, 3]
+
+SHUTDOWN_TIMEOUT = 1
+"""shutdown_timeout used by the bounded-shutdown test."""
+
+SLOW_HANDLER_SECONDS = 5
+"""How long that test's handler sleeps -- far longer than the shutdown bound,
+so an unbounded join would be obvious."""
+
+
+def sync[T](coro: Coroutine[Any, Any, T]) -> T:
+ """Run a store coroutine to completion (tests use MemoryStore only)."""
+ return asyncio.run(coro)
+
+
+@pytest.fixture
+def group_with_arrays(store: Store) -> zarr.Group:
+ """Create a group containing a regular array and a sharded array."""
+ root = zarr.open_group(store, mode="w")
+ zarr.create_array(root.store_path / "regular", shape=(4, 4), chunks=(2, 2), dtype="f8")
+ zarr.create_array(
+ root.store_path / "sharded",
+ shape=(8, 8),
+ chunks=(2, 2),
+ shards=(4, 4),
+ dtype="i4",
+ )
+ return root
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestNodeAppDoesNotExposeNonZarrKeys:
+ """node_app must never expose keys that are not part of the zarr hierarchy."""
+
+ def test_non_zarr_key_returns_404(self, store: Store, group_with_arrays: zarr.Group) -> None:
+ """A key that is not valid zarr metadata or a valid chunk key should return 404,
+ even if the underlying store contains data at that path."""
+ non_zarr_buf = cpu.buffer_prototype.buffer.from_bytes(b"secret data")
+ sync(store.set("secret.txt", non_zarr_buf))
+
+ app = node_app(group_with_arrays)
+ client = TestClient(app)
+
+ # The non-zarr key must not be accessible.
+ response = client.get("/secret.txt")
+ assert response.status_code == 404
+
+ def test_non_zarr_key_nested_returns_404(
+ self, store: Store, group_with_arrays: zarr.Group
+ ) -> None:
+ """A non-zarr key nested under a real array's path should return 404,
+ even though the path prefix matches a valid zarr node."""
+ non_zarr_buf = cpu.buffer_prototype.buffer.from_bytes(b"not a chunk")
+ sync(store.set("regular/notes.txt", non_zarr_buf))
+
+ app = node_app(group_with_arrays)
+ client = TestClient(app)
+
+ response = client.get("/regular/notes.txt")
+ assert response.status_code == 404
+
+ def test_valid_metadata_is_accessible(self, group_with_arrays: zarr.Group) -> None:
+ """Zarr metadata keys (zarr.json) for both the root group and child arrays
+ should be served with a 200 status."""
+ app = node_app(group_with_arrays)
+ client = TestClient(app)
+
+ # Root group metadata
+ response = client.get("/zarr.json")
+ assert response.status_code == 200
+
+ # Array metadata
+ response = client.get("/regular/zarr.json")
+ assert response.status_code == 200
+
+ def test_valid_chunk_is_accessible(self, group_with_arrays: zarr.Group) -> None:
+ """A valid, in-bounds chunk key for an array with written data should
+ be served with a 200 status."""
+ arr = group_with_arrays["regular"]
+ assert isinstance(arr, zarr.Array)
+ arr[:] = np.ones((4, 4))
+
+ app = node_app(group_with_arrays)
+ client = TestClient(app)
+
+ # c/0/0 is a valid chunk key for a (4,4) array with (2,2) chunks.
+ response = client.get("/regular/c/0/0")
+ assert response.status_code == 200
+
+ def test_out_of_bounds_chunk_key_returns_404(
+ self, store: Store, group_with_arrays: zarr.Group
+ ) -> None:
+ """A chunk key that is syntactically valid but references indices beyond
+ the array's chunk grid should return 404."""
+ arr = group_with_arrays["regular"]
+ assert isinstance(arr, zarr.Array)
+ arr[:] = np.ones((4, 4))
+
+ # Put real data at the out-of-grid key, so that a 404 can only come
+ # from the bounds check -- not from the key merely being absent.
+ planted = cpu.buffer_prototype.buffer.from_bytes(b"out of grid")
+ sync(store.set("regular/c/99/99", planted))
+ assert sync(store.get("regular/c/99/99", cpu.buffer_prototype)) is not None
+
+ app = node_app(group_with_arrays)
+ client = TestClient(app)
+
+ # (4,4) array with (2,2) chunks has grid shape (2,2), so c/99/99 is
+ # syntactically valid but out of bounds.
+ response = client.get("/regular/c/99/99")
+ assert response.status_code == 404
+
+ def test_empty_path_returns_404(self, group_with_arrays: zarr.Group) -> None:
+ """A request to the root path '/' should return 404 because an empty
+ string is not a valid zarr key."""
+ app = node_app(group_with_arrays)
+ client = TestClient(app)
+
+ response = client.get("/")
+ assert response.status_code == 404
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestShardedArrayByteRangeReads:
+ """Byte-range reads against a sharded array served via node_app."""
+
+ def test_range_read_returns_206(self, group_with_arrays: zarr.Group) -> None:
+ """A Range header requesting a specific byte range (e.g. bytes=0-7) should
+ return 206 Partial Content with exactly those bytes."""
+ arr = group_with_arrays["sharded"]
+ assert isinstance(arr, zarr.Array)
+ arr[:] = np.arange(64, dtype="i4").reshape((8, 8))
+
+ app = node_app(group_with_arrays)
+ client = TestClient(app)
+
+ # c/0/0 is the first shard key for an (8,8) array with (4,4) shards.
+ full_response = client.get("/sharded/c/0/0")
+ assert full_response.status_code == 200
+ full_body = full_response.content
+
+ # Request the first 8 bytes.
+ range_response = client.get("/sharded/c/0/0", headers={"Range": "bytes=0-7"})
+ assert range_response.status_code == 206
+ assert range_response.content == full_body[:8]
+
+ def test_suffix_range_read(self, group_with_arrays: zarr.Group) -> None:
+ """A suffix byte range (e.g. bytes=-4) should return the last N bytes
+ of the resource with a 206 status."""
+ arr = group_with_arrays["sharded"]
+ assert isinstance(arr, zarr.Array)
+ arr[:] = np.arange(64, dtype="i4").reshape((8, 8))
+
+ app = node_app(group_with_arrays)
+ client = TestClient(app)
+
+ full_response = client.get("/sharded/c/0/0")
+ full_body = full_response.content
+
+ # Request the last 4 bytes.
+ range_response = client.get("/sharded/c/0/0", headers={"Range": "bytes=-4"})
+ assert range_response.status_code == 206
+ assert range_response.content == full_body[-4:]
+
+ def test_offset_range_read(self, group_with_arrays: zarr.Group) -> None:
+ """An offset byte range (e.g. bytes=4-) should return all bytes from
+ the given offset to the end, with a 206 status."""
+ arr = group_with_arrays["sharded"]
+ assert isinstance(arr, zarr.Array)
+ arr[:] = np.arange(64, dtype="i4").reshape((8, 8))
+
+ app = node_app(group_with_arrays)
+ client = TestClient(app)
+
+ full_response = client.get("/sharded/c/0/0")
+ full_body = full_response.content
+
+ # Request everything from byte 4 onward.
+ range_response = client.get("/sharded/c/0/0", headers={"Range": "bytes=4-"})
+ assert range_response.status_code == 206
+ assert range_response.content == full_body[4:]
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestUnusableRangeHeadersAreIgnored:
+ """A Range this server cannot turn into a read is ignored, not refused.
+
+ RFC 9110 §14.2 requires ignoring a Range whose unit is unrecognized and
+ permits ignoring one that will not parse; either way the answer is 200
+ with the full representation. Refusing with 416 would tell a client the
+ object is unreadable when only the request shape was unsupported -- and
+ a multi-range request, which is legal to send and which proxies and
+ download accelerators do send, would take that at face value.
+ """
+
+ @pytest.mark.parametrize(
+ "header",
+ [
+ "bytes=abc-def",
+ "bytes=-abc",
+ "bytes=abc-",
+ "bytes=0-7,10-20",
+ "chars=0-7",
+ "bytes=",
+ "bytes=+0-1",
+ ],
+ )
+ def test_unusable_range_serves_full_representation(self, store: Store, header: str) -> None:
+ """Non-numeric bounds, multi-range, a non-'bytes' unit, an empty spec
+ and a non-canonical byte position all fall back to a plain 200."""
+ body = b"some data here"
+ sync(store.set("key", cpu.buffer_prototype.buffer.from_bytes(body)))
+
+ client = TestClient(store_app(store), raise_server_exceptions=False)
+
+ response = client.get("/key", headers={"Range": header})
+ assert response.status_code == 200
+ assert response.content == body
+ assert "content-range" not in response.headers
+
+
+class TestParseRangeHeader:
+ """Unit tests for _parse_range_header."""
+
+ def test_parser_rejects_an_inverted_range(self) -> None:
+ """Pin the parser itself: on a MemoryStore an unguarded inverted range
+ happens to return b"" and still yields 416, so the status code alone
+ cannot tell whether the guard is present."""
+ assert _parse_range_header("bytes=5-2") is _RangeVerdict.UNSATISFIABLE
+ assert _parse_range_header("bytes=0-0") is not _RangeVerdict.UNSATISFIABLE
+
+ def test_valid_range(self) -> None:
+ """'bytes=0-99' should parse into a RangeByteRequest with start=0 and
+ end=100 (end is exclusive, so the inclusive HTTP end is incremented)."""
+ from zarr.abc.store import RangeByteRequest
+
+ result = _parse_range_header("bytes=0-99")
+ assert result == RangeByteRequest(start=0, end=100)
+
+ def test_valid_suffix(self) -> None:
+ """'bytes=-50' should parse into a SuffixByteRequest requesting the
+ last 50 bytes of the resource."""
+ from zarr.abc.store import SuffixByteRequest
+
+ result = _parse_range_header("bytes=-50")
+ assert result == SuffixByteRequest(suffix=50)
+
+ def test_valid_offset(self) -> None:
+ """'bytes=10-' should parse into an OffsetByteRequest starting at
+ byte 10 and reading to the end of the resource."""
+ from zarr.abc.store import OffsetByteRequest
+
+ result = _parse_range_header("bytes=10-")
+ assert result == OffsetByteRequest(offset=10)
+
+ def test_non_bytes_unit(self) -> None:
+ """An unrecognized range unit must be ignored, per RFC 9110 §14.2."""
+ assert _parse_range_header("chars=0-7") is _RangeVerdict.IGNORE
+
+ def test_garbage_values(self) -> None:
+ """Non-numeric bounds are ignored rather than raising a ValueError."""
+ assert _parse_range_header("bytes=abc-def") is _RangeVerdict.IGNORE
+
+ def test_multi_range(self) -> None:
+ """Multi-range requests (e.g. bytes=0-7,10-20) are legal to send; this
+ server does not build multipart/byteranges, so it serves the whole
+ representation instead of refusing."""
+ assert _parse_range_header("bytes=0-7,10-20") is _RangeVerdict.IGNORE
+
+ def test_empty_spec(self) -> None:
+ """A Range header with no range specifier after 'bytes=' is ignored."""
+ assert _parse_range_header("bytes=") is _RangeVerdict.IGNORE
+
+ def test_non_canonical_byte_position(self) -> None:
+ """`int` would accept these; RFC 9110 defines a byte position as
+ 1*DIGIT, so they are not ranges and the header is ignored."""
+ assert _parse_range_header("bytes=+0-1") is _RangeVerdict.IGNORE
+ assert _parse_range_header("bytes= 0-1") is _RangeVerdict.IGNORE
+ assert _parse_range_header("bytes=0_0-1") is _RangeVerdict.IGNORE
+
+ def test_oversized_start_is_unsatisfiable(self) -> None:
+ """A first-byte-pos past any possible object names nothing."""
+ assert _parse_range_header("bytes=99999999999999999999-") is _RangeVerdict.UNSATISFIABLE
+
+ def test_zero_length_suffix_is_unsatisfiable(self) -> None:
+ """ "The last zero bytes" names nothing."""
+ assert _parse_range_header("bytes=-0") is _RangeVerdict.UNSATISFIABLE
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestWriteViaPut:
+ """store_app and node_app can be configured to accept PUT writes."""
+
+ def test_put_writes_to_store(self, store: Store) -> None:
+ """A PUT request to store_app with PUT enabled should write the
+ request body into the store at the given key."""
+ app = store_app(store, methods={"GET", "PUT"})
+ client = TestClient(app)
+
+ payload = b"hello zarr"
+ response = client.put("/some/key", content=payload)
+ assert response.status_code == 204
+
+ # Verify the data landed in the store.
+ buf = sync(store.get("some/key", cpu.buffer_prototype))
+ assert buf is not None
+ assert buf.to_bytes() == payload
+
+ def test_put_then_get_roundtrip(self, store: Store) -> None:
+ """Data written via PUT should be retrievable via a subsequent GET
+ at the same key."""
+ app = store_app(store, methods={"GET", "PUT"})
+ client = TestClient(app)
+
+ payload = b"\x00\x01\x02\x03"
+ client.put("/data/blob", content=payload)
+
+ response = client.get("/data/blob")
+ assert response.status_code == 200
+ assert response.content == payload
+
+ def test_put_rejected_when_not_configured(self, store: Store) -> None:
+ """PUT requests should return 405 Method Not Allowed when the server
+ is created with the default methods (GET only)."""
+ app = store_app(store)
+ client = TestClient(app)
+
+ response = client.put("/some/key", content=b"data")
+ assert response.status_code == 405
+
+ def test_put_on_node_validates_key(self, store: Store, group_with_arrays: zarr.Group) -> None:
+ """PUT requests via node_app should be rejected with 404 when the
+ target key is not a valid zarr key (metadata or chunk)."""
+ app = node_app(group_with_arrays, methods={"GET", "PUT"})
+ client = TestClient(app)
+
+ response = client.put("/not_a_zarr_key.bin", content=b"data")
+ assert response.status_code == 404
+
+ def test_put_to_valid_chunk_key_succeeds(self, group_with_arrays: zarr.Group) -> None:
+ """PUT requests via node_app to a valid chunk key should succeed
+ with 204, and the written data should be retrievable via GET."""
+ app = node_app(group_with_arrays, methods={"GET", "PUT"})
+ client = TestClient(app)
+
+ payload = b"\x00" * 32
+ response = client.put("/regular/c/0/0", content=payload)
+ assert response.status_code == 204
+
+ # Confirm it round-trips.
+ get_response = client.get("/regular/c/0/0")
+ assert get_response.status_code == 200
+ assert get_response.content == payload
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestMethodValidation:
+ """Only the methods the handler implements may be served."""
+
+ def test_supported_methods_are_served(self, store: Store) -> None:
+ """GET, PUT and HEAD each behave as their verb implies."""
+ app = store_app(store, methods={"GET", "PUT", "HEAD"})
+ client = TestClient(app)
+
+ assert client.put("/zarr.json", content=b'{"a":1}').status_code == 204
+ assert client.get("/zarr.json").content == b'{"a":1}'
+
+ # HEAD reports the same status as GET but carries no body.
+ head = client.head("/zarr.json")
+ assert head.status_code == 200
+ assert head.content == b""
+
+ def test_empty_method_set_raises(self, store: Store) -> None:
+ """Asking for no methods must be rejected rather than producing a
+ server that answers every verb, including writes: Starlette treats a
+ falsy `methods` on a Route as "match anything"."""
+ for build in (
+ lambda: store_app(store, methods=set()),
+ lambda: node_app(zarr.open_group(store, mode="a"), methods=set()),
+ ):
+ with pytest.raises(ValueError, match="at least one"):
+ build()
+
+ @pytest.mark.parametrize("method", ["DELETE", "POST", "PATCH", "OPTIONS", "TRACE"])
+ def test_unsupported_method_raises(self, store: Store, method: str) -> None:
+ """A verb the handler cannot implement is rejected when the app is
+ built, rather than silently answering as if it were a GET."""
+ for build in (
+ lambda: store_app(store, methods={"GET", method}), # type: ignore[arg-type]
+ lambda: node_app(zarr.open_group(store, mode="a"), methods={"GET", method}), # type: ignore[arg-type]
+ ):
+ with pytest.raises(ValueError, match=method):
+ build()
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestStoreAppEdgeCases:
+ """Edge cases for store_app."""
+
+ def test_get_nonexistent_key_returns_404(self, store: Store) -> None:
+ """GET for a key that does not exist in the store should return 404."""
+ app = store_app(store)
+ client = TestClient(app)
+
+ response = client.get("/no/such/key")
+ assert response.status_code == 404
+
+ def test_empty_path_returns_404(self, store: Store) -> None:
+ """GET to the root path '/' (empty key) should return 404 because
+ an empty string is not a valid store key."""
+ app = store_app(store)
+ client = TestClient(app)
+
+ response = client.get("/")
+ assert response.status_code == 404
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestNodeAppDirectArray:
+ """Serve a single array directly (not through a group)."""
+
+ def test_serve_nested_array_directly(self, store: Store) -> None:
+ """When node_app is given a nested array (not a group), requests
+ should use keys relative to that array's path. Metadata and in-bounds
+ chunks should return 200, and out-of-bounds chunks should return 404."""
+ root = zarr.open_group(store, mode="w")
+ arr = zarr.create_array(
+ root.store_path / "sub/nested",
+ shape=(4,),
+ chunks=(2,),
+ dtype="f8",
+ )
+ arr[:] = np.arange(4, dtype="f8")
+
+ # Serve the array directly — its prefix is "sub/nested".
+ app = node_app(arr)
+ client = TestClient(app)
+
+ # Metadata should be accessible at the array root.
+ response = client.get("/zarr.json")
+ assert response.status_code == 200
+
+ # Chunk keys are relative to the array.
+ response = client.get("/c/0")
+ assert response.status_code == 200
+
+ response = client.get("/c/1")
+ assert response.status_code == 200
+
+ # Out of bounds.
+ response = client.get("/c/99")
+ assert response.status_code == 404
+
+ def test_serve_root_array(self, store: Store) -> None:
+ """When node_app is given an array stored at the root of a store
+ (empty prefix), metadata and chunk keys should be accessible at
+ their natural paths."""
+ arr = zarr.create_array(
+ store,
+ shape=(6,),
+ chunks=(3,),
+ dtype="i4",
+ )
+ arr[:] = np.arange(6, dtype="i4")
+
+ # Root-level array has prefix = "".
+ app = node_app(arr)
+ client = TestClient(app)
+
+ response = client.get("/zarr.json")
+ assert response.status_code == 200
+
+ response = client.get("/c/0")
+ assert response.status_code == 200
+
+ response = client.get("/c/1")
+ assert response.status_code == 200
+
+ response = client.get("/c/2")
+ assert response.status_code == 404
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestContentType:
+ """Responses should have the correct Content-Type."""
+
+ def test_metadata_has_json_content_type(self, group_with_arrays: zarr.Group) -> None:
+ """Zarr metadata files (zarr.json) should be served with
+ Content-Type: application/json."""
+ app = node_app(group_with_arrays)
+ client = TestClient(app)
+
+ response = client.get("/zarr.json")
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "application/json"
+
+ def test_chunk_has_octet_stream_content_type(self, group_with_arrays: zarr.Group) -> None:
+ """Chunk data should be served with Content-Type: application/octet-stream
+ since it is binary data."""
+ arr = group_with_arrays["regular"]
+ assert isinstance(arr, zarr.Array)
+ arr[:] = np.ones((4, 4))
+
+ app = node_app(group_with_arrays)
+ client = TestClient(app)
+
+ response = client.get("/regular/c/0/0")
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "application/octet-stream"
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestCorsMiddleware:
+ """CORS middleware should add the expected headers."""
+
+ def test_cors_headers_present(self, store: Store) -> None:
+ """When cors_options are provided, responses should include the
+ Access-Control-Allow-Origin header matching the request origin."""
+ buf = cpu.buffer_prototype.buffer.from_bytes(b"data")
+ sync(store.set("key", buf))
+
+ cors = CorsOptions(allow_origins=["https://example.com"], allow_methods=["GET"])
+ app = store_app(store, cors_options=cors)
+ client = TestClient(app)
+
+ response = client.get("/key", headers={"Origin": "https://example.com"})
+ assert response.status_code == 200
+ assert response.headers["access-control-allow-origin"] == "https://example.com"
+
+ def test_cors_preflight(self, store: Store) -> None:
+ """CORS preflight OPTIONS requests should return 200 with the
+ Access-Control-Allow-Origin header when CORS is configured."""
+ cors = CorsOptions(allow_origins=["*"], allow_methods=["GET", "PUT"])
+ app = store_app(store, methods={"GET", "PUT"}, cors_options=cors)
+ client = TestClient(app)
+
+ response = client.options(
+ "/any/path",
+ headers={
+ "Origin": "https://example.com",
+ "Access-Control-Request-Method": "PUT",
+ },
+ )
+ assert response.status_code == 200
+ assert "access-control-allow-origin" in response.headers
+
+ def test_no_cors_headers_without_option(self, store: Store) -> None:
+ """When no cors_options are provided, responses should not include
+ any CORS headers, even if the request includes an Origin header."""
+ buf = cpu.buffer_prototype.buffer.from_bytes(b"data")
+ sync(store.set("key", buf))
+
+ app = store_app(store)
+ client = TestClient(app)
+
+ response = client.get("/key", headers={"Origin": "https://example.com"})
+ assert response.status_code == 200
+ assert "access-control-allow-origin" not in response.headers
+
+
+def _metadata_key(zarr_format: ZarrFormat) -> str:
+ """Return the metadata key for the given zarr format."""
+ return "zarr.json" if zarr_format == 3 else ".zarray"
+
+
+def _chunk_key(zarr_format: ZarrFormat, coords: str) -> str:
+ """Return a chunk key for the given format.
+
+ *coords* is a dot-separated string like ``"0.0"``. For v3 this becomes
+ ``"c/0/0"``; for v2 it is returned unchanged.
+ """
+ if zarr_format == 3:
+ return "c/" + coords.replace(".", "/")
+ return coords
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestNodeAppV2AndV3:
+ """Test node_app with both v2 and v3 arrays side by side."""
+
+ def test_metadata_accessible(self, store: Store, zarr_format: ZarrFormat) -> None:
+ """The format-appropriate metadata key should be served with 200."""
+ arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format)
+ app = node_app(arr)
+ client = TestClient(app)
+
+ response = client.get(f"/{_metadata_key(zarr_format)}")
+ assert response.status_code == 200
+
+ def test_chunk_accessible(self, store: Store, zarr_format: ZarrFormat) -> None:
+ """An in-bounds chunk key should be served with 200 for both formats."""
+ arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format)
+ arr[:] = np.ones(4)
+
+ app = node_app(arr)
+ client = TestClient(app)
+
+ response = client.get(f"/{_chunk_key(zarr_format, '0')}")
+ assert response.status_code == 200
+
+ def test_out_of_bounds_chunk_returns_404(self, store: Store, zarr_format: ZarrFormat) -> None:
+ """An out-of-bounds chunk key should return 404 for both formats."""
+ arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format)
+ arr[:] = np.ones(4)
+
+ # Plant data at the out-of-grid key so the 404 must come from the
+ # bounds check rather than from the key being absent.
+ key = _chunk_key(zarr_format, "99")
+ sync(store.set(key, cpu.buffer_prototype.buffer.from_bytes(b"out of grid")))
+ assert sync(store.get(key, cpu.buffer_prototype)) is not None
+
+ app = node_app(arr)
+ client = TestClient(app)
+
+ response = client.get(f"/{key}")
+ assert response.status_code == 404
+
+ def test_non_zarr_key_returns_404(self, store: Store, zarr_format: ZarrFormat) -> None:
+ """A non-zarr key should return 404 regardless of format."""
+ arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format)
+ non_zarr_buf = cpu.buffer_prototype.buffer.from_bytes(b"secret")
+ sync(store.set("secret.txt", non_zarr_buf))
+
+ app = node_app(arr)
+ client = TestClient(app)
+
+ response = client.get("/secret.txt")
+ assert response.status_code == 404
+
+ def test_data_roundtrip(self, store: Store, zarr_format: ZarrFormat) -> None:
+ """Data written to an array should be readable via store_app for
+ both formats."""
+ arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format)
+ arr[:] = np.arange(4, dtype="f8")
+
+ app = store_app(store)
+ client = TestClient(app)
+
+ # Metadata should be accessible.
+ response = client.get(f"/{_metadata_key(zarr_format)}")
+ assert response.status_code == 200
+
+ # First chunk should be accessible.
+ response = client.get(f"/{_chunk_key(zarr_format, '0')}")
+ assert response.status_code == 200
+ assert len(response.content) > 0
+
+
+class TestPathTraversalProtection:
+ """store_app and node_app must reject path-traversal attempts before
+ touching the store, regardless of URL-encoding tricks."""
+
+ def test_get_traversal_outside_store_root_returns_404(self, tmp_path: Any) -> None:
+ """A GET for a percent-encoded '../secret.txt' must not escape the
+ store root and read a file outside it."""
+ from zarr.storage import LocalStore
+
+ root = tmp_path / "store_root"
+ root.mkdir()
+ secret = tmp_path / "secret.txt"
+ secret.write_text("top secret contents")
+
+ store = LocalStore(root)
+ app = store_app(store, methods={"GET", "PUT"})
+ client = TestClient(app)
+
+ response = client.get("/..%2fsecret.txt")
+ assert response.status_code == 404
+ assert b"top secret" not in response.content
+
+ def test_put_traversal_outside_store_root_returns_404(self, tmp_path: Any) -> None:
+ """A PUT to a percent-encoded '../pwned.txt' must not escape the
+ store root and write a file outside it."""
+ from zarr.storage import LocalStore
+
+ root = tmp_path / "store_root"
+ root.mkdir()
+ pwned = tmp_path / "pwned.txt"
+
+ store = LocalStore(root)
+ app = store_app(store, methods={"GET", "PUT"})
+ client = TestClient(app)
+
+ response = client.put("/..%2fpwned.txt", content=b"pwned")
+ assert response.status_code == 404
+ assert not pwned.exists()
+
+ @pytest.mark.parametrize(
+ ("encoded_path", "climb_depth"),
+ [
+ ("/..%2fsecret.txt", 1),
+ ("/%2e%2e/secret.txt", 1),
+ ("/..%2f..%2fsecret.txt", 2),
+ ],
+ )
+ def test_encoded_traversal_variants_return_404(
+ self, tmp_path: Any, encoded_path: str, climb_depth: int
+ ) -> None:
+ """Various percent-encoded traversal spellings must all be rejected.
+
+ The store root is nested exactly ``climb_depth`` directories below
+ ``tmp_path`` and the secret lives at ``tmp_path/secret.txt`` -- the
+ exact location each traversal's ".." segments resolve to -- so a
+ case with a missing or deleted guard would actually reach the
+ secret instead of just returning 404 for an unrelated reason (e.g.
+ a two-level climb landing on a directory that happens to be empty).
+ """
+ from zarr.storage import LocalStore
+
+ parts = [f"level{i}" for i in range(climb_depth - 1)] + ["store_root"]
+ root = tmp_path.joinpath(*parts)
+ root.mkdir(parents=True)
+ secret = tmp_path / "secret.txt"
+ secret.write_text("top secret contents")
+
+ store = LocalStore(root)
+ app = store_app(store, methods={"GET", "PUT"})
+ client = TestClient(app)
+
+ response = client.get(encoded_path)
+ assert response.status_code == 404
+ assert b"top secret" not in response.content
+
+ def test_get_absolute_key_bypass_returns_404(self, tmp_path: Any) -> None:
+ """A percent-encoded leading slash decodes to an ABSOLUTE path param
+ (e.g. request '/%2fetc%2fhostname' -> path param '/etc/hostname').
+ '/etc/hostname'.split('/') -> ['', 'etc', 'hostname'] has no '.' or
+ '..' segment, so the two-element guard misses it, but LocalStore
+ resolves an absolute key by discarding its root entirely -- an
+ arbitrary-file read. The empty leading segment must be rejected."""
+ from zarr.storage import LocalStore
+
+ root = tmp_path / "store_root"
+ root.mkdir()
+ secret = tmp_path / "secret_abs.txt"
+ secret.write_text("top secret absolute contents")
+
+ store = LocalStore(root)
+ app = store_app(store, methods={"GET", "PUT"})
+ client = TestClient(app)
+
+ # Mirror the exploit: percent-encode every "/" (including the
+ # leading one) in the absolute secret path as "%2f".
+ encoded_path = "/" + str(secret).replace("/", "%2f")
+
+ response = client.get(encoded_path)
+ assert response.status_code == 404
+ assert b"top secret absolute" not in response.content
+ assert secret.read_text() == "top secret absolute contents"
+
+ def test_put_absolute_key_bypass_returns_404(self, tmp_path: Any) -> None:
+ """Same absolute-key vector as above, but for PUT: a percent-encoded
+ leading slash must not allow writing a file outside the store root."""
+ from zarr.storage import LocalStore
+
+ root = tmp_path / "store_root"
+ root.mkdir()
+ pwned = tmp_path / "pwned_abs.txt"
+
+ store = LocalStore(root)
+ app = store_app(store, methods={"GET", "PUT"})
+ client = TestClient(app)
+
+ encoded_path = "/" + str(pwned).replace("/", "%2f")
+
+ response = client.put(encoded_path, content=b"pwned")
+ assert response.status_code == 404
+ assert not pwned.exists()
+
+ @pytest.mark.parametrize(
+ "encoded_path",
+ [
+ "/..%5C..%5Cwin.ini",
+ "/%5CWindows%5Cwin.ini",
+ "/C:/Windows/win.ini",
+ "/C:%5CWindows",
+ "/%5C%5Chost%5Cshare%5Cx",
+ ],
+ )
+ def test_backslash_and_drive_traversal_variants_return_404(self, encoded_path: str) -> None:
+ """Backslash is a path separator on Windows, and a drive-qualified or
+ root-relative key discards a filesystem store's root entirely on
+ Windows, even though POSIX only ever treats '/' as a separator. The
+ guard must reject these purely from the string, before the store is
+ ever touched -- verified here by making the store raise if called."""
+ from unittest.mock import AsyncMock
+
+ from zarr.storage import MemoryStore
+
+ store = MemoryStore()
+ store.get = AsyncMock(side_effect=AssertionError("store.get should not be called")) # type: ignore[method-assign]
+ store.set = AsyncMock(side_effect=AssertionError("store.set should not be called")) # type: ignore[method-assign]
+
+ app = store_app(store, methods={"GET", "PUT"})
+ client = TestClient(app)
+
+ response = client.get(encoded_path)
+ assert response.status_code == 404
+
+ def test_put_backslash_traversal_returns_404(self) -> None:
+ """A PUT to a backslash-encoded '..\\..\\pwned.txt' must be rejected
+ before the store is touched, mirroring the GET case above."""
+ from unittest.mock import AsyncMock
+
+ from zarr.storage import MemoryStore
+
+ store = MemoryStore()
+ store.set = AsyncMock(side_effect=AssertionError("store.set should not be called")) # type: ignore[method-assign]
+
+ app = store_app(store, methods={"GET", "PUT"})
+ client = TestClient(app)
+
+ response = client.put("/..%5C..%5Cpwned.txt", content=b"pwned")
+ assert response.status_code == 404
+
+
+def _get_free_port() -> int:
+ """Return an unused TCP port on localhost."""
+ import socket
+
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ port: int = s.getsockname()[1]
+ return port
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestServeBackground:
+ """Test serve_background with store- and node-scoped apps."""
+
+ def test_background_server_over_a_store_app(self, store: Store) -> None:
+ """serve_background over a store app should return a BackgroundServer
+ that responds to HTTP requests and can be used as a context manager."""
+ import httpx
+
+ from zarr_http_server import serve_background
+
+ buf = cpu.buffer_prototype.buffer.from_bytes(b"hello")
+ sync(store.set("key", buf))
+
+ port = _get_free_port()
+ with serve_background(store_app(store), host="127.0.0.1", port=port) as server:
+ assert server.host == "127.0.0.1"
+ assert server.port == port
+ assert server.url == f"http://127.0.0.1:{port}"
+
+ response = httpx.get(f"{server.url}/key")
+ assert response.status_code == 200
+ assert response.content == b"hello"
+
+ def test_background_server_over_a_node_app(self, store: Store) -> None:
+ """serve_background over a node app should return a BackgroundServer
+ that responds to HTTP requests and can be used as a context manager."""
+ import httpx
+
+ from zarr_http_server import serve_background
+
+ arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8")
+ arr[:] = np.arange(4, dtype="f8")
+
+ port = _get_free_port()
+ with serve_background(node_app(arr), host="127.0.0.1", port=port) as server:
+ response = httpx.get(f"{server.url}/zarr.json")
+ assert response.status_code == 200
+
+
+class TestStoreFailuresAreNotReportedAsMisses:
+ """Under the v3 spec an absent chunk is an uninitialized one, and a reader
+ is right to substitute the array's fill value for it. A 404 therefore
+ asserts something about the store's contents, and an I/O failure must not
+ borrow it -- that would have a correct client materialize fill values over
+ data that exists."""
+
+ @pytest.mark.skipif(os.geteuid() == 0, reason="root bypasses the permission bits under test")
+ def test_unreadable_key_is_a_server_error(self, tmp_path: pathlib.Path) -> None:
+ root = tmp_path / "root"
+ root.mkdir()
+ (root / "key").write_bytes(b"real data")
+ os.chmod(root / "key", 0o000)
+
+ client = TestClient(store_app(LocalStore(str(root))), raise_server_exceptions=False)
+ try:
+ assert client.get("/key").status_code >= 500
+ finally:
+ os.chmod(root / "key", 0o600)
+
+ @pytest.mark.skipif(os.geteuid() == 0, reason="root bypasses the permission bits under test")
+ def test_unwritable_store_is_a_server_error(self, tmp_path: pathlib.Path) -> None:
+ root = tmp_path / "ro"
+ root.mkdir()
+ os.chmod(root, 0o500)
+
+ client = TestClient(
+ store_app(LocalStore(str(root)), methods={"GET", "PUT"}),
+ raise_server_exceptions=False,
+ )
+ try:
+ assert client.put("/key", content=b"data").status_code >= 500
+ finally:
+ os.chmod(root, 0o700)
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestShardGridBounds:
+ """A sharded array's storage grid is its shard grid, not its chunk grid."""
+
+ def test_out_of_shard_grid_key_returns_404(self, store: Store) -> None:
+ arr = zarr.create_array(store, shape=(8, 8), chunks=(2, 2), shards=(4, 4), dtype="i4")
+ arr[:] = np.arange(64, dtype="i4").reshape(8, 8)
+
+ # (8,8) with (4,4) shards has a 2x2 shard grid, so c/3/3 is out of it.
+ # Plant data there so the 404 must come from the bounds check.
+ sync(store.set("c/3/3", cpu.buffer_prototype.buffer.from_bytes(b"PLANTED")))
+ assert sync(store.get("c/3/3", cpu.buffer_prototype)) is not None
+
+ client = TestClient(node_app(arr))
+ assert client.get("/c/0/0").status_code == 200
+ assert client.get("/c/3/3").status_code == 404
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestWrongArityChunkKeys:
+ """A chunk key with the wrong number of coordinates is invalid, and must
+ not reach the grid comparison -- zip(strict=True) would raise there."""
+
+ @pytest.mark.parametrize("key", ["c/0", "c/0/0/0", "c/0/0/0/0"])
+ def test_wrong_arity_returns_404(self, store: Store, key: str) -> None:
+ arr = zarr.create_array(store, shape=(4, 4), chunks=(2, 2), dtype="f8")
+ arr[:] = np.ones((4, 4))
+
+ sync(store.set(key, cpu.buffer_prototype.buffer.from_bytes(b"PLANTED")))
+
+ client = TestClient(node_app(arr), raise_server_exceptions=False)
+ assert client.get(f"/{key}").status_code == 404
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestNodeNamesContainingAColon:
+ """A leading `:` is a drive reference to `ntpath`, so the guard rejects
+ it on every platform to keep it a pure string gate in front of any store.
+ The documented cost is that such a name is unreachable in the first
+ segment -- but only there."""
+
+ def test_colon_named_node_in_a_later_segment_is_served(self, store: Store) -> None:
+ root = zarr.open_group(store, mode="w")
+ sub = root.create_group("sub")
+ sub.create_array("a:b", shape=(2,), chunks=(2,), dtype="f8")
+
+ client = TestClient(node_app(root))
+ assert client.get("/sub/a:b/zarr.json").status_code == 200
+
+ def test_colon_named_node_in_the_first_segment_is_rejected(self, store: Store) -> None:
+ root = zarr.open_group(store, mode="w")
+ root.create_array("a:b", shape=(2,), chunks=(2,), dtype="f8")
+
+ client = TestClient(node_app(root))
+ assert client.get("/a:b/zarr.json").status_code == 404
+
+
+class TestChunkedBodyIsCapped:
+ """A chunked request carries no Content-Length, so the cap has to hold
+ while the body is being read rather than after it is buffered."""
+
+ def test_chunked_body_over_cap_is_rejected(self, tmp_path: pathlib.Path) -> None:
+ store = LocalStore(str(tmp_path / "root"))
+ client = TestClient(
+ store_app(store, methods={"GET", "PUT"}, max_body_size=64),
+ raise_server_exceptions=False,
+ )
+
+ def body() -> Iterator[bytes]:
+ for _ in range(20):
+ yield b"x" * 32
+
+ # httpx sends an iterator body with Transfer-Encoding: chunked.
+ assert client.put("/key", content=body()).status_code == 413
+ assert not (tmp_path / "root" / "key").exists()
+
+
+class TestHostileKeysAreNotServerErrors:
+ """A key the store cannot express is a miss, not a server fault: the
+ server must never answer 5xx for input a client can choose freely.
+
+ This needs a filesystem-backed store -- a `MemoryStore` accepts any key
+ as a dict key, so only `LocalStore` surfaces the underlying errors (an
+ embedded NUL, a name longer than the filesystem allows).
+ """
+
+ @pytest.mark.parametrize(
+ "path", ["/x%00y", "/ok.txt%00", "/" + "a" * 3000, "/" + "b" * 3000 + "/zarr.json"]
+ )
+ def test_unexpressable_key_returns_404_not_500(self, tmp_path: pathlib.Path, path: str) -> None:
+ store = LocalStore(str(tmp_path / "root"))
+ client = TestClient(store_app(store, methods={"GET", "PUT"}))
+
+ assert client.get(path).status_code == 404
+ assert client.put(path, content=b"x").status_code == 404
+
+
+class TestGenericStoreFailuresAreNotMisses:
+ """Only an error that answers about the *name* may become a 404.
+
+ `ENAMETOOLONG` says no such name is expressible, which is an answer about
+ the key. `EINVAL` is POSIX's catch-all and is reachable on a perfectly
+ ordinary short key -- a bad seek, an unsupported filesystem feature -- so
+ reporting it as absence would have a v3 reader write fill values over a
+ chunk that exists but could not be read.
+ """
+
+ @staticmethod
+ def _store_failing_with(code: int) -> Store:
+ class Failing(MemoryStore):
+ async def get(self, key: str, prototype: Any, byte_range: Any = None) -> Any:
+ raise OSError(code, os.strerror(code))
+
+ async def set(self, key: str, value: Any) -> None:
+ raise OSError(code, os.strerror(code))
+
+ return Failing()
+
+ def test_einval_is_not_reported_as_absent(self) -> None:
+ """The regression this class exists for."""
+ client = TestClient(
+ store_app(self._store_failing_with(errno.EINVAL), methods={"GET", "PUT"}),
+ raise_server_exceptions=False,
+ )
+
+ assert client.get("/c/0/0").status_code >= 500
+ assert client.put("/c/0/0", content=b"data").status_code >= 500
+
+ def test_enametoolong_is_still_a_miss(self) -> None:
+ """A name the store cannot express holds nothing, so 404 is honest."""
+ client = TestClient(
+ store_app(self._store_failing_with(errno.ENAMETOOLONG), methods={"GET", "PUT"}),
+ raise_server_exceptions=False,
+ )
+
+ assert client.get("/c/0/0").status_code == 404
+ assert client.put("/c/0/0", content=b"data").status_code == 404
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestPutBodyLimit:
+ """`Store.set` takes a whole buffer, so an unbounded body would let one
+ request size the server's memory use."""
+
+ def test_body_over_the_limit_is_rejected(self, store: Store) -> None:
+ client = TestClient(store_app(store, methods={"GET", "PUT"}, max_body_size=64))
+
+ assert client.put("/key", content=b"x" * 65).status_code == 413
+ # Nothing was written.
+ assert sync(store.get("key", cpu.buffer_prototype)) is None
+ # A body within the limit still succeeds.
+ assert client.put("/key", content=b"x" * 64).status_code == 204
+
+ def test_limit_can_be_lifted(self, store: Store) -> None:
+ client = TestClient(store_app(store, methods={"GET", "PUT"}, max_body_size=None))
+ assert client.put("/key", content=b"x" * 5000).status_code == 204
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestRangeResponseCorrectness:
+ """A 206 must describe which bytes it carries, and a range that cannot be
+ satisfied must say so rather than returning an empty 206."""
+
+ def test_206_carries_content_range(self, store: Store) -> None:
+ sync(store.set("key", cpu.buffer_prototype.buffer.from_bytes(b"0123456789")))
+ client = TestClient(store_app(store))
+
+ response = client.get("/key", headers={"Range": "bytes=2-5"})
+ assert response.status_code == 206
+ assert response.content == b"2345"
+ assert response.headers["Content-Range"] == "bytes 2-5/*"
+
+ def test_range_beyond_end_is_416(self, store: Store) -> None:
+ sync(store.set("key", cpu.buffer_prototype.buffer.from_bytes(b"0123456789")))
+ client = TestClient(store_app(store))
+
+ assert client.get("/key", headers={"Range": "bytes=1000-2000"}).status_code == 416
+
+ def test_inverted_range_is_416(self, store: Store) -> None:
+ sync(store.set("key", cpu.buffer_prototype.buffer.from_bytes(b"0123456789")))
+ client = TestClient(store_app(store))
+
+ assert client.get("/key", headers={"Range": "bytes=5-2"}).status_code == 416
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestZeroDimensionalArray:
+ """A 0-d array has exactly one chunk, spelled `0` in v2 and `c` in v3."""
+
+ @pytest.mark.parametrize("zarr_format", [2, 3])
+ def test_sole_chunk_is_served(self, store: Store, zarr_format: ZarrFormat) -> None:
+ arr = zarr.create_array(store, shape=(), dtype="i4", zarr_format=zarr_format)
+ arr[...] = 7
+
+ client = TestClient(node_app(arr))
+ chunk_key = "0" if zarr_format == 2 else "c"
+
+ assert client.get(f"/{chunk_key}").status_code == 200
+ # A 1-d coordinate is not valid for a 0-d grid.
+ assert client.get("/0/0").status_code == 404
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestGroupAndArrayMetadataKeysAreDistinct:
+ """A v2 group owns `.zgroup`; `.zarray` belongs to arrays, and vice versa."""
+
+ def test_node_does_not_claim_the_other_kind_of_metadata(self, store: Store) -> None:
+ root = zarr.open_group(store, mode="w", zarr_format=2)
+ arr = root.create_array("a", shape=(2,), chunks=(2,), dtype="f8")
+
+ # Plant both documents so a 404 reflects the key set, not absence.
+ sync(store.set(".zarray", cpu.buffer_prototype.buffer.from_bytes(b"{}")))
+ sync(store.set("a/.zgroup", cpu.buffer_prototype.buffer.from_bytes(b"{}")))
+
+ assert TestClient(node_app(root)).get("/.zarray").status_code == 404
+ assert TestClient(node_app(arr)).get("/.zgroup").status_code == 404
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestBackgroundServerReportsBoundPort:
+ """`port=0` asks the OS for a free port, so the server must report the
+ port it actually bound rather than the zero it was asked for."""
+
+ def test_port_zero_reports_the_bound_port(self, store: Store) -> None:
+ import httpx
+
+ from zarr_http_server import serve_background
+
+ sync(store.set("key", cpu.buffer_prototype.buffer.from_bytes(b"hello")))
+
+ with serve_background(store_app(store), host="127.0.0.1", port=0) as server:
+ assert server.port != 0
+ assert server.url == f"http://127.0.0.1:{server.port}"
+ # The reported URL is the one that actually serves the data.
+ assert httpx.get(f"{server.url}/key").content == b"hello"
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestUnopenableChildIsAnError:
+ """A child that cannot be *judged* must not be reported as absent.
+
+ 404 is a claim about the store's contents, and under the v3 spec an
+ absent chunk is an uninitialized one -- so a correct reader answers 404
+ by silently substituting the array's fill value. Returning it for a child
+ whose metadata could not be read would materialize zeros over data that
+ exists. Only a genuinely missing member is a 404; corrupt metadata, an
+ I/O error, or a codec this process lacks all surface as 5xx.
+ """
+
+ def test_child_with_unparseable_metadata_is_not_reported_as_missing(self, store: Store) -> None:
+ """A corrupt child metadata document must not yield 404."""
+ root = zarr.open_group(store, mode="w")
+ root.create_array("good", shape=(2,), chunks=(2,), dtype="f8")
+ sync(store.set("junk/zarr.json", cpu.buffer_prototype.buffer.from_bytes(b"not json")))
+
+ client = TestClient(node_app(root), raise_server_exceptions=False)
+
+ assert client.get("/good/zarr.json").status_code == 200
+ assert client.get("/junk/zarr.json").status_code >= 500
+ assert client.get("/junk/c/0").status_code >= 500
+
+ def test_absent_child_is_reported_as_missing(self, store: Store) -> None:
+ """A member that simply is not there is still a plain 404."""
+ root = zarr.open_group(store, mode="w")
+ root.create_array("good", shape=(2,), chunks=(2,), dtype="f8")
+
+ client = TestClient(node_app(root), raise_server_exceptions=False)
+
+ assert client.get("/nope/zarr.json").status_code == 404
+ assert client.get("/junk/c/0").status_code == 404
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestReadBackWithZarrClient:
+ """The round-trip the README leads with: serve an array, then open it
+ with a zarr client over HTTP.
+
+ This needs an HTTP-capable fsspec, which is a client-side concern that
+ `zarr-http-server` deliberately does not depend on -- it lives in the `docs`
+ dependency group, alongside the other deps the README examples need.
+ """
+
+ def test_served_array_reads_back_identically(self, store: Store) -> None:
+ """`zarr.open_array(server.url)` should return the same data that was
+ served, so the README's headline example stays true."""
+ pytest.importorskip("fsspec")
+ pytest.importorskip("aiohttp")
+
+ from zarr_http_server import serve_background
+
+ expected = np.arange(100, dtype="uint8").reshape(10, 10)
+ arr = zarr.create_array(store, data=expected, chunks=(5, 5), write_data=True)
+
+ port = _get_free_port()
+ with serve_background(node_app(arr), host="127.0.0.1", port=port) as server:
+ remote = zarr.open_array(server.url, mode="r")
+ np.testing.assert_array_equal(remote[:], expected)
+
+
+class TestBackgroundServerBoundedShutdown:
+ """BackgroundServer.shutdown() must not hang forever on a slow or stuck
+ in-flight request."""
+
+ def test_shutdown_returns_promptly_with_slow_inflight_request(self) -> None:
+ """A request that takes far longer than shutdown_timeout must not
+ prevent shutdown() from returning within roughly shutdown_timeout,
+ via uvicorn's force_exit rather than an unbounded thread join."""
+ import asyncio
+ import threading
+ import time
+
+ import httpx
+ from starlette.applications import Starlette
+ from starlette.responses import Response
+ from starlette.routing import Route
+
+ async def slow(request: Any) -> Response:
+ # Sleeps far longer than shutdown_timeout below, so a correct
+ # implementation must force the connection closed rather than
+ # wait for this to finish.
+ await asyncio.sleep(SLOW_HANDLER_SECONDS)
+ return Response(status_code=204)
+
+ app = Starlette(routes=[Route("/slow", slow, methods=["GET"])])
+ port = _get_free_port()
+ server = serve_background(
+ app, host="127.0.0.1", port=port, shutdown_timeout=SHUTDOWN_TIMEOUT
+ )
+ assert server is not None
+
+ request_errors: list[BaseException] = []
+
+ def make_slow_request() -> None:
+ try:
+ httpx.get(f"http://127.0.0.1:{port}/slow", timeout=10)
+ except Exception as exc: # noqa: BLE001 -- connection drop when the server force-closes is expected
+ request_errors.append(exc)
+
+ request_thread = threading.Thread(target=make_slow_request, daemon=True)
+ request_thread.start()
+ time.sleep(0.2) # give the request time to actually start
+
+ start = time.monotonic()
+ server.shutdown()
+ elapsed = time.monotonic() - start
+
+ # The property is that shutdown is *bounded*, not that it hits a
+ # particular wall-clock number. Derive the bound from the timeouts
+ # that produce it rather than hard-coding one: shutdown() waits
+ # `shutdown_timeout + _SHUTDOWN_JOIN_MARGIN` for a graceful stop, then
+ # `shutdown_timeout` more after force_exit. A literal here silently
+ # loses its headroom whenever one of those constants changes -- which
+ # is what happened when the margin was introduced.
+ bound = (SHUTDOWN_TIMEOUT + _SHUTDOWN_JOIN_MARGIN) + SHUTDOWN_TIMEOUT + 1.0
+ assert elapsed < bound, f"shutdown() took {elapsed:.2f}s, expected under {bound:.1f}s"
+ # ...and the point of it all: far less than the handler's own sleep,
+ # which an unbounded join would have waited out in full.
+ assert elapsed < SLOW_HANDLER_SECONDS
+
+ request_thread.join(timeout=10)
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestCorsOptionsCoverTheMiddleware:
+ """`CorsOptions` exposes every `CORSMiddleware` parameter, with our own
+ defaults only for the two the server knows about."""
+
+ _ORIGIN = "https://viewer.example"
+
+ def _client(self, store: Store, cors: CorsOptions) -> TestClient:
+ return TestClient(store_app(store, methods={"GET"}, cors_options=cors))
+
+ def test_ranged_response_is_readable_cross_origin(self, store: Store) -> None:
+ """`Content-Range` is not CORS-safelisted, so without `expose_headers`
+ a browser reads the bytes but cannot learn which bytes it got."""
+ sync(store.set("k", cpu.buffer_prototype.buffer.from_bytes(b"0123456789")))
+ client = self._client(store, {"allow_origins": [self._ORIGIN], "allow_methods": ["GET"]})
+
+ response = client.get("/k", headers={"Origin": self._ORIGIN, "Range": "bytes=0-3"})
+
+ assert response.status_code == 206
+ assert response.headers["access-control-expose-headers"] == "Content-Range"
+
+ def test_range_survives_a_preflight(self, store: Store) -> None:
+ """A preflight naming `Range` must be allowed, not answered 400."""
+ client = self._client(store, {"allow_origins": [self._ORIGIN], "allow_methods": ["GET"]})
+
+ preflight = client.options(
+ "/k",
+ headers={
+ "Origin": self._ORIGIN,
+ "Access-Control-Request-Method": "GET",
+ "Access-Control-Request-Headers": "range",
+ },
+ )
+
+ assert preflight.status_code == 200
+ assert "Range" in preflight.headers["access-control-allow-headers"]
+
+ def test_caller_value_replaces_the_default(self, store: Store) -> None:
+ """Our defaults apply only to absent keys; a supplied key wins outright
+ so `expose_headers: []` means "expose nothing", not "expose ours"."""
+ sync(store.set("k", cpu.buffer_prototype.buffer.from_bytes(b"data")))
+ base: CorsOptions = {"allow_origins": [self._ORIGIN], "allow_methods": ["GET"]}
+
+ empty = self._client(store, {**base, "expose_headers": []})
+ assert (
+ "access-control-expose-headers"
+ not in empty.get("/k", headers={"Origin": self._ORIGIN}).headers
+ )
+
+ custom = self._client(store, {**base, "expose_headers": ["X-Custom"]})
+ assert (
+ custom.get("/k", headers={"Origin": self._ORIGIN}).headers[
+ "access-control-expose-headers"
+ ]
+ == "X-Custom"
+ )
+
+ def test_parameters_beyond_the_original_two_are_reachable(self, store: Store) -> None:
+ """The regression this class exists for: `CorsOptions` used to carry
+ only `allow_origins` and `allow_methods`, sealing the rest away."""
+ sync(store.set("k", cpu.buffer_prototype.buffer.from_bytes(b"data")))
+ client = self._client(
+ store,
+ {
+ "allow_origin_regex": r"https://.*\.example",
+ "allow_credentials": True,
+ "max_age": 30,
+ },
+ )
+ origin = "https://sub.example"
+
+ response = client.get("/k", headers={"Origin": origin})
+ assert response.headers["access-control-allow-origin"] == origin
+ assert response.headers["access-control-allow-credentials"] == "true"
+
+ preflight = client.options(
+ "/k", headers={"Origin": origin, "Access-Control-Request-Method": "GET"}
+ )
+ assert preflight.headers["access-control-max-age"] == "30"
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestUvicornOptionsAreNotSealedOff:
+ """`uvicorn.Config` takes ~50 parameters; naming four of them and dropping
+ the rest would put TLS, proxy headers, `root_path` and log level out of
+ reach entirely."""
+
+ def test_options_reach_uvicorn_config(self, store: Store) -> None:
+ """A key this signature does not name still lands on the Config."""
+ from zarr_http_server import serve_background
+
+ server = serve_background(
+ store_app(store),
+ host="127.0.0.1",
+ port=0,
+ uvicorn_options={"root_path": "/api", "log_level": "warning"},
+ )
+ assert server is not None
+ try:
+ config = server._server.config
+ assert config.root_path == "/api"
+ assert config.log_level == "warning"
+ # Ours still apply where the caller did not override them.
+ assert config.timeout_graceful_shutdown == 5
+ finally:
+ server.shutdown()
+
+ def test_caller_options_win_over_ours(self, store: Store) -> None:
+ """The merge order is ours-then-theirs, so a caller can override even
+ an option this signature sets itself."""
+ from zarr_http_server import serve_background
+
+ server = serve_background(
+ store_app(store),
+ host="127.0.0.1",
+ port=0,
+ shutdown_timeout=5,
+ uvicorn_options={"timeout_graceful_shutdown": 11},
+ )
+ assert server is not None
+ try:
+ assert server._server.config.timeout_graceful_shutdown == 11
+ finally:
+ server.shutdown()
+
+ @pytest.mark.skipif(not hasattr(socket, "AF_UNIX"), reason="needs unix domain sockets")
+ def test_non_tcp_bind_reports_no_url(self, store: Store, tmp_path: pathlib.Path) -> None:
+ """A unix-socket bind has no host and port, so `url` must say so rather
+ than naming an address nothing is listening on."""
+ import httpx
+
+ from zarr_http_server import serve_background
+
+ sock = str(tmp_path / "s.sock")
+ server = serve_background(store_app(store), uvicorn_options={"uds": sock})
+ assert server is not None
+ try:
+ assert server.url is None
+ assert server.host is None
+ assert server.port is None
+ # ...and it really is serving, just not over TCP.
+ with httpx.Client(transport=httpx.HTTPTransport(uds=sock)) as client:
+ response = client.get("http://localhost/zarr.json", timeout=10)
+ assert response.status_code in (200, 404)
+ finally:
+ server.shutdown()
+
+
+class TestHeadDoesNotTransferTheBody:
+ """A HEAD body is discarded at the wire, so building one is pure waste."""
+
+ def test_head_does_not_read_the_value(self, tmp_path: pathlib.Path) -> None:
+ """The regression this class exists for: HEAD used to fall through to
+ the GET handler and pull the whole object to report its length."""
+ read = {"bytes": 0}
+
+ class CountingLocal(LocalStore):
+ async def get(self, key: str, prototype: Any, byte_range: Any = None) -> Any:
+ buf = await super().get(key, prototype, byte_range)
+ if buf is not None:
+ read["bytes"] += len(buf)
+ return buf
+
+ store = CountingLocal(str(tmp_path / "root"))
+ payload = b"x" * 100_000
+ sync(store.set("big", cpu.buffer_prototype.buffer.from_bytes(payload)))
+ client = TestClient(store_app(store))
+
+ read["bytes"] = 0
+ response = client.head("/big")
+
+ assert response.status_code == 200
+ assert response.headers["content-length"] == str(len(payload))
+ assert read["bytes"] == 0, "HEAD read the value to report its length"
+
+ @pytest.mark.parametrize("store", ["memory"], indirect=True)
+ def test_head_of_a_missing_key_is_404(self, store: Store) -> None:
+ client = TestClient(store_app(store))
+ assert client.head("/nope").status_code == 404
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestMetadataContentType:
+ """Metadata documents are JSON in every zarr format, not just v3."""
+
+ @pytest.mark.parametrize(
+ ("zarr_format", "key"), [(3, "zarr.json"), (2, ".zarray"), (2, ".zattrs")]
+ )
+ def test_metadata_is_served_as_json(
+ self, store: Store, zarr_format: ZarrFormat, key: str
+ ) -> None:
+ zarr.create_array(
+ store, name="a", shape=(4,), chunks=(2,), dtype="i4", zarr_format=zarr_format
+ )
+ sync(store.set(f"a/{key}", cpu.buffer_prototype.buffer.from_bytes(b"{}")))
+
+ response = TestClient(store_app(store)).get(f"/a/{key}")
+
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+
+
+@pytest.mark.parametrize("store", ["memory"], indirect=True)
+class TestCorsAllowMethodsMatchTheRoute:
+ """Advertising a method the route rejects is a promise the server cannot
+ keep: the browser caches the preflight and every later call 405s."""
+
+ def test_wildcard_expands_to_what_is_served(self, store: Store) -> None:
+ """`"*"` is the idiomatic "everything this app does", so it expands to
+ exactly that rather than to every verb Starlette knows."""
+ app = store_app(
+ store, methods={"GET"}, cors_options={"allow_origins": ["*"], "allow_methods": ["*"]}
+ )
+
+ preflight = TestClient(app).options(
+ "/k", headers={"Origin": "https://e.test", "Access-Control-Request-Method": "GET"}
+ )
+
+ advertised = preflight.headers["access-control-allow-methods"]
+ assert set(advertised.replace(" ", "").split(",")) == {"GET", "HEAD"}
+
+ def test_advertising_an_unserved_method_is_rejected(self, store: Store) -> None:
+ with pytest.raises(ValueError, match="does not serve"):
+ store_app(
+ store,
+ methods={"GET"},
+ cors_options={"allow_origins": ["*"], "allow_methods": ["GET", "DELETE"]},
+ )
+
+ def test_head_counts_as_served_when_get_is(self, store: Store) -> None:
+ """Starlette routes HEAD wherever GET goes, so naming it is not an error."""
+ store_app(
+ store,
+ methods={"GET"},
+ cors_options={"allow_origins": ["*"], "allow_methods": ["GET", "HEAD"]},
+ )
+
+ def test_absent_allow_methods_is_left_alone(self, store: Store) -> None:
+ """Starlette's GET-only default stands; widening it to everything
+ served would newly advertise PUT on a write-enabled app."""
+ app = store_app(store, methods={"GET", "PUT"}, cors_options={"allow_origins": ["*"]})
+
+ preflight = TestClient(app).options(
+ "/k", headers={"Origin": "https://e.test", "Access-Control-Request-Method": "GET"}
+ )
+
+ assert "PUT" not in preflight.headers["access-control-allow-methods"]
+
+
+class TestReadOnlyServing:
+ """The guarantees a read-only deployment rests on.
+
+ Two independent layers: `methods` decides what the route answers, and the
+ store decides whether a write could succeed at all. The second is the one
+ that survives a misconfiguration of the first, so both are pinned here.
+ """
+
+ @pytest.mark.parametrize("store", ["memory"], indirect=True)
+ @pytest.mark.parametrize("method", ["PUT", "POST", "DELETE", "PATCH"])
+ def test_default_app_refuses_every_mutating_method(self, store: Store, method: str) -> None:
+ """The default is read-only: no argument is needed to get there, and
+ nothing a client sends can write."""
+ sync(store.set("k", cpu.buffer_prototype.buffer.from_bytes(b"data")))
+ before = sync(store.get("k", cpu.buffer_prototype)).to_bytes()
+ client = TestClient(store_app(store), raise_server_exceptions=False)
+
+ response = client.request(method, "/k", content=b"overwritten")
+
+ assert response.status_code == 405
+ assert sync(store.get("k", cpu.buffer_prototype)).to_bytes() == before
+
+ @pytest.mark.parametrize("store", ["memory"], indirect=True)
+ def test_post_can_never_be_enabled(self, store: Store) -> None:
+ """POST is not merely unrouted, it is unconfigurable: there is no
+ handler behavior for it, so asking is an error rather than a no-op."""
+ with pytest.raises(ValueError, match="Unsupported HTTP method"):
+ store_app(store, methods={"GET", "POST"}) # type: ignore[arg-type]
+
+ def test_read_only_store_refuses_writes_independently_of_methods(
+ self, tmp_path: pathlib.Path
+ ) -> None:
+ """The layer that survives getting `methods` wrong.
+
+ Constructed through the private builder because the public entry
+ points now reject this combination outright; the handler check stays
+ as the backstop for a store whose `read_only` is not fixed.
+ """
+ from zarr_http_server._serve import _make_starlette_app
+
+ writable = LocalStore(str(tmp_path / "root"))
+ sync(writable.set("k", cpu.buffer_prototype.buffer.from_bytes(b"data")))
+
+ app = _make_starlette_app(methods={"GET", "PUT"})
+ app.state.store = writable.with_read_only(True)
+ app.state.node = None
+ app.state.prefix = ""
+ app.state.max_body_size = None
+
+ response = TestClient(app, raise_server_exceptions=False).put("/k", content=b"x")
+
+ assert response.status_code == 403
+ assert sync(writable.get("k", cpu.buffer_prototype)).to_bytes() == b"data"
+
+ def test_put_on_a_read_only_store_is_rejected_at_construction(
+ self, tmp_path: pathlib.Path
+ ) -> None:
+ """A write that could never succeed is a configuration error, not a
+ runtime 403 delivered to whoever happens to try first."""
+ store = LocalStore(str(tmp_path / "root")).with_read_only(True)
+
+ with pytest.raises(ValueError, match="store is read-only"):
+ store_app(store, methods={"GET", "PUT"})
+
+ def test_read_only_node_is_rejected_at_construction(self, tmp_path: pathlib.Path) -> None:
+ """The same check applies to a node, whose store it inherits."""
+ store = LocalStore(str(tmp_path / "root"))
+ zarr.create_array(store, shape=(4,), chunks=(2,), dtype="i4", compressors=None)
+ read_only_array = zarr.open_array(store, mode="r")
+
+ with pytest.raises(ValueError, match="store is read-only"):
+ node_app(read_only_array, methods={"GET", "PUT"})
+
+
+class TestMethodSetConstants:
+ """Named method sets let a call site state its intent, and make writable
+ deployments findable: every writable app must name one."""
+
+ @pytest.mark.parametrize("store", ["memory"], indirect=True)
+ def test_read_only_constant_matches_the_default(self, store: Store) -> None:
+ """Passing it explicitly and omitting `methods` are the same server, so
+ saying so out loud costs nothing."""
+ sync(store.set("k", cpu.buffer_prototype.buffer.from_bytes(b"data")))
+
+ default = TestClient(store_app(store), raise_server_exceptions=False)
+ named = TestClient(
+ store_app(store, methods=READ_ONLY_HTTP_METHODS), raise_server_exceptions=False
+ )
+
+ for method in ["GET", "HEAD", "PUT", "POST", "DELETE", "PATCH"]:
+ assert default.request(method, "/k").status_code == (
+ named.request(method, "/k").status_code
+ )
+
+ @pytest.mark.parametrize("store", ["memory"], indirect=True)
+ @pytest.mark.parametrize("method", ["PUT", "POST", "DELETE", "PATCH"])
+ def test_read_only_constant_refuses_writes(self, store: Store, method: str) -> None:
+ sync(store.set("k", cpu.buffer_prototype.buffer.from_bytes(b"data")))
+ client = TestClient(
+ store_app(store, methods=READ_ONLY_HTTP_METHODS), raise_server_exceptions=False
+ )
+
+ assert client.request(method, "/k", content=b"x").status_code == 405
+ assert sync(store.get("k", cpu.buffer_prototype)).to_bytes() == b"data"
+
+ @pytest.mark.parametrize("store", ["memory"], indirect=True)
+ def test_read_write_constant_permits_exactly_put(self, store: Store) -> None:
+ """It grants writes -- and still not POST, which has no handler."""
+ client = TestClient(
+ store_app(store, methods=READ_WRITE_HTTP_METHODS), raise_server_exceptions=False
+ )
+
+ assert client.put("/k", content=b"data").status_code == 204
+ assert client.post("/k", content=b"data").status_code == 405
+ assert sync(store.get("k", cpu.buffer_prototype)).to_bytes() == b"data"
+
+ def test_constants_cannot_be_mutated_by_a_caller(self) -> None:
+ """Frozen, so one caller cannot widen the default for every other."""
+ assert isinstance(READ_ONLY_HTTP_METHODS, frozenset)
+ assert isinstance(READ_WRITE_HTTP_METHODS, frozenset)
+ assert "PUT" not in READ_ONLY_HTTP_METHODS
+
+ def test_constants_match_the_types_they_model(self) -> None:
+ """The sets are derived from the Literals, so they cannot disagree
+ about what this server serves. Pinning the contents here makes
+ widening either type a deliberate, visible edit."""
+ assert frozenset(get_args(ReadOnlyHTTPMethod)) == READ_ONLY_HTTP_METHODS
+ assert set(READ_ONLY_HTTP_METHODS) == {"GET", "HEAD"}
+ assert set(READ_WRITE_HTTP_METHODS) == {"GET", "HEAD", "PUT"}
+ # Read-only is a strict subset: the only difference is the write verb.
+ assert READ_ONLY_HTTP_METHODS < READ_WRITE_HTTP_METHODS
+ assert {"PUT"} == READ_WRITE_HTTP_METHODS - READ_ONLY_HTTP_METHODS
+
+
+class TestServeAnyApp:
+ """`serve` runs whatever ASGI app it is handed, which is what makes
+ several nodes on one port possible."""
+
+ @staticmethod
+ def _two_mounted_arrays() -> tuple[Starlette, bytes, bytes]:
+ """Two arrays in *separate* stores, so no common parent exists and
+ mounting is the only way to serve both from one server."""
+ first, second = MemoryStore(), MemoryStore()
+ one = zarr.create_array(first, shape=(4,), chunks=(2,), dtype="i4", compressors=None)
+ other = zarr.create_array(second, shape=(4,), chunks=(2,), dtype="i4", compressors=None)
+ one[:] = 7
+ other[:] = 9
+
+ app = Starlette(
+ routes=[Mount("/first", app=node_app(one)), Mount("/second", app=node_app(other))]
+ )
+ return app, np.full(2, 7, dtype="i4").tobytes(), np.full(2, 9, dtype="i4").tobytes()
+
+ def test_mounted_apps_each_serve_their_own_node(self) -> None:
+ app, first_chunk, second_chunk = self._two_mounted_arrays()
+ client = TestClient(app, raise_server_exceptions=False)
+
+ assert client.get("/first/zarr.json").status_code == 200
+ assert client.get("/second/zarr.json").status_code == 200
+ assert client.get("/first/c/0").content == first_chunk
+ assert client.get("/second/c/0").content == second_chunk
+
+ @pytest.mark.parametrize(
+ "path",
+ [
+ "/first/%2e%2e/second/zarr.json",
+ "/first/..%2f..%2fsecond/zarr.json",
+ "/first/%2e%2e%2fsecond/c/0",
+ "/first/%2fsecond/zarr.json",
+ ],
+ )
+ def test_one_mount_cannot_reach_another(self, path: str) -> None:
+ """Per-node validation runs inside each mount, so composing apps does
+ not widen what any of them serves."""
+ app, _, _ = self._two_mounted_arrays()
+
+ assert TestClient(app, raise_server_exceptions=False).get(path).status_code == 404
+
+ def test_serve_runs_a_composed_app_in_the_background(self) -> None:
+ """The gap `serve` closes: a composed app previously had no way to use
+ the background-server ergonomics, only a blocking `uvicorn.run`."""
+ import httpx
+
+ app, first_chunk, second_chunk = self._two_mounted_arrays()
+
+ server = serve_background(app, host="127.0.0.1", port=0)
+ try:
+ assert server.url is not None
+ assert httpx.get(f"{server.url}/first/c/0", timeout=30).content == first_chunk
+ assert httpx.get(f"{server.url}/second/c/0", timeout=30).content == second_chunk
+ finally:
+ server.shutdown()
+
+
+class TestPortSelection:
+ """`port="auto"` prefers a predictable port but never fails over one.
+
+ An explicit port means the opposite -- bind exactly that or fail -- because
+ a caller who names one usually has something else expecting the server
+ there, and silently moving would break it while looking healthy.
+ """
+
+ @staticmethod
+ def _free_port() -> int:
+ """A port that was free a moment ago. Only ever used as the *preferred*
+ port, never bound afterwards, so the usual bind-then-close race does
+ not apply: if something takes it, that is the case under test."""
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return int(sock.getsockname()[1])
+
+ def test_preferred_port_is_used_when_free(self) -> None:
+ preferred = self._free_port()
+
+ sock = _bind_preferred_or_free("127.0.0.1", preferred)
+ try:
+ assert sock.getsockname()[1] == preferred
+ finally:
+ sock.close()
+
+ def test_falls_back_when_the_preferred_port_is_taken(self) -> None:
+ with socket.socket() as squatter:
+ squatter.bind(("127.0.0.1", 0))
+ squatter.listen()
+ taken = int(squatter.getsockname()[1])
+
+ sock = _bind_preferred_or_free("127.0.0.1", taken)
+ try:
+ assert sock.getsockname()[1] != taken
+ finally:
+ sock.close()
+
+ def test_address_family_follows_the_host(self) -> None:
+ """Hard-coding AF_INET would bind the wrong family for an IPv6 host."""
+ try:
+ sock = _bind_preferred_or_free("::1", 0)
+ except OSError: # pragma: no cover - depends on the host's networking
+ pytest.skip("no IPv6 loopback available")
+ try:
+ assert sock.family == socket.AF_INET6
+ finally:
+ sock.close()
+
+ @pytest.mark.parametrize("store", ["memory"], indirect=True)
+ def test_auto_produces_a_working_server(self, store: Store) -> None:
+ """Whichever port it lands on, `url` names it and the server answers."""
+ import httpx
+
+ server = serve_background(store_app(store))
+ try:
+ assert server.url is not None
+ assert server.port is not None
+ assert httpx.get(f"{server.url}/nope", timeout=30).status_code == 404
+ finally:
+ server.shutdown()
+
+ # uvicorn answers a failed bind with `sys.exit` on its own thread, which
+ # pytest reports as an unhandled thread exception -- and this package turns
+ # warnings into errors. That exit is exactly what the RuntimeError below
+ # reports to the caller, so it is expected here rather than a defect.
+ @pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
+ @pytest.mark.parametrize("store", ["memory"], indirect=True)
+ def test_an_explicit_port_that_is_taken_fails(self, store: Store) -> None:
+ """The regression this class exists for: `auto` must not leak into the
+ explicit case, where a collision has to be loud."""
+ with socket.socket() as squatter:
+ squatter.bind(("127.0.0.1", 0))
+ squatter.listen()
+ taken = int(squatter.getsockname()[1])
+
+ with pytest.raises(RuntimeError, match="may already be in use"):
+ serve_background(store_app(store), port=taken)
+
+ @pytest.mark.parametrize("store", ["memory"], indirect=True)
+ def test_port_zero_still_means_any_free_port(self, store: Store) -> None:
+ """`0` keeps its OS meaning rather than being folded into `auto`."""
+ server = serve_background(store_app(store), port=0)
+ try:
+ assert server.port not in (0, None)
+ finally:
+ server.shutdown()
diff --git a/packages/zarr-http-server/uv.lock b/packages/zarr-http-server/uv.lock
new file mode 100644
index 0000000000..946da90503
--- /dev/null
+++ b/packages/zarr-http-server/uv.lock
@@ -0,0 +1,2212 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
+ { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
+ { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
+ { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
+ { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
+ { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
+ { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
+ { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
+ { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
+ { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
+ { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
+ { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" },
+ { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" },
+ { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" },
+ { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" },
+ { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" },
+ { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" },
+ { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" },
+ { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" },
+ { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" },
+ { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" },
+ { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" },
+ { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" },
+ { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" },
+ { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" },
+ { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" },
+ { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" },
+ { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" },
+ { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" },
+ { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" },
+ { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" },
+ { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" },
+ { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" },
+ { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" },
+ { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" },
+ { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.14.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
+]
+
+[[package]]
+name = "appnope"
+version = "0.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" },
+]
+
+[[package]]
+name = "asttokens"
+version = "3.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
+]
+
+[[package]]
+name = "babel"
+version = "2.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
+]
+
+[[package]]
+name = "backrefs"
+version = "8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/56/4744bcd0c82184e80c52b0ac4076c261a8ffa1f1b343ff2f6e89ce0e1cef/backrefs-8.0.tar.gz", hash = "sha256:b556cd7d36c3a3a2f256b89590b176b8eddfb73bcfaee3a3ddd84ea66d21ce50", size = 7013081, upload-time = "2026-07-26T19:54:24.638Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/fd/9bf53b6a6f6f519ffaac765df2f2a25e5c2fc6d32cfd2b2747099e72c911/backrefs-8.0-py310-none-any.whl", hash = "sha256:4a627b817fd2dce43b79ab48da63613340509381cd8ce0897078a0bce79a2ab8", size = 380377, upload-time = "2026-07-26T19:54:17.457Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/29/4bd7ae72a2634da00379c2b3bcc5439e7c94620235c6afea8af15229a973/backrefs-8.0-py311-none-any.whl", hash = "sha256:f0c35cf0102ba6b6070c12a492be3c1c1d3f5839529784b9a9565d6d04569a01", size = 392169, upload-time = "2026-07-26T19:54:18.782Z" },
+ { url = "https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl", hash = "sha256:87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5", size = 398084, upload-time = "2026-07-26T19:54:19.954Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl", hash = "sha256:601ce68ca12385dbda06ce264406b4c4210cf5b79fd0fd627592365c92f29a88", size = 400040, upload-time = "2026-07-26T19:54:21.194Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl", hash = "sha256:9ec96efa080938be92323e8e730e57718c9c88eb15ad70bbef4e1766df591408", size = 411903, upload-time = "2026-07-26T19:54:23.221Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.7.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
+ { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
+ { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
+ { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
+ { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
+ { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
+ { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
+ { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
+ { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
+ { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
+ { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
+ { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
+ { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
+ { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
+ { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
+ { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
+ { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
+ { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
+ { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
+ { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
+ { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
+ { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
+ { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" },
+ { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" },
+ { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" },
+ { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" },
+ { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" },
+ { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" },
+ { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" },
+ { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" },
+ { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" },
+ { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
+ { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
+ { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
+ { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
+ { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
+ { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
+ { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
+ { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
+ { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
+ { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
+ { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "comm"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" },
+]
+
+[[package]]
+name = "debugpy"
+version = "1.8.21"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" },
+ { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" },
+ { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" },
+ { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" },
+ { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" },
+ { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" },
+]
+
+[[package]]
+name = "donfig"
+version = "0.8.1.post1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/25/71/80cc718ff6d7abfbabacb1f57aaa42e9c1552bfdd01e64ddd704e4a03638/donfig-0.8.1.post1.tar.gz", hash = "sha256:3bef3413a4c1c601b585e8d297256d0c1470ea012afa6e8461dc28bfb7c23f52", size = 19506, upload-time = "2024-05-23T14:14:31.513Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592, upload-time = "2024-05-23T14:13:55.283Z" },
+]
+
+[[package]]
+name = "executing"
+version = "2.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" },
+]
+
+[[package]]
+name = "fastjsonschema"
+version = "2.22.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/98/474719c58eddaf77fa443b063693e76d49db32bbe851bcbaf58d2700119f/fastjsonschema-2.22.1.tar.gz", hash = "sha256:0b83d1ce8d7845b959dcb20e1a5c3c8883b6541d9c52ab02cce5166b75ec805f", size = 382291, upload-time = "2026-07-27T13:31:08.515Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/e1/62cc96341f01bdff2ba967441939178fcd1900d11ce7e6554d9954a5d7ec/fastjsonschema-2.22.1-py3-none-any.whl", hash = "sha256:cf377ff5c9a6f4f3125fb35f75a2c5767bd824ffbcf62c209a93cd48d1453999", size = 26239, upload-time = "2026-07-27T13:31:03.251Z" },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
+ { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
+ { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
+ { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
+ { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" },
+ { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" },
+ { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" },
+ { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" },
+ { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" },
+ { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
+ { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
+ { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
+ { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
+ { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
+ { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
+]
+
+[[package]]
+name = "fsspec"
+version = "2026.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" },
+]
+
+[package.optional-dependencies]
+http = [
+ { name = "aiohttp" },
+]
+
+[[package]]
+name = "ghp-import"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
+]
+
+[[package]]
+name = "google-crc32c"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" },
+ { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" },
+ { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" },
+ { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" },
+ { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" },
+ { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" },
+ { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" },
+ { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" },
+ { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" },
+]
+
+[[package]]
+name = "griffe-inherited-docstrings"
+version = "1.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "griffelib" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/da/fd002dc5f215cd896bfccaebe8b4aa1cdeed8ea1d9d60633685bd61ff933/griffe_inherited_docstrings-1.1.3.tar.gz", hash = "sha256:cd1f937ec9336a790e5425e7f9b92f5a5ab17f292ba86917f1c681c0704cb64e", size = 26738, upload-time = "2026-02-21T09:38:44.312Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/20/4bc15f242181daad1c104e0a7d33be49e712461ea89e548152be0365b9ea/griffe_inherited_docstrings-1.1.3-py3-none-any.whl", hash = "sha256:aa7f6e624515c50d9325a5cfdf4b2acac547f1889aca89092d5da7278f739695", size = 6710, upload-time = "2026-02-20T11:06:38.75Z" },
+]
+
+[[package]]
+name = "griffelib"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpcore2"
+version = "2.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11" },
+ { name = "truststore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "httpx2"
+version = "2.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "httpcore2" },
+ { name = "idna" },
+ { name = "truststore" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" },
+]
+
+[[package]]
+name = "hypothesis"
+version = "6.165.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/7a/7a277ac07776191be594f74f6425649d529e4876f7d3ff1ee96d393ffdbc/hypothesis-6.165.3.tar.gz", hash = "sha256:687c5abb1a9c11478577c2cf18685c0eb82150d278477d3e14da290a1ef2a098", size = 502263, upload-time = "2026-08-11T01:23:09.1Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f9/c7/18152acad5f85f91554b2030000319b952a54151509953651ec40f37d50d/hypothesis-6.165.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:56af539c811b11ab5475704c300b8f0b46cc6dd0edc267e02a16487e803c77f8", size = 781671, upload-time = "2026-08-11T01:22:09.176Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/77/4293ea8a7fdb713956a8bf460b9070115df69f8216a900507633f9cdb225/hypothesis-6.165.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f40c10cfdb1ea2cd75e5d4e6e0cfdcb6198ab8406e8922666480e6dc11eea341", size = 777291, upload-time = "2026-08-11T01:22:15.991Z" },
+ { url = "https://files.pythonhosted.org/packages/02/fa/fa2071a6afaefc082dc7a033f41ae61436caf442d5973ba8ca9c29a69460/hypothesis-6.165.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a0854b1de4577f7e1beb1d681360285b5d678b65a809787ff4eab5b8b25efca", size = 1106490, upload-time = "2026-08-11T01:22:07.858Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/86/de724b7f9cd10e3be4efa21770457172e549d7576b1d8e29d6177eef5e47/hypothesis-6.165.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:360991cda8e488924905af48949033b90d4877ac97b9ad5d826d4d0f5a4b8cfb", size = 1135054, upload-time = "2026-08-11T01:22:29.499Z" },
+ { url = "https://files.pythonhosted.org/packages/12/6a/96721cf447bd3c64b5e6843dde4444b20f3ddd901ad366cc73d0e7314bf5/hypothesis-6.165.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf502000f4a8ef4c9ab9493ca3b4fe17ae3033c18a8e2a31cdd69515dc7d97be", size = 1155997, upload-time = "2026-08-11T01:21:48.496Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ae/a793cce6497f233b155f97684bf7d0e424c25613dd87b8af8a4e87820232/hypothesis-6.165.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:b9fcf47ad18f87f7c15bd36289bd45708bbfd250129d73bf554653e2f9afc931", size = 1111326, upload-time = "2026-08-11T01:22:21.9Z" },
+ { url = "https://files.pythonhosted.org/packages/28/8d/dc3cdfd55843d038effa2458a9c9bd73002218a8c0fd58c2c0ab7fa328db/hypothesis-6.165.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb05529cbcab5a317d03d7bb0e90d382f79ef1643e3568577916d0e24bfe70b", size = 1148079, upload-time = "2026-08-11T01:21:51.071Z" },
+ { url = "https://files.pythonhosted.org/packages/db/4b/2f62924ac41f3d3482b29ded4c213f27ff4a103e56e84eeb528d4900cac7/hypothesis-6.165.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:57eae10a64340cd621a78eae9cb0459bd68ea99fbaa933c4f00e34d5087b6376", size = 1281862, upload-time = "2026-08-11T01:21:39.274Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/d4/01c78b7b7348b6e8cef9b999109dfb93b14c7e1e38bc22170129f8b17181/hypothesis-6.165.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:19df0f2239052e9a870634a1d9bcdff95e2a2ab508573e5dd5c3d1ca545f5b3c", size = 1408437, upload-time = "2026-08-11T01:22:13.243Z" },
+ { url = "https://files.pythonhosted.org/packages/35/76/e940b5a5aaf75bcd4784f1f3f9bf2b9a642a706bc0a9639077ca84f1325f/hypothesis-6.165.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9781a8026adff4b4516404cf0e5f2cadcb471318c2882a264e1c57c4c092266f", size = 1281168, upload-time = "2026-08-11T01:21:58.964Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/84/b153e81a614f45e0902e3b9e8a8b079e64214c50abb6fbe9acc62ccf686d/hypothesis-6.165.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1dd7e05f88e3e108a5e4f5f71a3eaf205559e8951e3c1f1ffd04cea82ed3b731", size = 1323263, upload-time = "2026-08-11T01:21:52.374Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/5f/e5144d9e91ab7260650cb1ee032ca23208d49ebb1334845baf6407c1a9d9/hypothesis-6.165.3-cp310-abi3-win32.whl", hash = "sha256:d1389bda38cb222acc109aef5b31643ce799a39a76294a50ad8b84e32f92d76d", size = 667499, upload-time = "2026-08-11T01:21:47.36Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/18/f008b6f1f1c293d51c2776f8815d95bccb777dcf87df2a0ab56b273b47dc/hypothesis-6.165.3-cp310-abi3-win_amd64.whl", hash = "sha256:10cda6988ca4b1da389548b6fdd71af236b588a601fc1757e56eb8988e4240d8", size = 673643, upload-time = "2026-08-11T01:22:46.975Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/3d/e7eade134bd7f57d4071d82ae84691bc3017fe6945af0bb149578ba8b565/hypothesis-6.165.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f88fe4915f8dd4f8999a197f9e22c3a7177042aa994d35c0c7c7b22541d2885b", size = 783298, upload-time = "2026-08-11T01:22:14.706Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a5/668810f493feaf886a9240ad689792fb32450667c8e5770c3bcaa7fabeac/hypothesis-6.165.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c7d9f6c36b812f6069c7436492e12812cf541391954e21d5bd7fcafc9fb46700", size = 774856, upload-time = "2026-08-11T01:22:35.836Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/a0/0af93a70f5128763079ab714277ccad4a7de42d442d67dd43e3029178162/hypothesis-6.165.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423ca8e30087bb41db7e6f47dbf98690a2155241b9f1057e366dc138d7dcc4fe", size = 1105274, upload-time = "2026-08-11T01:23:03.591Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/00/ddcdc99beee469573addf5cfcd817c9a20a71832b1ca88404b6d3f99c44c/hypothesis-6.165.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c58c66f3e1b8d4091bb52664d5af4b0c1715293c6822d5344b166494534cd498", size = 1155379, upload-time = "2026-08-11T01:22:23.371Z" },
+ { url = "https://files.pythonhosted.org/packages/de/10/d574b21e63f16a1cad9c0cf4592aa170285940f034d04bb33a1e08025397/hypothesis-6.165.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3774882c4685e5474b7940697da55963e591b71c6dce593d90ac4128766371ad", size = 1279259, upload-time = "2026-08-11T01:21:53.63Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/3f/13f6b6c7d7d0b9bb570a18617eb659b015c312b1d8d1d3aaf9f2edea9628/hypothesis-6.165.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb3a397a5422c67387f4408989dbdfc2b1e0306f883f2aa79b9472c80958464", size = 1322605, upload-time = "2026-08-11T01:21:42.853Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/dd/243317f5fb8497601dc65d3ded984834eb5f36b8ec59e7853ef753ec0ee1/hypothesis-6.165.3-cp312-cp312-win_amd64.whl", hash = "sha256:dbb74811d54b6317ba0d2047aad269c09afefaa25d1849f8f33f80a638b0c3af", size = 670812, upload-time = "2026-08-11T01:22:50.245Z" },
+ { url = "https://files.pythonhosted.org/packages/03/3e/95cba31dbe775b99a4548cdae192e1ad15cee7b64fbdfe6cd4c9d00031b4/hypothesis-6.165.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:447f139d6dd70a5d8b178ef507463fb0430ace9ce42e3b2351d2803a391fe774", size = 783183, upload-time = "2026-08-11T01:22:45.286Z" },
+ { url = "https://files.pythonhosted.org/packages/56/0e/51bf125cdf7855b69097b8f59c73ef3cf5f4e3d68a16e808d2d1f08a1ff1/hypothesis-6.165.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6152c718606f1705e673c6b30a6ebd3ff08d340da85291dd3c432c73b28a9b3a", size = 774820, upload-time = "2026-08-11T01:22:24.825Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/69/b954f742b97441a5c49f8f8704826ee0637a6cce3a7d06ce85fbefc54ac5/hypothesis-6.165.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27fe7826ad83ccc2e8062f0fab43b137bab34cef1a149a926b34a7b8382ee22c", size = 1105186, upload-time = "2026-08-11T01:21:41.733Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/8a/33e41d9cc1be7661e0b4129c225a93c3f12544714300aadb95ae7eedf894/hypothesis-6.165.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1ff92876a324f7b9cdb92cedf103e380b7a12aa7df55ebcb16dd0f495a879e8", size = 1155215, upload-time = "2026-08-11T01:22:06.604Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/53/ba09526c9100ace5752908ac7251d2dc3960ce7e0e97a31152aaa26c33ee/hypothesis-6.165.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:53f1564c97d27fc109f212404d49cd71d7789777dbe0685628ffe9838df56240", size = 1279245, upload-time = "2026-08-11T01:21:56.182Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/93/fc637d355791a65364a3409ff06ade7ba5d3fc6f1d07a729781dec315fa0/hypothesis-6.165.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:788a9b0a7aae719a2b71a1c2f07e51deb1d0fe990164a9090c686833ed4bfbad", size = 1322370, upload-time = "2026-08-11T01:22:48.492Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/8d/826053ba0263143fed2b0e8af009dc868d8a932e7246e191deb8ca7ce8ff/hypothesis-6.165.3-cp313-cp313-win_amd64.whl", hash = "sha256:37830f0795abfdf738d2a5b6f829a73f3ab498de45a2e61b0bf3bd38d8c9ddb9", size = 670804, upload-time = "2026-08-11T01:22:00.103Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/27/3230f8de3d853b2b547731916ae1d1026bd197cd3f2d35dafc0b445da46b/hypothesis-6.165.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:38826441dbf528cc156388d0a05526086a12da3e1348353d3fa14de03e57c4b2", size = 783286, upload-time = "2026-08-11T01:22:40.816Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/28/9f9ca830d376c50babe55c616f6d99eea886c6ebcd8b512dcd5d56f9e40c/hypothesis-6.165.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87490115edd34a246a4ba8b1144cbdf571438c46c406ece05caf65908667c9a9", size = 774963, upload-time = "2026-08-11T01:23:05.409Z" },
+ { url = "https://files.pythonhosted.org/packages/33/3c/3c81f08ec1edce160da509c5785d78c0e25a7913899c4b9ff724bfd01420/hypothesis-6.165.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f51f4346cfa26bca68c68f7bbbd2b1812208bc9f572187c95ecab080ed402153", size = 1105730, upload-time = "2026-08-11T01:22:42.311Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/b5/f6f81b9aec9999ec63920d168617cab67a038be05487eff3410ccd072bfe/hypothesis-6.165.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4da89eb4b36b3260ff714d2ecc3274b9bd599fd96687d2d9ed53d5e1a801a7a7", size = 1155383, upload-time = "2026-08-11T01:21:57.58Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/27/7f3a8c6101675bf95c80cd8c9173d65892ca0b7b640551156dc4537fab1f/hypothesis-6.165.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eb6d31c14d7bdfe03e501d88ee296c149a74cc93e3d01c76ea335e64ee5f33ec", size = 1279606, upload-time = "2026-08-11T01:22:37.56Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/16/0c23e06a24e421e532f62a95021fae34f685f3a194c081c6991b4ab202b3/hypothesis-6.165.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0863e1a9258bc103abe616fa9471cfa66a1535ea404dd8a0bf360e0a29502397", size = 1322697, upload-time = "2026-08-11T01:22:27.857Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/56/8356dadf45e5c635b46aa2b57fa74f3210250a8e38b860b6b75f50ed0b42/hypothesis-6.165.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:53c56155f2cfbb45ec97fef9ea3b8453b4a34c48c3c5cacee16f97dd2a037994", size = 614859, upload-time = "2026-08-11T01:22:01.304Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/79/124d4faf235219acd685c359760a5cb3995609bc50ce465e54c3249841ee/hypothesis-6.165.3-cp314-cp314-win_amd64.whl", hash = "sha256:c48f41e950b5e602e2fdf8f92dcc8ac7bf715a003bf822afb7c9d5cbc41bc344", size = 670600, upload-time = "2026-08-11T01:22:10.356Z" },
+ { url = "https://files.pythonhosted.org/packages/01/7a/41ac5e68d9ce079d1b76d4c54126354df61b948c3d519d1289aca877eedc/hypothesis-6.165.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9563d3040178fb1f522665bcec6458cc0d21ab77d7c637058a8be4ea8c01d236", size = 781746, upload-time = "2026-08-11T01:22:34.472Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/fb/7ecc21aae63a83dbc8036f9a0544c6b3d798db566b97b5202bdf8e770f80/hypothesis-6.165.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1fe1783543b43ba9808c016950e5e84b3804dc3365ba77c37c427b5896a558a1", size = 773382, upload-time = "2026-08-11T01:22:55.279Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/f2/9cc2a4768f9a483b12e307ba585f5eb9c7f5500bd16ff82ddbf62a9a1b88/hypothesis-6.165.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ea34806a4df4e8305a096dcf8e53cdd903c96c1e0d2dd5b001d2283f639c3f1", size = 1103911, upload-time = "2026-08-11T01:23:00.286Z" },
+ { url = "https://files.pythonhosted.org/packages/54/9e/b551a494f84976ee5bb9374c197ccc126dea2ec6f22098d5f70705237473/hypothesis-6.165.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d188454b95ce46ba991e3c52161255d76af25170ad28591f6b30b045e501216e", size = 1154060, upload-time = "2026-08-11T01:22:20.413Z" },
+ { url = "https://files.pythonhosted.org/packages/69/37/8e22a236f1f1e599525549a34672fb0523109f571486fe209b12a84a942e/hypothesis-6.165.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1c47b15ce97a9b1346bc7d7013c5f215380f78ae01c0f73a1638bd8b98bdd76", size = 1277631, upload-time = "2026-08-11T01:22:30.965Z" },
+ { url = "https://files.pythonhosted.org/packages/13/0f/feb33bfc23853b4ba6360ff5e34235cd8bea0d7dd1eb21e17491f581c4e2/hypothesis-6.165.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:996077ef7a3bb332b6638f698ddf7555c82784b58dde80eeba3f07c0a322b40f", size = 1321326, upload-time = "2026-08-11T01:21:45.089Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/3b/ad56b56540a0719f493edec0dd442ebb21272147d2482ef505d19760a6d3/hypothesis-6.165.3-cp314-cp314t-win_amd64.whl", hash = "sha256:57a8273bdafe3f450afe66999fd130d4935d775eaf4ef63fcac0bee8015fc512", size = 670613, upload-time = "2026-08-11T01:22:05.294Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "ipykernel"
+version = "7.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "appnope", marker = "sys_platform == 'darwin'" },
+ { name = "comm" },
+ { name = "debugpy" },
+ { name = "ipython" },
+ { name = "jupyter-client" },
+ { name = "jupyter-core" },
+ { name = "matplotlib-inline" },
+ { name = "nest-asyncio2" },
+ { name = "packaging" },
+ { name = "psutil" },
+ { name = "pyzmq" },
+ { name = "tornado" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" },
+]
+
+[[package]]
+name = "ipython"
+version = "9.16.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "ipython-pygments-lexers" },
+ { name = "jedi" },
+ { name = "matplotlib-inline" },
+ { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
+ { name = "prompt-toolkit" },
+ { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" },
+ { name = "pygments" },
+ { name = "stack-data" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/96/b150fe7e25a5a29ae9ac1374e71488639605d39a1ea4abb74c9ce33af235/ipython-9.16.1.tar.gz", hash = "sha256:5a3d1f9a47ff216d6cf9cf863124f6a2c1a198d1354c546a4d24a370a283b64c", size = 4515302, upload-time = "2026-08-03T08:36:15.571Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/8e/1239df488393d61076653bfb29f759d0f60cab8e030abdf7c17c31539b51/ipython-9.16.1-py3-none-any.whl", hash = "sha256:4acae635506f6d352d94c4899a19d5f85f8bc4d230932342dca556fdab1c69b4", size = 625974, upload-time = "2026-08-03T08:36:13.654Z" },
+]
+
+[[package]]
+name = "ipython-pygments-lexers"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" },
+]
+
+[[package]]
+name = "jedi"
+version = "0.20.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "parso" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "jsonschema-specifications" },
+ { name = "referencing" },
+ { name = "rpds-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "referencing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+]
+
+[[package]]
+name = "jupyter-client"
+version = "8.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jupyter-core" },
+ { name = "python-dateutil" },
+ { name = "pyzmq" },
+ { name = "tornado" },
+ { name = "traitlets" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" },
+]
+
+[[package]]
+name = "jupyter-core"
+version = "5.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "platformdirs" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
+]
+
+[[package]]
+name = "markdown"
+version = "3.10.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "matplotlib-inline"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" },
+]
+
+[[package]]
+name = "mergedeep"
+version = "1.3.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
+]
+
+[[package]]
+name = "mkdocs"
+version = "1.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "ghp-import" },
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "mergedeep" },
+ { name = "mkdocs-get-deps" },
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "pyyaml" },
+ { name = "pyyaml-env-tag" },
+ { name = "watchdog" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" },
+]
+
+[[package]]
+name = "mkdocs-autorefs"
+version = "1.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "mkdocs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" },
+]
+
+[[package]]
+name = "mkdocs-get-deps"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mergedeep" },
+ { name = "platformdirs" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" },
+]
+
+[[package]]
+name = "mkdocs-material"
+version = "9.7.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "babel" },
+ { name = "backrefs" },
+ { name = "colorama" },
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "mkdocs" },
+ { name = "mkdocs-material-extensions" },
+ { name = "paginate" },
+ { name = "pygments" },
+ { name = "pymdown-extensions" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" },
+]
+
+[[package]]
+name = "mkdocs-material-extensions"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
+]
+
+[[package]]
+name = "mkdocstrings"
+version = "1.0.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "mkdocs" },
+ { name = "mkdocs-autorefs" },
+ { name = "pymdown-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" },
+]
+
+[[package]]
+name = "mkdocstrings-python"
+version = "2.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "griffelib" },
+ { name = "mkdocs-autorefs" },
+ { name = "mkdocstrings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" },
+]
+
+[[package]]
+name = "multidict"
+version = "6.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
+ { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
+ { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
+ { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
+ { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
+ { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
+ { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
+ { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
+ { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
+ { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
+ { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
+ { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
+ { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
+ { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
+ { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
+ { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
+ { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
+ { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
+ { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
+ { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
+ { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
+ { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
+ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
+]
+
+[[package]]
+name = "nbclient"
+version = "0.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jupyter-client" },
+ { name = "jupyter-core" },
+ { name = "nbformat" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/36/c9/94d73e5a01c5b926c3fa2496e97d7a8dc28ed5a77c0b2ed712f1a62e6694/nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895", size = 25288, upload-time = "2026-06-05T07:52:40.115Z" },
+]
+
+[[package]]
+name = "nbformat"
+version = "5.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "fastjsonschema" },
+ { name = "jsonschema" },
+ { name = "jupyter-core" },
+ { name = "traitlets" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/80f407a9525bc5bd9865e5c37db3b78867fa43217f8aac5eab22b5f028b3/nbformat-5.11.0.tar.gz", hash = "sha256:7dbaed4a69cae28c2b4d44ab7430a6af4544fb89455023f6f21550be757b60c8", size = 151822, upload-time = "2026-08-06T12:29:55.597Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/4a/0eece9dad5e73230ca972f7bc29456ce7d74772f123d401fcf67379008f7/nbformat-5.11.0-py3-none-any.whl", hash = "sha256:f70a17f591a9ccd1c601d5e61a4b20972703926df0ba42458ce14bf575766bb6", size = 79820, upload-time = "2026-08-06T12:29:54.178Z" },
+]
+
+[[package]]
+name = "nest-asyncio2"
+version = "1.7.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" },
+]
+
+[[package]]
+name = "numcodecs"
+version = "0.16.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/44/bd/8a391e7c356366224734efd24da929cc4796fff468bfb179fe1af6548535/numcodecs-0.16.5.tar.gz", hash = "sha256:0d0fb60852f84c0bd9543cc4d2ab9eefd37fc8efcc410acd4777e62a1d300318", size = 6276387, upload-time = "2025-11-21T02:49:48.986Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/75/cc/55420f3641a67f78392dc0bc5d02cb9eb0a9dcebf2848d1ac77253ca61fa/numcodecs-0.16.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:24e675dc8d1550cd976a99479b87d872cb142632c75cc402fea04c08c4898523", size = 1656287, upload-time = "2025-11-21T02:49:25.755Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/6c/86644987505dcb90ba6d627d6989c27bafb0699f9fd00187e06d05ea8594/numcodecs-0.16.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:94ddfa4341d1a3ab99989d13b01b5134abb687d3dab2ead54b450aefe4ad5bd6", size = 1148899, upload-time = "2025-11-21T02:49:26.87Z" },
+ { url = "https://files.pythonhosted.org/packages/97/1e/98aaddf272552d9fef1f0296a9939d1487914a239e98678f6b20f8b0a5c8/numcodecs-0.16.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b554ab9ecf69de7ca2b6b5e8bc696bd9747559cb4dd5127bd08d7a28bec59c3a", size = 8534814, upload-time = "2025-11-21T02:49:28.547Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/53/78c98ef5c8b2b784453487f3e4d6c017b20747c58b470393e230c78d18e8/numcodecs-0.16.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad1a379a45bd3491deab8ae6548313946744f868c21d5340116977ea3be5b1d6", size = 9173471, upload-time = "2025-11-21T02:49:30.444Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/20/2fdec87fc7f8cec950d2b0bea603c12dc9f05b4966dc5924ba5a36a61bf6/numcodecs-0.16.5-cp312-cp312-win_amd64.whl", hash = "sha256:845a9857886ffe4a3172ba1c537ae5bcc01e65068c31cf1fce1a844bd1da050f", size = 801412, upload-time = "2025-11-21T02:49:32.123Z" },
+ { url = "https://files.pythonhosted.org/packages/38/38/071ced5a5fd1c85ba0e14ba721b66b053823e5176298c2f707e50bed11d9/numcodecs-0.16.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25be3a516ab677dad890760d357cfe081a371d9c0a2e9a204562318ac5969de3", size = 1654359, upload-time = "2025-11-21T02:49:33.673Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c0/5f84ba7525577c1b9909fc2d06ef11314825fc4ad4378f61d0e4c9883b4a/numcodecs-0.16.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0107e839ef75b854e969cb577e140b1aadb9847893937636582d23a2a4c6ce50", size = 1144237, upload-time = "2025-11-21T02:49:35.294Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/00/787ea5f237b8ea7bc67140c99155f9c00b5baf11c49afc5f3bfefa298f95/numcodecs-0.16.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:015a7c859ecc2a06e2a548f64008c0ec3aaecabc26456c2c62f4278d8fc20597", size = 8483064, upload-time = "2025-11-21T02:49:36.454Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/e6/d359fdd37498e74d26a167f7a51e54542e642ea47181eb4e643a69a066c3/numcodecs-0.16.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84230b4b9dad2392f2a84242bd6e3e659ac137b5a1ce3571d6965fca673e0903", size = 9126063, upload-time = "2025-11-21T02:49:38.018Z" },
+ { url = "https://files.pythonhosted.org/packages/27/72/6663cc0382ddbb866136c255c837bcb96cc7ce5e83562efec55e1b995941/numcodecs-0.16.5-cp313-cp313-win_amd64.whl", hash = "sha256:5088145502ad1ebf677ec47d00eb6f0fd600658217db3e0c070c321c85d6cf3d", size = 799275, upload-time = "2025-11-21T02:49:39.558Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/9e/38e7ca8184c958b51f45d56a4aeceb1134ecde2d8bd157efadc98502cc42/numcodecs-0.16.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b05647b8b769e6bc8016e9fd4843c823ce5c9f2337c089fb5c9c4da05e5275de", size = 1654721, upload-time = "2025-11-21T02:49:40.602Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/37/260fa42e7b2b08e6e00ad632f8dd620961a60a459426c26cea390f8c68d0/numcodecs-0.16.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3832bd1b5af8bb3e413076b7d93318c8e7d7b68935006b9fa36ca057d1725a8f", size = 1146887, upload-time = "2025-11-21T02:49:41.721Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/15/e2e1151b5a8b14a15dfd4bb4abccce7fff7580f39bc34092780088835f3a/numcodecs-0.16.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49f7b7d24f103187f53135bed28bb9f0ed6b2e14c604664726487bb6d7c882e1", size = 8476987, upload-time = "2025-11-21T02:49:43.363Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/30/16a57fc4d9fb0ba06c600408bd6634f2f1753c54a7a351c99c5e09b51ee2/numcodecs-0.16.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aec9736d81b70f337d89c4070ee3ffeff113f386fd789492fa152d26a15043e4", size = 9102377, upload-time = "2025-11-21T02:49:45.508Z" },
+ { url = "https://files.pythonhosted.org/packages/31/a5/a0425af36c20d55a3ea884db4b4efca25a43bea9214ba69ca7932dd997b4/numcodecs-0.16.5-cp314-cp314-win_amd64.whl", hash = "sha256:b16a14303800e9fb88abc39463ab4706c037647ac17e49e297faa5f7d7dbbf1d", size = 819022, upload-time = "2025-11-21T02:49:47.39Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" },
+ { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" },
+ { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" },
+ { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" },
+ { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" },
+ { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" },
+ { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" },
+ { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" },
+ { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" },
+ { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" },
+ { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" },
+ { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" },
+ { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" },
+ { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" },
+ { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" },
+ { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" },
+ { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" },
+ { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" },
+ { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "paginate"
+version = "0.5.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" },
+]
+
+[[package]]
+name = "parso"
+version = "0.8.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
+[[package]]
+name = "pexpect"
+version = "4.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ptyprocess" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.11.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/98/0bf930c4f97d0266b58a89e36c015f56232c52b5d2f207215d48cca9e8f7/platformdirs-4.11.2.tar.gz", hash = "sha256:3a2ae5fca3520a01ab1be8b45613537f52ddf5b5f6f53d88233892dfbf0cd82d", size = 32716, upload-time = "2026-08-10T15:48:06.092Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/e2/4e6eee633809c376c024821b91ade709cbfd040ec53939ffbcc292aa7eee/platformdirs-4.11.2-py3-none-any.whl", hash = "sha256:7f89089b6ea71bda7962953edcf784b2e2d9d285b40ad88be2bb75c6e9d82ab4", size = 23361, upload-time = "2026-08-10T15:48:04.855Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "prompt-toolkit"
+version = "3.0.53"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wcwidth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" },
+ { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" },
+ { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" },
+ { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" },
+ { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" },
+ { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" },
+ { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" },
+ { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" },
+ { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" },
+ { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" },
+ { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" },
+ { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" },
+ { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" },
+ { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" },
+ { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" },
+ { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" },
+ { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" },
+ { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" },
+ { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
+ { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
+ { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
+ { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
+ { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
+ { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
+ { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
+ { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
+ { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
+ { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
+ { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
+ { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
+]
+
+[[package]]
+name = "psutil"
+version = "7.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" },
+ { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" },
+ { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" },
+ { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" },
+ { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" },
+ { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" },
+]
+
+[[package]]
+name = "ptyprocess"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" },
+]
+
+[[package]]
+name = "pure-eval"
+version = "0.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pymdown-extensions"
+version = "11.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "pyyaml-env-tag"
+version = "1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" },
+]
+
+[[package]]
+name = "pyzmq"
+version = "27.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "implementation_name == 'pypy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" },
+ { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" },
+ { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" },
+ { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" },
+ { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" },
+ { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" },
+ { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" },
+ { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" },
+ { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" },
+ { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" },
+ { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
+]
+
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "rpds-py" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
+[[package]]
+name = "rpds-py"
+version = "2026.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
+ { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
+ { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
+ { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
+ { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
+ { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
+ { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
+ { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
+ { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
+ { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
+ { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
+ { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
+ { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
+ { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
+ { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
+ { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
+ { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
+ { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
+ { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
+ { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
+ { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
+ { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
+ { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
+ { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
+ { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
+ { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
+ { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
+ { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
+ { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
+ { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
+ { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
+ { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
+ { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
+ { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
+ { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
+]
+
+[[package]]
+name = "stack-data"
+version = "0.6.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asttokens" },
+ { name = "executing" },
+ { name = "pure-eval" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
+]
+
+[[package]]
+name = "tornado"
+version = "6.5.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" },
+ { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" },
+ { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" },
+ { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" },
+]
+
+[[package]]
+name = "traitlets"
+version = "5.16.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/2e/a7fbfe268c8a3b32546930c0297c101d65a4a14c304ad5790a9f478f0e4e/traitlets-5.16.1.tar.gz", hash = "sha256:ed900c2b631aa3a112811139fa97b8d2c3bad5e989656bba4b7e52c7852c18c1", size = 166137, upload-time = "2026-08-03T08:32:36.848Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" },
+]
+
+[[package]]
+name = "truststore"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.51.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" },
+]
+
+[[package]]
+name = "watchdog"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
+ { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
+ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
+ { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
+ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
+]
+
+[[package]]
+name = "wcwidth"
+version = "0.8.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.24.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" },
+ { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" },
+ { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" },
+ { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" },
+ { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" },
+ { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" },
+ { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" },
+ { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" },
+ { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" },
+ { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" },
+ { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" },
+ { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" },
+ { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" },
+ { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" },
+ { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" },
+ { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" },
+ { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" },
+ { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" },
+ { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" },
+ { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" },
+]
+
+[[package]]
+name = "zarr"
+source = { editable = "../../" }
+dependencies = [
+ { name = "donfig" },
+ { name = "google-crc32c" },
+ { name = "numcodecs" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "typing-extensions" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "cast-value-rs", marker = "extra == 'cast-value-rs'" },
+ { name = "cupy-cuda12x", marker = "sys_platform != 'darwin' and extra == 'gpu'" },
+ { name = "donfig", specifier = ">=0.8" },
+ { name = "fsspec", marker = "extra == 'remote'", specifier = ">=2023.10.0" },
+ { name = "google-crc32c", specifier = ">=1.5" },
+ { name = "numcodecs", specifier = ">=0.14" },
+ { name = "numpy", specifier = ">=2" },
+ { name = "obstore", marker = "extra == 'remote'", specifier = ">=0.5.1" },
+ { name = "packaging", specifier = ">=22.0" },
+ { name = "typer", marker = "extra == 'cli'" },
+ { name = "typing-extensions", specifier = ">=4.14" },
+ { name = "universal-pathlib", marker = "extra == 'optional'" },
+]
+provides-extras = ["cast-value-rs", "cli", "gpu", "optional", "remote"]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "astroid", specifier = "==4.1.2" },
+ { name = "botocore" },
+ { name = "coverage", specifier = "==7.15.2" },
+ { name = "fsspec", specifier = ">=2023.10.0" },
+ { name = "griffe-inherited-docstrings", specifier = "==1.1.3" },
+ { name = "hypothesis", specifier = "==6.164.0" },
+ { name = "markdown-exec", extras = ["ansi"], specifier = "==1.12.3" },
+ { name = "mike", specifier = "==2.2.0" },
+ { name = "mkdocs", specifier = "==1.6.1" },
+ { name = "mkdocs-material", extras = ["imaging"], specifier = "==9.7.7" },
+ { name = "mkdocs-redirects", specifier = "==1.2.3" },
+ { name = "mkdocstrings", specifier = "==1.0.6" },
+ { name = "mkdocstrings-python", specifier = "==2.0.5" },
+ { name = "moto", extras = ["s3", "server"], specifier = "==5.2.2" },
+ { name = "mypy", specifier = "==2.3.0" },
+ { name = "numcodecs", extras = ["msgpack"] },
+ { name = "numpydoc", specifier = "==1.10.0" },
+ { name = "obstore", specifier = ">=0.5.1" },
+ { name = "pytest", specifier = "==9.1.1" },
+ { name = "pytest-accept", specifier = "==0.3.0" },
+ { name = "pytest-asyncio", specifier = "==1.4.0" },
+ { name = "pytest-benchmark", specifier = "==5.2.3" },
+ { name = "pytest-codspeed", specifier = "==5.0.3" },
+ { name = "pytest-cov", specifier = "==7.1.0" },
+ { name = "pytest-xdist", specifier = "==3.8.0" },
+ { name = "requests", specifier = "==2.34.2" },
+ { name = "ruff", specifier = "==0.16.0" },
+ { name = "s3fs", specifier = ">=2023.10.0" },
+ { name = "tomlkit", specifier = "==0.15.1" },
+ { name = "towncrier", specifier = "==25.8.0" },
+ { name = "universal-pathlib" },
+ { name = "uv", specifier = "==0.12.0" },
+]
+docs = [
+ { name = "astroid", specifier = "==4.1.2" },
+ { name = "griffe-inherited-docstrings", specifier = "==1.1.3" },
+ { name = "markdown-exec", extras = ["ansi"], specifier = "==1.12.3" },
+ { name = "mike", specifier = "==2.2.0" },
+ { name = "mkdocs", specifier = "==1.6.1" },
+ { name = "mkdocs-material", extras = ["imaging"], specifier = "==9.7.7" },
+ { name = "mkdocs-redirects", specifier = "==1.2.3" },
+ { name = "mkdocstrings", specifier = "==1.0.6" },
+ { name = "mkdocstrings-python", specifier = "==2.0.5" },
+ { name = "numcodecs", extras = ["msgpack"] },
+ { name = "pytest", specifier = "==9.1.1" },
+ { name = "ruff", specifier = "==0.16.0" },
+ { name = "s3fs", specifier = ">=2023.10.0" },
+ { name = "towncrier", specifier = "==25.8.0" },
+]
+release = [{ name = "towncrier", specifier = "==25.8.0" }]
+remote-tests = [
+ { name = "botocore" },
+ { name = "coverage", specifier = "==7.15.2" },
+ { name = "fsspec", specifier = ">=2023.10.0" },
+ { name = "hypothesis", specifier = "==6.164.0" },
+ { name = "moto", extras = ["s3", "server"], specifier = "==5.2.2" },
+ { name = "numpydoc", specifier = "==1.10.0" },
+ { name = "obstore", specifier = ">=0.5.1" },
+ { name = "pytest", specifier = "==9.1.1" },
+ { name = "pytest-accept", specifier = "==0.3.0" },
+ { name = "pytest-asyncio", specifier = "==1.4.0" },
+ { name = "pytest-benchmark", specifier = "==5.2.3" },
+ { name = "pytest-codspeed", specifier = "==5.0.3" },
+ { name = "pytest-cov", specifier = "==7.1.0" },
+ { name = "pytest-xdist", specifier = "==3.8.0" },
+ { name = "requests", specifier = "==2.34.2" },
+ { name = "s3fs", specifier = ">=2023.10.0" },
+ { name = "tomlkit", specifier = "==0.15.1" },
+ { name = "uv", specifier = "==0.12.0" },
+]
+test = [
+ { name = "coverage", specifier = "==7.15.2" },
+ { name = "hypothesis", specifier = "==6.164.0" },
+ { name = "numpydoc", specifier = "==1.10.0" },
+ { name = "pytest", specifier = "==9.1.1" },
+ { name = "pytest-accept", specifier = "==0.3.0" },
+ { name = "pytest-asyncio", specifier = "==1.4.0" },
+ { name = "pytest-benchmark", specifier = "==5.2.3" },
+ { name = "pytest-codspeed", specifier = "==5.0.3" },
+ { name = "pytest-cov", specifier = "==7.1.0" },
+ { name = "pytest-xdist", specifier = "==3.8.0" },
+ { name = "tomlkit", specifier = "==0.15.1" },
+ { name = "uv", specifier = "==0.12.0" },
+]
+
+[[package]]
+name = "zarr-http-server"
+source = { editable = "." }
+dependencies = [
+ { name = "starlette" },
+ { name = "uvicorn" },
+ { name = "zarr" },
+]
+
+[package.dev-dependencies]
+docs = [
+ { name = "griffe-inherited-docstrings" },
+ { name = "mkdocs" },
+ { name = "mkdocs-material" },
+ { name = "mkdocstrings" },
+ { name = "mkdocstrings-python" },
+ { name = "ruff" },
+]
+examples = [
+ { name = "fsspec", extra = ["http"] },
+ { name = "httpx" },
+]
+test = [
+ { name = "httpx" },
+ { name = "httpx2" },
+ { name = "hypothesis" },
+ { name = "ipykernel" },
+ { name = "nbclient" },
+ { name = "nbformat" },
+ { name = "pytest" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "starlette", specifier = ">=1.0" },
+ { name = "uvicorn", specifier = ">=0.29" },
+ { name = "zarr", editable = "../../" },
+]
+
+[package.metadata.requires-dev]
+docs = [
+ { name = "griffe-inherited-docstrings", specifier = "==1.1.3" },
+ { name = "mkdocs", specifier = "==1.6.1" },
+ { name = "mkdocs-material", specifier = "==9.7.7" },
+ { name = "mkdocstrings", specifier = "==1.0.6" },
+ { name = "mkdocstrings-python", specifier = "==2.0.5" },
+ { name = "ruff", specifier = "==0.16.0" },
+]
+examples = [
+ { name = "fsspec", extras = ["http"] },
+ { name = "httpx" },
+]
+test = [
+ { name = "httpx" },
+ { name = "httpx2" },
+ { name = "hypothesis" },
+ { name = "ipykernel" },
+ { name = "nbclient" },
+ { name = "nbformat" },
+ { name = "pytest" },
+]
diff --git a/packages/zarr-indexing/.readthedocs.yaml b/packages/zarr-indexing/.readthedocs.yaml
new file mode 100644
index 0000000000..c1925182f7
--- /dev/null
+++ b/packages/zarr-indexing/.readthedocs.yaml
@@ -0,0 +1,43 @@
+# Read the Docs configuration for the zarr-indexing docs site, separate from
+# the zarr-python site configured by the repo-root .readthedocs.yaml. The RTD
+# project for zarr-indexing must set its configuration-file path to
+# packages/zarr-indexing/.readthedocs.yaml.
+version: 2
+
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.12"
+ jobs:
+ post_checkout:
+ # Cancel pull request builds that do not touch this package. Exit code
+ # 183 cancels the build and reports success to the Git provider. Scoped
+ # to PR builds ("external" versions) because origin/main is only a
+ # meaningful diff base there.
+ - |
+ if [ "$READTHEDOCS_VERSION_TYPE" = "external" ] && git diff --quiet origin/main -- packages/zarr-indexing;
+ then
+ exit 183;
+ fi
+ install:
+ - pip install --upgrade pip
+ - pip install ./packages/zarr-indexing --group packages/zarr-indexing/pyproject.toml:docs
+ build:
+ html:
+ # Build from inside the package rather than pointing `-f` at its config
+ # from the repo root. mkdocs resolves some settings relative to the
+ # current working directory rather than to the config file, so building
+ # from elsewhere looks for them in the wrong place -- and silently, since
+ # the paths are valid, just wrong. zarr-indexing hit this: with
+ # `pymdownx.snippets` and a relative `base_path`, its snippets were
+ # searched for under the repo-root docs/ and the build failed with
+ # SnippetMissingError, while `just docs-check` passed because it runs
+ # from here. Building from the package directory makes this identical to
+ # the local and CI invocations, so a green build there means a green
+ # build here.
+ #
+ # $READTHEDOCS_OUTPUT is absolute, so the cd does not affect it.
+ - cd packages/zarr-indexing && mkdocs build --strict --site-dir $READTHEDOCS_OUTPUT/html
+
+mkdocs:
+ configuration: packages/zarr-indexing/mkdocs.yml
diff --git a/packages/zarr-indexing/CHANGELOG.md b/packages/zarr-indexing/CHANGELOG.md
new file mode 100644
index 0000000000..7c4bc92cad
--- /dev/null
+++ b/packages/zarr-indexing/CHANGELOG.md
@@ -0,0 +1,3 @@
+# Release notes
+
+
diff --git a/packages/zarr-indexing/CONTRIBUTING.md b/packages/zarr-indexing/CONTRIBUTING.md
new file mode 100644
index 0000000000..632e24929f
--- /dev/null
+++ b/packages/zarr-indexing/CONTRIBUTING.md
@@ -0,0 +1,27 @@
+# Contributing to zarr-indexing
+
+Package-scoped development commands live in the [`justfile`](./justfile)
+(requires [just](https://github.com/casey/just)):
+
+```
+just test # run the test suite (extra args go to pytest)
+just lint # ruff, same invocation as CI
+just typecheck # pyright, same invocation as CI
+just docs-check # strict build of the docs site
+just check # all of the above
+just docs-serve # serve the docs site locally
+```
+
+Run them from this directory, or from anywhere in the repository as
+`just packages/zarr-indexing/`.
+
+The test recipe runs against the workspace-root environment, because the
+chunk-resolution tests exercise this package against `zarr`'s chunk grids and
+`zarr` is deliberately not a dependency of this package.
+
+## License
+
+MIT
+
+The package lives at `packages/zarr-indexing` inside the
+[zarr-python](https://github.com/zarr-developers/zarr-python) repository.
diff --git a/packages/zarr-indexing/LICENSE.txt b/packages/zarr-indexing/LICENSE.txt
new file mode 100644
index 0000000000..1e8da4d242
--- /dev/null
+++ b/packages/zarr-indexing/LICENSE.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015-2025 Zarr Developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/zarr-indexing/README.md b/packages/zarr-indexing/README.md
new file mode 100644
index 0000000000..7e2cec10dd
--- /dev/null
+++ b/packages/zarr-indexing/README.md
@@ -0,0 +1,52 @@
+# zarr-indexing
+
+Composable, lazy coordinate transforms for Zarr array indexing.
+
+Documentation:
+
+This package implements TensorStore-inspired index transforms. The core idea:
+every indexing operation (slicing, fancy indexing, etc.) produces a coordinate
+mapping from user space to storage space. These mappings compose lazily — no
+I/O until you explicitly read or write.
+
+Key types:
+
+- `LazyArray` — wraps a system-memory/basic-indexing source and adds a `.lazy`
+ accessor: `LazyArray.from_numpy(numpy_array).lazy[10:50, ::2].lazy.oindex[[3, 1, 1], :]`
+ composes a transform and returns a new view without reading data, and
+ `result()` materializes it into owned system memory. `LazyArray(source)` uses
+ the conservative basic reader; `from_numpy` explicitly selects NumPy's
+ optimized reader. Device arrays require an explicit custom reader responsible
+ for transferring values into the supplied system-memory output buffer.
+- `Reader` — the explicit backend execution boundary: transforms say which
+ values belong in the result, while readers say how a backend obtains them
+- `IndexDomain` — a rectangular region of integer coordinates
+- `IndexTransform` — maps input coordinates to storage coordinates
+- `ChunkPlan` and `ChunkProjection` — lazily partition a selection over a
+ caller-selected grid and pair each chunk-local transform with its placement in
+ the request, without binding a storage backend or scheduler
+- `ConstantMap`, `DimensionMap`, `ArrayMap` — the three ways a single output
+ dimension can depend on the input
+- `compose` — chain two transforms into one
+
+The package depends only on NumPy and the standard library; it does not import
+`zarr`. It is developed in the [zarr-python](https://github.com/zarr-developers/zarr-python)
+repository and consumed by `zarr` to resolve array indexing operations.
+
+## Installation
+
+```bash
+pip install zarr-indexing
+```
+
+## Examples
+
+- [Lazy indexing a NumPy array](examples/lazy_indexing_numpy/README.md)
+- [Lazy indexing with Dask](examples/lazy_indexing_dask/README.md)
+
+## Contributing
+
+Development commands, the test suite and the docs build are described in
+[CONTRIBUTING.md](https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-indexing/CONTRIBUTING.md)
+in the repository. Issues and pull requests go to
+[zarr-developers/zarr-python](https://github.com/zarr-developers/zarr-python).
diff --git a/packages/zarr-indexing/changes/3906.feature.md b/packages/zarr-indexing/changes/3906.feature.md
new file mode 100644
index 0000000000..4a4b754bd8
--- /dev/null
+++ b/packages/zarr-indexing/changes/3906.feature.md
@@ -0,0 +1 @@
+Reworked the JSON layer to conform to the [ndsel](https://github.com/zarr-developers/ndsel) draft wire format, which adapts TensorStore's `IndexTransform`. A new `zarr_indexing.messages` module (`parse_ndsel`, `normalize_ndsel`, `NdselError`) is a pure JSON-to-JSON layer that accepts all five message kinds (`point`/`box`/`slice`/`points`/`transform`) and normalizes them to the canonical transform body, enforcing the full ndsel error taxonomy. The package is checked against the vendored, language-agnostic ndsel conformance corpus. Serialization produces and consumes the canonical body (`IndexTransform.to_json`/`from_json`, and the `IndexDomain` pair). On serialization, orthogonal (`oindex`) `index_array` maps no longer emit `input_dimension` alongside `index_array` (a combination both ndsel and TensorStore reject), and degenerate all-singleton index arrays collapse to constant maps; the in-memory `input_dimension` is reconstructed from the array's dependency axes on load.
diff --git a/packages/zarr-indexing/changes/4222.bugfix.1.md b/packages/zarr-indexing/changes/4222.bugfix.1.md
new file mode 100644
index 0000000000..8cff5be6e4
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.bugfix.1.md
@@ -0,0 +1,39 @@
+Correctness fixes to indexing and resolution, all reachable from 0.1.0:
+
+- An integer index applied to an axis a previous `oindex`/`vindex` step had
+ already indexed left an all-singleton `ArrayMap` still naming the axis the
+ integer removed, which after renumbering aliased a different one. Such a map
+ now collapses to a `ConstantMap` at composition time.
+- A `vindex` selection whose coordinate arrays are not on the leading axes
+ (`vindex[..., i, j]`, `vindex[..., mask]`) laid out its result incorrectly and
+ raised a shape mismatch on a partitioned read. Gathered dimensions now follow
+ NumPy's placement rule, and the per-part gather is realigned to the scatter.
+- An `oindex`/`vindex` step whose entries are all slices, applied to a view with
+ a fancy-indexed axis, applied those slices positionally to every axis of the
+ existing index array — including broadcast singletons — truncating it to size
+ 0, so `result()` returned an unwritten buffer. Reindexing is now
+ dependency-aware.
+- `parts()` raised on a view emptied by a slice over an axis of extent 1; an
+ empty domain now yields no parts, matching `result()`.
+- Negative-stride chunk projection swapped the endpoints while keeping the step
+ negative, selecting nothing where the reversed axis was meant. Composition
+ evaluated an inner index array over `range(size)` rather than the outer
+ domain's own range, resolving every coordinate wrongly whenever that domain
+ did not start at 0 — which both step-1 and negative-step slices produce.
+- A domain dimension no output map depends on, left behind when a later basic
+ index consumes the axis a `vindex` array varied over, was miscounted in three
+ places: the partition walk's out-selection rank, the lowering engine's axis
+ restoration, and the correlated gather's broadcast.
+- The parts of a correlated view narrowed to a single point came back rank 1
+ where the view was rank 0, so the documented
+ `out[part.out_selection] = part.view.result()` assembly raised `ValueError`.
+- `result()` could return memory shared with the wrapped array: an unpartitioned
+ read of a basic selection lowered to plain slicing and handed back a view of
+ the source, and `numpy.array(view, copy=True)` inherited the alias. It now
+ always allocates, and verifies the parts covered the output before returning.
+- `IndexTransform.from_json` rejects a non-integer `index_array` with an
+ `NdselError` carrying `invalid_json`, rather than truncating a float array,
+ coercing booleans, or leaking NumPy's conversion error for strings.
+- `result(parts=...)` raises `ValueError` rather than `AssertionError` when the
+ supplied parts do not tile the view, and `with_parts` / `with_parts_per_axis`
+ raise the documented `ValueError` for non-iterable input.
diff --git a/packages/zarr-indexing/changes/4222.bugfix.md b/packages/zarr-indexing/changes/4222.bugfix.md
new file mode 100644
index 0000000000..0717e1e384
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.bugfix.md
@@ -0,0 +1,9 @@
+An adversarial review of the whole package found, and this fixes, several
+defects at its boundaries: `result()` and `__array__(copy=True)` could hand back
+a live view of a source that merely stored its data in NumPy; the wire format
+emitted a document nothing could load for a selection that selects nothing, and
+its domain loader validated nothing; chunk-selection lowering described a
+transposed block in two separate cases; a map derived from a vectorized
+selection carried a stale `input_dimension`, which made one view's answer depend
+on how it was partitioned; and `oindex` over a correlated view applied NumPy's
+vectorized rule instead of the outer product.
diff --git a/packages/zarr-indexing/changes/4222.feature.1.md b/packages/zarr-indexing/changes/4222.feature.1.md
new file mode 100644
index 0000000000..bc62baf357
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.feature.1.md
@@ -0,0 +1,7 @@
+Added `UnitStepReader` / `unit_step_reader`: a backend adapter for sources
+whose basic indexing accepts only ascending step-1 slices (FFI bindings, HTTP
+range endpoints). Every key it presents is `slice(start, stop, 1)` per axis;
+strides, reversals, and gathers are applied to the in-memory block by the
+residual lowering. The integrations guide documents the companion dense-box
+re-partition idiom — resolving a unit-stride rectangular view as one backend
+slab read while keeping partitioned reads for strided and fancy selections.
diff --git a/packages/zarr-indexing/changes/4222.feature.2.md b/packages/zarr-indexing/changes/4222.feature.2.md
new file mode 100644
index 0000000000..722f56db99
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.feature.2.md
@@ -0,0 +1,24 @@
+Added `LazyArray`, which grafts the full NumPy indexing dialect onto any source
+exposing `shape`, `dtype`, and basic integer/slice `__getitem__` — a chunked
+store, an FFI binding, an HTTP endpoint. `view.lazy[...]`, `.lazy.oindex[...]`
+and `.lazy.vindex[...]` each compose an `IndexTransform` and return a new view
+without reading anything; `result()` materializes. Selections use positional
+NumPy semantics (negatives wrap, scalars drop their axis, coordinate arrays keep
+order and duplicates), which the new `zarr_indexing.boundary` module translates
+into the algebra's literal coordinates. The wrapper describes reads only, and
+behaves as a duck array: eager `__getitem__` and `__array__` make it a
+`dask.array.from_array` source.
+
+A read is divided along a **partitioning** — discovered from the wrapped array,
+or chosen with `with_parts` / `with_parts_per_axis` / `unpartitioned`.
+`parts()` yields one `Partition` per box, pairing a resolvable sub-view with
+where its cells belong in the result; `result()` is the assembly of that walk,
+and re-partitioning never changes what it returns. `base_shape` says which shape a partitioning is expressed in. `is_box`, `bounding_box()`
+and `strides()` report whether a selection is rectangular, so a consumer can
+dispatch a slab read against a gather.
+
+See the [guide](https://zarr-indexing.readthedocs.io/en/latest/guide/) for the
+model and the
+[design notes](https://zarr-indexing.readthedocs.io/en/latest/design-notes/)
+for the box/query distinction, the relationship to TensorStore, and current
+scope limits.
diff --git a/packages/zarr-indexing/changes/4222.feature.3.md b/packages/zarr-indexing/changes/4222.feature.3.md
new file mode 100644
index 0000000000..ef0e1512c2
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.feature.3.md
@@ -0,0 +1,9 @@
+Added source-independent chunk planning. `plan_chunks(transform, grids)` returns
+a lazy, reusable `ChunkPlan` whose `ChunkProjection`s each pair a chunk-local
+transform with a transform back to the request, over one shared cell domain, so
+a consumer can read a chunk and place its values without re-deriving either. The
+same representation covers basic, orthogonal and vectorized indexing, and
+carries global chunk bounds plus conservative full/partial/unknown coverage.
+I/O, buffering and scheduling stay with the consumer. `zarr_indexing.grid` gained
+`EdgeDimensionGrid` and `dimension_grids_from_chunks` for building the per-axis
+grids it takes.
diff --git a/packages/zarr-indexing/changes/4222.feature.4.md b/packages/zarr-indexing/changes/4222.feature.4.md
new file mode 100644
index 0000000000..623d381083
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.feature.4.md
@@ -0,0 +1,8 @@
+`LazyArray` has an explicit reader boundary: an `IndexTransform` decides which
+values belong in a result, and a `Reader` decides how one backend obtains them,
+preserving the transform exactly. `LazyArray(source)` is conservative and assumes
+only basic indexing; `LazyArray.from_numpy(array)` selects the optimized NumPy
+reader; `with_reader` selects any other. Readers do not define indexing
+semantics, partitioning, scheduling, or result ownership. Both built-in readers
+lower through NumPy system memory, so a device array needs a custom reader that
+transfers into the supplied output buffer.
diff --git a/packages/zarr-indexing/changes/4222.feature.5.md b/packages/zarr-indexing/changes/4222.feature.5.md
new file mode 100644
index 0000000000..859667775f
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.feature.5.md
@@ -0,0 +1,8 @@
+Added the `zarr_indexing.testing` subpackage, behind a `testing` extra
+(`pip install zarr-indexing[testing]`), carrying the Hypothesis machinery this
+package tests itself with. `ChainedIndexingStateMachine` composes basic,
+orthogonal and vectorized selections onto a `LazyArray` wrapping an array you
+supply, then checks every view's shape, `result()`, and assembled `parts()`
+against NumPy; `zarr_indexing.testing.strategies` exports the selection
+strategies alone, for a project with its own harness. Nothing outside the
+subpackage imports Hypothesis.
diff --git a/packages/zarr-indexing/changes/4222.feature.6.md b/packages/zarr-indexing/changes/4222.feature.6.md
new file mode 100644
index 0000000000..8c65f947e6
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.feature.6.md
@@ -0,0 +1,9 @@
+Negative-step slices are supported, following merged ndsel 1.0-draft.2 and
+TensorStore 0.1.84: `arr[::-1]`, `arr[5:1:-2]`, and reversal composed over an
+already-strided or already-gathered view. One desugaring rule covers both signs —
+omitted bounds resolve on the side the traversal starts and stops, and the origin
+is `trunc(start / step)` — while a reversed interval is an error rather than a
+silently empty selection. A reversing slice normally yields a negative domain
+origin, since the result stays anchored to the source coordinate frame;
+`LazyArray` re-bases every view to origin 0, so its positional dialect is
+unaffected.
diff --git a/packages/zarr-indexing/changes/4222.feature.md b/packages/zarr-indexing/changes/4222.feature.md
new file mode 100644
index 0000000000..c02ab54edb
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.feature.md
@@ -0,0 +1,11 @@
+Fancy selections compose without restriction, on both `LazyArray` and
+`IndexTransform`: a second `oindex`/`vindex`/mask step may land on any axis of
+an already-fancy view, including axes an existing index array merely broadcasts
+along. Array-carrying transforms are chained through `compose`, and resolution
+handles the resulting mixed, correlated and diagonal index-array structures on
+one shared pointwise path, classified by `IndexTransform.index_array_structure`.
+Only hand-built affine diagonals — an index array and a slice map bound to the
+same axis — remain unsupported.
+
+`__dask_tokenize__` digests a view's canonical transform body rather than
+embedding it, so tokens stay small for large fancy selections.
diff --git a/packages/zarr-indexing/changes/4222.misc.md b/packages/zarr-indexing/changes/4222.misc.md
new file mode 100644
index 0000000000..1bd73e1c80
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.misc.md
@@ -0,0 +1,5 @@
+Restructured the documentation-contract tests: the snippet include graph is
+now discovered by scanning the rendered markdown instead of hand-maintained
+registries, prose and navigation assertions moved out of CI, and
+`pymdownx.snippets` now sets `check_paths: true` so an unresolvable include
+fails `mkdocs build --strict` instead of silently rendering nothing.
diff --git a/packages/zarr-indexing/changes/4222.removal.1.md b/packages/zarr-indexing/changes/4222.removal.1.md
new file mode 100644
index 0000000000..33ff9cb423
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.removal.1.md
@@ -0,0 +1,12 @@
+The canonical JSON converters are now methods on the types that own the
+serialization: `IndexTransform.to_json()` / `IndexTransform.from_json()`,
+`IndexDomain.to_json()` / `IndexDomain.from_json()`, and `to_json()` on each
+output map kind. `output_index_map_from_json` remains a function, in
+`zarr_indexing.output_map`, because the wire form is a tagged union and
+loading it dispatches rather than belonging to any one kind.
+
+The free functions they replace — `transform_to_canonical`,
+`transform_from_canonical`, `index_domain_to_json`, `index_domain_from_json`,
+`output_index_map_to_json`, and the historical aliases
+`index_transform_to_json` / `index_transform_from_json` — are removed. There
+had been two spellings of each conversion; there is now one.
diff --git a/packages/zarr-indexing/changes/4222.removal.2.md b/packages/zarr-indexing/changes/4222.removal.2.md
new file mode 100644
index 0000000000..d3321ea81c
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.removal.2.md
@@ -0,0 +1,15 @@
+Operations moved onto the types that own them, following the arrangement
+TensorStore uses (public headers are the types; every transform operation
+lives in `internal/` and surfaces as a method):
+
+- `compose(outer, inner)` is now `outer.compose(inner)`, and the algorithm
+ moved to the private `zarr_indexing._composition`.
+- `selection_to_transform(selection, transform, mode)` is now
+ `transform.select(selection, mode)`.
+- `index_array_structure(transform)` is now the `transform.index_array_structure`
+ property.
+- `array_map_dependent_axis(m)` is now the `ArrayMap.dependent_axis` property,
+ alongside a new `ArrayMap.dependency_axes` giving every axis a map varies over.
+
+`zarr_indexing.affine` is now the private `zarr_indexing._affine`; it was
+never exported or documented.
diff --git a/packages/zarr-indexing/changes/4222.removal.3.md b/packages/zarr-indexing/changes/4222.removal.3.md
new file mode 100644
index 0000000000..d5210f4c8d
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.removal.3.md
@@ -0,0 +1,6 @@
+`with_parts` is now three named methods — `with_parts`, `with_parts_per_axis`
+and `unpartitioned` — instead of one parameter whose meaning was decided by the
+type of what it was given. `Partition.array` is `Partition.view`, no longer the
+inverse of `LazyArray.array`. `ArrayMap`, `IndexTransform` and `Partition` can
+be compared and hashed, which `frozen=True` had implied and neither could do.
+`LazyArray.base_shape` says which shape a partitioning is expressed in.
diff --git a/packages/zarr-indexing/changes/4222.removal.md b/packages/zarr-indexing/changes/4222.removal.md
new file mode 100644
index 0000000000..5782c9ab4d
--- /dev/null
+++ b/packages/zarr-indexing/changes/4222.removal.md
@@ -0,0 +1,11 @@
+`ArrayMap` no longer has an `input_dimension` field: what a map depends on is
+read from its full-rank index array's shape (its non-singleton axes), the
+single source of truth. A selection narrowed to a single coordinate is now
+built as the `ConstantMap` it equals (`array_map_or_constant`), so a length-1
+fancy selection classifies as a box; hand-built all-singleton or shared-axis
+`ArrayMap`s resolve through the pointwise path. The wire format is unaffected —
+it never carried the field.
+
+The provisional tuple resolver and selector bridge are gone with it:
+`iter_chunk_transforms` and `sub_transform_to_selections` are removed, their
+role taken by `plan_chunks` and the paired projections it returns.
diff --git a/packages/zarr-indexing/changes/README.md b/packages/zarr-indexing/changes/README.md
new file mode 100644
index 0000000000..feb3f8674e
--- /dev/null
+++ b/packages/zarr-indexing/changes/README.md
@@ -0,0 +1,25 @@
+Writing a changelog entry for `zarr-indexing`
+-----------------------------------------------
+
+Fragments in **this** directory are release notes for the `zarr-indexing`
+package only — kept separate from the parent zarr-python `changes/`
+directory so a PR touching only `packages/zarr-indexing/` produces a
+release note for this package only.
+
+Please put a new file in this directory named `xxxx..md`, where
+
+- `xxxx` is the pull request number associated with this entry
+- `` is one of:
+ - feature
+ - bugfix
+ - doc
+ - removal
+ - misc
+
+Inside the file, please write a short description of what you have
+changed, and how it impacts users of `zarr-indexing`.
+
+A `zarr-indexing` release runs `towncrier build` in `packages/zarr-indexing/`,
+which consumes the fragments here and updates `CHANGELOG.md`. Fragments
+that describe parent zarr-python changes (not the transforms package)
+belong in the top-level `changes/` directory, not here.
diff --git a/packages/zarr-indexing/docs/_static/favicon-96x96.png b/packages/zarr-indexing/docs/_static/favicon-96x96.png
new file mode 100644
index 0000000000..e77977ccf4
Binary files /dev/null and b/packages/zarr-indexing/docs/_static/favicon-96x96.png differ
diff --git a/packages/zarr-indexing/docs/_static/logo_bw.png b/packages/zarr-indexing/docs/_static/logo_bw.png
new file mode 100644
index 0000000000..df1979d3cc
Binary files /dev/null and b/packages/zarr-indexing/docs/_static/logo_bw.png differ
diff --git a/packages/zarr-indexing/docs/api/boundary.md b/packages/zarr-indexing/docs/api/boundary.md
new file mode 100644
index 0000000000..4f9fa99531
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/boundary.md
@@ -0,0 +1,5 @@
+---
+title: boundary
+---
+
+::: zarr_indexing.boundary
diff --git a/packages/zarr-indexing/docs/api/chunk_resolution.md b/packages/zarr-indexing/docs/api/chunk_resolution.md
new file mode 100644
index 0000000000..0d81ec3829
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/chunk_resolution.md
@@ -0,0 +1,5 @@
+---
+title: chunk_resolution
+---
+
+::: zarr_indexing.chunk_resolution
diff --git a/packages/zarr-indexing/docs/api/domain.md b/packages/zarr-indexing/docs/api/domain.md
new file mode 100644
index 0000000000..b039d3a7f4
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/domain.md
@@ -0,0 +1,5 @@
+---
+title: domain
+---
+
+::: zarr_indexing.domain
diff --git a/packages/zarr-indexing/docs/api/errors.md b/packages/zarr-indexing/docs/api/errors.md
new file mode 100644
index 0000000000..994a74248a
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/errors.md
@@ -0,0 +1,5 @@
+---
+title: errors
+---
+
+::: zarr_indexing.errors
diff --git a/packages/zarr-indexing/docs/api/grid.md b/packages/zarr-indexing/docs/api/grid.md
new file mode 100644
index 0000000000..b7c376eb85
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/grid.md
@@ -0,0 +1,22 @@
+---
+title: grid
+---
+
+`zarr_indexing.grid` owns compact chunk-grid metadata so indexing plans can be
+constructed without importing Zarr. `FixedDimension(size, extent)` represents
+regular chunks in constant memory, including a clipped final data region;
+`VaryingDimension(edges, extent)` represents explicit rectilinear chunk edges.
+`ChunkGrid(dimensions=...)` combines these dimensions and returns `ChunkSpec`
+objects whose `shape` is the valid data size and whose `codec_shape` preserves
+the full codec-buffer size at a regular-grid boundary.
+
+`dimension_grids_from_chunks` returns these compact dimensions: integer chunk
+shapes become `FixedDimension` instances and explicit per-axis edge sequences
+become `VaryingDimension` instances. `DimensionGridLike` remains the narrow
+protocol used by the chunk planner, while `EdgeDimensionGrid` is kept for
+explicit edge-based and coordinate-origin examples.
+
+Zarr's array implementation can later import these compact grid types from
+`zarr_indexing`; this package intentionally has no import dependency on Zarr.
+
+::: zarr_indexing.grid
diff --git a/packages/zarr-indexing/docs/api/index.md b/packages/zarr-indexing/docs/api/index.md
new file mode 100644
index 0000000000..674c701a0f
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/index.md
@@ -0,0 +1,87 @@
+---
+title: API reference
+---
+
+# API reference
+
+Choose the guide stopping point that matches your job before following module
+links:
+
+- **Use lazy indexing:** finish
+ [Lazy views compose](../guide/index.md#lazy-views-compose),
+ then open [`zarr_indexing.lazy_array`](lazy_array.md) for `LazyArray`.
+- **Integrate a chunked source:** finish
+ [One cell domain, two projections](../guide/index.md#one-cell-domain-two-projections),
+ then open [`zarr_indexing.chunk_resolution`](chunk_resolution.md) for
+ `plan_chunks`. Start with
+ [Coordinates are addresses](../guide/index.md#coordinates-are-addresses) if
+ literal coordinates are unfamiliar.
+
+The modules are layered: the transform algebra at the bottom, chunk resolution
+and the wire format built on top of it.
+
+**The transform algebra**
+
+- [`zarr_indexing.domain`](domain.md) — `IndexDomain`, a rectangular region of
+ integer coordinates with an explicit (possibly non-zero) origin
+- [`zarr_indexing.output_map`](output_map.md) — `ConstantMap`, `DimensionMap`,
+ and `ArrayMap`: three representations of a set of integer coordinates, one
+ per storage dimension
+- [`zarr_indexing.transform`](transform.md) — `IndexTransform`, which pairs a
+ domain with output maps, plus the indexing (`[...]`, `.oindex`, `.vindex`),
+ `intersect`, and `translate` operations, and `selection_to_transform`
+ transforms into one
+
+**Chunk resolution**
+
+- [`zarr_indexing.chunk_resolution`](chunk_resolution.md) —
+ `plan_chunks`, which lazily projects a request through a caller-selected grid,
+ plus the reusable `ChunkPlan` and paired-transform `ChunkProjection` values
+- [`zarr_indexing.grid`](grid.md) — `DimensionGridLike`, the Protocol
+ describing the narrow chunk-grid surface chunk resolution consumes, so that
+ nothing here imports `zarr`, plus `EdgeDimensionGrid` and
+ `dimension_grids_from_chunks`, a concrete per-axis grid for callers with no
+ zarr grid to hand
+
+**Lazy arrays**
+
+- [`zarr_indexing.lazy_array`](lazy_array.md) — `LazyArray`, a wrapper for
+ system-memory/basic-indexing sources that adds a `.lazy` accessor for
+ TensorStore-style deferred indexing, plus `Partition` and `parts()` /
+ `with_parts()`, which determine the boxes a read is broken into. Device
+ sources require an explicit custom reader that transfers into the supplied
+ system-memory output
+- [`zarr_indexing.reader`](reader.md) — `Reader`, the backend execution boundary
+ that obtains the values described by a complete transform; `basic_reader`
+ serves conservative duck arrays and `numpy_reader` is selected explicitly by
+ `LazyArray.from_numpy`
+- [`zarr_indexing.boundary`](boundary.md) — the translation between NumPy's
+ positional dialect and the transform algebra's literal coordinates
+
+**The ndsel wire format** (see [the guide](../ndsel.md))
+
+- [`zarr_indexing.messages`](messages.md) — `parse_ndsel` / `normalize_ndsel`,
+ the pure JSON→JSON message layer, and `NdselError`
+- [`zarr_indexing.json`](json.md) — lowering between canonical ndsel bodies and
+ in-memory transforms
+
+**Errors**
+
+- [`zarr_indexing.errors`](errors.md) — the index-error types this package
+ raises, also exported at the top level. `zarr.errors` defines classes of the
+ same names, which are different objects; both subclass `IndexError`
+
+**Test support** (needs the `testing` extra)
+
+- [`zarr_indexing.testing.stateful`](testing_stateful.md) —
+ `ChainedIndexingStateMachine`, a Hypothesis state machine that composes
+ indexing steps onto a `LazyArray` wrapping your array and checks every step
+ against NumPy, plus `apply_selection`, the NumPy model it checks against
+- [`zarr_indexing.testing.strategies`](testing_strategies.md) — the selection
+ strategies the machine draws from, for a project that has its own harness
+
+Every name listed in `zarr_indexing.__all__` is re-exported at the top level,
+so `from zarr_indexing import IndexTransform` and
+`from zarr_indexing.transform import IndexTransform` are equivalent.
+`zarr_indexing.testing` is deliberately not among them: it imports
+`hypothesis`, which the rest of the package does not.
diff --git a/packages/zarr-indexing/docs/api/json.md b/packages/zarr-indexing/docs/api/json.md
new file mode 100644
index 0000000000..0183ab30e7
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/json.md
@@ -0,0 +1,5 @@
+---
+title: json
+---
+
+::: zarr_indexing.json
diff --git a/packages/zarr-indexing/docs/api/lazy_array.md b/packages/zarr-indexing/docs/api/lazy_array.md
new file mode 100644
index 0000000000..f855511d46
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/lazy_array.md
@@ -0,0 +1,27 @@
+---
+title: lazy_array
+---
+
+`LazyArray.lazy[...]` is metadata-only: every derived view keeps the same
+reader and composes its transform without reading data. `result()` allocates
+owned system memory, then calls that reader once for each projected part.
+Rectangular parts write directly into their final slices; advanced placement
+may first use an owned dense temporary. `LazyArray(source)` assumes only basic
+indexing, while `LazyArray.from_numpy(array)` explicitly selects NumPy's
+optimized reader.
+
+The built-in readers lower through NumPy system memory and support sources
+whose basic reads can be converted there. They do not implicitly transfer
+device arrays; a device source needs an explicit custom reader that transfers
+into the supplied system-memory output. Derived views and parts share their
+reader and part views may be materialized concurrently, so stateful readers
+must synchronize their own mutable state.
+
+Every public `Partition.view.transform` directly maps that view's zero-origin
+coordinates into its raw `Partition.view.array`, including for non-first
+partitions. `Partition.projection.chunk_transform` intentionally stays local to
+the selected chunk. During materialization the reader receives both frames in
+one `ReadContext`: the public global transform in `context.transform` and the
+same local plan in `context.projection`.
+
+::: zarr_indexing.lazy_array
diff --git a/packages/zarr-indexing/docs/api/messages.md b/packages/zarr-indexing/docs/api/messages.md
new file mode 100644
index 0000000000..6c2a434540
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/messages.md
@@ -0,0 +1,5 @@
+---
+title: messages
+---
+
+::: zarr_indexing.messages
diff --git a/packages/zarr-indexing/docs/api/output_map.md b/packages/zarr-indexing/docs/api/output_map.md
new file mode 100644
index 0000000000..55114a6997
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/output_map.md
@@ -0,0 +1,5 @@
+---
+title: output_map
+---
+
+::: zarr_indexing.output_map
diff --git a/packages/zarr-indexing/docs/api/reader.md b/packages/zarr-indexing/docs/api/reader.md
new file mode 100644
index 0000000000..39129c4d47
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/reader.md
@@ -0,0 +1,51 @@
+---
+title: Readers
+---
+
+# Readers
+
+An `IndexTransform` defines which source value belongs at every result
+position. A `Reader` defines how a particular backend obtains those values.
+Readers do not define indexing semantics, partitioning, scheduling, or result
+ownership.
+
+`Reader.read_into(source, context, out)` receives a `ReadContext` whose
+`transform` maps zero-origin output-buffer coordinates to global coordinates in
+`source`, with `context.transform.domain.shape == out.shape`. Its optional
+`projection` is the existing plan for a partitioned read. The projection's
+`chunk_transform` remains chunk-local, its `cell_transform` describes result
+placement, and its `chunk_domain` describes the grid cell. The global read
+transform and the projection's chunk transform deliberately use different
+coordinate frames.
+
+An implementation must fill every cell of `out` in place, preserve the global
+transform's exact values, order, and dtype, and return `None`. It must neither
+replace nor retain `out`, which may be a strided writable view. Backend
+exceptions propagate unchanged. Derived part views share their reader and may
+be resolved concurrently, so a stateful reader owns its own synchronization.
+
+Reader wrappers compose by intercepting this one operation and forwarding the
+same source, context, and output buffer to an inner reader:
+
+```python
+class RecordingReader:
+ def __init__(self, inner):
+ self.inner = inner
+ self.calls = []
+
+ def read_into(self, source, context, out, /):
+ self.calls.append((source, context, out))
+ self.inner.read_into(source, context, out)
+
+
+inner = RecordingReader(numpy_reader)
+outer = RecordingReader(inner)
+view = LazyArray.from_numpy(array).with_reader(outer)
+values = view.result()
+```
+
+Both wrappers observe the same three objects, in outer-to-inner order. This
+delegation pattern supports policies such as logging and caching without
+library-defined wrapper primitives.
+
+::: zarr_indexing.reader
diff --git a/packages/zarr-indexing/docs/api/testing_stateful.md b/packages/zarr-indexing/docs/api/testing_stateful.md
new file mode 100644
index 0000000000..0aeb57df29
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/testing_stateful.md
@@ -0,0 +1,5 @@
+---
+title: testing.stateful
+---
+
+::: zarr_indexing.testing.stateful
diff --git a/packages/zarr-indexing/docs/api/testing_strategies.md b/packages/zarr-indexing/docs/api/testing_strategies.md
new file mode 100644
index 0000000000..1dd7d59457
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/testing_strategies.md
@@ -0,0 +1,5 @@
+---
+title: testing.strategies
+---
+
+::: zarr_indexing.testing.strategies
diff --git a/packages/zarr-indexing/docs/api/transform.md b/packages/zarr-indexing/docs/api/transform.md
new file mode 100644
index 0000000000..8e67a162c1
--- /dev/null
+++ b/packages/zarr-indexing/docs/api/transform.md
@@ -0,0 +1,18 @@
+---
+title: transform
+---
+
+An `IndexTransform` is a function between coordinate spaces, and its field
+names follow the function, not the data:
+
+| the API says | in array terms |
+| --- | --- |
+| input space (`domain`, `input_rank`) | request coordinates — the result being built |
+| output space (`output`, one map per dimension) | source coordinates — where values are read |
+
+`output` is not data: it is the rule, per source dimension, for producing
+coordinates. Values flow source → request, against the arrow. The
+[guide](../guide/index.md#a-transform-points-from-the-request-to-the-source)
+demonstrates each output map form against its NumPy counterpart.
+
+::: zarr_indexing.transform
diff --git a/packages/zarr-indexing/docs/design-notes.md b/packages/zarr-indexing/docs/design-notes.md
new file mode 100644
index 0000000000..78b352d376
--- /dev/null
+++ b/packages/zarr-indexing/docs/design-notes.md
@@ -0,0 +1,296 @@
+---
+title: Design notes
+---
+
+# Design notes
+
+This page records advanced rationale that the API does not state directly: how
+this library relates to TensorStore, why rectangular selections are a category
+rather than a fast path, and what is deliberately not implemented yet. The
+visual guide owns the mechanics of
+[literal coordinates](guide/index.md#coordinates-are-addresses),
+[view composition](guide/index.md#lazy-views-compose),
+[chunk plans](guide/index.md#a-request-becomes-a-chunk-plan), and
+[their paired projections](guide/index.md#one-cell-domain-two-projections).
+
+## Relationship to TensorStore
+
+The core is [TensorStore's](https://google.github.io/tensorstore/index_space.html)
+index-transform model, reimplemented in Python against NumPy. The visual guide
+introduces the shared model in
+[Coordinates are addresses](guide/index.md#coordinates-are-addresses) and
+[Lazy views compose](guide/index.md#lazy-views-compose); the comparison here is
+about the deliberately matching semantics:
+
+- **The model.** Both use an `IndexTransform` made of an input domain and one
+ output index map per storage dimension, in constant, affine, and index-array
+ forms.
+- **Slice semantics.** Slice bounds are literal domain coordinates: no
+ clamping, no negative wrapping, non-empty intervals must be contained in the
+ domain, and a strided slice's domain origin is `trunc(start/step)` rounded
+ toward zero. Every one of those rules was executed against tensorstore 0.1.84
+ and is pinned in `tests/test_tensorstore_parity.py`.
+- **The wire format.** A canonical [ndsel](ndsel.md) transform body is,
+ field-for-field, a TensorStore `IndexTransform` minus the `kind`
+ discriminator, and `tests/test_ndsel_tensorstore.py` loads our bodies into
+ `tensorstore.IndexTransform(json=...)` and round-trips them back through our
+ engine layer.
+
+The representations differ in one place: index arrays. Both models want an index
+array at the transform's full input rank, with singleton axes for the dimensions
+a map does not vary over. TensorStore enforces it — its JSON parser rejects a
+rank-1 array over a rank-2 domain outright, with `Index array for output
+dimension 0 has rank 1 but must have rank 2` (checked against tensorstore
+0.1.84) — while our loader is the more permissive of the two and also accepts a
+lower-rank array that broadcasts against the input domain. That is a
+compatibility affordance, not a difference in the model: ndsel leaves index-array
+rank to [the engine layer](ndsel.md#lowering-to-a-transform), and everything the
+algebra builds itself is at full rank.
+
+The reason full rank matters here is that we *derive* meaning from those
+singletons rather than merely tolerating them: an array full-sized on one axis
+and singleton elsewhere is orthogonal, and one varying over several shared axes
+is vectorized, so the distinction is readable off the shape — and the shape is
+the *only* place it lives. An earlier `ArrayMap.input_dimension` field pinned
+the orthogonal axis redundantly and was retired: the one shape it disambiguated
+(a single-coordinate array, all axes singleton) is now normalized away at
+construction, collapsed to the `ConstantMap` it equals, exactly as
+[the serializer](api/json.md) has always collapsed it on the wire.
+
+Four deliberate differences:
+
+| | TensorStore | `zarr-indexing` |
+| --- | --- | --- |
+| Dialect | One strict dialect everywhere: literal coordinates, no negative wrapping | The algebra keeps that dialect; each public boundary picks its own. [`LazyArray`](api/lazy_array.md) speaks positional NumPy, `zarr.Array.lazy` speaks literal. [`zarr_indexing.boundary`](api/boundary.md) is the translation |
+| Scheduling | An internal C++ scheduler owns concurrency and chunk ordering | [`parts()`](api/lazy_array.md) exposes the partition structure so the caller's own scheduler — dask, a thread pool, a task queue — drives it |
+| Wire format | Implementation-defined JSON, specified by what the implementation accepts | [ndsel](ndsel.md) is spec-first, with a vendored language-agnostic conformance corpus every implementation runs |
+| Backends | A driver ecosystem (zarr, N5, neuroglancer, GCS, …) built into the library | No drivers. The default reader needs `shape`, `dtype`, basic integer/slice indexing, and selected slabs convertible to NumPy system memory; other backends use explicit custom readers. A device reader owns transfer into the supplied system-memory output |
+
+The mechanics of a
+[chunk plan](guide/index.md#a-request-becomes-a-chunk-plan) and its
+[paired projections](guide/index.md#one-cell-domain-two-projections) belong to
+the visual guide.
+The relevant comparison is that both libraries use the paired-transform
+boundary rather than a read key plus scatter indices, so slices, outer products,
+and correlated gathers remain ordinary transforms that a consumer can lower to
+its own execution vocabulary.
+
+The ownership boundary differs. `plan_chunks` retains only the logical request
+and caller-supplied grid; it does not own reads, writes, buffers, locks, or
+scheduling. Zarr can therefore plan reads against an inner codec-chunk grid and
+writes against an atomic shard grid; napari or dask can turn the same
+projections into tasks without putting a dask dependency in this package.
+`coverage` is relative to that selected grid: `full` proves a blind replacement
+safe, `partial` proves it is not, and `unknown` conservatively covers fancy
+selections whose duplicates would require additional work to classify.
+
+The comparison also runs the other way. TensorStore is a mature, heavily
+optimized C++ system whose performance this library cannot approach: resolution
+here is Python-level bookkeeping over NumPy, and the per-part overhead is
+significant. This library is small and depends on nothing beyond NumPy, so the
+algebra can be adopted by a Python project that wants the model without the C++
+runtime.
+
+## Bounding-box selections vs query selections
+
+Every selection this library can express falls into exactly one of two
+categories. The boundary between them is structural, not a heuristic:
+
+**A box** is a transform whose output maps are all `ConstantMap` or
+`DimensionMap` — no `ArrayMap`. Such a map is affine and monotone: storage
+coordinate `offset + stride * i` for `i` running over an interval. The whole
+selection is therefore described by `O(ndim)` integers — an interval and a
+stride per dimension — composition and intersection are interval arithmetic,
+and the coordinates it touches form a regular lattice. Basic indexing produces
+one, and composing basic indexing with basic indexing keeps one.
+
+**A query** is a transform with at least one `ArrayMap` — an explicit lookup
+table of coordinates. It costs `O(n)` to store, it has no locality (the
+coordinates may repeat, reverse, or scatter arbitrarily), and intersecting it
+with a region means scanning it. `oindex`, `vindex`, and boolean masks all
+produce one, and once an axis is a query, subsequent basic indexing cannot make
+it a box again. A second query composes onto any axis of an existing one —
+including the axes it merely broadcasts along — by evaluating the existing
+lookup tables at the new coordinates.
+
+Those coordinate arrays are ordered sequences, never mathematical sets. Their
+order and duplicate entries are part of the indexing semantics and must survive
+planning and materialization.
+
+[ndsel](ndsel.md) encodes the same split in its message kinds: `point`, `box`,
+and `slice` desugar to constant and affine output maps and are always boxes;
+`points` desugars to `index_array` maps, and a `transform` body is a box
+exactly when none of its output maps carries an `index_array`. A consumer can
+therefore classify a selection off the wire without materializing anything:
+
+```python
+from zarr_indexing import IndexTransform
+
+IndexTransform.from_shape((100, 80))[10:50, ::4].to_json()["output"]
+# [{'offset': 0, 'stride': 1, 'input_dimension': 0},
+# {'offset': 0, 'stride': 4, 'input_dimension': 1}]
+
+import numpy as np
+gather = IndexTransform.from_shape((100, 80)).oindex[np.array([90, 3, 3]), slice(None)]
+gather.to_json()["output"][0]
+# {'offset': 0, 'stride': 1, 'index_array': [[90], [3], [3]],
+# 'index_array_bounds': ['-inf', '+inf']}
+```
+
+The distinction matters to consumers of a selection. A box can be tiled into
+rectangular dask chunks or passed to a viewer or tile server that only accepts
+rectangles; a query cannot, and has to be resolved into a gather. A box can also
+be served as a single strided slab read, but the read has to be strided: reading
+its bounding box and discarding the rest transfers proportionally more data as
+soon as any stride exceeds 1. The two also behave differently under
+partitioning: a box touches a regularly-spaced run of parts, in increasing
+order, each at most once — a stride larger than a part's extent skips parts
+outright, so the run is not contiguous — while a query can touch any subset of
+them, in any order, more than once.
+
+[`LazyArray`](api/lazy_array.md) exposes the category directly:
+
+```python
+import numpy as np
+import zarr
+
+from zarr_indexing import LazyArray
+
+arr = zarr.create_array({}, shape=(100, 80), chunks=(30, 40), dtype="int32")
+arr[:] = np.arange(8000).reshape(100, 80)
+lazy = LazyArray(arr)
+
+slab = lazy.lazy[10:50, ::4]
+slab.is_box # True
+slab.bounding_box() # ((10, 50), (0, 77))
+slab.strides() # (1, 4)
+slab.shape # (40, 20)
+
+gather = lazy.lazy.oindex[[90, 3, 3], :]
+gather.is_box # False
+gather.bounding_box() # ((3, 91), (0, 80))
+gather.strides() # None
+gather.shape # (3, 80)
+```
+
+`bounding_box()` is defined for both: it is the hull, the smallest interval per
+storage dimension containing every coordinate the selection reaches.
+`strides()` is defined only for a box and gives the step per dimension.
+Together the two describe a box selection completely.
+
+Both are needed, because a box is dense in its hull only when every stride is
+1. The slab above spans a 40x77 hull over the 40x20 cells it selects, so a
+consumer that issued one rectangular read of the hull and discarded the rest
+would transfer 3.85x the data. A query's hull is looser still and carries no
+stride at all: 88 rows of hull over three selected rows. An empty *box* touches
+no coordinate to report an interval around, so `bounding_box()` is `None` while
+`strides()` still answers — the step is a property of the selection's shape, not
+of the region it reaches. Only a query returns `None` from both.
+
+There is deliberately no separate `BoxView` type today. A statically-typed
+rectangular-only view is a plausible next step, but it should be introduced by
+a consumer that needs the guarantee in its signatures rather than
+speculatively; `is_box` is the runtime check until then.
+
+## Negative-origin domains and prependable grids
+
+Literal coordinates let a domain grow at its lower end without changing the
+identity of anything already present. Prepending three cells extends `[0, 6)`
+to `[-3, 6)`: the new cells receive addresses `-3`, `-2`, and `-1`, while the
+old cells keep addresses `0` through `5`. Coordinate `0` does not become
+coordinate `3`.
+
+The adjacent intervals `[-3, 0)`, `[0, 3)`, and `[3, 6)` follow the half-open
+adjacency rule: each stopping boundary is included exactly once as the next
+interval's starting boundary.
+
+```text
+before [0, 6):
+
+ | 0 1 2 | 3 4 5 |
+chunk coordinate | 0 | 1 |
+
+after [-3, 6):
+
+| -3 -2 -1 | 0 1 2 | 3 4 5 |
+| -1 | 0 | 1 | chunk coordinate
+```
+
+The same holds for chunk grids. `EdgeDimensionGrid` is the convenient
+concrete grid for a zero-origin array: its chunk offsets are prefix sums
+starting at zero. `DimensionGridLike` is the more general protocol consumed
+by chunk planning, so it admits grids with negative chunk and cell
+coordinates, including this prependable example:
+
+```python
+--8<-- "snippets/coordinate_origins.py:prepend-grid"
+```
+
+Here the literal cell domain `[-3, 0)` belongs to chunk `-1`. Both public
+projection transforms share the same synthetic input cell domain `[0, 3)`.
+Evaluating its three points shows the two distinct outputs:
+`chunk_transform` produces zero-origin chunk-local coordinates `0, 1, 2`,
+while `cell_transform` produces the literal request coordinates `-3, -2, -1`.
+The shared input domain is not itself the chunk-local coordinate frame.
+
+## Related work
+
+TensorStore is the prior art for the transform algebra, as described above. At
+the execution boundary, this package instead gives each backend a `ReadContext`
+through a `Reader`. Its global transform answers **which values?**; the reader
+answers **how does this backend obtain them?** A partition view's transform
+directly addresses the raw source in global coordinates. Its optional
+projection retains the paired planning transforms, of which only
+`chunk_transform` addresses zero-origin chunk-local coordinates. The reader
+must preserve the global transform exactly, but it does not participate in
+indexing semantics, partitioning, scheduling, or result ownership.
+
+Earlier versions used a capability taxonomy modeled on historical indexing
+dialects. That model required deciding which fragment of a request a backend
+could accept and finishing the rest elsewhere. A reader lowers the complete
+transform and can compose through delegation instead. This resembles
+[zarrita.js store extensions](https://zarrita.dev/packages/zarrita.html), where
+storage-specific behavior is an explicit extension point rather than an
+inferred array capability. The implementation remains independently authored:
+no code is shared with TensorStore, xarray, or zarrita.js.
+
+## Current scope
+
+Negative steps are supported as of ndsel 1.0-draft.2: `a[::-1]` reverses, one
+desugaring rule covers both signs, and a reversed interval is an error rather
+than a silently empty selection. One consequence: a negative step normally
+produces a negative domain origin. Reversing a length-20 zero-origin axis gives
+the domain `[-19, 1)`, because the result stays anchored to the source
+coordinate frame and a reversing map traverses that frame backwards. `LazyArray`
+re-bases every view to origin 0, so the positional dialect never exposes it; a
+caller working with `IndexTransform` directly will see it, and re-bases
+explicitly with `translate_domain_to` for NumPy-shaped coordinates.
+
+Fancy selections compose without restriction: a second `oindex`/`vindex`/mask
+step may land on any axis of an already-fancy view, including axes an existing
+index array merely broadcasts along, so
+`lazy.oindex[[2, 0], :].lazy.oindex[:, [1, 3]]` selects the outer product it
+spells. An array-carrying transform is composed — the new selection is applied
+to an identity transform over the current domain and chained on with `compose`,
+which evaluates the existing lookup tables at the new coordinates — rather than
+rewritten in place. Resolution classifies the result by structure
+(`index_array_structure`): pure per-axis outer products keep the orthogonal
+resolvers, and everything else — correlated maps, mixtures, index arrays
+sharing an input axis (a diagonal gather, reachable only by hand-building a
+transform) — takes the pointwise path that collapses the joint block.
+
+Three limits remain, all intentional and all expected to be lifted:
+
+- **Affine diagonals.** A hand-built transform in which an *index array* and a
+ *slice map* bind the same input dimension, or two slice maps share one, is
+ rejected at resolution with `NotImplementedError`. No selection dialect
+ produces one; supporting them means lowering the slice maps into the joint
+ block too. *Planned.*
+- **Finite explicit bounds only.** `IndexDomain` has no implicit or unbounded
+ dimensions; the message layer will normalize a body with `"-inf"`/`"+inf"`
+ bounds, but the engine layer refuses to lower one into a transform.
+ TensorStore supports both. *Planned.*
+- **Labels are carried, not propagated.** `IndexDomain` holds optional
+ dimension labels and the wire format round-trips them, but indexing
+ operations build new domains without them, so a label does not survive a
+ slice. *Planned.*
diff --git a/packages/zarr-indexing/docs/examples/lazy_indexing_dask.md b/packages/zarr-indexing/docs/examples/lazy_indexing_dask.md
new file mode 100644
index 0000000000..5c722fe355
--- /dev/null
+++ b/packages/zarr-indexing/docs/examples/lazy_indexing_dask.md
@@ -0,0 +1,7 @@
+--8<-- "lazy_indexing_dask/README.md"
+
+## Source Code
+
+```python
+--8<-- "lazy_indexing_dask/lazy_indexing_dask.py"
+```
diff --git a/packages/zarr-indexing/docs/examples/lazy_indexing_numpy.md b/packages/zarr-indexing/docs/examples/lazy_indexing_numpy.md
new file mode 100644
index 0000000000..8a09ce4d01
--- /dev/null
+++ b/packages/zarr-indexing/docs/examples/lazy_indexing_numpy.md
@@ -0,0 +1,15 @@
+--8<-- "lazy_indexing_numpy/README.md"
+
+`LazyArray(source)` uses the conservative built-in reader: `source` must expose
+`shape`, `dtype`, and basic integer/slice indexing, and every selected slab must
+be convertible to NumPy system memory. Coordinate arrays passed through
+`oindex` or `vindex` are ordered and duplicate-preserving; they are not sets.
+When a view is partitioned, each `Partition.view.transform` addresses the raw
+source globally while `Partition.projection.chunk_transform` stays
+zero-origin and chunk-local.
+
+## Source Code
+
+```python
+--8<-- "lazy_indexing_numpy/lazy_indexing_numpy.py"
+```
diff --git a/packages/zarr-indexing/docs/examples/system_memory_chunk_cache.md b/packages/zarr-indexing/docs/examples/system_memory_chunk_cache.md
new file mode 100644
index 0000000000..e4d92ba2b6
--- /dev/null
+++ b/packages/zarr-indexing/docs/examples/system_memory_chunk_cache.md
@@ -0,0 +1,11 @@
+--8<-- "system_memory_chunk_cache/README.md"
+
+`LazyArray` owns the indexing-derived result shape and assembly, while the
+example's `SystemMemoryChunkReader` owns synchronous system-memory cache state
+and chunk reads for each materialized part.
+
+## Source Code
+
+```python
+--8<-- "system_memory_chunk_cache/system_memory_chunk_cache.py"
+```
diff --git a/packages/zarr-indexing/docs/guide/index.md b/packages/zarr-indexing/docs/guide/index.md
new file mode 100644
index 0000000000..780cfd7e56
--- /dev/null
+++ b/packages/zarr-indexing/docs/guide/index.md
@@ -0,0 +1,444 @@
+# Visual guide
+
+The whole model in one sentence: indexing through `LazyArray.lazy` builds a
+view, chunk planning partitions its coordinates, and `result()` materializes
+the view. This page follows one familiar NumPy selection, `source[2:5]`,
+through those stages.
+
+The first four sections are for anyone indexing arrays: coordinates,
+transforms, composition, and result axes. **If you are using lazy indexing
+rather than building a storage backend, you can stop after section four.**
+The last two sections are for integrators: they turn a request into a chunk
+plan and pair each chunk read with its place in the result.
+
+Throughout, one division of labor holds: the transform answers **which
+values?** and is independent of the backend; the reader answers **how do I
+obtain them?** and must preserve the transform exactly.
+
+## An index selects coordinates {#an-index-selects-coordinates}
+
+Begin with an ordinary NumPy array. `source` contains the values 10 through 15.
+The selection `source[2:5]` takes source coordinates 2, 3, and 4, containing
+the values 12, 13, and 14.
+
+```text
+source coordinate | 0 1 2 3 4 5
+source value | 10 11 12 13 14 15
+selection | [12 13 14]
+ source[2:5]
+
+result coordinate | 0 1 2
+source coordinate | 2 3 4
+result value | 12 13 14
+```
+
+The result defines its own coordinates: `0`, `1`, and `2`. The aligned rows
+make the correspondence explicit: those result coordinates receive values
+`12`, `13`, and `14` from source coordinates `2`, `3`, and `4`.
+
+The wrapper below gives the same familiar selection a lazy spelling. Indexing
+through `.lazy` creates `view`; the last line asks for its values and checks the
+observable NumPy result.
+
+```python
+--8<-- "snippets/canonical_slice.py:canonical-slice"
+```
+
+The important first step is simply that an index describes which source values
+fill a result in a particular order. The next section gives the numbers on both
+sides of that description a precise meaning.
+
+## Coordinates are addresses {#coordinates-are-addresses}
+
+### How to read a half-open interval
+
+`[0, 1)` is a **half-open interval**: start at 0, inclusive, and stop at 1, exclusive.
+The `[` includes the lower boundary, while the `)` excludes the upper boundary.
+For integer coordinates, `[0, 1)` therefore enumerates the ordered sequence
+`[0]`.
+
+Half-openness lets adjacent slices and chunks meet without a gap or overlap.
+Concatenation is ordered: the first interval is followed by the second. When
+the first interval's exclusive stop matches the second interval's inclusive
+start, the shared boundary coordinate appears exactly once.
+
+- `[0, 1) -> [0]` — Start at 0 and stop before 1, so the sequence contains only 0.
+- `[1, 3) -> [1, 2]` — Start at 1 and stop before 3, so the sequence contains 1 and 2.
+- `concat([0, 1), [1, 3)) = [0, 3)` — Append the second interval after the
+ first. Their matching exclusive/inclusive boundary produces one continuous
+ interval without a gap or duplicated coordinate.
+
+Explicit coordinates, including coordinate arrays, are always ordered
+sequences rather than mathematical sets. Their order is semantic, and repeated
+coordinates remain repeated in the result.
+
+In the transform algebra, **coordinates are just integers**. A negative
+coordinate is a real address in a domain, with the same status as zero or a
+positive coordinate; it is not automatically shorthand for counting backward
+from an array's end.
+
+```text
+domain [-2, 3)
+
+coordinate | -2 -1 0 1 2
+status | address address address address address
+```
+
+`IndexDomain` makes those bounds explicit. In the example below, narrowing the
+domain at `-1` selects the literal address `-1`; the wrapper at the end treats
+`-1` the way NumPy does — as the last position.
+
+```python
+--8<-- "snippets/coordinate_origins.py:coordinate-origin"
+```
+
+Why carry literal coordinates at all? They let independently described
+regions keep stable addresses — a domain can even grow at its lower end
+without renumbering what is already there. The [design
+notes](../design-notes.md#negative-origin-domains-and-prependable-grids) work
+through that prepending example; nothing else in this guide depends on it.
+
+The literal model and NumPy's positional model are both useful, but they answer
+different questions:
+
+| Surface | Meaning of an integer index | Meaning of `-1` |
+| --- | --- | --- |
+| `IndexDomain` and `IndexTransform` | A literal coordinate in the current domain | The actual address `-1`, if the domain contains it |
+| `LazyArray.lazy` | A NumPy-style position in the current view | The last position, normalized before it reaches the transform algebra |
+
+`LazyArray` uses positions because it is an array-like wrapper: each derived
+view starts at position zero and negative indices wrap exactly as they do in
+NumPy. The lower-level domain and transform types keep literal coordinates.
+
+### A transform points from the request to the source
+
+An `IndexTransform` records how every coordinate in a request finds its source
+coordinate. For the slice from the first section, request coordinate `i` maps
+to source coordinate `i + 2`. This direction is deliberate: request to source,
+not source to request.
+
+```text
+request coordinate | 0 1 2
+ | | | |
+ i + 2 | v v v
+source coordinate | 2 3 4
+source value | 12 13 14
+```
+
+A transform speaks function vocabulary while this guide speaks array
+vocabulary. The two line up like this:
+
+| the API says | this guide says |
+| --- | --- |
+| input space (`domain`, `input_rank`) | request coordinates — the result being built |
+| output space (`output`, one map per dimension) | source coordinates — where values are read |
+
+`output` names the output side of the coordinate *function*, not the data:
+values flow source → request, against the arrow. The neutral names exist
+because transforms compose — in a chain, an interior transform's output space
+is just the next transform's input space, neither a request nor a source.
+
+### The three map kinds, in NumPy terms
+
+Every output dimension is produced by one of three map forms. Each has a
+NumPy counterpart, shown executably below. The examples share one helper —
+and it doubles as the answer to how a bare transform meets data at all: a
+reader materializes it into a buffer.
+
+```python
+--8<-- "snippets/output_maps.py:resolve-helper"
+```
+
+`DimensionMap` is an arithmetic rule — the slice above is one, mapping
+request `i` to source coordinate `i + 2`:
+
+```python
+--8<-- "snippets/output_maps.py:dimension-map"
+```
+
+`ArrayMap` stores explicit source coordinates for irregular or fancy
+indexing; order and repeats survive into the result:
+
+```python
+--8<-- "snippets/output_maps.py:array-map"
+```
+
+`ConstantMap` fixes one source coordinate for every request cell. Whether an
+axis appears in the result is decided by the **domain**, never by the map:
+`image[2, :]` compiles to a `ConstantMap(2)` with no corresponding domain
+axis (the axis is dropped), while pairing a constant map with a length-`n`
+domain axis that no map consumes yields `n` cells all reading one
+coordinate — a broadcast, the one arrangement with no NumPy index
+counterpart:
+
+```python
+--8<-- "snippets/output_maps.py:constant-map"
+```
+
+Together, the request domain and these per-source-dimension maps are the
+complete reusable description of an index.
+
+## Lazy views compose {#lazy-views-compose}
+
+A lazy view can be indexed again. Each step changes the request-to-source
+description, but it does not read an intermediate array. The chain is reduced
+to one direct transform from the newest request to the original source.
+
+```text
+source[2:5][::-1][1:]
+
+new request | intermediate view | original source
+------------+-------------------+----------------
+ 0 | 1 | 3
+ 1 | 2 | 2
+
+direct map: request i -> source (3 - i)
+```
+
+The executable example first selects `source[2:5]`, then reverses that view
+and trims its first element:
+
+```python
+--8<-- "snippets/lazy_composition.py:lazy-composition"
+```
+
+Immediately after `composed` is created—and before the final `result()` call—its
+metadata is ready to inspect:
+
+| Available without reading | Value in this example |
+| --- | --- |
+| `composed.shape` | `(2,)` |
+| `composed.transform` | One transform mapping request `i` to source `3 - i` |
+
+Neither property needs source values. Composition works only on the coordinate
+description; the assertion's call to `result()` is the first operation in the
+example that materializes the selected data.
+
+!!! warning "Stop here: the materialization boundary"
+ Indexing through `.lazy[...]` never reads. These do:
+
+ - `result()`
+ - eager indexing of the wrapper: `view[...]`
+ - `numpy.asarray(view)`, or passing the view to any NumPy function
+ (`numpy.add(view, 1)` converts, and therefore materializes, the view)
+
+ Python arithmetic such as `view + 1` raises `TypeError` instead: this
+ wrapper defers indexing, not a general compute graph.
+
+ Nor does it write. There is no `__setitem__`, so `view[...] = values`
+ raises `TypeError` too, and a wrapped source needs no `__setitem__` of
+ its own. A consumer that writes plans the selection with `plan_chunks`
+ and performs its own read-modify-write, keeping chunk atomicity and
+ concurrent-writer policy on the backend's side of the boundary.
+
+## An index defines a result array {#an-index-defines-a-result-array}
+
+An index chooses source points and also defines how those points are arranged in
+the result. In the 3-by-4 image below, `image[1, :]` and `image[1:2, :]` choose
+the same four source points: values `4`, `5`, `6`, and `7`.
+
+```text
+same selected source cells
+
+source coordinate | (1, 0) (1, 1) (1, 2) (1, 3)
+value | 4 5 6 7
+
+image[1, :]
+
+result coordinate | 0 1 2 3
+value | 4 5 6 7
+shape | (4,); source axis 0 is omitted
+
+image[1:2, :]
+
+result coordinate | (0, 0) (0, 1) (0, 2) (0, 3)
+value | 4 5 6 7
+shape | (1, 4); source axis 0 is retained with length 1
+```
+
+The integer in `image[1, :]` fixes source axis 0. No result coordinate varies
+along that axis, so it is omitted and the result shape is `(4,)`. The slice in
+`image[1:2, :]` preserves source axis 0 as a length-one result axis, so the
+result shape is `(1, 4)`.
+
+```python
+--8<-- "snippets/axis_manipulation.py:axis-shape-comparison"
+```
+
+`None` inserts a new length-one axis without selecting different source points.
+Here it produces the shape `(4, 1)`:
+
+```python
+--8<-- "snippets/axis_manipulation.py:axis-insertion"
+```
+
+## A request becomes a chunk plan {#a-request-becomes-a-chunk-plan}
+
+Continue with the 3-by-4 image and `image[1, :]` introduced above. Giving the
+image a 2-by-2 chunk shape does not change the four selected values or their
+order. It changes only how the work is divided: columns 0 and 1 come from chunk
+`(0, 0)`, while columns 2 and 3 come from chunk `(0, 1)`.
+
+```text
+ column
+ 0 1 | 2 3
+ ----------+----------
+row 0 0 1 | 2 3
+row 1 [4] [5]| [6] [7] <- image[1, :]
+ ----------+----------
+row 2 8 9 | 10 11
+
+ left part right part
+chunk_coords (0, 0) (0, 1)
+global chunk_domain [0,2) x [0,2) [0,2) x [2,4)
+selected global cells (1,0), (1,1) (1,2), (1,3)
+chunk-local cells (1,0), (1,1) (1,0), (1,1)
+request coordinates 0, 1 2, 3
+```
+
+Every planned chunk keeps three coordinate frames distinct:
+
+- `chunk_coords` identifies a cell in the chunk grid. Chunk coordinates are
+ literal integers, so a grid that grows at its lower end can hold a chunk
+ whose coordinate really is `-1` — not an alias for the final chunk (see the
+ [design notes](../design-notes.md#negative-origin-domains-and-prependable-grids)).
+- `chunk_domain` gives that chunk's bounds in **global source coordinates**.
+ Here the two domains are `[0, 2) × [0, 2)` and `[0, 2) × [2, 4)`.
+- Chunk-local positions start from zero inside each chunk. Global column 2 is
+ therefore local column 0 in chunk `(0, 1)`. This zero-origin local frame is
+ separate from both the global `chunk_domain` and the possibly negative
+ chunk coordinate.
+
+`plan_chunks` needs only a transform and the chunk layout: one grid object
+per source dimension. A per-dimension grid answers four questions — which
+chunk contains a source index, where a chunk starts, how long it is, and
+the vectorized form of the first (`index_to_chunk`, `chunk_offset`,
+`chunk_size`, `indices_to_chunks`). The library builds these from chunk
+sizes via `dimension_grids_from_chunks`; the executable example hand-rolls
+one instead, to show that the whole contract is those four answers. It
+plans the canonical request over 2-by-2 chunks, and iterates the same plan
+again to show that planning is reusable. (The two transforms it inspects on
+each projection are the next section's subject.)
+
+```python
+--8<-- "snippets/chunk_projection.py:chunk-projection"
+```
+
+The plan describes work but does not perform it. It contains no array source,
+storage backend, codec pipeline, buffer, or scheduler. A Zarr reader, a task
+queue, or a viewport can consume the same logical plan and decide independently
+how and when to fetch its two chunks.
+
+On the wrapper, this partitioning is called **parts**: `with_parts(shape)`
+gives a `LazyArray` a grid of uniform boxes to divide its reads along
+(re-partitioning is a pure setter — it changes how a read is divided, never
+what `result()` returns), and a wrapped array advertising its own `chunks`
+is partitioned that way automatically.
+
+A zero-length source axis has no chunks. `LazyArray` accepts a positive uniform
+part shape for that axis, or explicit per-axis spellings `()`, `(0,)`, and
+`(0, 0)`; each produces no parts and the same empty result. Zero-sized parts
+remain invalid on a nonempty axis.
+
+## One cell domain, two projections {#one-cell-domain-two-projections}
+
+A chunk read has to answer two questions at once: which cells belong to this
+chunk, and where does each of those cells belong in the requested result?
+
+Think of a projection as a small table with one row per selected cell. For
+each row, `chunk_transform` gives the cell's zero-origin address inside the
+chunk, and `cell_transform` gives the position in the requested result that
+receives its value. The row numbers of that table are the shared **cell
+domain** — a synthetic input space both transforms accept, which is why one
+input point can be evaluated on both sides.
+
+```text
+left chunk (0, 0)
+
+shared cell coordinate | 0 1
+cell_transform | v v
+request coordinate | 0 1
+
+shared cell coordinate | 0 1
+chunk_transform | v v
+chunk-local coordinate | (1, 0) (1, 1)
+
+right chunk (0, 1)
+
+shared cell coordinate | 0 1
+request coordinate | 2 3
+chunk-local coordinate | (1, 0) (1, 1)
+```
+
+The directions are exact: **shared synthetic input cell domain → request via
+`cell_transform`**, and **shared cell domain → chunk-local via
+`chunk_transform`**. Neither arrow starts at the request or maps one output
+space into the other.
+
+On the wrapper, `view.parts()` returns one `Partition` per planned chunk;
+each bundles a sub-view of the request (`.view`), that chunk's projection
+(`.projection`), and the NumPy selection placing its values in the result
+(`.out_selection`).
+
+Within one `Partition`, the frames divide: `Partition.view.transform` is a
+different, global transform — it maps the part view directly into the raw
+wrapped source — while only `Partition.projection.chunk_transform` uses
+zero-origin chunk-local coordinates. Readers receive both so the global
+source address and the local planning frame cannot be confused.
+
+| Projection field | What its output coordinates mean |
+| --- | --- |
+| `cell_transform` | Literal coordinates in the original request; its output rank is the request rank |
+| `chunk_transform` | Zero-origin coordinates in the selected chunk's local frame; its output rank is the source rank |
+
+The cell domain enumerates corresponding cells; it is not itself either
+output coordinate space. The canonical row selection has a one-dimensional
+request and a two-dimensional source, so its paired projections have request
+rank one and source rank two.
+
+### Order and duplicates need the request-side projection
+
+Orthogonal indexing (`.lazy.oindex`) applies each axis's indexer
+independently, like `numpy.ix_` — an outer product; the
+[pattern reference](patterns.md) develops the dialects. It can visit source
+cells in an order that does not match chunk order, and it can visit one
+source cell more than once. In the request below, row 4 comes first and
+row 1 appears twice.
+
+```text
+request position | 0 1 2
+source row | 4 1 1
+result row | row 4 row 1 row 1
+```
+
+A source bounding box cannot reconstruct this result. The box spanning rows 1
+through 4 also includes unrequested rows 2 and 3, and its increasing coordinate
+order does not record that row 4 comes first. Narrowing the read to just rows 1
+and 4 still does not record the second use of row 1. For the same reason, a
+chunk-local selector alone says which cells to read inside a chunk but cannot
+say which request positions receive them, especially when the chunks are
+processed in a different order.
+
+The executable example assembles the 3-by-4 request from a 6-by-8 source with
+3-by-4 chunks. Each `Partition` resolves its own sub-view — the global
+transform addressing the raw source — and `out_selection` places those
+values at their request-side positions; the paired projection stays
+available on `part.projection` for consumers that read chunks directly.
+The assertion checks the reordered, duplicated result against direct NumPy
+indexing.
+
+```python
+--8<-- "snippets/chunk_projection.py:advanced-projection"
+```
+
+The paired representation preserves information that a bounding box or local
+selector discards: exact request order, duplicate destinations, and the
+correspondence between every request position and its chunk-local source cell.
+
+---
+
+
diff --git a/packages/zarr-indexing/docs/guide/integrations.md b/packages/zarr-indexing/docs/guide/integrations.md
new file mode 100644
index 0000000000..03661ed43c
--- /dev/null
+++ b/packages/zarr-indexing/docs/guide/integrations.md
@@ -0,0 +1,214 @@
+# Integration boundaries
+
+This package supplies indexing plans. It does **not** supply scheduling,
+caching, codecs, or async orchestration. A consumer decides when projections
+run, how decoded chunks are obtained, and where completed values are retained.
+An `IndexTransform` says which source values belong in a result; a `Reader`
+lowers that complete transform for one backend. The reader does not choose
+indexing semantics or result ownership.
+
+## Zarr chunk dispatch
+
+A Zarr-oriented reader can consume each public `ChunkProjection` and use its
+`chunk_coords` to obtain one decoded chunk from its own storage and codec
+layers. This tiny source keeps four in-memory chunks keyed by their global
+chunk coordinates and records the exact reads. For each projection, the
+consumer enumerates the shared synthetic input cell domain, evaluates
+`chunk_transform` to read zero-origin chunk-local coordinates, and evaluates
+`cell_transform` to place each value at its literal request coordinate.
+
+```python
+--8<-- "snippets/integrations.py:zarr-consumer"
+```
+
+The two reads are exactly `(0, 0)` and `(0, 1)`; untouched chunks `(1, 0)` and
+`(1, 1)` are never read. The assembled request is `[4, 5, 6, 7]`. The
+example intentionally begins with already decoded in-memory chunks: storage
+keys, codecs, scheduling, caching, and asynchronous orchestration remain the
+consumer's policy rather than responsibilities of the plan.
+
+## One slab read or many part reads
+
+A backend with its own native subset read — a Rust or C zarr implementation,
+a database, an HTTP range endpoint — resolves a **dense box** (`is_box` with
+every stride 1) best as a single read: hand it the whole selection and let it
+dispatch to chunks, decode in parallel, and partial-decode shards on its own
+side of the boundary. Splitting that read along this library's partitioning
+only adds round-trips. Every **other** selection — a strided box, an `oindex`
+or `vindex` gather — is where the partitioning earns its keep. The **cover**
+of a read is the smallest step-1 slab enclosing every coordinate it needs;
+partitioned, each part's cover is bounded by that part's box, so a sparse
+selection can never force one read of its whole bounding hull (the smallest
+rectangle containing every selected coordinate — a thousand rows for the two
+of `oindex[[0, 999]]`).
+
+The composed view carries enough to make that call at materialization time,
+and re-partitioning is a pure setter, so the policy is three lines:
+
+```python
+--8<-- "snippets/integrations.py:dense-box-repartition"
+```
+
+The corner gather reads four single cells instead of the 10-by-10 hull, and
+the dense box becomes exactly one backend call. Both regimes go through
+`result()`; only the partitioning in force differs.
+
+### Sources that accept only unit-step slices
+
+The default `basic_reader` pushes strided and descending selections down as
+positive-step slices, which reads the minimum but assumes the source accepts
+any step. Many backends do not: FFI bindings and range requests often
+support nothing but `slice(start, stop, 1)`. Select
+[`unit_step_reader`][zarr_indexing.reader.UnitStepReader] for such a source
+and every key it receives is an ascending unit-step slice per axis, with
+strides, reversals, and gathers applied to the in-memory block instead:
+
+```python
+view = LazyArray(source).with_reader(unit_step_reader)
+```
+
+A strided selection then over-reads its cover by the stride factor, which the
+partitioning above bounds by one part.
+
+## napari-like consumer
+
+This is a **napari-like consumer**, not a napari integration. It models the
+boundary a viewport could use without importing or claiming support for
+napari. `RecordingArray` exposes a chunked, basic-indexing source — and its
+`chunks` attribute is why the reads below split along `(2, 2)` boxes:
+`LazyArray` discovers a partitioning from the wrapped array at construction
+(`read_chunk_sizes`, then `chunks`), with `with_parts` as the explicit
+override. Composing the
+visible slice records no reads. Only `result()` materializes it, with the exact
+source selectors `1:2, 0:2` and `1:2, 2:4`; neither selector crosses into an
+untouched neighboring chunk.
+
+```python
+--8<-- "snippets/integrations.py:viewport-consumer"
+```
+
+The viewport owns its interaction loop and any cancellation, caching, or
+background execution. `LazyArray` contributes the composable selection and
+the partition plan, then resolves only when the consumer asks for the result.
+
+### A system-memory chunk cache
+
+Napari accepts NumPy-like array objects and can defer materialization until an
+image region is displayed. The indexing plan still deliberately owns no cache
+or scheduler. A viewport adapter can place that policy around the plan, as the
+executable reference below demonstrates.
+
+This remains a **napari-like consumer, not a napari integration**. It models
+only decoded chunks resident in system memory, synchronously.
+
+For setup instructions and the complete executable, see the
+[system-memory chunk cache example](../examples/system_memory_chunk_cache.md).
+
+```text
+ read succeeds
+NEW -> QUEUED -> LOADING -------------> READY -> EVICTED
+ ^ |
+ | | read fails
+ | v
+ +--------- FAILED
+ retry
+
+EVICTED -> QUEUED
+ reload
+```
+
+The example keeps the lifecycle records and transitions explicit:
+
+```python
+--8<-- "system_memory_chunk_cache/system_memory_chunk_cache.py:chunk-cache-types"
+```
+
+Its source represents already decoded chunks and records each read:
+
+```python
+--8<-- "system_memory_chunk_cache/system_memory_chunk_cache.py:chunk-cache-source"
+```
+
+`LazyArray` converts a cache selection into transforms and partitions, then
+allocates and assembles the result. The facade constructs exactly one tuple
+from `view.parts()`: it derives the chunk coordinates to pin from that tuple,
+then passes the same owned parts to `view.result(parts=parts)`. Planning is
+therefore performed once for the request rather than repeated during
+materialization. Neither pinning nor the result call rebuilds the plan; both
+reuse those prepared `Partition` objects.
+
+`SystemMemoryChunkReader` receives one `ReadContext` for each materialized
+part. Its global `context.transform` directly addresses the raw source, while
+`context.projection.chunk_transform` addresses the already identified chunk
+locally. The reader consumes that supplied projection directly; it never calls
+the chunk planner. `LazyArray` retains responsibility for the projection's
+result placement and final assembly. The reader owns only cache state and
+source reads, while `SystemMemoryChunkCache` remains the thin NumPy-style facade
+that prepares and pins the one plan:
+
+Its indexing dialects remain explicit: `cache[key]` accepts basic indexing
+(integers, slices, ellipsis, and new axes), while `cache.oindex[key]` combines
+per-axis index arrays as an outer product. Array keys are not silently treated
+as orthogonal by plain square brackets; callers choose that behavior through
+the named accessor.
+
+```python
+--8<-- "system_memory_chunk_cache/system_memory_chunk_cache.py:chunk-cache-wrapper"
+```
+
+### Follow one viewport through the cache
+
+```python
+--8<-- "system_memory_chunk_cache/system_memory_chunk_cache.py:chunk-cache-worked-example"
+```
+
+The worked example uses a 6-by-8 image, 3-by-4 chunks, and capacity for two
+decoded chunks. Every read delta follows directly from the viewport request:
+
+| Step | Viewport | New reads | Resident afterward | Why |
+| --- | --- | --- | --- | --- |
+| 1 | `image[1:5, 2]` | `(0, 0)`, `(1, 0)` | `(0, 0)`, `(1, 0)` | Both projected chunks are loaded and assembled as `[10, 18, 26, 34]`. |
+| 2 | `image[3:5, 2]` | None | `(0, 0)`, `(1, 0)` | The ready buffer for `(1, 0)` is reused and becomes most recently used. |
+| 3 | `image[0:2, 5]` | `(0, 1)` | `(0, 1)`, `(1, 0)` | Placement returns `[5, 13]`, then LRU pressure evicts `(0, 0)`. |
+| 4 | `image[1:5, 2]` | `(0, 0)` | `(0, 0)`, `(1, 0)` | The evicted chunk is reloaded while the required ready chunk is retained. |
+| 5 | `image[3:5, 4:6]` | `(1, 1)` fails; no repeated read; `(1, 1)` succeeds after retry | `(0, 0)`, `(1, 1)` | Failure is retained until explicit retry; the repaired source then returns `[[28, 29], [36, 37]]`. |
+
+Chunks required by an active request are pinned through assembly, so a request
+may temporarily span more chunks than the steady-state capacity. Capacity is
+counted in decoded chunks—not records or bytes—and eviction occurs only after
+all requested values have been placed. Because pinning and materialization use
+the same prepared tuple, those lifecycle decisions cannot drift from the parts
+that are actually read, and the cache never has to infer or reconstruct a
+projection.
+
+The event log makes the failure boundary equally explicit:
+
+| Chunk | Transition | Reason |
+| --- | --- | --- |
+| `(1, 1)` | `NEW -> QUEUED` | requested |
+| `(1, 1)` | `QUEUED -> LOADING` | queue drained |
+| `(1, 1)` | `LOADING -> FAILED` | source read failed |
+| `(1, 1)` | `FAILED -> QUEUED` | explicit retry |
+| `(1, 1)` | `QUEUED -> LOADING` | queue drained |
+| `(1, 1)` | `LOADING -> READY` | source read completed |
+
+A repeated request while the record is `FAILED` creates no event and performs
+no source read. The retained failure forces the caller to choose when retry is
+appropriate. A real viewport adapter could drain the queue in workers and
+invalidate its canvas when chunks become ready without changing the selection
+or projection semantics shown here.
+
+[Napari's image-layer documentation](https://napari.org/dev/howtos/layers/image.html)
+describes its NumPy-like array boundary. Neuroglancer's
+[`ChunkState`](https://github.com/google/neuroglancer/blob/master/src/chunk_manager/base.ts)
+is conceptual prior art for making residency explicit. This example is a
+smaller, independently authored, synchronous teaching model; it does not copy
+that implementation or reproduce its full worker/GPU lifecycle.
+
+---
+
+
diff --git a/packages/zarr-indexing/docs/guide/patterns.md b/packages/zarr-indexing/docs/guide/patterns.md
new file mode 100644
index 0000000000..9c3501da8a
--- /dev/null
+++ b/packages/zarr-indexing/docs/guide/patterns.md
@@ -0,0 +1,327 @@
+# Indexing pattern reference
+
+Every NumPy indexing idiom is modeled by an `IndexTransform`: a domain (the
+result's coordinates) and one output map per source dimension. This page
+builds that model **by hand for each idiom**, so the anatomy is explicit —
+which map kind an idiom needs, where the offset and stride go, and how an
+index array's shape spells outer-product versus pointwise. Each model is
+then proven equal to what the selection compiler derives, and its values
+are checked against NumPy.
+
+## The idiom-to-model matrix
+
+Each idiom over a 6-by-8 `image`, shown two ways: the Python construction,
+and the same transform in **wire form** — the [ndsel](../ndsel.md)
+canonical body `to_json` produces. Both spell the whole
+object: a domain whose extent is the result shape, then one output map per
+source dimension. The index-array variables (`rows`, `columns`,
+`mask_rows, mask_columns = np.nonzero(mask)`, and friends) are defined in
+the executable matrix at the end of the page.
+
+**`image[1:5, ::2]`** — box. The offset picks where cell 0 reads; the
+stride skips:
+
+=== "Python"
+
+ ```python
+ IndexTransform(
+ domain=IndexDomain.from_shape((4, 4)),
+ output=(
+ DimensionMap(input_dimension=0, offset=1),
+ DimensionMap(input_dimension=1, stride=2),
+ ),
+ )
+ ```
+
+=== "JSON"
+
+ ```json
+ {
+ "input_rank": 2,
+ "input_inclusive_min": [0, 0],
+ "input_exclusive_max": [4, 4],
+ "input_labels": ["", ""],
+ "output": [
+ {"offset": 1, "stride": 1, "input_dimension": 0},
+ {"offset": 0, "stride": 2, "input_dimension": 1}
+ ]
+ }
+ ```
+
+**`image[2, :]`** — box. A rank-1 domain with two output maps: the dropped
+axis survives as the wire's constant form, a bare `{"offset": 2}`:
+
+=== "Python"
+
+ ```python
+ IndexTransform(
+ domain=IndexDomain.from_shape((8,)),
+ output=(ConstantMap(2), DimensionMap(input_dimension=0)),
+ )
+ ```
+
+=== "JSON"
+
+ ```json
+ {
+ "input_rank": 1,
+ "input_inclusive_min": [0],
+ "input_exclusive_max": [8],
+ "input_labels": [""],
+ "output": [
+ {"offset": 2},
+ {"offset": 0, "stride": 1, "input_dimension": 0}
+ ]
+ }
+ ```
+
+**`image[::-2, :]`** — box. Reversal is nothing but a negative stride, and
+the offset is where cell 0 reads (row 5):
+
+=== "Python"
+
+ ```python
+ IndexTransform(
+ domain=IndexDomain.from_shape((3, 8)),
+ output=(
+ DimensionMap(input_dimension=0, offset=5, stride=-2),
+ DimensionMap(input_dimension=1),
+ ),
+ )
+ ```
+
+=== "JSON"
+
+ ```json
+ {
+ "input_rank": 2,
+ "input_inclusive_min": [0, 0],
+ "input_exclusive_max": [3, 8],
+ "input_labels": ["", ""],
+ "output": [
+ {"offset": 5, "stride": -2, "input_dimension": 0},
+ {"offset": 0, "stride": 1, "input_dimension": 1}
+ ]
+ }
+ ```
+
+**`image[2:2, :]`** — box. Emptiness lives in the domain
+(`input_exclusive_max[0]` equals the minimum); the maps are ordinary:
+
+=== "Python"
+
+ ```python
+ IndexTransform(
+ domain=IndexDomain.from_shape((0, 8)),
+ output=(
+ DimensionMap(input_dimension=0, offset=2),
+ DimensionMap(input_dimension=1),
+ ),
+ )
+ ```
+
+=== "JSON"
+
+ ```json
+ {
+ "input_rank": 2,
+ "input_inclusive_min": [0, 0],
+ "input_exclusive_max": [0, 8],
+ "input_labels": ["", ""],
+ "output": [
+ {"offset": 2, "stride": 1, "input_dimension": 0},
+ {"offset": 0, "stride": 1, "input_dimension": 1}
+ ]
+ }
+ ```
+
+**`image[mask]`** — query. A mask is its nonzero coordinates: two
+correlated index arrays over one flat axis, entry `i` of each pairing into
+one cell:
+
+=== "Python"
+
+ ```python
+ IndexTransform(
+ domain=IndexDomain.from_shape((10,)),
+ output=(ArrayMap(mask_rows), ArrayMap(mask_columns)),
+ )
+ ```
+
+=== "JSON"
+
+ ```json
+ {
+ "input_rank": 1,
+ "input_inclusive_min": [0],
+ "input_exclusive_max": [10],
+ "input_labels": [""],
+ "output": [
+ {"offset": 0, "stride": 1, "index_array": [0, 0, 1, 1, 2, 3, 3, 4, 5, 5], "index_array_bounds": ["-inf", "+inf"]},
+ {"offset": 0, "stride": 1, "index_array": [0, 5, 2, 7, 4, 1, 6, 3, 0, 5], "index_array_bounds": ["-inf", "+inf"]}
+ ]
+ }
+ ```
+
+**`image[np.ix_(rows, columns)]`** — query. The outer product is spelled by
+nesting: `[[4], [1], [1]]` varies down the first axis, `[[2, 5]]` across
+the second, each singleton along the other:
+
+=== "Python"
+
+ ```python
+ IndexTransform(
+ domain=IndexDomain.from_shape((3, 2)),
+ output=(ArrayMap(rows.reshape(3, 1)), ArrayMap(columns.reshape(1, 2))),
+ )
+ ```
+
+=== "JSON"
+
+ ```json
+ {
+ "input_rank": 2,
+ "input_inclusive_min": [0, 0],
+ "input_exclusive_max": [3, 2],
+ "input_labels": ["", ""],
+ "output": [
+ {"offset": 0, "stride": 1, "index_array": [[4], [1], [1]], "index_array_bounds": ["-inf", "+inf"]},
+ {"offset": 0, "stride": 1, "index_array": [[2, 5]], "index_array_bounds": ["-inf", "+inf"]}
+ ]
+ }
+ ```
+
+**`image[vector_rows, vector_columns]`** — query. Pointwise: two flat
+arrays over one shared axis:
+
+=== "Python"
+
+ ```python
+ IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(ArrayMap(vector_rows), ArrayMap(vector_columns)),
+ )
+ ```
+
+=== "JSON"
+
+ ```json
+ {
+ "input_rank": 1,
+ "input_inclusive_min": [0],
+ "input_exclusive_max": [3],
+ "input_labels": [""],
+ "output": [
+ {"offset": 0, "stride": 1, "index_array": [4, 1, 1], "index_array_bounds": ["-inf", "+inf"]},
+ {"offset": 0, "stride": 1, "index_array": [2, 5, 2], "index_array_bounds": ["-inf", "+inf"]}
+ ]
+ }
+ ```
+
+**`image[broadcast_rows, broadcast_columns]`** — query. NumPy broadcasting,
+materialized: each map carries the full `(2, 3)` block:
+
+=== "Python"
+
+ ```python
+ IndexTransform(
+ domain=IndexDomain.from_shape((2, 3)),
+ output=(
+ ArrayMap(np.broadcast_to(broadcast_rows, (2, 3))),
+ ArrayMap(np.broadcast_to(broadcast_columns, (2, 3))),
+ ),
+ )
+ ```
+
+=== "JSON"
+
+ ```json
+ {
+ "input_rank": 2,
+ "input_inclusive_min": [0, 0],
+ "input_exclusive_max": [2, 3],
+ "input_labels": ["", ""],
+ "output": [
+ {"offset": 0, "stride": 1, "index_array": [[0, 0, 0], [3, 3, 3]], "index_array_bounds": ["-inf", "+inf"]},
+ {"offset": 0, "stride": 1, "index_array": [[1, 4, 6], [1, 4, 6]], "index_array_bounds": ["-inf", "+inf"]}
+ ]
+ }
+ ```
+
+**`image[rows, 2:6]`** — query. One lookup table (order and repeats kept)
+beside one ordinary affine map — one index array makes the whole selection
+a query:
+
+=== "Python"
+
+ ```python
+ IndexTransform(
+ domain=IndexDomain.from_shape((3, 4)),
+ output=(
+ ArrayMap(rows.reshape(3, 1)),
+ DimensionMap(input_dimension=1, offset=2),
+ ),
+ )
+ ```
+
+=== "JSON"
+
+ ```json
+ {
+ "input_rank": 2,
+ "input_inclusive_min": [0, 0],
+ "input_exclusive_max": [3, 4],
+ "input_labels": ["", ""],
+ "output": [
+ {"offset": 0, "stride": 1, "index_array": [[4], [1], [1]], "index_array_bounds": ["-inf", "+inf"]},
+ {"offset": 2, "stride": 1, "input_dimension": 1}
+ ]
+ }
+ ```
+
+Two structural rules do all the work:
+
+- **Category**: `ConstantMap` and `DimensionMap` entries keep a selection a
+ box at any composition depth; one `ArrayMap` makes it a query permanently.
+ See the [design notes](../design-notes.md#bounding-box-selections-vs-query-selections)
+ for why consumers dispatch on this.
+- **Fancy flavor is spelled by shape**: index arrays varying over distinct
+ axes (singleton elsewhere) form an outer product; arrays sharing their
+ non-singleton axes pair pointwise.
+
+## The executable matrix
+
+Each case hand-builds the model, checks shape, category, and NumPy values
+(resolved through the public reader), then proves the selection compiler
+derives the same transform. One wrinkle the last assert documents: compiled
+*basic* selections keep literal domains (`t[1:5, ...]` starts at
+coordinate 1 — see [Positions vs literal coordinates](#positions-vs-literal-coordinates)),
+so they equal the zero-origin models after `translate_domain_to`:
+
+```python
+--8<-- "snippets/indexing_patterns.py:indexing-patterns"
+```
+
+`LazyArray` adds nothing to these semantics: it is a regular array-like API
+whose `.lazy`, `.lazy.oindex`, and `.lazy.vindex` accessors compile the same
+dialects to the same transforms — the only difference is the return type, a
+view instead of an array. The test suite holds the wrapper to this matrix.
+
+## Positions vs literal coordinates
+
+| Surface | Meaning of an integer index | Meaning of `-1` |
+| --- | --- | --- |
+| `IndexDomain` and `IndexTransform` | A literal coordinate in the current domain | The address `-1`, when the domain contains it |
+| `LazyArray.lazy` | A NumPy-style position in the current view | The last position, normalized before transform composition |
+
+The wrapper's three indexing modes all use positions in the current view. Each
+derived view begins at position zero, while the transform algebra underneath
+retains literal coordinates. The
+[Coordinates are addresses](index.md#coordinates-are-addresses) section develops
+that distinction with non-zero and negative-origin domains.
+
+---
+
+
diff --git a/packages/zarr-indexing/docs/index.md b/packages/zarr-indexing/docs/index.md
new file mode 100644
index 0000000000..1dd151d8ca
--- /dev/null
+++ b/packages/zarr-indexing/docs/index.md
@@ -0,0 +1,53 @@
+# zarr-indexing
+
+This library is for modelling and transforming NumPy-style array indexing expressions. It separates
+the *declaration* of an array indexing expression from the result of that expression.
+
+Developed for use in [`zarr`](https://zarr.readthedocs.io).
+
+Inspired by [TensorStore](https://google.github.io/tensorstore/), which pioneered
+the approach used here.
+
+
+## Install
+
+`zarr-indexing` is developed in the
+[zarr-python repository](https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-indexing)
+and released independently of `zarr` itself:
+
+```
+pip install zarr-indexing
+```
+
+## Quickstart
+
+Wrap an array, compose a lazy view through `.lazy`, and call `result()` when
+you want its values:
+
+```python
+--8<-- "snippets/canonical_slice.py:landing-quickstart"
+```
+
+Nothing is read until the `result()` call, however many selections are
+composed. [Lazy views compose](guide/index.md#lazy-views-compose) shows how
+the chain stays one description, and where the materialization boundary is.
+
+## Learn more
+
+- [Visual guide](guide/index.md) — one selection followed from coordinates to
+ chunk plan. Using lazy indexing, start at
+ [An index selects coordinates](guide/index.md#an-index-selects-coordinates);
+ integrating a chunked backend, start at
+ [A request becomes a chunk plan](guide/index.md#a-request-becomes-a-chunk-plan).
+- [Indexing pattern reference](guide/patterns.md) — every selection form with
+ its NumPy-verified result.
+- [Integration boundaries](guide/integrations.md) — what a reader, writer, or
+ scheduler owns, and what the plan owns.
+- [Lazy indexing a NumPy array](examples/lazy_indexing_numpy.md) and
+ [with Dask](examples/lazy_indexing_dask.md) — runnable examples.
+- [The ndsel wire format](ndsel.md) — the JSON form of a selection.
+- [Design notes](design-notes.md) — TensorStore lineage, box vs query, and
+ deliberate limits.
+- [API reference](api/index.md)
+- [Changelog](https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-indexing/CHANGELOG.md)
+ · [License (MIT)](https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-indexing/LICENSE.txt)
diff --git a/packages/zarr-indexing/docs/ndsel.md b/packages/zarr-indexing/docs/ndsel.md
new file mode 100644
index 0000000000..94971d49bf
--- /dev/null
+++ b/packages/zarr-indexing/docs/ndsel.md
@@ -0,0 +1,159 @@
+---
+title: The ndsel wire format
+---
+
+# The ndsel wire format
+
+[ndsel](https://github.com/zarr-developers/ndsel) is a draft JSON
+representation of NumPy-style n-dimensional selections, adapted from
+TensorStore's `IndexTransform` model. This page documents the wire format, not
+the coordinate model: [Coordinates are addresses](guide/index.md#coordinates-are-addresses)
+introduces literal coordinates, and
+[Lazy views compose](guide/index.md#lazy-views-compose) shows how views combine
+before a transform is serialized. `zarr-indexing` implements ndsel in two layers:
+
+| Layer | Module | Depends on | Job |
+| --- | --- | --- | --- |
+| Message | [`zarr_indexing.messages`](api/messages.md) | stdlib only | JSON in, canonical JSON out. Validates and desugars. Never rounds, clamps, or drops information. |
+| Engine | [`zarr_indexing.json`](api/json.md) | NumPy | Lowers a *canonical* body into an in-memory [`IndexTransform`](api/transform.md), and back. |
+
+Constraints that only make sense for a real array — finite bounds, index
+arrays as `ndarray`s — live in the engine layer and nowhere else. As a result,
+`messages` normalizes a message with `"-inf"` bounds that
+`IndexTransform.from_json` refuses to lower.
+
+## Two entry points
+
+[`parse_ndsel`](api/messages.md#zarr_indexing.messages.parse_ndsel)
+structurally validates a message of any kind and returns it unchanged. Use it
+to confirm that a message is well formed while keeping it in its compact
+shorthand form.
+
+[`normalize_ndsel`](api/messages.md#zarr_indexing.messages.normalize_ndsel)
+desugars a message into the single deterministic **canonical transform body**
+of the spec (section 4.3): a bare `IndexTransform` body without the `kind`
+discriminator.
+
+```python
+from zarr_indexing import normalize_ndsel
+
+normalize_ndsel({"kind": "box", "inclusive_min": [10, 5], "shape": [40, 1]})
+# {'input_rank': 2,
+# 'input_inclusive_min': [10, 5],
+# 'input_exclusive_max': [50, 6],
+# 'input_labels': ['', ''],
+# 'output': [{'offset': 0, 'stride': 1, 'input_dimension': 0},
+# {'offset': 0, 'stride': 1, 'input_dimension': 1}]}
+```
+
+Normalization is idempotent: re-tag the output with `kind: "transform"` and
+normalizing it again returns the same body. Because the canonical body is
+field-for-field a TensorStore `IndexTransform` minus `kind`, a normalized
+message loads directly into `tensorstore.IndexTransform(json=...)`.
+
+Both entry points raise
+[`NdselError`](api/messages.md#zarr_indexing.messages.NdselError), which
+carries the spec `reason` code (`unknown_kind`, `rank_mismatch`, `step_zero`,
+`output_map_conflict`, …) alongside a human-readable detail, so callers can
+branch on the code rather than on message text.
+
+## The five message kinds
+
+Four are shorthands; the fifth is the canonical form itself.
+
+| `kind` | Fields | Selects |
+| --- | --- | --- |
+| `point` | `coords` | A single element. Normalizes to rank 0 with one `constant` output map per dimension. |
+| `box` | `inclusive_min`, one of `exclusive_max` / `inclusive_max` / `shape`, `labels` | A rectangular region. Exactly one upper-bound spelling may appear. |
+| `slice` | `start`, `stop`, `step`, `labels` | A strided region, one Python-style slice per dimension. |
+| `points` | `coords` (a list of coordinate rows) | An explicit list of points — the `vindex` case. Normalizes to one `index_array` output map per dimension over a shared rank-1 input domain. |
+| `transform` | `input_rank`, `input_inclusive_min`, one of the three `input_*` upper bounds, `input_labels`, `output` | The full canonical form. |
+
+Value rules the message layer enforces throughout: every integer is a 64-bit
+signed value; JSON booleans are **not** integers (Python's
+`isinstance(True, int)` is guarded against explicitly); the `"-inf"` / `"+inf"`
+sentinels are legal only in bound positions; and an implicit bound is the
+one-element `[n]`-bracket form, whose implicit/explicit flag survives
+normalization intact.
+
+## Lowering to a transform
+
+The engine layer converts between canonical bodies and `IndexTransform`s:
+
+```python
+from zarr_indexing import IndexTransform
+
+t = IndexTransform.from_json(canonical)
+t.to_json() == canonical
+```
+
+`IndexDomain` carries the same pair for a bare domain body, and each output
+map kind has a `to_json`; `output_index_map_from_json` dispatches the wire's
+tagged union back to the right kind.
+
+Two engine constraints apply here and only here. A canonical body carrying a
+`"-inf"` or `"+inf"` bound cannot be lowered — an `IndexDomain` addresses a
+finite array — so `IndexTransform.from_json` raises. And implicit bounds lower
+*by value*: the `[n]`-bracket flag is a message-layer concern, and the engine
+keeps only the integer.
+
+### The `index_array` round trip
+
+ndsel and TensorStore both **reject** an output map that carries both
+`input_dimension` and `index_array`. The in-memory
+[`ArrayMap`](api/output_map.md#zarr_indexing.output_map.ArrayMap), though,
+records an `input_dimension` to pin the axis an orthogonal (`oindex`) array
+varies over. The serializer bridges that gap in both directions:
+
+- **On serialize**, a non-degenerate `index_array` map is emitted *without*
+ `input_dimension`.
+- **On load**, the in-memory `input_dimension` is reconstructed from the
+ full-rank array's dependency axes — its non-singleton axes. An array that
+ solely owns a single non-singleton axis is orthogonal; arrays that share
+ non-singleton axes, or vary over several, are correlated (`vindex`), and get
+ `input_dimension = None`. A single 1-D array over a rank-1 domain is
+ inherently ambiguous between the two flavors and reconstructs as
+ orthogonal, which is behaviorally identical in that case.
+
+There is one deliberate exception, and it is the only place a round trip changes
+representation rather than preserving it. An all-singleton `index_array` — size
+1 — selects the same coordinate regardless of the input, so it is collapsed to
+a `constant` map on serialize:
+
+```python
+from zarr_indexing import IndexTransform
+
+IndexTransform.from_shape((100, 100)).oindex[[5], 0:2].to_json()
+# {'input_rank': 2,
+# 'input_inclusive_min': [0, 0],
+# 'input_exclusive_max': [1, 2],
+# 'input_labels': ['', ''],
+# 'output': [{'offset': 5},
+# {'offset': 0, 'stride': 1, 'input_dimension': 1}]}
+```
+
+The size-1 input dimension stays in the domain, unconsumed by any output map.
+The transform is still valid and the output shape is unchanged. A length-1
+`oindex` selection therefore round-trips behaviorally (an `ArrayMap` comes back
+as a `ConstantMap`) rather than by object identity.
+
+## Conformance
+
+The package is checked against the language-agnostic ndsel conformance corpus,
+vendored unmodified under
+[`tests/conformance/`](https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-indexing/tests/conformance)
+— one JSON file per message kind plus `errors.json`, with the source commit
+recorded in `PROVENANCE.md`. Each fixture is either a *success* case
+(`input` + expected `normalized` body) or an *error* case (`input` + expected
+reason code), and an implementation is conformant iff `normalize` reproduces
+every one. `tests/test_conformance.py` runs the whole corpus as one
+parametrized test per fixture, so a corpus update reports failures fixture by
+fixture rather than as a single opaque assertion.
+
+Do not edit the vendored files; to pick up spec changes, re-vendor from a newer
+ndsel commit and update the recorded SHA.
+
+A second, optional test (`tests/test_ndsel_tensorstore.py`, skipped unless
+`tensorstore` is installed) checks against TensorStore itself by loading
+canonical bodies into `tensorstore.IndexTransform` and re-loading TensorStore's
+own `to_json()` output back through the engine layer.
diff --git a/packages/zarr-indexing/docs/snippets/axis_manipulation.py b/packages/zarr-indexing/docs/snippets/axis_manipulation.py
new file mode 100644
index 0000000000..1d1369fd87
--- /dev/null
+++ b/packages/zarr-indexing/docs/snippets/axis_manipulation.py
@@ -0,0 +1,29 @@
+"""Indexing defines result axes as well as selected source points."""
+
+import numpy as np
+
+from zarr_indexing import LazyArray
+
+# --8<-- [start:axis-shape-comparison]
+image = np.arange(12).reshape(3, 4)
+lazy = LazyArray.from_numpy(image)
+
+integer_view = lazy.lazy[1, :]
+slice_view = lazy.lazy[1:2, :]
+
+INTEGER_RESULT = integer_view.result()
+SLICE_RESULT = slice_view.result()
+
+assert INTEGER_RESULT.tolist() == [4, 5, 6, 7]
+assert INTEGER_RESULT.shape == (4,)
+assert SLICE_RESULT.tolist() == [[4, 5, 6, 7]]
+assert SLICE_RESULT.shape == (1, 4)
+# --8<-- [end:axis-shape-comparison]
+
+# --8<-- [start:axis-insertion]
+inserted_view = lazy.lazy[1, :, None]
+INSERTED_RESULT = inserted_view.result()
+
+assert INSERTED_RESULT.tolist() == [[4], [5], [6], [7]]
+assert INSERTED_RESULT.shape == (4, 1)
+# --8<-- [end:axis-insertion]
diff --git a/packages/zarr-indexing/docs/snippets/canonical_slice.py b/packages/zarr-indexing/docs/snippets/canonical_slice.py
new file mode 100644
index 0000000000..fc4417f641
--- /dev/null
+++ b/packages/zarr-indexing/docs/snippets/canonical_slice.py
@@ -0,0 +1,30 @@
+"""The canonical basic-selection example used throughout the guide."""
+
+import numpy as np
+
+from zarr_indexing import LazyArray
+
+
+# --8<-- [start:landing-quickstart]
+import numpy as np
+
+from zarr_indexing import LazyArray
+
+source = np.array([10, 11, 12, 13, 14, 15])
+view = LazyArray.from_numpy(source).lazy[2:5]
+
+view.result()
+# array([12, 13, 14])
+# --8<-- [end:landing-quickstart]
+
+LANDING_QUICKSTART_RESULT = view.result()
+assert LANDING_QUICKSTART_RESULT.tolist() == [12, 13, 14]
+
+
+# --8<-- [start:canonical-slice]
+source = np.array([10, 11, 12, 13, 14, 15])
+lazy = LazyArray.from_numpy(source)
+view = lazy.lazy[2:5]
+
+assert view.result().tolist() == [12, 13, 14]
+# --8<-- [end:canonical-slice]
diff --git a/packages/zarr-indexing/docs/snippets/chunk_projection.py b/packages/zarr-indexing/docs/snippets/chunk_projection.py
new file mode 100644
index 0000000000..5c56aafbbd
--- /dev/null
+++ b/packages/zarr-indexing/docs/snippets/chunk_projection.py
@@ -0,0 +1,57 @@
+"""Use public chunk projections without choosing a storage backend."""
+
+import numpy as np
+from numpy.typing import NDArray
+from typing import cast
+
+from zarr_indexing import DimensionGridLike, IndexTransform, LazyArray, plan_chunks
+
+
+# --8<-- [start:chunk-projection]
+class RegularGrid:
+ """A small parameterized implementation of the public grid protocol."""
+
+ def __init__(self, size: int) -> None:
+ self.size = size
+
+ def index_to_chunk(self, index: int) -> int:
+ return index // self.size
+
+ def chunk_offset(self, chunk: int) -> int:
+ return chunk * self.size
+
+ def chunk_size(self, chunk: int) -> int:
+ return self.size
+
+ def indices_to_chunks(self, indices: NDArray[np.intp]) -> NDArray[np.intp]:
+ return np.floor_divide(indices, self.size).astype(np.intp)
+
+
+transform = IndexTransform.from_shape((3, 4))[1, 0:4]
+grids = cast(
+ tuple[DimensionGridLike, DimensionGridLike],
+ (RegularGrid(2), RegularGrid(2)),
+)
+plan = plan_chunks(transform, grids)
+PROJECTIONS = tuple(plan)
+assert tuple(projection.chunk_coords for projection in plan) == ((0, 0), (0, 1))
+
+PAIRED_DOMAINS = tuple(
+ (projection.chunk_transform.domain, projection.cell_transform.domain)
+ for projection in PROJECTIONS
+)
+assert all(chunk_domain == cell_domain for chunk_domain, cell_domain in PAIRED_DOMAINS)
+# --8<-- [end:chunk-projection]
+
+
+# --8<-- [start:advanced-projection]
+image = np.arange(48).reshape(6, 8)
+advanced = LazyArray.from_numpy(image).with_parts((3, 4)).lazy.oindex[[4, 1, 1], 2:6]
+
+ADVANCED_EXPECTED = image[[4, 1, 1]][:, 2:6]
+ADVANCED_RESULT = np.empty_like(ADVANCED_EXPECTED)
+for part in advanced.parts():
+ ADVANCED_RESULT[part.out_selection] = part.view.result()
+
+np.testing.assert_array_equal(ADVANCED_RESULT, ADVANCED_EXPECTED)
+# --8<-- [end:advanced-projection]
diff --git a/packages/zarr-indexing/docs/snippets/coordinate_origins.py b/packages/zarr-indexing/docs/snippets/coordinate_origins.py
new file mode 100644
index 0000000000..724312e5e8
--- /dev/null
+++ b/packages/zarr-indexing/docs/snippets/coordinate_origins.py
@@ -0,0 +1,65 @@
+"""Literal coordinate domains and a grid that supports prepending."""
+
+import numpy as np
+from numpy.typing import NDArray
+from typing import cast
+
+from zarr_indexing import (
+ DimensionGridLike,
+ IndexDomain,
+ IndexTransform,
+ LazyArray,
+ plan_chunks,
+)
+
+
+# --8<-- [start:coordinate-origin]
+domain = IndexDomain(inclusive_min=(-2,), exclusive_max=(3,))
+assert domain.contains((-1,))
+assert domain.narrow(-1).inclusive_min == (-1,)
+
+values = np.array([10, 20, 30, 40, 50])
+assert LazyArray.from_numpy(values).lazy[-1:].result().tolist() == [50]
+# --8<-- [end:coordinate-origin]
+
+
+# --8<-- [start:prepend-grid]
+class PrependableGrid:
+ """A regular grid whose coordinates may extend below zero."""
+
+ def index_to_chunk(self, index: int) -> int:
+ return index // 3
+
+ def chunk_offset(self, chunk: int) -> int:
+ return chunk * 3
+
+ def chunk_size(self, chunk: int) -> int:
+ return 3
+
+ def indices_to_chunks(self, indices: NDArray[np.intp]) -> NDArray[np.intp]:
+ return np.floor_divide(indices, 3).astype(np.intp)
+
+
+projection, = plan_chunks(
+ IndexTransform.identity(IndexDomain((-3,), (0,))),
+ cast(tuple[DimensionGridLike], (PrependableGrid(),)),
+)
+assert projection.chunk_coords == (-1,)
+assert projection.chunk_domain == IndexDomain((-3,), (0,))
+assert projection.chunk_transform.domain == IndexDomain((0,), (3,))
+
+
+assert projection.chunk_transform.domain == projection.cell_transform.domain
+PREPEND_SHARED_CELL_COORDS = ((0,), (1,), (2,))
+prepend_shared_cell_points = np.asarray(PREPEND_SHARED_CELL_COORDS, dtype=np.intp)
+PREPEND_CHUNK_LOCAL_COORDS = tuple(
+ tuple(point)
+ for point in projection.chunk_transform.apply_many(prepend_shared_cell_points).tolist()
+)
+PREPEND_REQUEST_COORDS = tuple(
+ tuple(point)
+ for point in projection.cell_transform.apply_many(prepend_shared_cell_points).tolist()
+)
+assert PREPEND_CHUNK_LOCAL_COORDS == ((0,), (1,), (2,))
+assert PREPEND_REQUEST_COORDS == ((-3,), (-2,), (-1,))
+# --8<-- [end:prepend-grid]
diff --git a/packages/zarr-indexing/docs/snippets/indexing_patterns.py b/packages/zarr-indexing/docs/snippets/indexing_patterns.py
new file mode 100644
index 0000000000..86ec75e4bc
--- /dev/null
+++ b/packages/zarr-indexing/docs/snippets/indexing_patterns.py
@@ -0,0 +1,210 @@
+"""Every indexing idiom hand-built as an IndexTransform, proven against the compiler."""
+
+from typing import Any, Literal, TypedDict
+
+import numpy as np
+
+from zarr_indexing import (
+ ArrayMap,
+ ConstantMap,
+ DimensionMap,
+ IndexDomain,
+ IndexTransform,
+ ReadContext,
+ numpy_reader,
+)
+
+
+# --8<-- [start:indexing-patterns]
+class PatternCase(TypedDict):
+ """One indexing idiom: its hand-built transform model and its NumPy result."""
+
+ name: str
+ mode: Literal["basic", "oindex", "vindex"]
+ selection: Any
+ transform: IndexTransform
+ expected: Any
+ shape: tuple[int, ...]
+ category: Literal["box", "query"]
+
+
+image = np.arange(48).reshape(6, 8)
+rows = np.array([4, 1, 1], dtype=np.intp)
+columns = np.array([2, 5], dtype=np.intp)
+mask = image % 5 == 0
+mask_rows, mask_columns = np.nonzero(mask)
+vector_rows = np.array([4, 1, 1], dtype=np.intp)
+vector_columns = np.array([2, 5, 2], dtype=np.intp)
+broadcast_rows = np.array([[0], [3]], dtype=np.intp)
+broadcast_columns = np.array([[1, 4, 6]], dtype=np.intp)
+
+PATTERN_CASES: tuple[PatternCase, ...] = (
+ {
+ # image[1:5, ::2] — an offset picks where cell 0 reads; a stride skips.
+ "name": "basic-slice",
+ "mode": "basic",
+ "selection": (slice(1, 5), slice(None, None, 2)),
+ "transform": IndexTransform(
+ domain=IndexDomain.from_shape((4, 4)),
+ output=(
+ DimensionMap(input_dimension=0, offset=1),
+ DimensionMap(input_dimension=1, stride=2),
+ ),
+ ),
+ "expected": image[1:5, ::2],
+ "shape": (4, 4),
+ "category": "box",
+ },
+ {
+ # image[2, :] — the dropped axis survives as a ConstantMap: the result
+ # is rank 1, but there is still one output map per source dimension.
+ "name": "integer-axis-removal",
+ "mode": "basic",
+ "selection": (2, slice(None)),
+ "transform": IndexTransform(
+ domain=IndexDomain.from_shape((8,)),
+ output=(ConstantMap(2), DimensionMap(input_dimension=0)),
+ ),
+ "expected": image[2, :],
+ "shape": (8,),
+ "category": "box",
+ },
+ {
+ # image[::-2, :] — reversal is only a negative stride; the offset is
+ # where result cell 0 reads (the last selected row, 5).
+ "name": "negative-stride",
+ "mode": "basic",
+ "selection": (slice(None, None, -2), slice(None)),
+ "transform": IndexTransform(
+ domain=IndexDomain.from_shape((3, 8)),
+ output=(
+ DimensionMap(input_dimension=0, offset=5, stride=-2),
+ DimensionMap(input_dimension=1),
+ ),
+ ),
+ "expected": image[::-2, :],
+ "shape": (3, 8),
+ "category": "box",
+ },
+ {
+ # image[2:2, :] — emptiness lives in the domain; the maps are ordinary.
+ "name": "empty-selection",
+ "mode": "basic",
+ "selection": (slice(2, 2), slice(None)),
+ "transform": IndexTransform(
+ domain=IndexDomain.from_shape((0, 8)),
+ output=(
+ DimensionMap(input_dimension=0, offset=2),
+ DimensionMap(input_dimension=1),
+ ),
+ ),
+ "expected": image[2:2, :],
+ "shape": (0, 8),
+ "category": "box",
+ },
+ {
+ # image[mask] — a mask is its nonzero coordinates: two correlated
+ # ArrayMaps over one flat result axis, row i paired with column i.
+ "name": "boolean-mask",
+ "mode": "vindex",
+ "selection": mask,
+ "transform": IndexTransform(
+ domain=IndexDomain.from_shape((10,)),
+ output=(ArrayMap(mask_rows), ArrayMap(mask_columns)),
+ ),
+ "expected": image[mask],
+ "shape": (10,),
+ "category": "query",
+ },
+ {
+ # image[np.ix_(rows, columns)] — the outer product is spelled by shape:
+ # each array varies over its own distinct axis, singleton on the other.
+ "name": "orthogonal",
+ "mode": "oindex",
+ "selection": (rows, columns),
+ "transform": IndexTransform(
+ domain=IndexDomain.from_shape((3, 2)),
+ output=(ArrayMap(rows.reshape(3, 1)), ArrayMap(columns.reshape(1, 2))),
+ ),
+ "expected": image[np.ix_(rows, columns)],
+ "shape": (3, 2),
+ "category": "query",
+ },
+ {
+ # image[vector_rows, vector_columns] — pointwise: both arrays share
+ # the same axis, so entry i of each pairs into one coordinate.
+ "name": "vectorized",
+ "mode": "vindex",
+ "selection": (vector_rows, vector_columns),
+ "transform": IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(ArrayMap(vector_rows), ArrayMap(vector_columns)),
+ ),
+ "expected": image[vector_rows, vector_columns],
+ "shape": (3,),
+ "category": "query",
+ },
+ {
+ # image[broadcast_rows, broadcast_columns] — NumPy broadcasting,
+ # materialized: each map carries the full (2, 3) broadcast block.
+ "name": "broadcasting",
+ "mode": "vindex",
+ "selection": (broadcast_rows, broadcast_columns),
+ "transform": IndexTransform(
+ domain=IndexDomain.from_shape((2, 3)),
+ output=(
+ ArrayMap(np.broadcast_to(broadcast_rows, (2, 3))),
+ ArrayMap(np.broadcast_to(broadcast_columns, (2, 3))),
+ ),
+ ),
+ "expected": image[broadcast_rows, broadcast_columns],
+ "shape": (2, 3),
+ "category": "query",
+ },
+ {
+ # image[rows, 2:6] — one lookup-table axis (repeats and order kept)
+ # beside one ordinary affine axis: one ArrayMap makes the whole
+ # selection a query.
+ "name": "repeated-out-of-order",
+ "mode": "oindex",
+ "selection": (rows, slice(2, 6)),
+ "transform": IndexTransform(
+ domain=IndexDomain.from_shape((3, 4)),
+ output=(
+ ArrayMap(rows.reshape(3, 1)),
+ DimensionMap(input_dimension=1, offset=2),
+ ),
+ ),
+ "expected": image[rows, 2:6],
+ "shape": (3, 4),
+ "category": "query",
+ },
+)
+
+base = IndexTransform.from_shape(image.shape)
+
+
+def resolve(transform: IndexTransform) -> np.ndarray[Any, Any]:
+ """Materialize a transform against `image` through the public reader."""
+ out = np.empty(transform.domain.shape, dtype=image.dtype)
+ numpy_reader.read_into(image, ReadContext(transform), out)
+ return out
+
+
+for case in PATTERN_CASES:
+ transform = case["transform"]
+
+ # The model is the idiom: shape, values, and category all follow from it.
+ assert transform.domain.shape == case["shape"]
+ np.testing.assert_array_equal(resolve(transform), case["expected"])
+ is_query = any(isinstance(m, ArrayMap) for m in transform.output)
+ assert ("query" if is_query else "box") == case["category"]
+
+ # The selection compiler derives the same transform. Compiled basic
+ # selections keep literal domains (t[1:5, ...] starts at 1, not 0);
+ # re-zeroing exposes the equality with the NumPy-shaped model.
+ compiled = base[case["selection"]] if case["mode"] == "basic" else (
+ getattr(base, case["mode"])[case["selection"]]
+ )
+ assert compiled.translate_domain_to((0,) * compiled.input_rank) == transform
+# --8<-- [end:indexing-patterns]
diff --git a/packages/zarr-indexing/docs/snippets/integrations.py b/packages/zarr-indexing/docs/snippets/integrations.py
new file mode 100644
index 0000000000..5699c0d319
--- /dev/null
+++ b/packages/zarr-indexing/docs/snippets/integrations.py
@@ -0,0 +1,167 @@
+"""Boundaries for chunked-source and viewport consumers."""
+
+from typing import Any
+
+import numpy as np
+
+from zarr_indexing import IndexDomain, LazyArray
+
+
+# --8<-- [start:zarr-consumer]
+class RecordingChunkSource:
+ """A decoded-chunk source keyed by public chunk coordinates."""
+
+ def __init__(self, chunks: dict[tuple[int, ...], np.ndarray[Any, Any]]) -> None:
+ self.chunks = chunks
+ self.reads: list[tuple[int, ...]] = []
+
+ def read(self, chunk_coords: tuple[int, ...]) -> np.ndarray[Any, Any]:
+ self.reads.append(chunk_coords)
+ return self.chunks[chunk_coords]
+
+
+def _domain_points(domain: IndexDomain) -> np.ndarray[Any, np.dtype[np.intp]]:
+ """Enumerate a rectangular domain with a trailing coordinate axis."""
+ if domain.ndim == 0:
+ return np.empty((1, 0), dtype=np.intp)
+ points = np.moveaxis(np.indices(domain.shape, dtype=np.intp), 0, -1).reshape(
+ -1, domain.ndim
+ )
+ points += np.asarray(domain.inclusive_min, dtype=np.intp)
+ return points
+
+
+def _gather_and_scatter(
+ destination: np.ndarray[Any, Any],
+ source: np.ndarray[Any, Any],
+ source_points: np.ndarray[Any, np.dtype[np.intp]],
+ destination_points: np.ndarray[Any, np.dtype[np.intp]],
+) -> np.ndarray[Any, Any]:
+ """Gather and scatter a flattened point batch, including rank zero."""
+ values = np.asarray(source[tuple(source_points.T)]).reshape(-1)
+ if destination_points.shape[-1] == 0:
+ destination[()] = values.reshape(destination.shape)[()]
+ else:
+ destination[tuple(destination_points.T)] = values
+ return values
+
+
+zarr_image = np.arange(12).reshape(3, 4)
+zarr_chunks = {
+ (chunk_row, chunk_column): zarr_image[
+ chunk_row * 2 : (chunk_row + 1) * 2,
+ chunk_column * 2 : (chunk_column + 1) * 2,
+ ]
+ for chunk_row in range(2)
+ for chunk_column in range(2)
+}
+zarr_source = RecordingChunkSource(zarr_chunks)
+zarr_view = LazyArray.from_numpy(zarr_image).with_parts((2, 2)).lazy[1, 0:4]
+ZARR_RESULT = np.empty(zarr_view.shape, dtype=zarr_image.dtype)
+shared_domains: list[tuple[IndexDomain, IndexDomain]] = []
+chunk_local_coords: list[tuple[tuple[int, ...], ...]] = []
+request_coords: list[tuple[tuple[int, ...], ...]] = []
+read_values: list[tuple[int, ...]] = []
+
+for part in zarr_view.parts():
+ projection = part.projection
+ assert projection.chunk_transform.domain == projection.cell_transform.domain
+ shared_domains.append(
+ (projection.chunk_transform.domain, projection.cell_transform.domain)
+ )
+ domain = projection.chunk_transform.domain
+ cell_points = _domain_points(domain)
+ local_points_array = projection.chunk_transform.apply_many(cell_points)
+ result_points_array = projection.cell_transform.apply_many(cell_points)
+ chunk = zarr_source.read(projection.chunk_coords)
+ values_array = _gather_and_scatter(
+ ZARR_RESULT, chunk, local_points_array, result_points_array
+ )
+ local_points = tuple(tuple(point) for point in local_points_array.tolist())
+ result_points = tuple(tuple(point) for point in result_points_array.tolist())
+ values = tuple(int(value) for value in values_array)
+ chunk_local_coords.append(local_points)
+ request_coords.append(result_points)
+ read_values.append(values)
+
+ZARR_SOURCE_KEYS = tuple(zarr_source.chunks)
+ZARR_SOURCE_READS = tuple(zarr_source.reads)
+ZARR_DISPATCHED_CHUNKS = ZARR_SOURCE_READS
+ZARR_SHARED_DOMAINS = tuple(shared_domains)
+ZARR_CHUNK_LOCAL_COORDS = tuple(chunk_local_coords)
+ZARR_REQUEST_COORDS = tuple(request_coords)
+ZARR_READ_VALUES = tuple(read_values)
+assert ZARR_SOURCE_KEYS == ((0, 0), (0, 1), (1, 0), (1, 1))
+assert ZARR_SOURCE_READS == ((0, 0), (0, 1))
+assert ZARR_CHUNK_LOCAL_COORDS == (((1, 0), (1, 1)), ((1, 0), (1, 1)))
+assert ZARR_REQUEST_COORDS == (((0,), (1,)), ((2,), (3,)))
+assert ZARR_READ_VALUES == ((4, 5), (6, 7))
+assert ZARR_RESULT.tolist() == [4, 5, 6, 7]
+# --8<-- [end:zarr-consumer]
+
+
+# --8<-- [start:viewport-consumer]
+class RecordingArray:
+ """An array-like source that records the basic reads it receives."""
+
+ def __init__(self, data: np.ndarray[Any, Any], chunks: tuple[int, ...]) -> None:
+ self._data = data
+ self.chunks = chunks
+ self.keys: list[tuple[slice, ...]] = []
+
+ @property
+ def shape(self) -> tuple[int, ...]:
+ return self._data.shape
+
+ @property
+ def dtype(self) -> np.dtype[Any]:
+ return self._data.dtype
+
+ def __getitem__(self, key: tuple[slice, ...]) -> np.ndarray[Any, Any]:
+ self.keys.append(key)
+ return self._data[key]
+
+
+viewport_source = RecordingArray(np.arange(12).reshape(3, 4), chunks=(2, 2))
+viewport = LazyArray(viewport_source).lazy[1, 0:4]
+VIEWPORT_READS_BEFORE_RESULT = tuple(viewport_source.keys)
+assert VIEWPORT_READS_BEFORE_RESULT == ()
+assert viewport.result().tolist() == [4, 5, 6, 7]
+
+VIEWPORT_SOURCE_KEYS = tuple(viewport_source.keys)
+VIEWPORT_SOURCE_CHUNKS = tuple(
+ (key[0].start // 2, key[1].start // 2) for key in VIEWPORT_SOURCE_KEYS
+)
+assert VIEWPORT_SOURCE_KEYS == (
+ (slice(1, 2, 1), slice(0, 2, 1)),
+ (slice(1, 2, 1), slice(2, 4, 1)),
+)
+assert VIEWPORT_SOURCE_CHUNKS == ((0, 0), (0, 1))
+# --8<-- [end:viewport-consumer]
+
+
+# --8<-- [start:dense-box-repartition]
+def materialize(view: LazyArray) -> Any:
+ """Read a dense box as one slab; resolve everything else per part."""
+ strides = view.strides()
+ if view.is_box and strides is not None and all(s == 1 for s in strides):
+ view = view.with_parts(view.base_shape)
+ return view.result()
+
+
+slab_source = RecordingArray(np.arange(100).reshape(10, 10), chunks=(4, 4))
+slab = LazyArray(slab_source)
+
+dense = slab.lazy[2:9, 1:8] # a dense box: every stride 1
+assert materialize(dense).shape == (7, 7)
+assert len(slab_source.keys) == 1 # one slab read; the source dispatches
+
+slab_source.keys.clear()
+gather = slab.lazy.oindex[[0, 9], [0, 9]] # a query: keep the chunk parts
+assert materialize(gather).tolist() == [[0, 9], [90, 99]]
+assert len(slab_source.keys) == 4 # four covers, each inside one chunk
+assert all(
+ (key[0].stop - key[0].start) * (key[1].stop - key[1].start) == 1
+ for key in slab_source.keys
+)
+# --8<-- [end:dense-box-repartition]
diff --git a/packages/zarr-indexing/docs/snippets/lazy_composition.py b/packages/zarr-indexing/docs/snippets/lazy_composition.py
new file mode 100644
index 0000000000..a4d9884b2b
--- /dev/null
+++ b/packages/zarr-indexing/docs/snippets/lazy_composition.py
@@ -0,0 +1,14 @@
+"""Composing views keeps indexing lazy until the final result call."""
+
+import numpy as np
+
+from zarr_indexing import LazyArray
+
+
+# --8<-- [start:lazy-composition]
+source = np.array([10, 11, 12, 13, 14, 15])
+view = LazyArray.from_numpy(source).lazy[2:5]
+composed = view.lazy[::-1].lazy[1:]
+
+assert composed.result().tolist() == source[2:5][::-1][1:].tolist()
+# --8<-- [end:lazy-composition]
diff --git a/packages/zarr-indexing/docs/snippets/output_maps.py b/packages/zarr-indexing/docs/snippets/output_maps.py
new file mode 100644
index 0000000000..a78fc73b50
--- /dev/null
+++ b/packages/zarr-indexing/docs/snippets/output_maps.py
@@ -0,0 +1,67 @@
+"""The three output map kinds, each demonstrated against its NumPy counterpart."""
+
+from typing import Any
+
+import numpy as np
+
+from zarr_indexing import (
+ ArrayMap,
+ ConstantMap,
+ DimensionMap,
+ IndexDomain,
+ IndexTransform,
+ ReadContext,
+ numpy_reader,
+)
+
+# --8<-- [start:resolve-helper]
+source = np.array([10, 11, 12, 13, 14, 15])
+
+
+def resolve(transform: IndexTransform, values: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]:
+ """Materialize `transform` against `values` through the public reader."""
+ out = np.empty(transform.domain.shape, dtype=values.dtype)
+ numpy_reader.read_into(values, ReadContext(transform), out)
+ return out
+# --8<-- [end:resolve-helper]
+
+
+# --8<-- [start:dimension-map]
+# DimensionMap is an affine rule: request i reads source offset + stride * i.
+# Its NumPy counterpart is a basic slice.
+sliced = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(DimensionMap(input_dimension=0, offset=2, stride=1),),
+)
+assert resolve(sliced, source).tolist() == source[2:5].tolist()
+
+# A negative stride walks the source backward, like a negative-step slice.
+reversed_view = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(DimensionMap(input_dimension=0, offset=4, stride=-2),),
+)
+assert resolve(reversed_view, source).tolist() == source[4::-2].tolist()
+# --8<-- [end:dimension-map]
+
+# --8<-- [start:array-map]
+# ArrayMap is an explicit list of source coordinates; order and duplicates
+# are semantic. Its NumPy counterpart is fancy indexing.
+gather = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(ArrayMap(index_array=np.array([4, 1, 1])),),
+)
+assert resolve(gather, source).tolist() == source[[4, 1, 1]].tolist()
+# --8<-- [end:array-map]
+
+# --8<-- [start:constant-map]
+# ConstantMap reads one source coordinate for every request cell. No NumPy
+# selection spells this operation: source[0] drops the axis, and a repeated
+# fancy index source[[0, 0, 0, 0]] matches the values but degrades the
+# description to a coordinate list. The value-faithful counterpart is a
+# broadcast.
+repeat = IndexTransform(
+ domain=IndexDomain.from_shape((4,)),
+ output=(ConstantMap(offset=0),),
+)
+assert resolve(repeat, source).tolist() == np.broadcast_to(source[0:1], (4,)).tolist()
+# --8<-- [end:constant-map]
diff --git a/packages/zarr-indexing/examples/lazy_indexing_dask/README.md b/packages/zarr-indexing/examples/lazy_indexing_dask/README.md
new file mode 100644
index 0000000000..c9edb903ef
--- /dev/null
+++ b/packages/zarr-indexing/examples/lazy_indexing_dask/README.md
@@ -0,0 +1,55 @@
+# Lazy Indexing with Dask
+
+This example demonstrates how to use `zarr_indexing.LazyArray` with Dask, both as
+an array Dask can wrap and as a source of independent tasks, and compares the two
+ways of deferring an indexing operation.
+
+The example shows how to:
+
+- Pass a `LazyArray` — over a Zarr array or over a view of one — to
+ `dask.array.from_array`
+- Build one Dask task per partition from `parts()`, compute them in parallel, and
+ place each result with the partition's `out_selection`
+- Read `is_complete` to tell which partitions cover a stored chunk completely
+- Rely on `__dask_tokenize__`, so that equal selections produce equal tokens and
+ Dask can cache and deduplicate the work
+- Measure what a task graph costs for indexing-only work, against composing the
+ same selections into one transform
+
+A `LazyArray` exposes no `chunks` attribute, so `dask.array.from_array` chooses
+its own block size unless one is given. The partitioning that `parts()` reports
+is discovered from the wrapped array and is independent of Dask's blocks.
+
+## Choosing Between Them
+
+If Dask is doing arithmetic across chunks, reductions, rechunking, or distributed
+execution, it is the right tool, and its task graph is what makes that work.
+
+If Dask is used *only* to defer indexing — take a view now, read it later, with
+no computation in between — then the graph is overhead. Dask slices the chunk
+grid on every indexing operation and records another layer, so composing
+selections costs time proportional to both the depth of the chain and the number
+of chunks in the array, and reading walks what was accumulated. `LazyArray`
+composes each selection into the single transform it already holds, so composing
+is independent of the depth of the chain, and reading enumerates only the
+partitions the selection touches. The last test in this example prints both, and
+the gap widens with the number of chunks and the number of selections.
+
+## Running the Example
+
+The script declares its dependencies inline
+([PEP 723](https://peps.python.org/pep-0723/)), so the easiest way to run it is
+with [uv](https://docs.astral.sh/uv/), which installs them automatically:
+
+```bash
+cd packages/zarr-indexing
+uv run --with-editable . examples/lazy_indexing_dask/lazy_indexing_dask.py
+```
+
+Alternatively, run it with plain Python, in which case you must first install
+`zarr`, `zarr-indexing`, `dask[array]`, `numpy`, and `pytest` yourself:
+
+```bash
+cd packages/zarr-indexing
+python examples/lazy_indexing_dask/lazy_indexing_dask.py
+```
diff --git a/packages/zarr-indexing/examples/lazy_indexing_dask/lazy_indexing_dask.py b/packages/zarr-indexing/examples/lazy_indexing_dask/lazy_indexing_dask.py
new file mode 100644
index 0000000000..d6ed43f322
--- /dev/null
+++ b/packages/zarr-indexing/examples/lazy_indexing_dask/lazy_indexing_dask.py
@@ -0,0 +1,181 @@
+# /// script
+# requires-python = ">=3.12"
+# dependencies = [
+# "zarr @ git+https://github.com/zarr-developers/zarr-python.git@main",
+# "zarr-indexing>=0.1",
+# "dask[array]==2025.3.0",
+# "numpy==2.4.3",
+# "pytest==9.0.2"
+# ]
+# ///
+#
+
+"""
+Demonstrate using zarr_indexing.LazyArray with Dask
+"""
+
+import sys
+import time
+
+import dask
+import dask.array as da
+import numpy as np
+import pytest
+import zarr
+from dask.base import tokenize
+
+from zarr_indexing import LazyArray
+
+
+@pytest.fixture
+def source() -> zarr.Array:
+ """A chunked Zarr array to wrap."""
+ array = zarr.create_array(store={}, shape=(40, 30), chunks=(10, 10), dtype="i4")
+ array[:] = np.arange(40 * 30).reshape(40, 30)
+ return array
+
+
+def test_from_array(source: zarr.Array) -> None:
+ """Hand a LazyArray to `dask.array.from_array`."""
+ lazy = LazyArray(source)
+
+ # `from_array` needs `shape`, `dtype`, and `__getitem__`, which the wrapper
+ # provides. Each Dask block reads its own region through the wrapper.
+ array = da.from_array(lazy, chunks=(10, 10))
+ print(array)
+ assert np.array_equal(array.compute(scheduler="threads"), source[:])
+
+ # A view works the same way, and its shape is the shape of the selection.
+ view = LazyArray(source).lazy[5:35, 3:27]
+ array = da.from_array(view, chunks=(10, 10))
+ assert array.shape == (30, 24)
+ assert np.array_equal(array.compute(scheduler="threads"), source[5:35, 3:27])
+
+
+def test_parts_as_tasks(source: zarr.Array) -> None:
+ """Build one task per partition and compute them in parallel."""
+ view = LazyArray(source).lazy[5:35, 3:27]
+
+ # The partitioning is discovered from the wrapped array's chunks, so each
+ # partition of the view lies within one stored chunk.
+ parts = list(view.parts())
+ print(f"{len(parts)} parts for a {view.shape} view of a {source.shape} array")
+
+ # A partition carries a sub-view to resolve and where its result belongs, so
+ # the reads are independent and the placement needs no coordination.
+ @dask.delayed
+ def read(part: object) -> np.ndarray:
+ return part.view.result()
+
+ blocks = dask.compute(*[read(part) for part in parts], scheduler="threads")
+
+ result = np.empty(view.shape, dtype=view.dtype)
+ for part, block in zip(parts, blocks, strict=True):
+ result[part.out_selection] = block
+ assert np.array_equal(result, source[5:35, 3:27])
+
+ # `is_complete` reports whether a partition covers its whole partition of
+ # the base array, which a writer uses to choose between overwriting a chunk
+ # and reading it first.
+ complete = [part.box for part in parts if part.is_complete]
+ print(f"{len(complete)} of {len(parts)} parts cover their chunk completely")
+
+
+def test_tokenize(source: zarr.Array) -> None:
+ """Deterministic tokens let Dask cache and deduplicate work."""
+ lazy = LazyArray(source)
+
+ # Two wrappers over the same array and the same selection are the same task
+ # to Dask, whether or not they are the same Python object.
+ assert tokenize(lazy) == tokenize(LazyArray(source))
+ assert tokenize(lazy.lazy[0:10]) == tokenize(LazyArray(source).lazy[0:10])
+
+ # Different selections are different tasks.
+ assert tokenize(lazy.lazy[0:10]) != tokenize(lazy.lazy[10:20])
+
+ # Selections that describe the same region are the same task, however they
+ # were composed.
+ assert tokenize(lazy.lazy[0:20].lazy[5:10]) == tokenize(lazy.lazy[5:10])
+
+
+def test_indexing_only_workload() -> None:
+ """Compare an accumulating task graph with a fused transform.
+
+ Dask records each indexing operation as another graph layer, and slices the
+ chunk grid to build it, so composing selections costs time proportional to
+ the number of selections and the number of chunks. `LazyArray` composes each
+ selection into the single transform it already holds, so the cost of
+ composing does not grow with the depth of the chain, and reading resolves
+ that one transform rather than walking a graph.
+
+ Timings are printed rather than asserted, since they depend on the machine.
+ """
+ data = np.zeros((2000, 4), dtype="i4") # 2000 chunks, one row each
+
+ def dask_chain(depth: int) -> da.Array:
+ array = da.from_array(data, chunks=(1, 4))
+ for _ in range(depth):
+ array = array[1:]
+ return array
+
+ def lazy_chain(depth: int) -> LazyArray:
+ view = LazyArray.from_numpy(data)
+ for _ in range(depth):
+ view = view.lazy[1:]
+ return view
+
+ # Read once through each path first, so the timings below exclude the cost
+ # of importing and initializing the machinery.
+ dask_chain(1)[:2].compute(scheduler="synchronous")
+ lazy_chain(1).lazy[:2].result()
+
+ header = (
+ f"{'selections':>10} {'dask compose':>13} {'dask read':>10} {'layers':>7}"
+ f" {'LazyArray compose':>18} {'LazyArray read':>15}"
+ )
+ print(header)
+ for depth in (1, 5, 20):
+ start = time.perf_counter()
+ chained = dask_chain(depth)
+ dask_compose = time.perf_counter() - start
+
+ start = time.perf_counter()
+ from_dask = chained[:2].compute(scheduler="synchronous")
+ dask_read = time.perf_counter() - start
+
+ start = time.perf_counter()
+ view = lazy_chain(depth)
+ lazy_compose = time.perf_counter() - start
+
+ start = time.perf_counter()
+ from_lazy = view.lazy[:2].result()
+ lazy_read = time.perf_counter() - start
+
+ # Both paths describe the same selection, so they read the same data.
+ assert np.array_equal(from_dask, from_lazy)
+
+ layers = len(chained.__dask_graph__().layers)
+ print(
+ f"{depth:>10} {dask_compose * 1e3:>12.2f}ms {dask_read * 1e3:>9.2f}ms {layers:>7}"
+ f" {lazy_compose * 1e3:>17.3f}ms {lazy_read * 1e3:>14.3f}ms"
+ )
+
+
+if __name__ == "__main__":
+ # Run the example with printed output, and a dummy pytest configuration file specified.
+ # Without the dummy configuration file, at test time pytest will attempt to use the
+ # configuration file in the project root, which will error because Zarr is using some
+ # plugins that are not installed in this example.
+ sys.exit(
+ pytest.main(
+ [
+ "-s",
+ __file__,
+ f"-c {__file__}",
+ # Suppress: "PytestAssertRewriteWarning: Module already imported so
+ # cannot be rewritten; zarr"
+ "-W",
+ "ignore::pytest.PytestAssertRewriteWarning",
+ ]
+ )
+ )
diff --git a/packages/zarr-indexing/examples/lazy_indexing_numpy/README.md b/packages/zarr-indexing/examples/lazy_indexing_numpy/README.md
new file mode 100644
index 0000000000..e76e065047
--- /dev/null
+++ b/packages/zarr-indexing/examples/lazy_indexing_numpy/README.md
@@ -0,0 +1,38 @@
+# Lazy Indexing a NumPy Array
+
+This example demonstrates how to wrap an array in `zarr_indexing.LazyArray` and
+index it without reading data.
+
+The example shows how to:
+
+- Wrap a NumPy array and read the forwarded `shape`, `dtype`, and `ndim`
+- Compose selections through `.lazy[...]`, `.lazy.oindex[...]`, and
+ `.lazy.vindex[...]`, and materialize the composed view once with `result()`
+- Tell a box selection (slices and integers, described by an interval and a step
+ per dimension) from a query selection (points gathered through an index array)
+ using `is_box`, `bounding_box()`, and `strides()`
+- Declare a partitioning with `with_parts()`, iterate it with `parts()`, and
+ assemble a result from the partitions
+
+`LazyArray` wraps any object exposing `shape`, `dtype`, and `__getitem__`, so the
+same API applies to a Zarr array, and the partitioning is then discovered from
+the array's chunks. The Dask example covers that case.
+
+## Running the Example
+
+The script declares its dependencies inline
+([PEP 723](https://peps.python.org/pep-0723/)), so the easiest way to run it is
+with [uv](https://docs.astral.sh/uv/), which installs them automatically:
+
+```bash
+cd packages/zarr-indexing
+uv run --with-editable . examples/lazy_indexing_numpy/lazy_indexing_numpy.py
+```
+
+Alternatively, run it with plain Python, in which case you must first install
+`zarr-indexing`, `numpy`, and `pytest` yourself:
+
+```bash
+cd packages/zarr-indexing
+python examples/lazy_indexing_numpy/lazy_indexing_numpy.py
+```
diff --git a/packages/zarr-indexing/examples/lazy_indexing_numpy/lazy_indexing_numpy.py b/packages/zarr-indexing/examples/lazy_indexing_numpy/lazy_indexing_numpy.py
new file mode 100644
index 0000000000..ae29a7fc51
--- /dev/null
+++ b/packages/zarr-indexing/examples/lazy_indexing_numpy/lazy_indexing_numpy.py
@@ -0,0 +1,133 @@
+# /// script
+# requires-python = ">=3.12"
+# dependencies = [
+# "zarr-indexing>=0.1",
+# "numpy==2.4.3",
+# "pytest==9.0.2"
+# ]
+# ///
+#
+
+"""
+Demonstrate lazy indexing over a plain NumPy array with zarr_indexing.LazyArray
+"""
+
+import sys
+
+import numpy as np
+import pytest
+
+from zarr_indexing import LazyArray
+
+
+def test_wrap_and_compose() -> None:
+ """Wrap an array, compose selections without reading, then materialize once."""
+ data = np.arange(12 * 8).reshape(12, 8)
+ lazy = LazyArray.from_numpy(data)
+
+ # The wrapper forwards the attributes an array consumer expects.
+ assert lazy.shape == (12, 8)
+ assert lazy.dtype == data.dtype
+ assert lazy.ndim == 2
+
+ # `.lazy[...]` returns another LazyArray. No element of `data` is read.
+ view = lazy.lazy[2:10, ::2]
+ print(view)
+ assert view.shape == (8, 4)
+
+ # Selections compose. Each step narrows the view; still nothing is read.
+ smaller = view.lazy[1:5, 1:3]
+
+ # `result()` performs the read. NumPy is the reference for the whole chain.
+ assert np.array_equal(smaller.result(), data[2:10, ::2][1:5, 1:3])
+
+ # Selections use positional NumPy semantics: indices count from zero within
+ # the current view, and negative indices count from the end.
+ assert np.array_equal(lazy.lazy[-1].result(), data[-1])
+ assert np.array_equal(lazy.lazy[::-1].result(), data[::-1])
+
+ # Orthogonal and vectorized indexing are available under the same accessor.
+ rows = np.array([9, 1, 4])
+ assert np.array_equal(lazy.lazy.oindex[rows, :].result(), data[rows, :])
+ cols = np.array([0, 3, 7])
+ assert np.array_equal(lazy.lazy.vindex[rows, cols].result(), data[rows, cols])
+
+ # A LazyArray is also an ordinary duck array: __getitem__ reads immediately,
+ # and np.asarray materializes the view.
+ assert np.array_equal(lazy[2:4, 0], data[2:4, 0])
+ assert np.array_equal(np.asarray(view), data[2:10, ::2])
+
+
+def test_box_and_query_selections() -> None:
+ """Distinguish selections that describe a region from selections that gather points."""
+ data = np.arange(12 * 8).reshape(12, 8)
+ lazy = LazyArray.from_numpy(data)
+
+ # A box selection is built from slices and integers alone. It is described
+ # completely by an interval and a step per dimension, so a consumer can
+ # serve it as one strided read.
+ box = lazy.lazy[2:10, ::2]
+ print(f"box: is_box={box.is_box} bounding_box={box.bounding_box()} strides={box.strides()}")
+ assert box.is_box
+ assert box.bounding_box() == ((2, 10), (0, 7))
+ assert box.strides() == (1, 2)
+
+ # A query selection gathers points through an index array. Its coordinates
+ # are a lookup table, so `strides()` is undefined and `bounding_box()` is
+ # the hull of the points rather than an exact description.
+ query = lazy.lazy.oindex[np.array([9, 1, 4]), :]
+ print(f"query: is_box={query.is_box} bounding_box={query.bounding_box()}")
+ assert not query.is_box
+ assert query.strides() is None
+ assert query.bounding_box() == ((1, 10), (0, 8))
+
+ # Composing a box onto a query keeps it a query.
+ assert not query.lazy[0:2, 0:2].is_box
+
+
+def test_parts() -> None:
+ """Iterate the partitions a view covers, and assemble the result from them."""
+ data = np.arange(12 * 8).reshape(12, 8)
+
+ # A plain NumPy array declares no partitioning, so `with_parts` states one.
+ # Partitioning changes the granularity of reads, never the result.
+ lazy = LazyArray.from_numpy(data).with_parts((4, 4))
+ view = lazy.lazy[2:10, ::2]
+
+ parts = list(view.parts())
+ print(f"{len(parts)} parts")
+ for part in parts[:2]:
+ print(f" base_coords={part.base_coords} box={part.box} complete={part.is_complete}")
+
+ # Each part carries a sub-view of its own, where that sub-view lands in the
+ # result, and whether it covers its partition completely. Resolving the
+ # parts and placing them is what `result()` does.
+ assembled = np.empty(view.shape, dtype=view.dtype)
+ for part in parts:
+ assembled[part.out_selection] = part.view.result()
+ assert np.array_equal(assembled, view.result())
+
+ # The partitioning is a read strategy, so a different one gives the same data.
+ assert np.array_equal(
+ LazyArray.from_numpy(data).with_parts((5, 3)).lazy[2:10, ::2].result(), assembled
+ )
+
+
+if __name__ == "__main__":
+ # Run the example with printed output, and a dummy pytest configuration file specified.
+ # Without the dummy configuration file, at test time pytest will attempt to use the
+ # configuration file in the project root, which will error because Zarr is using some
+ # plugins that are not installed in this example.
+ sys.exit(
+ pytest.main(
+ [
+ "-s",
+ __file__,
+ f"-c {__file__}",
+ # Suppress: "PytestAssertRewriteWarning: Module already imported so
+ # cannot be rewritten; zarr"
+ "-W",
+ "ignore::pytest.PytestAssertRewriteWarning",
+ ]
+ )
+ )
diff --git a/packages/zarr-indexing/examples/system_memory_chunk_cache/README.md b/packages/zarr-indexing/examples/system_memory_chunk_cache/README.md
new file mode 100644
index 0000000000..d6271f9401
--- /dev/null
+++ b/packages/zarr-indexing/examples/system_memory_chunk_cache/README.md
@@ -0,0 +1,46 @@
+# System-memory chunk cache
+
+This executable reference architecture demonstrates a small, synchronous
+system-memory chunk cache for a NumPy-like image consumer. It is inspired by
+Neuroglancer's explicit chunk lifecycle:
+
+```text
+NEW -> QUEUED -> LOADING -> READY
+ |
+ v
+ FAILED
+
+READY -> EVICTED
+FAILED -> QUEUED (explicit retry)
+```
+
+`RecordingChunkSource` owns decoded source-chunk reads and records them for the
+example. `LazyArray` converts NumPy-style indexing into transforms and
+partitions, then assembles the final result. `SystemMemoryChunkReader`
+intercepts each materialized part and owns the lifecycle records, queue
+draining, resident ready buffers, LRU eviction, retained load failures, and
+explicit retry. The reader owns cache state and source reads, but not result
+shape or assembly.
+
+Each request calls `view.parts()` once and keeps the resulting tuple. The cache
+pins the tuple's chunk coordinates, then materializes with
+`view.result(parts=parts)`, so scheduling and assembly reuse one plan. Every
+reader call consumes the exact projection attached to its `ReadContext`; the
+reader does not invoke the chunk planner again.
+
+The requests demonstrate lazy selections, paired chunk projections, overlapping
+viewport requests that reuse resident chunks, eviction under capacity pressure,
+a retained failure that does not retry implicitly, and an explicit retry after
+the source is repaired. The integration guide contains the detailed request
+table.
+
+This is synchronous system-memory reference architecture, not a
+production-ready cache, scheduler, renderer, or complete napari integration.
+Its types are intentionally not exported by `zarr_indexing`.
+
+## Running the example
+
+```bash
+cd packages/zarr-indexing
+uv run --with-editable . examples/system_memory_chunk_cache/system_memory_chunk_cache.py
+```
diff --git a/packages/zarr-indexing/examples/system_memory_chunk_cache/system_memory_chunk_cache.py b/packages/zarr-indexing/examples/system_memory_chunk_cache/system_memory_chunk_cache.py
new file mode 100644
index 0000000000..1645d89b2c
--- /dev/null
+++ b/packages/zarr-indexing/examples/system_memory_chunk_cache/system_memory_chunk_cache.py
@@ -0,0 +1,432 @@
+# /// script
+# requires-python = ">=3.12"
+# dependencies = [
+# "zarr-indexing>=0.1",
+# "numpy==2.4.3",
+# ]
+# ///
+#
+from __future__ import annotations
+
+from contextlib import contextmanager
+from dataclasses import dataclass
+from enum import StrEnum
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+
+from zarr_indexing import (
+ IndexDomain,
+ LazyArray,
+ ReadContext,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterator
+
+
+type ChunkCoords = tuple[int, ...]
+
+
+# --8<-- [start:chunk-cache-types]
+class ChunkState(StrEnum):
+ NEW = "new"
+ QUEUED = "queued"
+ LOADING = "loading"
+ READY = "ready"
+ FAILED = "failed"
+ EVICTED = "evicted"
+
+
+@dataclass(slots=True)
+class ChunkRecord:
+ state: ChunkState = ChunkState.NEW
+ buffer: np.ndarray[Any, Any] | None = None
+ error: Exception | None = None
+ last_access: int = -1
+
+
+@dataclass(frozen=True, slots=True)
+class ChunkEvent:
+ chunk_coords: ChunkCoords
+ previous: ChunkState
+ current: ChunkState
+ reason: str
+
+
+class ChunkLoadError(RuntimeError):
+ pass
+
+
+# --8<-- [end:chunk-cache-types]
+
+
+# --8<-- [start:chunk-cache-source]
+class RecordingChunkSource:
+ def __init__(self, data: np.ndarray[Any, Any], chunks: tuple[int, ...]) -> None:
+ self._data = data
+ self.chunks = chunks
+ self.reads: list[ChunkCoords] = []
+ self.failures: set[ChunkCoords] = set()
+
+ @property
+ def shape(self) -> tuple[int, ...]:
+ return self._data.shape
+
+ @property
+ def dtype(self) -> np.dtype[Any]:
+ return self._data.dtype
+
+ def __getitem__(self, key: Any) -> np.ndarray[Any, Any]:
+ raise AssertionError("the cache must read complete chunks through read_chunk")
+
+ def read_chunk(self, chunk_coords: ChunkCoords) -> np.ndarray[Any, Any]:
+ self.reads.append(chunk_coords)
+ if chunk_coords in self.failures:
+ raise OSError(f"source read failed for chunk {chunk_coords}")
+ key = tuple(
+ slice(coord * size, min((coord + 1) * size, extent))
+ for coord, size, extent in zip(chunk_coords, self.chunks, self.shape, strict=True)
+ )
+ return self._data[key].copy()
+
+
+def _domain_points(domain: IndexDomain) -> np.ndarray[Any, np.dtype[np.intp]]:
+ """Enumerate a rectangular domain with a trailing coordinate axis."""
+ if domain.ndim == 0:
+ return np.empty((1, 0), dtype=np.intp)
+ points = np.moveaxis(np.indices(domain.shape, dtype=np.intp), 0, -1).reshape(-1, domain.ndim)
+ points += np.asarray(domain.inclusive_min, dtype=np.intp)
+ return points
+
+
+def _gather_and_scatter(
+ destination: np.ndarray[Any, Any],
+ source: np.ndarray[Any, Any],
+ source_points: np.ndarray[Any, np.dtype[np.intp]],
+ destination_points: np.ndarray[Any, np.dtype[np.intp]],
+) -> np.ndarray[Any, Any]:
+ """Gather and scatter a flattened point batch, including rank zero."""
+ values = np.asarray(source[tuple(source_points.T)]).reshape(-1)
+ if destination_points.shape[-1] == 0:
+ destination[()] = values.reshape(destination.shape)[()]
+ else:
+ destination[tuple(destination_points.T)] = values
+ return values
+
+
+# --8<-- [end:chunk-cache-source]
+
+
+# --8<-- [start:chunk-cache-wrapper]
+LEGAL_TRANSITIONS: dict[ChunkState, frozenset[ChunkState]] = {
+ ChunkState.NEW: frozenset({ChunkState.QUEUED}),
+ ChunkState.QUEUED: frozenset({ChunkState.LOADING}),
+ ChunkState.LOADING: frozenset({ChunkState.READY, ChunkState.FAILED}),
+ ChunkState.READY: frozenset({ChunkState.EVICTED}),
+ ChunkState.FAILED: frozenset({ChunkState.QUEUED}),
+ ChunkState.EVICTED: frozenset({ChunkState.QUEUED}),
+}
+
+
+class _OrthogonalIndexer:
+ """Expose outer-product indexing without changing ``cache[key]`` semantics."""
+
+ def __init__(self, getitem: Callable[[Any], np.ndarray[Any, Any]]) -> None:
+ self._getitem = getitem
+
+ def __getitem__(self, key: Any) -> np.ndarray[Any, Any]:
+ return self._getitem(key)
+
+
+class SystemMemoryChunkReader:
+ def __init__(self, *, capacity: int) -> None:
+ self.capacity = capacity
+ self._records: dict[ChunkCoords, ChunkRecord] = {}
+ self._queue: list[ChunkCoords] = []
+ self._clock = 0
+ self._requests = 0
+ self.events: list[ChunkEvent] = []
+ self.projection_uses: list[tuple[str, str]] = []
+
+ def state(self, chunk_coords: ChunkCoords) -> ChunkState:
+ return self._record(chunk_coords).state
+
+ def resident(self) -> tuple[ChunkCoords, ...]:
+ return tuple(
+ sorted(
+ coords
+ for coords, record in self._records.items()
+ if record.state is ChunkState.READY
+ )
+ )
+
+ def _record(self, chunk_coords: ChunkCoords) -> ChunkRecord:
+ return self._records.setdefault(chunk_coords, ChunkRecord())
+
+ def _transition(self, chunk_coords: ChunkCoords, current: ChunkState, reason: str) -> None:
+ record = self._record(chunk_coords)
+ if current not in LEGAL_TRANSITIONS[record.state]:
+ raise ValueError(f"illegal chunk transition {record.state} -> {current}")
+ previous = record.state
+ record.state = current
+ self.events.append(ChunkEvent(chunk_coords, previous, current, reason))
+
+ def retry(self, chunk_coords: ChunkCoords) -> None:
+ record = self._record(chunk_coords)
+ if record.state is not ChunkState.FAILED:
+ raise ValueError(f"retry requires failed chunk {chunk_coords}, got {record.state}")
+ record.error = None
+ self._transition(chunk_coords, ChunkState.QUEUED, "explicit retry")
+ self._queue.append(chunk_coords)
+
+ @contextmanager
+ def request(self, required: tuple[ChunkCoords, ...]) -> Iterator[None]:
+ """Prepare every part and defer eviction until one request completes."""
+ self._prepare(required)
+ self._requests += 1
+ try:
+ yield
+ except Exception:
+ self._requests -= 1
+ raise
+ else:
+ self._requests -= 1
+ if self._requests == 0:
+ self._evict(pinned=frozenset())
+
+ def _touch(self, record: ChunkRecord) -> None:
+ self._clock += 1
+ record.last_access = self._clock
+
+ def _queue_once(self, chunk_coords: ChunkCoords) -> None:
+ record = self._record(chunk_coords)
+ if record.state in {ChunkState.QUEUED, ChunkState.LOADING, ChunkState.READY}:
+ return
+ if record.state is ChunkState.FAILED:
+ raise ValueError(f"failed chunk {chunk_coords} requires explicit retry")
+ self._transition(chunk_coords, ChunkState.QUEUED, "requested")
+ self._queue.append(chunk_coords)
+
+ def _prepare(self, required: tuple[ChunkCoords, ...]) -> None:
+ for chunk_coords in required:
+ record = self._record(chunk_coords)
+ if record.state is ChunkState.FAILED:
+ assert record.error is not None
+ raise ChunkLoadError(
+ f"chunk {chunk_coords} is failed; call retry first"
+ ) from record.error
+
+ for chunk_coords in required:
+ record = self._record(chunk_coords)
+ if record.state is ChunkState.READY:
+ self._touch(record)
+ else:
+ self._queue_once(chunk_coords)
+
+ def _ensure_ready(
+ self,
+ source: RecordingChunkSource,
+ required: tuple[ChunkCoords, ...],
+ ) -> None:
+ if self._requests == 0:
+ self._prepare(required)
+ self._drain(source, frozenset(required))
+
+ def _drain(self, source: RecordingChunkSource, required: frozenset[ChunkCoords]) -> None:
+ pending = self._queue
+ self._queue = []
+ for index, chunk_coords in enumerate(pending):
+ if chunk_coords not in required:
+ self._queue.append(chunk_coords)
+ continue
+ record = self._record(chunk_coords)
+ self._transition(chunk_coords, ChunkState.LOADING, "queue drained")
+ try:
+ record.buffer = source.read_chunk(chunk_coords)
+ except OSError as error:
+ record.buffer = None
+ record.error = error
+ self._transition(chunk_coords, ChunkState.FAILED, "source read failed")
+ self._queue.extend(pending[index + 1 :])
+ raise ChunkLoadError(f"could not load chunk {chunk_coords}") from error
+ record.error = None
+ self._transition(chunk_coords, ChunkState.READY, "source read completed")
+ self._touch(record)
+
+ def _evict(self, *, pinned: frozenset[ChunkCoords]) -> None:
+ while len(self.resident()) > self.capacity:
+ candidates = (
+ (record.last_access, chunk_coords)
+ for chunk_coords, record in self._records.items()
+ if record.state is ChunkState.READY and chunk_coords not in pinned
+ )
+ _, chunk_coords = min(candidates)
+ record = self._record(chunk_coords)
+ record.buffer = None
+ self._transition(chunk_coords, ChunkState.EVICTED, "LRU capacity")
+
+ def read_into(
+ self,
+ source: RecordingChunkSource,
+ context: ReadContext,
+ out: np.ndarray[Any, Any],
+ /,
+ ) -> None:
+ projection = context.projection
+ if projection is None:
+ raise ValueError("SystemMemoryChunkReader requires context.projection")
+ required = (projection.chunk_coords,)
+ self._ensure_ready(source, required)
+ record = self._record(projection.chunk_coords)
+ assert record.buffer is not None
+ cell_points = _domain_points(projection.chunk_transform.domain)
+ chunk_points = projection.chunk_transform.apply_many(cell_points)
+ destination_points = _domain_points(context.transform.domain)
+ _gather_and_scatter(out, record.buffer, chunk_points, destination_points)
+ self.projection_uses.append(("chunk_transform", "context.transform"))
+ if self._requests == 0:
+ self._evict(pinned=frozenset())
+
+
+class SystemMemoryChunkCache:
+ def __init__(self, source: RecordingChunkSource, *, capacity: int) -> None:
+ self.source = source
+ self.reader = SystemMemoryChunkReader(capacity=capacity)
+ self._lazy = LazyArray(source).with_reader(self.reader)
+
+ @property
+ def shape(self) -> tuple[int, ...]:
+ return self.source.shape
+
+ @property
+ def dtype(self) -> np.dtype[Any]:
+ return self.source.dtype
+
+ @property
+ def oindex(self) -> _OrthogonalIndexer:
+ return _OrthogonalIndexer(lambda key: self._read(key, orthogonal=True))
+
+ @property
+ def events(self) -> list[ChunkEvent]:
+ return self.reader.events
+
+ @property
+ def projection_uses(self) -> tuple[tuple[str, str], ...]:
+ return tuple(self.reader.projection_uses)
+
+ def state(self, chunk_coords: ChunkCoords) -> ChunkState:
+ return self.reader.state(chunk_coords)
+
+ def resident(self) -> tuple[ChunkCoords, ...]:
+ return self.reader.resident()
+
+ def retry(self, chunk_coords: ChunkCoords) -> None:
+ self.reader.retry(chunk_coords)
+
+ def __getitem__(self, key: Any) -> np.ndarray[Any, Any]:
+ return self._read(key, orthogonal=False)
+
+ def _read(self, key: Any, *, orthogonal: bool) -> np.ndarray[Any, Any]:
+ self.reader.projection_uses.clear()
+ lazy = self._lazy.lazy
+ view = lazy.oindex[key] if orthogonal else lazy[key]
+ # One prepared tuple is the request plan: pin from it, then hand the
+ # same owned parts back to LazyArray for assembly without replanning.
+ parts = tuple(view.parts())
+ required = tuple(dict.fromkeys(part.base_coords for part in parts))
+ with self.reader.request(required):
+ return np.asarray(view.result(parts=parts))
+
+
+# --8<-- [end:chunk-cache-wrapper]
+
+
+# --8<-- [start:chunk-cache-worked-example]
+image = np.arange(48).reshape(6, 8)
+source = RecordingChunkSource(image, chunks=(3, 4))
+cache = SystemMemoryChunkCache(source, capacity=2)
+
+READS_BEFORE_SELECTION = tuple(source.reads)
+INITIAL_RESULT = cache[1:5, 2]
+INITIAL_READS = tuple(source.reads)
+
+before_overlap = len(source.reads)
+OVERLAP_RESULT = cache[3:5, 2]
+OVERLAP_NEW_READS = tuple(source.reads[before_overlap:])
+
+before_eviction = len(source.reads)
+EVICTION_RESULT = cache[0:2, 5]
+EVICTION_NEW_READS = tuple(source.reads[before_eviction:])
+AFTER_EVICTION_RESIDENT = cache.resident()
+
+before_reload = len(source.reads)
+RELOAD_RESULT = cache[1:5, 2]
+RELOAD_NEW_READS = tuple(source.reads[before_reload:])
+AFTER_RELOAD_RESIDENT = cache.resident()
+
+source.failures.add((1, 1))
+failed_once = False
+try:
+ cache[3:5, 4:6]
+except ChunkLoadError:
+ failed_once = True
+assert failed_once
+FAILED_READ_COUNT = source.reads.count((1, 1))
+failed_twice = False
+try:
+ cache[3:5, 4:6]
+except ChunkLoadError:
+ failed_twice = True
+assert failed_twice
+FAILED_REPEAT_READ_COUNT = source.reads.count((1, 1))
+FAILURE_READ_COUNTS = (FAILED_READ_COUNT, FAILED_REPEAT_READ_COUNT)
+
+source.failures.remove((1, 1))
+cache.retry((1, 1))
+before_retry = len(source.reads)
+RETRY_RESULT = cache[3:5, 4:6]
+RETRY_NEW_READS = tuple(source.reads[before_retry:])
+RETRY_STATE = cache.state((1, 1)).value
+WORKED_EVENTS = tuple(cache.events)
+FAILED_TRANSITIONS = tuple(
+ event.current.value for event in WORKED_EVENTS if event.chunk_coords == (1, 1)
+)
+FAILED_EVENT_ROWS = tuple(
+ (event.previous.value, event.current.value, event.reason)
+ for event in WORKED_EVENTS
+ if event.chunk_coords == (1, 1)
+)
+# --8<-- [end:chunk-cache-worked-example]
+
+assert READS_BEFORE_SELECTION == ()
+assert INITIAL_RESULT.tolist() == [10, 18, 26, 34]
+assert INITIAL_READS == ((0, 0), (1, 0))
+assert OVERLAP_RESULT.tolist() == [26, 34]
+assert OVERLAP_NEW_READS == ()
+assert EVICTION_RESULT.tolist() == [5, 13]
+assert EVICTION_NEW_READS == ((0, 1),)
+assert AFTER_EVICTION_RESIDENT == ((0, 1), (1, 0))
+assert RELOAD_RESULT.tolist() == [10, 18, 26, 34]
+assert RELOAD_NEW_READS == ((0, 0),)
+assert AFTER_RELOAD_RESIDENT == ((0, 0), (1, 0))
+assert FAILURE_READ_COUNTS == (1, 1)
+assert RETRY_RESULT.tolist() == [[28, 29], [36, 37]]
+assert RETRY_NEW_READS == ((1, 1),)
+assert RETRY_STATE == "ready"
+assert FAILED_TRANSITIONS == (
+ "queued",
+ "loading",
+ "failed",
+ "queued",
+ "loading",
+ "ready",
+)
+assert FAILED_EVENT_ROWS == (
+ ("new", "queued", "requested"),
+ ("queued", "loading", "queue drained"),
+ ("loading", "failed", "source read failed"),
+ ("failed", "queued", "explicit retry"),
+ ("queued", "loading", "queue drained"),
+ ("loading", "ready", "source read completed"),
+)
diff --git a/packages/zarr-indexing/justfile b/packages/zarr-indexing/justfile
new file mode 100644
index 0000000000..1b7164f647
--- /dev/null
+++ b/packages/zarr-indexing/justfile
@@ -0,0 +1,67 @@
+# Development verbs for the zarr-indexing package. Recipes run with this
+# directory as the working directory regardless of where `just` is invoked.
+
+# List available recipes
+default:
+ @just --list
+
+# The chunk-resolution tests exercise this package against zarr's ChunkGrid, so
+# they need an environment that has both `zarr` and this package installed.
+# `zarr` is deliberately not a dependency of this package, and the repo is not
+# a uv workspace, so run against the repo-root environment (which provides
+# `zarr`) with this package layered in as an editable overlay — the same
+# invocation CI uses.
+# Run the test suite; extra args are passed to pytest
+test *args:
+ uv run --project ../.. --group test --with-editable . python -m pytest tests src/zarr_indexing {{ args }}
+
+# TensorStore is the oracle for the parity suites, which skip without it. It
+# ships binary wheels only, so it rides in as a run-time overlay rather than
+# joining a dependency group; if a future Python lacks a tensorstore wheel,
+# gate the CI job that calls this on the matrix version.
+# Run the tensorstore parity suites; extra args are passed to pytest
+test-tensorstore *args:
+ uv run --project ../.. --group test --with-editable . --with 'tensorstore>=0.1.84' python -m pytest tests/test_ndsel_tensorstore.py tests/test_tensorstore_parity.py {{ args }}
+
+# Lint with the same invocation CI uses. Ruff is pinned to the repo-wide
+# version (see pyproject.toml [dependency-groups] docs); bump together.
+lint:
+ uvx ruff@0.16.0 check .
+
+# Type-check the package sources, documentation Python, and their contract tests
+typecheck:
+ uv run --group test --with pyright pyright
+
+# Run everything CI runs for this package
+check: lint typecheck test test-tensorstore docs-check
+
+# Preview the changelog that the next release would generate
+changelog-draft:
+ uvx towncrier build --draft --version Unreleased
+
+# Build this package's documentation site, warnings as errors
+docs-check:
+ env DISABLE_MKDOCS_2_WARNING=true uv run --group docs mkdocs build --strict
+
+# With no argument, uses port 8000 if free, otherwise an ephemeral free port;
+# an explicitly requested port is used as-is so a conflict fails loudly.
+# Serve this package's documentation site
+docs-serve port="":
+ #!/usr/bin/env bash
+ set -euo pipefail
+ port="{{ port }}"
+ if [ -z "$port" ]; then
+ port=$(uv run --group docs python -c '
+ import socket
+ s = socket.socket()
+ try:
+ s.bind(("127.0.0.1", 8000))
+ except OSError:
+ s.close()
+ s = socket.socket()
+ s.bind(("127.0.0.1", 0))
+ print(s.getsockname()[1])
+ s.close()
+ ')
+ fi
+ exec env DISABLE_MKDOCS_2_WARNING=true uv run --group docs mkdocs serve -a "localhost:$port"
diff --git a/packages/zarr-indexing/mkdocs.yml b/packages/zarr-indexing/mkdocs.yml
new file mode 100644
index 0000000000..d97e63b150
--- /dev/null
+++ b/packages/zarr-indexing/mkdocs.yml
@@ -0,0 +1,155 @@
+site_name: zarr-indexing
+# The package lives in the zarr-python monorepo; point the header source
+# widget at the package directory rather than the repository root.
+repo_name: zarr-python/packages/zarr-indexing
+repo_url: https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-indexing
+# Absolute because mkdocs would otherwise append this to repo_url's subpath.
+edit_uri: https://github.com/zarr-developers/zarr-python/edit/main/packages/zarr-indexing/docs/
+site_description: Composable, lazy coordinate transforms for Zarr array indexing.
+site_author: Davis Bennett
+site_url: !ENV [READTHEDOCS_CANONICAL_URL, 'https://zarr-indexing.readthedocs.io/']
+docs_dir: docs
+use_directory_urls: true
+exclude_docs: |
+ snippets/*.py
+
+# --strict promotes warnings to errors, but broken link anchors and pages
+# missing from nav are only INFO by default — a strict build passed with
+# both breakages. Warn so strict actually fails on them.
+validation:
+ links:
+ anchors: warn
+ nav:
+ omitted_files: warn
+
+# Top-level rank is consistent: collections (Guide, Examples, API Reference)
+# and standalone artifacts (landing, ndsel spec, design notes, changelog).
+# Guide mirrors the docs/guide/ directory — its three pages are one
+# collection (learn / look up / integrate), pydantic's Concepts pattern at
+# small scale; navigation.indexes makes the Guide entry itself land on the
+# visual guide. No tabs: at eight content pages, hiding sections costs more
+# than it organizes.
+nav:
+ - index.md
+ - Guide:
+ - guide/index.md
+ - Indexing patterns: guide/patterns.md
+ - Integration boundaries: guide/integrations.md
+ - Examples:
+ - Lazy indexing a NumPy array: examples/lazy_indexing_numpy.md
+ - Lazy indexing with Dask: examples/lazy_indexing_dask.md
+ - System-memory chunk cache: examples/system_memory_chunk_cache.md
+ - The ndsel wire format: ndsel.md
+ - Design notes: design-notes.md
+ - API Reference:
+ - api/index.md
+ - 'zarr_indexing.transform': api/transform.md
+ - 'zarr_indexing.domain': api/domain.md
+ - 'zarr_indexing.output_map': api/output_map.md
+ - 'zarr_indexing.chunk_resolution': api/chunk_resolution.md
+ - 'zarr_indexing.grid': api/grid.md
+ - 'zarr_indexing.lazy_array': api/lazy_array.md
+ - 'zarr_indexing.reader': api/reader.md
+ - 'zarr_indexing.boundary': api/boundary.md
+ - 'zarr_indexing.json': api/json.md
+ - 'zarr_indexing.messages': api/messages.md
+ - 'zarr_indexing.errors': api/errors.md
+ - 'zarr_indexing.testing.stateful': api/testing_stateful.md
+ - 'zarr_indexing.testing.strategies': api/testing_strategies.md
+ - Changelog: https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-indexing/CHANGELOG.md
+ # This site is a Read the Docs subproject of zarr-python; give readers a way
+ # back to the parent docs, which list every companion package.
+ - 'zarr-python ↪': https://zarr.readthedocs.io/
+
+watch:
+ - src
+
+theme:
+ language: en
+ name: material
+ logo: _static/logo_bw.png
+ favicon: _static/favicon-96x96.png
+
+ palette:
+ # Light mode
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ toggle:
+ icon: material/brightness-7
+ name: Switch to dark mode
+
+ # Dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ toggle:
+ icon: material/brightness-4
+ name: Switch to light mode
+
+ font:
+ text: Roboto
+ code: Roboto Mono
+
+ features:
+ - content.code.annotate
+ - content.code.copy
+ - content.tabs.link
+ - navigation.indexes
+ - navigation.instant
+ - navigation.tracking
+ - search.suggest
+ - search.share
+
+plugins:
+ - autorefs
+ - search
+ - mkdocstrings:
+ enable_inventory: true
+ handlers:
+ python:
+ paths: [src]
+ options:
+ allow_inspection: true
+ docstring_section_style: list
+ docstring_style: numpy
+ inherited_members: true
+ line_length: 60
+ separate_signature: true
+ show_root_heading: true
+ show_signature_annotations: true
+ show_source: true
+ show_symbol_type_toc: true
+ signature_crossrefs: true
+ show_if_no_docstring: true
+ extensions:
+ - griffe_inherited_docstrings
+
+ inventories:
+ - https://docs.python.org/3/objects.inv
+ - https://numpy.org/doc/stable/objects.inv
+ - https://zarr.readthedocs.io/en/stable/objects.inv
+
+markdown_extensions:
+ - admonition
+ - attr_list
+ - def_list
+ - footnotes
+ - md_in_html
+ - pymdownx.details
+ - pymdownx.superfences
+ - toc:
+ permalink: true
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ - pymdownx.inlinehilite
+ # Content tabs (Python/JSON pairs in the pattern matrix); content.tabs.link
+ # in the theme features keeps every pair switched together.
+ - pymdownx.tabbed:
+ alternate_style: true
+ - pymdownx.snippets:
+ base_path: [docs, examples]
+ # Fail the build on an unresolvable include or missing region instead
+ # of silently rendering nothing. tests/test_doc_examples.py mirrors
+ # base_path when it verifies the include graph.
+ check_paths: true
diff --git a/packages/zarr-indexing/pyproject.toml b/packages/zarr-indexing/pyproject.toml
new file mode 100644
index 0000000000..60f69a1dbb
--- /dev/null
+++ b/packages/zarr-indexing/pyproject.toml
@@ -0,0 +1,171 @@
+[build-system]
+requires = ["hatchling>=1.29.0", "hatch-vcs"]
+build-backend = "hatchling.build"
+
+[project]
+name = "zarr-indexing"
+dynamic = ["version"]
+description = "Composable, lazy coordinate transforms for Zarr array indexing."
+readme = "README.md"
+requires-python = ">=3.12"
+license = "MIT"
+license-files = ["LICENSE.txt"]
+authors = [
+ { name = "Davis Bennett", email = "davis.v.bennett@gmail.com" },
+]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "Intended Audience :: Information Technology",
+ "Intended Audience :: Science/Research",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Topic :: Scientific/Engineering",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Typing :: Typed",
+]
+keywords = ["zarr"]
+dependencies = [
+ "numpy>=2",
+]
+
+[project.optional-dependencies]
+# `zarr_indexing.testing` — a Hypothesis state machine and selection strategies
+# for projects checking their own array against this package. Nothing else in
+# the package imports hypothesis.
+testing = ["hypothesis>=6.160.0"]
+
+[project.urls]
+Homepage = "https://github.com/zarr-developers/zarr-python"
+Source = "https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-indexing"
+Issues = "https://github.com/zarr-developers/zarr-python/issues"
+Changelog = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-indexing/CHANGELOG.md"
+Documentation = "https://zarr-indexing.readthedocs.io/"
+
+[dependency-groups]
+# The transform tests exercise chunk resolution against zarr's ChunkGrid
+# (tests/test_chunk_resolution.py) and are collected by the parent zarr-python
+# test suite, which already has zarr installed. `zarr` is intentionally NOT
+# listed here to avoid a workspace dependency cycle; run these tests from the
+# repo root (`uv run pytest packages/zarr-indexing/tests`), not in isolation.
+# `hypothesis` arrives via the `testing` extra, which is what
+# `zarr_indexing.testing` needs; the repo-root `test` group pins the exact
+# version CI runs against. Bump the two together.
+test = ["pytest", "hypothesis>=6.160.0"]
+docs = [
+ # Pins match the zarr-python docs environment in the repo-root
+ # pyproject.toml so the two sites render with the same toolchain.
+ "mkdocs-material==9.7.7",
+ "mkdocs==1.6.1",
+ "mkdocstrings==1.0.6",
+ "mkdocstrings-python==2.0.5",
+ "griffe-inherited-docstrings==1.1.3",
+ # mkdocstrings uses ruff to format rendered signatures
+ "ruff==0.15.22",
+]
+
+[tool.hatch.version]
+source = "vcs"
+tag-pattern = '^zarr_indexing-v(?P.+)$'
+# `git_describe_command` ensures we get the zarr_indexing tags instead of latest.
+# `local_scheme` strips the git commit info so the appending info is just a counter from latest tag.
+# test-pypi doesn't accept git commit info in tags, and the count should be enough to distinguish unique runs.
+raw-options = { root = "../..", git_describe_command = "git describe --dirty --tags --long --match zarr_indexing-v*", local_scheme = "no-local-version" }
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/zarr_indexing"]
+
+# An allowlist, so nothing that merely happens to sit in the package directory
+# — a scratch script, a stray notebook — can ride along in a release. The list
+# keeps an sdist self-testing: `tests/` carries the vendored ndsel conformance
+# corpus, and `tests/test_doc_examples.py` executes `docs/snippets/*.py` and
+# `examples/*/*.py`, so those are part of the suite rather than decoration.
+# `pyproject.toml`, `README.md` and `LICENSE.txt` are added by hatchling itself.
+[tool.hatch.build.targets.sdist]
+include = [
+ "/src",
+ "/tests",
+ "/docs",
+ "/examples",
+ "/mkdocs.yml",
+ "/justfile",
+ "/CHANGELOG.md",
+ "/CONTRIBUTING.md",
+]
+
+[tool.ruff]
+extend = "../../pyproject.toml"
+target-version = "py312"
+
+[tool.ruff.lint.per-file-ignores]
+# Chunk discovery and __dask_tokenize__ deliberately catch Exception: a
+# foreign source's attributes may fail arbitrarily and discovery must degrade
+# to "no information"; a token call must never raise. Configured here (not as
+# noqa comments) because the pinned pre-commit ruff and the floating CI ruff
+# disagree on whether these rules fire, and RUF100 strips the comments.
+"src/zarr_indexing/lazy_array.py" = ["BLE001", "S110"]
+
+[tool.pytest.ini_options]
+minversion = "7"
+# src is collected for its doctests: every public object's Examples section
+# executes under --doctest-modules, so the documented examples cannot rot.
+testpaths = ["tests", "src/zarr_indexing"]
+pythonpath = ["."]
+xfail_strict = true
+addopts = ["-ra", "--strict-config", "--strict-markers", "--doctest-modules"]
+doctest_optionflags = [
+ "NORMALIZE_WHITESPACE",
+ "ELLIPSIS",
+ "IGNORE_EXCEPTION_DETAIL",
+]
+filterwarnings = [
+ "error",
+]
+
+[tool.pyright]
+include = [
+ "src",
+ "docs/snippets",
+ "tests/test_doc_examples.py",
+]
+enableExperimentalFeatures = true
+typeCheckingMode = "strict"
+pythonVersion = "3.12"
+# This strict config was written for zarr-metadata's JSON/dataclass-shaped
+# code. zarr-indexing is numpy-heavy, and numpy's stubs return partially
+# unknown types (e.g. `ndarray[Unknown, Unknown]`, `dtype[Unknown]`) even for
+# fully-typed call sites, so the reportUnknown* family below cannot reasonably
+# be satisfied here. Downgraded to warnings (not silenced) rather than
+# disabled outright, and CI (which only fails the pyright job on errors, not
+# warnings) still surfaces them for visibility.
+reportUnknownVariableType = "warning"
+reportUnknownArgumentType = "warning"
+reportUnknownMemberType = "warning"
+reportUnknownParameterType = "warning"
+
+[tool.numpydoc_validation]
+checks = [
+ "GL10",
+ "SS04",
+ "PR02",
+ "PR03",
+ "PR05",
+ "PR06",
+]
+
+[tool.towncrier]
+# Fragments for this package live alongside the package source, separate
+# from the parent zarr-python `changes/` directory, so a PR touching only
+# `packages/zarr-indexing/` produces a release note for this package only.
+directory = "changes"
+filename = "CHANGELOG.md"
+package = "zarr_indexing"
+underlines = ["", "", ""]
+title_format = "## {version} ({project_date})"
+issue_format = "[#{issue}](https://github.com/zarr-developers/zarr-python/issues/{issue})"
+start_string = "\n"
diff --git a/packages/zarr-indexing/src/zarr_indexing/__init__.py b/packages/zarr-indexing/src/zarr_indexing/__init__.py
new file mode 100644
index 0000000000..9acfd28a21
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/__init__.py
@@ -0,0 +1,116 @@
+"""Composable, lazy coordinate transforms for zarr array indexing.
+
+This package implements TensorStore-inspired index transforms. The core idea:
+every indexing operation (slicing, fancy indexing, etc.) produces a coordinate
+mapping from user space to storage space. These mappings compose lazily — no
+I/O until you explicitly read or write.
+
+Key types:
+
+- `IndexDomain` — a rectangular region of integer coordinates
+- `IndexTransform` — maps input coordinates to storage coordinates
+- `ConstantMap`, `DimensionMap`, `ArrayMap` — the three ways a single
+ output dimension can depend on the input (see `output_map.py`)
+- `IndexTransform.compose` — chain two transforms into one
+
+`LazyArray` wraps a system-memory/basic-indexing source and gives it deferred
+indexing through `.lazy[...]`, yielding its reads as `Partition`s. Other
+backends use an explicit `Reader` adapter.
+
+`plan_chunks` projects a transform through a caller-selected chunk grid without
+coupling the result to a storage backend or scheduler. `selection_to_transform`
+is also exported for consumers starting with a NumPy-style selection. The
+`DimensionGridLike` Protocol describes the narrow grid surface chunk resolution
+consumes without importing zarr.
+"""
+
+from importlib.metadata import version
+
+from zarr_indexing.chunk_resolution import (
+ ChunkCoverage,
+ ChunkPlan,
+ ChunkProjection,
+ plan_chunks,
+)
+from zarr_indexing.domain import IndexDomain
+from zarr_indexing.errors import BoundsCheckError, VindexInvalidSelectionError
+from zarr_indexing.grid import (
+ ChunkGrid,
+ ChunkSpec,
+ DimensionGrid,
+ DimensionGridLike,
+ EdgeDimensionGrid,
+ FixedDimension,
+ VaryingDimension,
+ dimension_grids_from_chunks,
+)
+from zarr_indexing.json import (
+ IndexDomainJSON,
+ IndexTransformJSON,
+ OutputIndexMapJSON,
+)
+from zarr_indexing.lazy_array import LazyArray, Partition
+from zarr_indexing.messages import NdselError, normalize_ndsel, parse_ndsel
+from zarr_indexing.output_map import (
+ ArrayMap,
+ ConstantMap,
+ DimensionMap,
+ OutputIndexMap,
+ output_index_map_from_json,
+)
+from zarr_indexing.reader import (
+ BasicReader,
+ NumPyReader,
+ ReadContext,
+ Reader,
+ UnitStepReader,
+ basic_reader,
+ numpy_reader,
+ unit_step_reader,
+)
+from zarr_indexing.transform import (
+ IndexTransform,
+)
+
+__version__ = version("zarr-indexing")
+
+__all__ = [
+ "ArrayMap",
+ "BasicReader",
+ "BoundsCheckError",
+ "ChunkCoverage",
+ "ChunkGrid",
+ "ChunkPlan",
+ "ChunkProjection",
+ "ChunkSpec",
+ "ConstantMap",
+ "DimensionGrid",
+ "DimensionGridLike",
+ "DimensionMap",
+ "EdgeDimensionGrid",
+ "FixedDimension",
+ "IndexDomain",
+ "IndexDomainJSON",
+ "IndexTransform",
+ "IndexTransformJSON",
+ "LazyArray",
+ "NdselError",
+ "NumPyReader",
+ "OutputIndexMap",
+ "OutputIndexMapJSON",
+ "Partition",
+ "ReadContext",
+ "Reader",
+ "UnitStepReader",
+ "VaryingDimension",
+ "VindexInvalidSelectionError",
+ "__version__",
+ "basic_reader",
+ "dimension_grids_from_chunks",
+ "normalize_ndsel",
+ "numpy_reader",
+ "output_index_map_from_json",
+ "parse_ndsel",
+ "plan_chunks",
+ "unit_step_reader",
+]
diff --git a/packages/zarr-indexing/src/zarr_indexing/_affine.py b/packages/zarr-indexing/src/zarr_indexing/_affine.py
new file mode 100644
index 0000000000..d1846ffe2d
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/_affine.py
@@ -0,0 +1,77 @@
+"""Checked affine coordinate arithmetic."""
+
+from __future__ import annotations
+
+from typing import Any, overload
+
+import numpy as np
+import numpy.typing as npt
+
+_INTP_INFO = np.iinfo(np.intp)
+
+
+def _fits_intp(value: int) -> bool:
+ return _INTP_INFO.min <= value <= _INTP_INFO.max
+
+
+@overload
+def checked_affine(offset: int, stride: int, coordinates: int) -> int: ...
+
+
+@overload
+def checked_affine(
+ offset: int,
+ stride: int,
+ coordinates: npt.NDArray[np.integer[Any]],
+) -> npt.NDArray[np.intp]: ...
+
+
+def checked_affine(
+ offset: int,
+ stride: int,
+ coordinates: int | npt.NDArray[np.integer[Any]],
+) -> int | npt.NDArray[np.intp]:
+ """Evaluate ``offset + stride * coordinates`` without integer overflow.
+
+ Bounds are established with Python integers before coordinates are cast or
+ NumPy performs fixed-width arithmetic. The common representable case then
+ uses an ``np.intp`` fast path whose multiplication and addition were proven
+ safe; cancellation cases use exact object arithmetic.
+ """
+ offset = int(offset)
+ stride = int(stride)
+ if not isinstance(coordinates, np.ndarray):
+ mapped = offset + stride * int(coordinates)
+ if not _fits_intp(mapped):
+ raise OverflowError(f"output coordinate {mapped} is outside np.intp range")
+ return mapped
+
+ if coordinates.size == 0:
+ return np.empty(coordinates.shape, dtype=np.intp)
+
+ coordinate_min = int(np.min(coordinates))
+ coordinate_max = int(np.max(coordinates))
+ product_at_min = stride * coordinate_min
+ product_at_max = stride * coordinate_max
+ mapped_at_min = offset + product_at_min
+ mapped_at_max = offset + product_at_max
+ mapped_min = min(mapped_at_min, mapped_at_max)
+ mapped_max = max(mapped_at_min, mapped_at_max)
+ if not _fits_intp(mapped_min) or not _fits_intp(mapped_max):
+ invalid = mapped_min if not _fits_intp(mapped_min) else mapped_max
+ raise OverflowError(f"output coordinate {invalid} is outside np.intp range")
+
+ safe_fixed_width = (
+ _fits_intp(coordinate_min)
+ and _fits_intp(coordinate_max)
+ and _fits_intp(offset)
+ and _fits_intp(stride)
+ and _fits_intp(product_at_min)
+ and _fits_intp(product_at_max)
+ )
+ if safe_fixed_width:
+ intp_coordinates = coordinates.astype(np.intp, copy=False)
+ return np.asarray(offset + stride * intp_coordinates, dtype=np.intp)
+
+ exact = offset + stride * coordinates.astype(object)
+ return np.asarray(exact, dtype=np.intp)
diff --git a/packages/zarr-indexing/src/zarr_indexing/_composition.py b/packages/zarr-indexing/src/zarr_indexing/_composition.py
new file mode 100644
index 0000000000..f90ea26fa7
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/_composition.py
@@ -0,0 +1,242 @@
+"""Composition — chaining two transforms into one.
+
+`compose(outer, inner)` is the operation that makes views stack. `outer` maps
+user coordinates to intermediate coordinates, `inner` maps those intermediate
+coordinates to output coordinates, and the result maps user coordinates
+straight through — so a view of a view of an array is still a single
+`IndexTransform`, and indexing never accumulates layers to walk at read time.
+
+Composition works one output map at a time, and each case reduces to
+substituting the outer map into the inner one:
+
+- A `ConstantMap` inner map ignores its input, so it survives unchanged.
+- A `DimensionMap` inner map is affine, so composing it with an outer
+ `ConstantMap` or `DimensionMap` folds into new `offset`/`stride` values;
+ composing it with an outer `ArrayMap` leaves the index array alone and
+ rescales around it.
+- An `ArrayMap` inner map must be *evaluated* at the coordinates the outer
+ transform produces, which is the only case that touches array data.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+
+from zarr_indexing._affine import checked_affine
+from zarr_indexing.errors import BoundsCheckError
+from zarr_indexing.output_map import (
+ ArrayMap,
+ ConstantMap,
+ DimensionMap,
+ OutputIndexMap,
+ array_map_or_constant,
+)
+from zarr_indexing.transform import IndexTransform
+
+
+def compose(outer: IndexTransform, inner: IndexTransform) -> IndexTransform:
+ """Compose two IndexTransforms.
+
+ `outer` maps user coords (rank m) to intermediate coords (rank n).
+ `inner` maps intermediate coords (rank n) to output coords (rank p).
+ The result maps user coords (rank m) to output coords (rank p).
+
+ Precondition: `outer.output_rank == inner.domain.ndim`.
+
+ Examples
+ --------
+ Chained indexing — `source[2:5]`, then `[::-1]` on the result — collapses
+ to a single transform (a reversed axis keeps literal coordinates, so the
+ composed domain is `[-4, -1)`):
+
+ >>> inner = IndexTransform.from_shape((10,))[2:5]
+ >>> outer = IndexTransform.identity(inner.domain)[::-1]
+ >>> chained = compose(outer, inner)
+ >>> chained == inner[::-1]
+ True
+ >>> [chained.apply((i,)) for i in (-4, -3, -2)]
+ [(4,), (3,), (2,)]
+ >>> np.arange(10)[2:5][::-1].tolist()
+ [4, 3, 2]
+ """
+ if outer.output_rank != inner.domain.ndim:
+ raise ValueError(
+ f"outer output rank ({outer.output_rank}) must match inner input rank "
+ f"({inner.domain.ndim})"
+ )
+
+ _validate_outer_outputs(outer, inner)
+
+ result_output = [
+ _compose_single(outer, inner_map, inner.domain.inclusive_min) for inner_map in inner.output
+ ]
+
+ return IndexTransform(domain=outer.domain, output=tuple(result_output))
+
+
+def _validate_outer_outputs(outer: IndexTransform, inner: IndexTransform) -> None:
+ """Prove that every intermediate coordinate is in the inner domain.
+
+ An empty outer domain has no points, so containment is vacuously true. For
+ a nonempty domain, each map form has an exact, constant-space range proof:
+ constants are singletons, dimension maps are affine intervals, and array
+ maps need only their extrema.
+ """
+ if any(extent == 0 for extent in outer.domain.shape):
+ return
+
+ for axis, (outer_map, inner_lo, inner_hi) in enumerate(
+ zip(
+ outer.output,
+ inner.domain.inclusive_min,
+ inner.domain.exclusive_max,
+ strict=True,
+ )
+ ):
+ output_lo, output_hi = _output_bounds(outer, outer_map)
+ if output_lo < inner_lo or output_hi >= inner_hi:
+ raise BoundsCheckError(
+ f"outer output dimension {axis} produces coordinates "
+ f"[{output_lo}, {output_hi}] outside the inner input domain "
+ f"[{inner_lo}, {inner_hi})"
+ )
+
+
+def _output_bounds(outer: IndexTransform, output_map: OutputIndexMap) -> tuple[int, int]:
+ """Return the exact inclusive bounds of one output map on a nonempty domain."""
+ if isinstance(output_map, ConstantMap):
+ return output_map.offset, output_map.offset
+
+ if isinstance(output_map, DimensionMap):
+ input_lo = outer.domain.inclusive_min[output_map.input_dimension]
+ input_hi = outer.domain.exclusive_max[output_map.input_dimension]
+ first = output_map.offset + output_map.stride * input_lo
+ last = output_map.offset + output_map.stride * (input_hi - 1)
+ return min(first, last), max(first, last)
+
+ index_lo = int(output_map.index_array.min())
+ index_hi = int(output_map.index_array.max())
+ first = output_map.offset + output_map.stride * index_lo
+ last = output_map.offset + output_map.stride * index_hi
+ return min(first, last), max(first, last)
+
+
+def _compose_single(
+ outer: IndexTransform, inner_map: OutputIndexMap, inner_origin: tuple[int, ...]
+) -> OutputIndexMap:
+ """Compose a single inner output map with the full outer transform."""
+ if isinstance(inner_map, ConstantMap):
+ return ConstantMap(offset=inner_map.offset)
+
+ if isinstance(inner_map, DimensionMap):
+ return _compose_dimension(outer, inner_map)
+
+ # inner_map: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap)
+ return _compose_array(outer, inner_map, inner_origin)
+
+
+def _compose_dimension(outer: IndexTransform, inner_map: DimensionMap) -> OutputIndexMap:
+ """Compose when inner is a DimensionMap.
+
+ storage = offset_i + stride_i * intermediate[dim_i]
+ where intermediate[dim_i] = outer.output[dim_i](user_input)
+ """
+ dim_i = inner_map.input_dimension
+ offset_i = inner_map.offset
+ stride_i = inner_map.stride
+ outer_map = outer.output[dim_i]
+
+ if isinstance(outer_map, ConstantMap):
+ return ConstantMap(offset=checked_affine(offset_i, stride_i, outer_map.offset))
+
+ if isinstance(outer_map, DimensionMap):
+ return DimensionMap(
+ input_dimension=outer_map.input_dimension,
+ offset=offset_i + stride_i * outer_map.offset,
+ stride=stride_i * outer_map.stride,
+ )
+
+ # outer_map: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap)
+ # Affine post-composition leaves the index array (and hence its full
+ # input rank and dependency axes) untouched.
+ return ArrayMap(
+ index_array=outer_map.index_array,
+ offset=offset_i + stride_i * outer_map.offset,
+ stride=stride_i * outer_map.stride,
+ )
+
+
+def _dimension_positions(
+ outer: IndexTransform, outer_map: DimensionMap, inner_origin: int
+) -> np.ndarray[Any, np.dtype[np.intp]]:
+ """Build exact positional indices for an affine outer map."""
+ dimension = outer_map.input_dimension
+ extent = outer.domain.shape[dimension]
+ start = checked_affine(
+ outer_map.offset - inner_origin,
+ outer_map.stride,
+ outer.domain.inclusive_min[dimension],
+ )
+ shape = (1,) * dimension + (extent,) + (1,) * (outer.input_rank - dimension - 1)
+ if extent == 0:
+ return np.empty(shape, dtype=np.intp)
+ if extent == 1:
+ return np.full(shape, start, dtype=np.intp)
+ steps = np.arange(extent, dtype=np.intp)
+ return checked_affine(start, outer_map.stride, steps).reshape(shape)
+
+
+def _array_positions(outer_map: ArrayMap, inner_origin: int) -> np.ndarray[Any, np.dtype[np.intp]]:
+ """Build exact positional indices without fixed-width affine overflow."""
+ return checked_affine(outer_map.offset - inner_origin, outer_map.stride, outer_map.index_array)
+
+
+def _positions_for_axis(
+ outer: IndexTransform, outer_map: OutputIndexMap, inner_origin: int
+) -> int | np.ndarray[Any, np.dtype[np.intp]]:
+ """Convert one intermediate coordinate map to inner-array positions."""
+ if isinstance(outer_map, ConstantMap):
+ return outer_map.offset - inner_origin
+ if isinstance(outer_map, DimensionMap):
+ return _dimension_positions(outer, outer_map, inner_origin)
+ return _array_positions(outer_map, inner_origin)
+
+
+def _compose_array(
+ outer: IndexTransform, inner_map: ArrayMap, inner_origin: tuple[int, ...]
+) -> OutputIndexMap:
+ """Compose when inner is an ArrayMap.
+
+ storage = offset_i + stride_i * arr_i[intermediate]
+ We need to evaluate arr_i at the intermediate coordinates produced by outer.
+
+ Both domains carry their own origin, and neither is necessarily 0 — a step-1
+ slice keeps its literal bounds and a negative step produces a negative
+ origin, so non-zero origins are the ordinary case here rather than the exotic
+ one. The intermediate coordinates are read over the *outer* domain's own
+ range, and the inner array is addressed positionally from the *inner*
+ domain's origin.
+ """
+ arr_i = inner_map.index_array
+ if any(extent == 0 for extent in outer.domain.shape):
+ # The empty map is singleton on every non-empty axis: it varies over no
+ # axis at all, and the emptiness lives in the domain emitted alongside.
+ empty_shape = tuple(0 if extent == 0 else 1 for extent in outer.domain.shape)
+ return ArrayMap(
+ index_array=np.empty(empty_shape, dtype=arr_i.dtype),
+ offset=inner_map.offset,
+ stride=inner_map.stride,
+ )
+
+ positions = tuple(
+ 0 if size == 1 else _positions_for_axis(outer, outer_map, origin)
+ for outer_map, origin, size in zip(outer.output, inner_origin, arr_i.shape, strict=True)
+ )
+ # A gather narrowed to one coordinate — scalar or all-singleton — is the
+ # ConstantMap it equals; `array_map_or_constant` normalizes both.
+ gathered = np.asarray(arr_i[positions])
+ if gathered.ndim == 0:
+ return ConstantMap(offset=checked_affine(inner_map.offset, inner_map.stride, int(gathered)))
+ return array_map_or_constant(gathered, offset=inner_map.offset, stride=inner_map.stride)
diff --git a/packages/zarr-indexing/src/zarr_indexing/_selector.py b/packages/zarr-indexing/src/zarr_indexing/_selector.py
new file mode 100644
index 0000000000..3a632ec886
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/_selector.py
@@ -0,0 +1,41 @@
+"""Internal scalar-selector coercion shared by indexing dialects."""
+
+from __future__ import annotations
+
+import operator
+from typing import Any, SupportsIndex, cast
+
+import numpy as np
+
+
+def is_bool_scalar(value: Any) -> bool:
+ """Return whether ``value`` is a Python or NumPy boolean scalar."""
+ return isinstance(value, (bool, np.bool_))
+
+
+def as_scalar_index(value: Any) -> int | None:
+ """Return a non-boolean scalar selector as an exact Python integer.
+
+ Selector coercion follows Python's ``__index__`` protocol, rather than
+ accepting only the concrete integer classes we happen to know about. In
+ particular, ``operator.index`` rejects lossy ``__int__``-only objects and
+ validates that ``__index__`` really returned an integer.
+ """
+ if is_bool_scalar(value):
+ return None
+ # ndarray defines ``__index__`` for its scalar-integer case, but the
+ # attribute also makes non-scalar and non-integer arrays look like
+ # ``SupportsIndex`` at runtime. Those are array selectors, not malformed
+ # scalar selectors, and must continue through array dtype validation.
+ if isinstance(value, np.ndarray):
+ array = cast("np.ndarray[Any, np.dtype[Any]]", value)
+ if array.ndim != 0 or array.dtype.kind not in "iu":
+ return None
+ if not isinstance(value, SupportsIndex):
+ return None
+ return operator.index(cast(SupportsIndex, value))
+
+
+def require_index(value: Any) -> int:
+ """Coerce a required slice component through ``__index__``."""
+ return operator.index(value)
diff --git a/packages/zarr-indexing/src/zarr_indexing/_wire.py b/packages/zarr-indexing/src/zarr_indexing/_wire.py
new file mode 100644
index 0000000000..c3e6fd82b6
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/_wire.py
@@ -0,0 +1,119 @@
+"""Shared lowering rules between the canonical ndsel wire form and the engine.
+
+Package-private: the types that serialize themselves (`IndexDomain`,
+`IndexTransform`, the output map kinds) all need these, so they cannot live in
+any one of them, and they are not API. The three engine constraints named in
+[`zarr_indexing.json`][zarr_indexing.json] — finite bounds, implicit bounds
+lowering by value, integer `index_array` content — are enforced here.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+
+from zarr_indexing.messages import NdselError
+
+if TYPE_CHECKING:
+ from zarr_indexing.domain import IndexDomain
+ from zarr_indexing.json import BoundJSON
+
+
+def lower_bound(bound: BoundJSON, where: str) -> int:
+ """Lower a canonical bound to a finite integer, rejecting infinities.
+
+ Reached only with a bound the message layer has already validated as an
+ `index-value`, so the one thing left to rule out is a sentinel: an
+ `IndexDomain` addresses a finite array.
+ """
+ value = bound[0] if isinstance(bound, list) else bound
+ if value == "-inf" or value == "+inf":
+ raise NdselError(
+ "invalid_json",
+ f"{where} is infinite ({value!r}); an IndexDomain addresses a finite "
+ f"array and cannot lower an infinite bound",
+ )
+ return int(value)
+
+
+def lower_index_array(raw: Any, where: str) -> np.ndarray[Any, np.dtype[np.intp]]:
+ """Lower a canonical `index_array` to `intp`, rejecting non-integer content.
+
+ The message layer carries `index_array` verbatim — the spec defers its shape
+ and type to the engine — so this is where the content is checked. An index
+ array names output coordinates, and nothing but an integer names one: converting
+ `[0.9, 1.9]` would silently read cells 0 and 1, and `[true, false]` cells 1
+ and 0. Strings raise here rather than leaking NumPy's own conversion error.
+ """
+ if not isinstance(raw, list):
+ # A bare integer would become a rank-0 array and then be widened into a
+ # length-1 map, so a document that names no cells would select one.
+ raise NdselError(
+ "invalid_json",
+ f"{where} must be an array of integers, got {raw!r}",
+ )
+ try:
+ arr = np.asarray(raw)
+ except (TypeError, ValueError) as exc:
+ raise NdselError("invalid_json", f"{where} is not an array: {exc}") from exc
+ if arr.size == 0 and arr.dtype.kind == "f":
+ # An empty JSON list carries no element type and NumPy defaults it to
+ # float64. An empty selection is legal, so take it as an empty index array.
+ return np.zeros(arr.shape, dtype=np.intp)
+ if arr.dtype.kind not in "iu":
+ raise NdselError(
+ "invalid_json",
+ f"{where} must hold integers, got an array of {arr.dtype.name}; an "
+ f"index array names output coordinates, which floats, booleans and "
+ f"strings do not",
+ )
+ return np.asarray(arr, dtype=np.intp)
+
+
+def lower_labels(labels: list[str]) -> tuple[str, ...] | None:
+ """All-empty labels collapse to `None` so a label-free domain round-trips."""
+ return None if all(label == "" for label in labels) else tuple(labels)
+
+
+def emit_labels(labels: tuple[str, ...] | None, rank: int) -> list[str]:
+ """Emit canonical labels: `[""]*rank` when the domain is unlabeled."""
+ return [""] * rank if labels is None else list(labels)
+
+
+def full_rank_index_array(
+ arr: np.ndarray[Any, np.dtype[np.intp]],
+ domain: IndexDomain,
+ where: str,
+) -> np.ndarray[Any, np.dtype[np.intp]]:
+ """Give an incoming `index_array` the input rank the engine requires.
+
+ ndsel leaves index-array rank unvalidated, so a conformant producer may send
+ an array of lower rank that broadcasts against the domain. A non-empty one
+ is aligned to the *trailing* input dimensions, which is how NumPy broadcasts
+ and how a producer omitting leading singletons means it to be read.
+
+ An empty array is a different matter: `[]` is the only spelling of every
+ empty shape once the leading axis is the zero-length one, so the axis it
+ varies over cannot be read off it. It is recovered from the domain, which
+ can only be empty on the axis in question — and rejected when the domain
+ leaves that ambiguous. This package never emits such a document (an empty
+ map is degenerate and collapses to a constant, as TensorStore's does), so
+ this path exists for external producers alone.
+ """
+ if arr.size == 0 and arr.ndim != domain.ndim:
+ empty_axes = [k for k, extent in enumerate(domain.shape) if extent == 0]
+ if len(empty_axes) != 1:
+ raise NdselError(
+ "invalid_json",
+ f"{where}.index_array is empty, but the input domain has "
+ f"{len(empty_axes)} zero-length dimensions, so the axis it varies "
+ f"over cannot be recovered",
+ )
+ shape = [1] * domain.ndim
+ shape[empty_axes[0]] = 0
+ return arr.reshape(tuple(shape))
+
+ if arr.ndim < domain.ndim:
+ return arr.reshape((1,) * (domain.ndim - arr.ndim) + arr.shape)
+ return arr
diff --git a/packages/zarr-indexing/src/zarr_indexing/boundary.py b/packages/zarr-indexing/src/zarr_indexing/boundary.py
new file mode 100644
index 0000000000..e7f1b1c4bd
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/boundary.py
@@ -0,0 +1,374 @@
+"""The positional (NumPy) selection dialect, lowered onto the transform algebra.
+
+The transform algebra uses **literal domain coordinates**: an index is a point
+in the view's own coordinate system, which after `view = arr[10:50]` runs from
+10 to 49, and a negative index is a negative coordinate rather than an offset
+from the end (TensorStore's convention — see `zarr_indexing.transform`).
+
+NumPy uses **positions**: index 0 always means the first element of the object
+being indexed, and `-1` means the last. This module translates between the two.
+It validates a selection against the view's shape with NumPy semantics, then
+shifts every coordinate by the domain's origin so the transform layer sees
+literal coordinates.
+
+Note
+----
+`zarr.Array` currently carries its own copy of this normalization, tuned to a
+different boundary contract (`Array.lazy[...]` deliberately exposes the literal
+dialect, so a view's coordinates keep their meaning across composition). This
+module is the generic, zarr-free version used by `LazyArray`; consolidating
+zarr's copy onto it is left to a follow-up.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, Literal
+
+import numpy as np
+
+from zarr_indexing._selector import as_scalar_index, is_bool_scalar, require_index
+
+if TYPE_CHECKING:
+ from zarr_indexing.domain import IndexDomain
+
+SelectionMode = Literal["basic", "orthogonal", "vectorized"]
+
+
+def _as_index_array(sel: Any) -> np.ndarray[Any, np.dtype[Any]] | None:
+ """Return `sel` as an ndarray if it is array-like, else None."""
+ if isinstance(sel, np.ndarray):
+ return sel
+ if isinstance(sel, (list, tuple)):
+ arr = np.asarray(sel)
+ if arr.size == 0 and arr.dtype.kind == "f":
+ # An empty Python list carries no element type and NumPy defaults it
+ # to float64. Selecting nothing is legal — NumPy takes `a[np.ix_([])]`
+ # — so read it as the empty integer selection it spells, rather than
+ # rejecting it for a dtype it never had a chance to have.
+ return np.zeros(arr.shape, dtype=np.intp)
+ if arr.dtype.kind in "biu":
+ return arr
+ raise IndexError(
+ f"arrays used as indices must be of integer or boolean type; got dtype {arr.dtype}"
+ )
+ return None
+
+
+def _axes_consumed(sel: Any, mode: SelectionMode) -> int:
+ """How many axes of the view a single selection entry consumes."""
+ if sel is None:
+ return 0
+ arr = _as_index_array(sel)
+ # A multidimensional boolean mask consumes one axis per mask dimension.
+ # Orthogonal indexing is per-axis by construction, so a mask there is 1-D
+ # and consumes exactly one axis.
+ if mode == "vectorized" and arr is not None and arr.dtype == np.bool_:
+ return arr.ndim
+ return 1
+
+
+def _normalize_int(value: int, size: int, axis: int) -> int:
+ """Bounds-check a positional integer index, wrapping negatives NumPy-style."""
+ idx = value
+ if idx < 0:
+ idx += size
+ if idx < 0 or idx >= size:
+ raise IndexError(f"index {value} is out of bounds for axis {axis} with size {size}")
+ return idx
+
+
+def _normalize_slice(sel: slice, size: int, axis: int) -> tuple[int, int, int]:
+ """Resolve a positional slice to `(start, stop, step)`, either direction.
+
+ `slice.indices` already applies NumPy's rules — negative bounds count from
+ the end, out-of-range bounds clamp, and a reversed slice runs downward with
+ `stop` one *below* the last selected position. The one thing it does not do
+ is canonicalize an empty result: it can hand back a stop on the far side of
+ the start (`5:2` going up, `2:5` going down), which the transform layer
+ reads as a direction error rather than an empty selection. Collapsing it to
+ `stop == start` keeps NumPy's "empty, not an error" answer.
+ """
+ start_bound = None if sel.start is None else require_index(sel.start)
+ stop_bound = None if sel.stop is None else require_index(sel.stop)
+ step = 1 if sel.step is None else require_index(sel.step)
+ if step == 0:
+ raise ValueError(f"slice step cannot be zero (axis {axis})") # ValueError: NumPy parity
+ start, stop, step = slice(start_bound, stop_bound, step).indices(size)
+ stop = max(stop, start) if step > 0 else min(stop, start)
+ return start, stop, step
+
+
+def _normalize_int_array(
+ arr: np.ndarray[Any, np.dtype[Any]], size: int, axis: int
+) -> np.ndarray[Any, np.dtype[np.intp]]:
+ """Bounds-check a positional integer index array, wrapping negatives."""
+ if arr.dtype.kind not in "iu":
+ raise IndexError(
+ f"arrays used as indices must be of integer or boolean type; got dtype {arr.dtype}"
+ )
+ # Cast before wrapping: an unsigned array cannot represent the intermediate
+ # negative values, and `intp` covers every index NumPy can address.
+ out = arr.astype(np.intp, copy=True)
+ if out.size > 0:
+ negative = out < 0
+ if bool(negative.any()):
+ out = np.where(negative, out + size, out)
+ lo, hi = int(out.min()), int(out.max())
+ if lo < 0 or hi >= size:
+ bad = lo if lo < 0 else hi
+ raise IndexError(f"index {bad} is out of bounds for axis {axis} with size {size}")
+ return out
+
+
+def _expanded_axis_walk(entries: tuple[Any, ...], ndim: int, mode: SelectionMode) -> list[int]:
+ """The starting axis each entry addresses, with an ellipsis expanded.
+
+ The returned list has one entry per element of `entries`; the value for an
+ `Ellipsis` (or a `newaxis`) is the axis it starts at, which is also the axis
+ the following entry resumes from once the skipped axes are accounted for.
+ """
+ for sel in entries:
+ if is_bool_scalar(sel):
+ raise IndexError(
+ "boolean scalars are not valid indices; use a boolean array "
+ "matching the shape of the axes it selects"
+ )
+ if sum(1 for sel in entries if sel is Ellipsis) > 1:
+ raise IndexError("an index can only have a single ellipsis ('...')")
+ consumed = sum(_axes_consumed(sel, mode) for sel in entries if sel is not Ellipsis)
+ if consumed > ndim:
+ raise IndexError(
+ f"too many indices for array: array has {ndim} dimensions, but {consumed} were indexed"
+ )
+
+ axes: list[int] = []
+ axis = 0
+ for sel in entries:
+ axes.append(axis)
+ axis += (ndim - consumed) if sel is Ellipsis else _axes_consumed(sel, mode)
+ return axes
+
+
+def validate_advanced_selection(
+ selection: Any,
+ domain: IndexDomain,
+ mode: Literal["orthogonal", "vectorized"],
+) -> None:
+ """Validate advanced-index selector dtypes and boolean mask extents.
+
+ This is the validation shared by positional callers such as `LazyArray`
+ and direct `IndexTransform.oindex` / `.vindex` callers. It deliberately
+ does not normalize coordinates: direct transforms use literal coordinates,
+ whereas positional callers shift and wrap them separately.
+ """
+ entries: tuple[Any, ...] = selection if isinstance(selection, tuple) else (selection,)
+ axes = _expanded_axis_walk(entries, domain.ndim, mode)
+
+ for sel, axis in zip(entries, axes, strict=True):
+ arr = _as_index_array(sel)
+ if arr is None:
+ continue
+ if arr.dtype == np.bool_:
+ n_axes = _axes_consumed(sel, mode)
+ expected = domain.shape[axis : axis + n_axes]
+ if arr.shape != tuple(expected):
+ extent = (
+ f"dimension {expected[0]}"
+ if len(expected) == 1
+ else f"dimensions {tuple(expected)}"
+ )
+ raise IndexError(
+ f"boolean index has shape {arr.shape} but {extent} has shape {tuple(expected)}"
+ )
+ elif arr.dtype.kind not in "iu":
+ raise IndexError(
+ f"arrays used as indices must be of integer or boolean type; got dtype {arr.dtype}"
+ )
+
+
+def split_scalar_axes(
+ selection: Any,
+ domain: IndexDomain,
+ mode: SelectionMode,
+) -> tuple[tuple[Any, ...] | None, Any]:
+ """Peel scalar integer indices out of a fancy selection.
+
+ A scalar integer drops its axis, and neither the orthogonal nor the
+ vectorized path of the transform algebra models that — both widen a scalar
+ into a length-1 index array, which keeps the axis — so the scalars are split
+ off here and applied as a separate basic step first.
+
+ Applying them *first* is this package's rule, not NumPy's. NumPy groups a
+ scalar with the advanced indices for the purpose of placing the broadcast
+ result, so the two disagree when a scalar and an index array are separated:
+ `a[0, ..., [1, 2]]` has shape `(2, 3)` for a `(2, 3, 4)` array, where
+ `a[0][..., [1, 2]]` has shape `(3, 2)`. The earlier claim here that they
+ always agree rested on `a[0, [1, 2], :]`, where the indices are adjacent and
+ they happen to. Scalar-first is the documented dialect (see the `lazy_array`
+ module docstring) — the divergence is deliberate, and this note exists so
+ that the correct end is not "fixed" later.
+
+ Parameters
+ ----------
+ selection
+ A positional orthogonal or vectorized selection.
+ domain
+ The domain of the view being indexed.
+ mode
+ `"orthogonal"` or `"vectorized"`; controls how many axes each entry
+ covers, which decides where the scalars sit.
+
+ Returns
+ -------
+ tuple[tuple[Any, ...] | None, Any]
+ `(basic_selection, remaining_selection)`. `basic_selection` is a
+ full-rank basic selection in **literal** domain coordinates that drops
+ the scalar axes, or `None` when the selection has no scalar entries (in
+ which case `remaining_selection` is `selection` unchanged).
+
+ Raises
+ ------
+ IndexError
+ If a boolean scalar is used as an index, an index is out of bounds, or
+ too many indices are supplied.
+ """
+ entries = selection if isinstance(selection, tuple) else (selection,)
+ axes = _expanded_axis_walk(entries, domain.ndim, mode)
+
+ scalar_axes: dict[int, int] = {}
+ remaining: list[Any] = []
+ for sel, axis in zip(entries, axes, strict=True):
+ scalar = as_scalar_index(sel)
+ if scalar is not None:
+ scalar_axes[axis] = _normalize_int(scalar, domain.shape[axis], axis)
+ else:
+ remaining.append(sel)
+
+ if len(scalar_axes) == 0:
+ return None, selection
+
+ basic: list[Any] = []
+ for axis in range(domain.ndim):
+ lo = domain.inclusive_min[axis]
+ if axis in scalar_axes:
+ basic.append(lo + scalar_axes[axis])
+ else:
+ basic.append(slice(lo, domain.exclusive_max[axis]))
+ return tuple(basic), tuple(remaining)
+
+
+def normalize_positional_selection(
+ selection: Any,
+ domain: IndexDomain,
+ mode: SelectionMode,
+) -> Any:
+ """Translate a positional (NumPy-dialect) selection into literal coordinates.
+
+ Positions are zero-based offsets into the current view; negatives wrap
+ from the end. The returned selection addresses the same cells in the
+ literal coordinate system `domain` uses, ready for
+ `zarr_indexing.transform.selection_to_transform`.
+
+ Parameters
+ ----------
+ selection
+ A NumPy-style selection: integers, slices, `Ellipsis`, integer arrays or
+ lists, or boolean arrays.
+ domain
+ The domain of the view being indexed. Its shape defines the positional
+ bounds and its origin the coordinate shift.
+ mode
+ Which selection dialect the entries follow: `"basic"` (integers and
+ slices), `"orthogonal"` (per-axis arrays, outer product), or
+ `"vectorized"` (correlated coordinate arrays or a single mask).
+
+ Returns
+ -------
+ tuple[Any, ...]
+ The selection with every coordinate expressed in literal domain
+ coordinates.
+
+ Raises
+ ------
+ IndexError
+ If a boolean scalar is used as an index, a boolean mask does not match
+ the shape of the axes it covers, an index is out of bounds, or too many
+ indices are supplied.
+ """
+ entries = selection if isinstance(selection, tuple) else (selection,)
+ shape = domain.shape
+ origin = domain.inclusive_min
+ ndim = domain.ndim
+
+ if mode in ("orthogonal", "vectorized"):
+ validate_advanced_selection(selection, domain, mode)
+ else:
+ for sel in entries:
+ if is_bool_scalar(sel):
+ raise IndexError(
+ "boolean scalars are not valid indices; use a boolean array "
+ "matching the shape of the axes it selects"
+ )
+
+ n_ellipsis = sum(1 for sel in entries if sel is Ellipsis)
+ if n_ellipsis > 1:
+ raise IndexError("an index can only have a single ellipsis ('...')")
+ consumed = sum(_axes_consumed(sel, mode) for sel in entries if sel is not Ellipsis)
+ if consumed > ndim:
+ raise IndexError(
+ f"too many indices for array: array has {ndim} dimensions, but {consumed} were indexed"
+ )
+
+ result: list[Any] = []
+ axis = 0
+ for sel in entries:
+ if sel is Ellipsis:
+ # Passed through rather than expanded: every mode of
+ # `selection_to_transform` expands an ellipsis (and pads short
+ # selections) to whole-axis slices itself, and `vectorized` mode
+ # rejects an explicit slice, so expanding here would turn a legal
+ # partial coordinate selection into an error.
+ result.append(Ellipsis)
+ axis += ndim - consumed
+ continue
+ if sel is None:
+ # newaxis: no axis of the view is consumed, and there is no
+ # coordinate to shift. The transform layer decides whether the mode
+ # accepts it.
+ result.append(None)
+ continue
+
+ arr = _as_index_array(sel)
+ if arr is not None and arr.dtype == np.bool_:
+ n_axes = _axes_consumed(sel, mode)
+ expected = shape[axis : axis + n_axes]
+ if arr.shape != tuple(expected):
+ raise IndexError(
+ f"boolean index has shape {arr.shape} but the axes it "
+ f"covers have shape {tuple(expected)}"
+ )
+ for offset, positions in enumerate(np.nonzero(arr)):
+ result.append(positions.astype(np.intp) + origin[axis + offset])
+ axis += n_axes
+ continue
+
+ if axis >= ndim:
+ raise IndexError(
+ f"too many indices for array: array has {ndim} dimensions, "
+ f"but {consumed} were indexed"
+ )
+ size = shape[axis]
+ if arr is not None:
+ result.append(_normalize_int_array(arr, size, axis) + origin[axis])
+ elif isinstance(sel, slice):
+ start, stop, step = _normalize_slice(sel, size, axis)
+ result.append(slice(start + origin[axis], stop + origin[axis], step))
+ elif (scalar := as_scalar_index(sel)) is not None:
+ result.append(_normalize_int(scalar, size, axis) + origin[axis])
+ else:
+ raise IndexError(f"unsupported selection type: {type(sel)!r}")
+ axis += 1
+
+ # Axes the selection did not mention are left to `selection_to_transform`,
+ # which pads them with whole-axis slices in every mode.
+ return tuple(result)
diff --git a/packages/zarr-indexing/src/zarr_indexing/chunk_resolution.py b/packages/zarr-indexing/src/zarr_indexing/chunk_resolution.py
new file mode 100644
index 0000000000..af148685a0
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/chunk_resolution.py
@@ -0,0 +1,595 @@
+"""Chunk resolution — mapping transforms to chunk-level I/O.
+
+Given an `IndexTransform` (which coordinates a user wants to access) and a
+`ChunkGrid` (how storage is divided into chunks), chunk resolution answers:
+
+ For each chunk, which storage coordinates does this transform touch,
+ and where do those values land in the output buffer?
+
+The algorithm is:
+
+1. **Enumerate candidate chunks** — determine which chunks could possibly
+ be touched by the transform's output coordinate ranges.
+
+2. **Intersect** — for each candidate chunk, call
+ `transform.intersect(chunk_domain)` to restrict the transform to
+ coordinates within that chunk. If the intersection is empty, skip it.
+
+3. **Translate** — shift the restricted transform to chunk-local coordinates
+ via `transform.translate(-chunk_origin)`.
+
+4. **Project** — pair the chunk-local storage transform with a transform back
+ to the request's cells. Both use the same compact, zero-origin domain.
+
+Sorted one-dimensional correlated array maps can be partitioned directly
+because every touched chunk owns a contiguous slice of the index array. That
+case bypasses candidate enumeration and repeated intersection.
+
+The public result is a lazy, reusable `ChunkPlan`. Each `ChunkProjection` is
+source-independent: it identifies the chunk and expresses both sides of the
+gather without assuming NumPy selectors, a codec pipeline, or an execution
+scheduler.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Literal
+
+import numpy as np
+
+from zarr_indexing._affine import checked_affine
+from zarr_indexing.domain import IndexDomain
+from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap
+from zarr_indexing.transform import (
+ IndexTransform,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Iterator, Sequence
+
+ from zarr_indexing.grid import DimensionGridLike
+
+_OutIndices = (
+ dict[int, np.ndarray[Any, np.dtype[np.intp]]] | np.ndarray[Any, np.dtype[np.intp]] | None
+)
+
+_ChunkTransformResult = tuple[
+ tuple[int, ...],
+ IndexTransform,
+ _OutIndices,
+]
+
+type ChunkCoverage = Literal["full", "partial", "unknown"]
+
+
+def _data_size(dim_grid: DimensionGridLike, chunk_ix: int) -> int:
+ """Return a chunk's data extent, falling back for narrow-protocol grids."""
+ data_size = getattr(dim_grid, "data_size", None)
+ if data_size is None:
+ return dim_grid.chunk_size(chunk_ix)
+ return int(data_size(chunk_ix))
+
+
+@dataclass(frozen=True, slots=True)
+class ChunkProjection:
+ """One source-independent projection of a request through a chunk.
+
+ Both transforms share a synthetic input domain. ``chunk_transform`` maps
+ that domain to chunk-local storage coordinates; ``cell_transform`` maps it
+ to the original request domain.
+
+ Attributes
+ ----------
+ chunk_coords
+ Coordinates of the selected cell in the caller's grid.
+ chunk_domain
+ Bounds of that grid cell in global storage coordinates.
+ chunk_transform
+ Mapping from the shared synthetic domain to chunk-local storage.
+ cell_transform
+ Mapping from the shared synthetic domain to request coordinates.
+ coverage
+ Whether the request is proven to cover the whole grid cell exactly
+ once. Fancy selections are conservatively ``"unknown"``.
+
+ Examples
+ --------
+ Row 1 of a `(3, 4)` array with `(2, 2)` chunks touches only part of the
+ first chunk, whose domain spans rows `[0, 2)` and columns `[0, 2)`:
+
+ >>> from zarr_indexing import IndexTransform
+ >>> from zarr_indexing.grid import dimension_grids_from_chunks
+ >>> grids = dimension_grids_from_chunks((2, 2), shape=(3, 4))
+ >>> plan = plan_chunks(IndexTransform.from_shape((3, 4))[1, :], grids)
+ >>> first = next(iter(plan))
+ >>> first.chunk_coords
+ (0, 0)
+ >>> first.chunk_domain.shape
+ (2, 2)
+ >>> first.coverage
+ 'partial'
+ """
+
+ chunk_coords: tuple[int, ...]
+ chunk_domain: IndexDomain
+ chunk_transform: IndexTransform
+ cell_transform: IndexTransform
+ coverage: ChunkCoverage
+
+ def __post_init__(self) -> None:
+ if self.chunk_transform.domain != self.cell_transform.domain:
+ raise ValueError(
+ "chunk_transform and cell_transform must share an input domain; "
+ f"got {self.chunk_transform.domain!r} and {self.cell_transform.domain!r}"
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class ChunkPlan:
+ """A reusable, lazy partition of an index transform over a chunk grid.
+
+ Construct plans with `plan_chunks`; iterating either the plan or
+ `projections()` performs a fresh chunk walk.
+
+ Examples
+ --------
+ Row 1 of a `(3, 4)` array with `(2, 2)` chunks crosses two chunks, and
+ the plan can be walked again after it is exhausted:
+
+ >>> from zarr_indexing import IndexTransform
+ >>> from zarr_indexing.grid import dimension_grids_from_chunks
+ >>> grids = dimension_grids_from_chunks((2, 2), shape=(3, 4))
+ >>> plan = plan_chunks(IndexTransform.from_shape((3, 4))[1, :], grids)
+ >>> [p.chunk_coords for p in plan]
+ [(0, 0), (0, 1)]
+ >>> [p.chunk_coords for p in plan.projections()]
+ [(0, 0), (0, 1)]
+ """
+
+ transform: IndexTransform
+ """The composed request this plan partitions."""
+
+ dimension_grids: tuple[DimensionGridLike, ...]
+ """One grid per storage dimension, defining the chunk layout the plan walks."""
+
+ def projections(self) -> Iterator[ChunkProjection]:
+ """Return a fresh iterator over the chunks touched by this plan."""
+ return _iter_chunk_projections(self.transform, self.dimension_grids)
+
+ def __iter__(self) -> Iterator[ChunkProjection]:
+ """Equivalent to `projections()`: each iteration performs a fresh chunk walk."""
+ return self.projections()
+
+
+def plan_chunks(
+ transform: IndexTransform,
+ dimension_grids: Sequence[DimensionGridLike],
+) -> ChunkPlan:
+ """Plan a transform against a caller-selected chunk grid.
+
+ Parameters
+ ----------
+ transform
+ Mapping from the request domain to storage coordinates.
+ dimension_grids
+ One storage grid per transform output dimension.
+
+ Returns
+ -------
+ ChunkPlan
+ A reusable plan whose projections are computed lazily.
+
+ Examples
+ --------
+ Row 1 of a `(3, 4)` array with `(2, 2)` chunks touches the two chunks in
+ the top grid row, each contributing a `(2, 2)` chunk domain:
+
+ >>> from zarr_indexing import IndexTransform
+ >>> from zarr_indexing.grid import dimension_grids_from_chunks
+ >>> grids = dimension_grids_from_chunks((2, 2), shape=(3, 4))
+ >>> plan = plan_chunks(IndexTransform.from_shape((3, 4))[1, :], grids)
+ >>> [p.chunk_coords for p in plan]
+ [(0, 0), (0, 1)]
+ >>> [p.chunk_domain.shape for p in plan]
+ [(2, 2), (2, 2)]
+ """
+ grids = tuple(dimension_grids)
+ if len(grids) != transform.output_rank:
+ raise ValueError(
+ "dimension_grids must have one entry per transform output dimension; "
+ f"got {len(grids)} grids for output rank {transform.output_rank}"
+ )
+ return ChunkPlan(transform=transform, dimension_grids=grids)
+
+
+def _one_dimensional_array_map(
+ transform: IndexTransform,
+) -> tuple[ArrayMap, np.ndarray[Any, np.dtype[np.intp]]] | None:
+ """Return a nonempty 1-D single-ArrayMap transform's map and storage coords.
+
+ A one-dimensional array selection has no cross-dimensional correlation to
+ preserve — the orthogonal and vectorized flavors coincide there — so the
+ sorted fast path applies to either spelling. The computed storage
+ coordinates are also reused by general resolution when they are unsorted.
+ """
+ if transform.input_rank != 1 or transform.output_rank != 1:
+ return None
+
+ m = transform.output[0]
+ if not isinstance(m, ArrayMap) or m.index_array.ndim != 1 or m.index_array.size == 0:
+ return None
+
+ return m, checked_affine(m.offset, m.stride, m.index_array)
+
+
+def _iter_sorted_1d_array_map(
+ m: ArrayMap,
+ storage: np.ndarray[Any, np.dtype[np.intp]],
+ dim_grid: DimensionGridLike,
+) -> Iterator[_ChunkTransformResult]:
+ """Resolve a sorted 1-D ArrayMap one touched chunk at a time."""
+ start = 0
+ while start < storage.size:
+ chunk = dim_grid.index_to_chunk(int(storage[start]))
+ chunk_start = dim_grid.chunk_offset(chunk)
+ chunk_stop = chunk_start + _data_size(dim_grid, chunk)
+ stop = int(np.searchsorted(storage, chunk_stop, side="left"))
+
+ restricted = IndexTransform(
+ domain=IndexDomain(inclusive_min=(0,), exclusive_max=(stop - start,)),
+ output=(
+ ArrayMap(
+ index_array=m.index_array[start:stop],
+ offset=m.offset,
+ stride=m.stride,
+ ),
+ ),
+ )
+ local = restricted.translate((-chunk_start,))
+ surviving = np.arange(start, stop, dtype=np.intp)
+
+ yield (chunk,), local, surviving
+ start = stop
+
+
+def _iter_chunk_transform_results(
+ transform: IndexTransform,
+ dim_grids: Sequence[DimensionGridLike],
+) -> Iterator[_ChunkTransformResult]:
+ """Resolve a transform into private intersection bookkeeping.
+
+ The survivor arrays are an implementation detail immediately converted to
+ a public `cell_transform` by `_iter_chunk_projections`.
+ """
+
+ if any(size == 0 for size in transform.domain.shape):
+ # An empty view touches no chunk. Checked on the domain rather than on
+ # the index arrays: an axis of genuine extent 1 is stored as a broadcast
+ # singleton, so a slice that empties the domain does not shrink the
+ # array, and the emptiness shows only here.
+ return
+
+ array_map_1d = _one_dimensional_array_map(transform)
+ if array_map_1d is not None:
+ sorted_map, storage = array_map_1d
+ if storage[0] <= storage[-1] and bool(np.all(storage[1:] >= storage[:-1])):
+ dim_grid = dim_grids[0]
+ first_chunk = dim_grid.index_to_chunk(int(storage[0]))
+ if dim_grid.chunk_size(first_chunk) > 0:
+ yield from _iter_sorted_1d_array_map(sorted_map, storage, dim_grid)
+ return
+
+ # Enumerate candidate chunks via the cartesian product of per-slot candidate
+ # chunk ids, then for each candidate intersect the transform with the chunk
+ # domain (`transform.intersect` handles orthogonal and vectorized cases
+ # alike, filtering out combinations it does not actually touch).
+ #
+ # A slot covers one or more output dimensions and contributes exactly the
+ # chunk-coordinate tuples those dimensions can touch:
+ #
+ # - `ConstantMap`/`DimensionMap` dims each form their own slot with a
+ # contiguous range — a single chunk for a constant, and the span between
+ # the first and last chunk for a slice. These are already tight (or
+ # nearly so).
+ # - Orthogonal `ArrayMap` (fancy) dims each form their own slot with only
+ # the *distinct* chunk ids the index array actually lands in
+ # (`np.unique`), never the dense `range(min_chunk, max_chunk + 1)`
+ # between them. A sparse fancy selection (e.g. two far-apart coordinates)
+ # would otherwise enumerate every chunk in the bounding box, making
+ # resolution scale with grid size instead of with the number of selected
+ # coordinates.
+ # - Correlated (vindex) `ArrayMap` dims share one *joint* slot holding the
+ # distinct chunk-coordinate tuples the points actually land in. The
+ # cartesian product of their per-dimension distinct sets would include
+ # combinations no point touches — quadratic in the number of selected
+ # points for a diagonal selection — while the joint distinct set is
+ # bounded by the point count (see zarr-python gh-4174).
+ structure = transform.index_array_structure
+ correlated_dims: list[int] = []
+ correlated_chunk_ids: list[np.ndarray[Any, np.dtype[np.intp]]] = []
+ slot_dims: list[tuple[int, ...]] = []
+ slot_candidates: list[Sequence[tuple[int, ...]]] = []
+ for out_dim, m in enumerate(transform.output):
+ dg = dim_grids[out_dim]
+ if isinstance(m, ConstantMap):
+ # Single chunk
+ coordinate = checked_affine(m.offset, 0, 0)
+ c = dg.index_to_chunk(coordinate)
+ slot_dims.append((out_dim,))
+ slot_candidates.append(((c,),))
+ elif isinstance(m, DimensionMap):
+ d = m.input_dimension
+ dim_lo = transform.domain.inclusive_min[d]
+ dim_hi = transform.domain.exclusive_max[d]
+ if dim_lo >= dim_hi:
+ return # empty domain
+ first_storage = checked_affine(m.offset, m.stride, dim_lo)
+ if m.stride > 0:
+ s_min = first_storage
+ s_max = checked_affine(m.offset, m.stride, dim_hi - 1)
+ elif m.stride < 0:
+ s_min = checked_affine(m.offset, m.stride, dim_hi - 1)
+ s_max = first_storage
+ else:
+ s_min = s_max = first_storage
+ first = dg.index_to_chunk(s_min)
+ last = dg.index_to_chunk(s_max)
+ slot_dims.append((out_dim,))
+ point_count = dim_hi - dim_lo
+ chunk_count = last - first + 1
+ if point_count < chunk_count:
+ steps = np.arange(point_count, dtype=np.intp)
+ storage = checked_affine(first_storage, m.stride, steps)
+ chunk_ids = dg.indices_to_chunks(storage)
+ slot_candidates.append([(int(c),) for c in np.unique(chunk_ids)])
+ else:
+ slot_candidates.append([(c,) for c in range(first, last + 1)])
+ else:
+ # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap).
+ # Storage coordinates were already computed for a correlated 1-D map.
+ storage = (
+ array_map_1d[1]
+ if array_map_1d is not None
+ else checked_affine(m.offset, m.stride, m.index_array)
+ )
+ if storage.size == 0:
+ # Empty fancy selection: no coordinates, so no chunks are touched.
+ return
+ # Keep the index-array shape: correlated maps broadcast against each
+ # other below, and raveling first would lose the singleton axes.
+ chunk_ids = dg.indices_to_chunks(storage)
+ if structure == "orthogonal":
+ slot_dims.append((out_dim,))
+ slot_candidates.append([(int(c),) for c in np.unique(chunk_ids)])
+ else:
+ # Every index array of a general transform joins one joint
+ # slot: their chunk ids broadcast over the shared block, so the
+ # distinct tuples enumerate only combinations some point
+ # actually touches.
+ correlated_dims.append(out_dim)
+ correlated_chunk_ids.append(chunk_ids)
+
+ if len(correlated_dims) == 1:
+ slot_dims.append((correlated_dims[0],))
+ slot_candidates.append([(int(c),) for c in np.unique(correlated_chunk_ids[0])])
+ elif len(correlated_dims) >= 2:
+ # Group the points jointly: distinct rows of the per-point chunk
+ # coordinates, O(points log points) regardless of grid size.
+ broadcast = np.broadcast_arrays(*correlated_chunk_ids)
+ stacked = np.stack([b.ravel() for b in broadcast], axis=1)
+ joint = np.unique(stacked, axis=0)
+ slot_dims.append(tuple(correlated_dims))
+ slot_candidates.append([tuple(int(c) for c in row) for row in joint])
+
+ import itertools
+
+ output_rank = len(transform.output)
+ for combo in itertools.product(*slot_candidates):
+ chunk_coords_list = [0] * output_rank
+ for dims, part in zip(slot_dims, combo, strict=True):
+ for d, c in zip(dims, part, strict=True):
+ chunk_coords_list[d] = c
+ chunk_coords = tuple(chunk_coords_list)
+
+ # Build the chunk domain in storage space
+ chunk_min: list[int] = []
+ chunk_max: list[int] = []
+ chunk_shift: list[int] = []
+ for out_dim, c in enumerate(chunk_coords):
+ dg = dim_grids[out_dim]
+ c_start = dg.chunk_offset(c)
+ c_size = _data_size(dg, c)
+ chunk_min.append(c_start)
+ chunk_max.append(c_start + c_size)
+ chunk_shift.append(-c_start)
+
+ chunk_domain = IndexDomain(
+ inclusive_min=tuple(chunk_min),
+ exclusive_max=tuple(chunk_max),
+ )
+
+ # Intersect transform with chunk domain
+ result = transform.intersect(chunk_domain)
+ if result is None:
+ continue
+
+ restricted, surviving = result
+
+ # Translate to chunk-local coordinates
+ local = restricted.translate(tuple(chunk_shift))
+
+ yield (chunk_coords, local, surviving)
+
+
+def _covers_whole_chunk(transform: IndexTransform, chunk_shape: tuple[int, ...]) -> bool:
+ """Whether an affine chunk-local transform bijects onto every chunk cell."""
+ domain = transform.domain
+ used_nontrivial_inputs: set[int] = set()
+ for out_dim, m in enumerate(transform.output):
+ extent = chunk_shape[out_dim]
+ if isinstance(m, ConstantMap):
+ if extent != 1 or m.offset != 0:
+ return False
+ elif isinstance(m, DimensionMap):
+ if abs(m.stride) != 1:
+ return False
+ lo = domain.inclusive_min[m.input_dimension]
+ hi = domain.exclusive_max[m.input_dimension]
+ if hi <= lo:
+ if extent != 0:
+ return False
+ continue
+ first = m.offset + m.stride * lo
+ last = m.offset + m.stride * (hi - 1)
+ if min(first, last) != 0 or max(first, last) != extent - 1:
+ return False
+ if extent > 1:
+ if m.input_dimension in used_nontrivial_inputs:
+ return False
+ used_nontrivial_inputs.add(m.input_dimension)
+ else:
+ return False
+ nontrivial_inputs = {dimension for dimension, extent in enumerate(domain.shape) if extent > 1}
+ return used_nontrivial_inputs == nontrivial_inputs
+
+
+def _orthogonal_cell_transform(
+ original: IndexTransform,
+ restricted: IndexTransform,
+ survivors: dict[int, np.ndarray[Any, np.dtype[np.intp]]] | np.ndarray[Any, np.dtype[np.intp]],
+) -> IndexTransform:
+ """Map a compacted orthogonal intersection back to request coordinates."""
+ by_input_dimension: dict[int, np.ndarray[Any, np.dtype[np.intp]]] = {}
+ if isinstance(survivors, dict):
+ survivor_items = survivors.items()
+ else:
+ array_output_dimensions = [
+ output_dimension
+ for output_dimension, output_map in enumerate(original.output)
+ if isinstance(output_map, ArrayMap)
+ ]
+ if len(array_output_dimensions) != 1:
+ raise ValueError(
+ "one survivor array requires exactly one orthogonal ArrayMap; "
+ f"found output dimensions {array_output_dimensions}"
+ )
+ survivor_items = ((array_output_dimensions[0], survivors),)
+
+ for output_dimension, positions in survivor_items:
+ output_map = original.output[output_dimension]
+ if not isinstance(output_map, ArrayMap):
+ raise TypeError(
+ f"survivors for output dimension {output_dimension} do not describe an ArrayMap"
+ )
+ input_dimension = output_map.dependent_axis
+ if input_dimension is None:
+ raise ValueError(
+ f"output dimension {output_dimension} has no orthogonal input dimension"
+ )
+ by_input_dimension[input_dimension] = np.asarray(positions, dtype=np.intp)
+
+ output: list[ConstantMap | DimensionMap | ArrayMap] = []
+ rank = original.input_rank
+ for input_dimension in range(rank):
+ positions = by_input_dimension.get(input_dimension)
+ if positions is None:
+ output.append(DimensionMap(input_dimension=input_dimension))
+ continue
+ shape = (1,) * input_dimension + (positions.size,) + (1,) * (rank - input_dimension - 1)
+ output.append(
+ ArrayMap(
+ index_array=positions.reshape(shape),
+ offset=original.domain.inclusive_min[input_dimension],
+ )
+ )
+ return IndexTransform(domain=restricted.domain, output=tuple(output))
+
+
+def _correlated_cell_transform(
+ original: IndexTransform,
+ restricted: IndexTransform,
+ survivors: np.ndarray[Any, np.dtype[np.intp]],
+) -> IndexTransform:
+ """Map compacted correlated points back through the request's row-major domain."""
+ positions = np.asarray(survivors, dtype=np.intp)
+ # Correlated broadcast axes already contribute positional survivor offsets;
+ # residual affine axes still contribute literal coordinates. Remove only
+ # the latter origins before unraveling the fully positional flat offsets.
+ literal_axes = {
+ output_map.input_dimension
+ for output_map in original.output
+ if isinstance(output_map, DimensionMap)
+ }
+ origin_offset = 0
+ flat_stride = 1
+ for input_dimension in range(original.input_rank - 1, -1, -1):
+ if input_dimension in literal_axes:
+ origin_offset += original.domain.inclusive_min[input_dimension] * flat_stride
+ extent = original.domain.shape[input_dimension]
+ flat_stride *= extent
+ coordinates = np.unravel_index(
+ checked_affine(-origin_offset, 1, positions), original.domain.shape
+ )
+ output = tuple(
+ ArrayMap(
+ index_array=np.asarray(coordinate, dtype=np.intp),
+ offset=origin,
+ )
+ for coordinate, origin in zip(coordinates, original.domain.inclusive_min, strict=True)
+ )
+ return IndexTransform(domain=restricted.domain, output=output)
+
+
+def _cell_transform(
+ original: IndexTransform,
+ restricted: IndexTransform,
+ survivors: _OutIndices,
+) -> IndexTransform:
+ """Convert private survivor bookkeeping into a direction-neutral transform."""
+ if survivors is None:
+ return IndexTransform.identity(restricted.domain)
+ if original.index_array_structure == "general":
+ if isinstance(survivors, dict):
+ raise ValueError("general intersections require one shared survivor array")
+ return _correlated_cell_transform(original, restricted, survivors)
+ return _orthogonal_cell_transform(original, restricted, survivors)
+
+
+def _iter_chunk_projections(
+ transform: IndexTransform,
+ dim_grids: Sequence[DimensionGridLike],
+) -> Iterator[ChunkProjection]:
+ """Convert private intersection results into public paired projections."""
+ for chunk_coords, chunk_transform, survivors in _iter_chunk_transform_results(
+ transform, dim_grids
+ ):
+ chunk_min = tuple(
+ grid.chunk_offset(coord) for grid, coord in zip(dim_grids, chunk_coords, strict=True)
+ )
+ chunk_shape = tuple(
+ _data_size(grid, coord) for grid, coord in zip(dim_grids, chunk_coords, strict=True)
+ )
+ chunk_domain = IndexDomain(
+ inclusive_min=chunk_min,
+ exclusive_max=tuple(
+ origin + extent for origin, extent in zip(chunk_min, chunk_shape, strict=True)
+ ),
+ )
+ cell_transform = _cell_transform(transform, chunk_transform, survivors)
+ synthetic_origin = (0,) * chunk_transform.input_rank
+ chunk_transform = chunk_transform.translate_domain_to(synthetic_origin)
+ cell_transform = cell_transform.translate_domain_to(synthetic_origin)
+ if survivors is not None or any(isinstance(m, ArrayMap) for m in chunk_transform.output):
+ coverage: ChunkCoverage = "unknown"
+ elif _covers_whole_chunk(chunk_transform, chunk_shape):
+ coverage = "full"
+ else:
+ coverage = "partial"
+ yield ChunkProjection(
+ chunk_coords=chunk_coords,
+ chunk_domain=chunk_domain,
+ chunk_transform=chunk_transform,
+ cell_transform=cell_transform,
+ coverage=coverage,
+ )
diff --git a/packages/zarr-indexing/src/zarr_indexing/domain.py b/packages/zarr-indexing/src/zarr_indexing/domain.py
new file mode 100644
index 0000000000..a353b81254
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/domain.py
@@ -0,0 +1,325 @@
+"""Index domains — rectangular regions in N-dimensional integer space.
+
+An `IndexDomain` represents the set of valid coordinates for an array or
+array view. It is the cartesian product of per-dimension integer ranges::
+
+ IndexDomain(inclusive_min=(2, 5), exclusive_max=(10, 20))
+ # represents {(i, j) : 2 <= i < 10, 5 <= j < 20}
+
+Unlike NumPy, domains can have **non-zero origins**. After slicing
+`arr[5:10]`, the result has origin 5 and shape 5 — coordinates 5 through
+9 are valid. This follows the TensorStore convention.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+
+from zarr_indexing.errors import BoundsCheckError
+
+if TYPE_CHECKING:
+ from zarr_indexing.json import IndexDomainJSON
+
+
+@dataclass(frozen=True, slots=True)
+class IndexDomain:
+ """A rectangular region in N-dimensional index space.
+
+ The valid coordinates are the integers in
+ `[inclusive_min[d], exclusive_max[d])` for each dimension `d`.
+
+ Examples
+ --------
+ >>> domain = IndexDomain(inclusive_min=(2, 5), exclusive_max=(10, 20))
+ >>> domain.shape
+ (8, 15)
+
+ Unlike a NumPy shape, a domain keeps literal coordinates: narrowing to
+ `[5, 10)` gives a region whose valid coordinates are 5 through 9, not
+ re-zeroed:
+
+ >>> view = IndexDomain.from_shape((10,)).narrow(slice(5, 10))
+ >>> view.origin, view.shape
+ ((5,), (5,))
+ >>> view.contains((5,)), view.contains((0,))
+ (True, False)
+ """
+
+ inclusive_min: tuple[int, ...]
+ """The lower corner: each dimension's smallest literal coordinate. May be negative."""
+
+ exclusive_max: tuple[int, ...]
+ """Each dimension's upper bound, excluded: valid coordinates end at `exclusive_max - 1`."""
+
+ labels: tuple[str, ...] | None = None
+ """Optional per-dimension names; carried through the wire format, never consulted by indexing."""
+ # Lazily-memoized shape. Excluded from init/repr/eq/hash: it is derived
+ # state, not part of the domain's identity. The domain is frozen, so the
+ # value is computed at most once (see `shape`). `None` is the unset
+ # sentinel; an empty shape caches as `()`.
+ _shape: tuple[int, ...] | None = field(default=None, init=False, repr=False, compare=False)
+
+ def __post_init__(self) -> None:
+ if len(self.inclusive_min) != len(self.exclusive_max):
+ raise ValueError(
+ f"inclusive_min and exclusive_max must have the same length. "
+ f"Got {len(self.inclusive_min)} and {len(self.exclusive_max)}."
+ )
+ for i, (lo, hi) in enumerate(zip(self.inclusive_min, self.exclusive_max, strict=True)):
+ if lo > hi:
+ raise ValueError(
+ f"inclusive_min must be <= exclusive_max for all dimensions. "
+ f"Dimension {i}: {lo} > {hi}"
+ )
+ if self.labels is not None and len(self.labels) != len(self.inclusive_min):
+ raise ValueError(
+ f"labels must have the same length as dimensions. "
+ f"Got {len(self.labels)} labels for {len(self.inclusive_min)} dimensions."
+ )
+
+ @classmethod
+ def from_shape(cls, shape: tuple[int, ...]) -> IndexDomain:
+ """Create a domain with origin at zero."""
+ return cls(
+ inclusive_min=(0,) * len(shape),
+ exclusive_max=shape,
+ )
+
+ @property
+ def ndim(self) -> int:
+ """Number of dimensions."""
+ return len(self.inclusive_min)
+
+ @property
+ def origin(self) -> tuple[int, ...]:
+ """The lower corner of the domain — an alias for `inclusive_min`, and may be negative."""
+ return self.inclusive_min
+
+ @property
+ def shape(self) -> tuple[int, ...]:
+ """Per-dimension extents: `exclusive_max - inclusive_min` for each dimension."""
+ cached = self._shape
+ if cached is None:
+ cached = tuple(
+ hi - lo for lo, hi in zip(self.inclusive_min, self.exclusive_max, strict=True)
+ )
+ object.__setattr__(self, "_shape", cached)
+ return cached
+
+ def contains(self, index: tuple[int, ...]) -> bool:
+ """Whether the literal coordinate `index` lies inside this domain.
+
+ Coordinates are literal, not NumPy-style offsets: a negative value is
+ the coordinate itself, valid only if the domain's bounds include it.
+ A tuple of the wrong length is simply not contained (returns `False`).
+ """
+ if len(index) != self.ndim:
+ return False
+ return all(
+ lo <= idx < hi
+ for lo, hi, idx in zip(self.inclusive_min, self.exclusive_max, index, strict=True)
+ )
+
+ def contains_domain(self, other: IndexDomain) -> bool:
+ """Whether every coordinate of `other` lies inside this domain.
+
+ An empty `other` within this domain's bounds is contained. A rank
+ mismatch returns `False` rather than raising.
+ """
+ if other.ndim != self.ndim:
+ return False
+ return all(
+ self_lo <= other_lo and other_hi <= self_hi
+ for self_lo, self_hi, other_lo, other_hi in zip(
+ self.inclusive_min,
+ self.exclusive_max,
+ other.inclusive_min,
+ other.exclusive_max,
+ strict=True,
+ )
+ )
+
+ def intersect(self, other: IndexDomain) -> IndexDomain | None:
+ """Return the overlap of this domain with `other`, or `None` if they are disjoint.
+
+ Raises
+ ------
+ ValueError
+ If the two domains have different ranks.
+ """
+ if other.ndim != self.ndim:
+ raise ValueError(
+ f"Cannot intersect domains with different ranks: {self.ndim} vs {other.ndim}"
+ )
+ new_min = tuple(
+ max(a, b) for a, b in zip(self.inclusive_min, other.inclusive_min, strict=True)
+ )
+ new_max = tuple(
+ min(a, b) for a, b in zip(self.exclusive_max, other.exclusive_max, strict=True)
+ )
+ if any(lo >= hi for lo, hi in zip(new_min, new_max, strict=True)):
+ return None
+ return IndexDomain(inclusive_min=new_min, exclusive_max=new_max)
+
+ def translate(self, offset: tuple[int, ...]) -> IndexDomain:
+ """Return this domain shifted by `offset` per dimension; the shape is unchanged.
+
+ Offsets may be negative, and the result may have a negative origin.
+
+ Raises
+ ------
+ ValueError
+ If `offset` does not have one entry per dimension.
+ """
+ if len(offset) != self.ndim:
+ raise ValueError(
+ f"Offset must have same length as domain dimensions. "
+ f"Domain has {self.ndim} dimensions, offset has {len(offset)}."
+ )
+ new_min = tuple(lo + off for lo, off in zip(self.inclusive_min, offset, strict=True))
+ new_max = tuple(hi + off for hi, off in zip(self.exclusive_max, offset, strict=True))
+ return IndexDomain(inclusive_min=new_min, exclusive_max=new_max)
+
+ def narrow(self, selection: Any) -> IndexDomain:
+ """Apply a basic selection and return a narrowed domain.
+
+ Indices are absolute coordinates, not NumPy-style offsets: `-3` names
+ the coordinate `-3`, and is out of bounds unless the domain contains it.
+ Integer indices produce a length-1 extent. Strided slices are not
+ supported — use `IndexTransform` for strides.
+
+ Raises
+ ------
+ BoundsCheckError
+ If a bound lies outside this domain. A slice bound used to be
+ clamped instead, so `narrow(slice(-3, None))` on `[0, 10)` quietly
+ returned the whole axis — reading as the NumPy spelling of "the last
+ three" and answering with something else — and `narrow(slice(20,
+ 30))` returned a domain its own parent did not contain. The rest of
+ the algebra states no clamping and no negative wrapping as an
+ invariant and enforces it; this is the one place that did not.
+ """
+ normalized = _normalize_selection(selection, self.ndim)
+ new_inclusive_min: list[int] = []
+ new_exclusive_max: list[int] = []
+ for dim_idx, (sel, dim_lo, dim_hi) in enumerate(
+ zip(normalized, self.inclusive_min, self.exclusive_max, strict=True)
+ ):
+ if isinstance(sel, int):
+ if sel < dim_lo or sel >= dim_hi:
+ raise BoundsCheckError(
+ f"index {sel} is out of bounds for dimension {dim_idx} "
+ f"with domain [{dim_lo}, {dim_hi})"
+ )
+ new_inclusive_min.append(sel)
+ new_exclusive_max.append(sel + 1)
+ else:
+ start, stop, step = sel.start, sel.stop, sel.step
+ if step is not None and step != 1:
+ raise ValueError(
+ "IndexDomain.narrow only supports step=1 slices. "
+ f"Got step={step}. Use IndexTransform for strided access."
+ )
+ abs_start = dim_lo if start is None else start
+ abs_stop = dim_hi if stop is None else stop
+ for bound, name in ((abs_start, "start"), (abs_stop, "stop")):
+ if bound < dim_lo or bound > dim_hi:
+ raise BoundsCheckError(
+ f"slice {name} {bound} is out of bounds for dimension "
+ f"{dim_idx} with domain [{dim_lo}, {dim_hi}); indices "
+ f"here are absolute coordinates, so they are neither "
+ f"clamped to the domain nor counted from its end"
+ )
+ # An empty interval is legal; a reversed one is the same request
+ # spelled backwards, and reads as empty rather than as an error.
+ new_inclusive_min.append(abs_start)
+ new_exclusive_max.append(max(abs_stop, abs_start))
+ return IndexDomain(
+ inclusive_min=tuple(new_inclusive_min),
+ exclusive_max=tuple(new_exclusive_max),
+ )
+
+ # -- serialization ------------------------------------------------------
+
+ def to_json(self) -> IndexDomainJSON:
+ """Convert to the canonical ndsel JSON representation.
+
+ Examples
+ --------
+ >>> IndexDomain(inclusive_min=(0,), exclusive_max=(3,)).to_json()
+ {'input_inclusive_min': [0], 'input_exclusive_max': [3], 'input_labels': ['']}
+ """
+ from zarr_indexing._wire import emit_labels
+
+ return {
+ "input_inclusive_min": list(self.inclusive_min),
+ "input_exclusive_max": list(self.exclusive_max),
+ "input_labels": emit_labels(self.labels, self.ndim),
+ }
+
+ @classmethod
+ def from_json(cls, data: IndexDomainJSON) -> IndexDomain:
+ """Construct from the canonical ndsel JSON representation.
+
+ The document is validated by the message layer first, exactly as a
+ transform body is. Reading the keys directly would be a second,
+ undefended way into the same objects: `int(value)` alone accepts
+ `3.9`, `"3"` and `True`, and each of those builds a domain that is
+ not the document's.
+
+ Examples
+ --------
+ >>> domain = IndexDomain.from_json(
+ ... {"input_inclusive_min": [1], "input_exclusive_max": [4], "input_labels": [""]}
+ ... )
+ >>> (domain.inclusive_min, domain.exclusive_max, domain.shape)
+ ((1,), (4,), (3,))
+ >>> IndexDomain.from_json(domain.to_json()) == domain
+ True
+ """
+ from zarr_indexing._wire import lower_bound, lower_labels
+ from zarr_indexing.messages import NdselError, normalize_ndsel
+
+ # The annotation says what a well-formed caller passes; this is a parser
+ # of documents that arrive from elsewhere, so the shape is checked
+ # rather than assumed.
+ if not isinstance(data, dict): # pyright: ignore[reportUnnecessaryIsInstance]
+ raise NdselError("invalid_json", f"an index domain must be a JSON object, got {data!r}")
+ body = normalize_ndsel({**data, "kind": "transform"})
+ return cls(
+ inclusive_min=tuple(
+ lower_bound(b, f"input_inclusive_min[{i}]")
+ for i, b in enumerate(body["input_inclusive_min"])
+ ),
+ exclusive_max=tuple(
+ lower_bound(b, f"input_exclusive_max[{i}]")
+ for i, b in enumerate(body["input_exclusive_max"])
+ ),
+ labels=lower_labels(body["input_labels"]),
+ )
+
+
+def _normalize_selection(selection: Any, ndim: int) -> tuple[int | slice, ...]:
+ """Normalize a basic selection to a tuple of ints/slices with length ndim."""
+ if not isinstance(selection, tuple):
+ selection = (selection,)
+ result: list[int | slice] = []
+ ellipsis_seen = False
+ for sel in selection:
+ if sel is Ellipsis:
+ if ellipsis_seen:
+ raise IndexError("an index can only have a single ellipsis ('...')")
+ ellipsis_seen = True
+ num_missing = ndim - (len(selection) - 1)
+ result.extend([slice(None)] * num_missing)
+ else:
+ result.append(sel)
+ while len(result) < ndim:
+ result.append(slice(None))
+ if len(result) > ndim:
+ raise IndexError(
+ f"too many indices for array: array has {ndim} dimensions, "
+ f"but {len(result)} were indexed"
+ )
+ return tuple(result)
diff --git a/packages/zarr-indexing/src/zarr_indexing/errors.py b/packages/zarr-indexing/src/zarr_indexing/errors.py
new file mode 100644
index 0000000000..efd3b1ecd6
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/errors.py
@@ -0,0 +1,67 @@
+"""Canonical index-error types raised by the transform algebra.
+
+Both subclass the built-in `IndexError`, so an `except IndexError` catch site
+keeps working unchanged whichever library raised.
+
+`zarr.errors` defines classes of the same names, and they are *not* these
+objects: `zarr.errors.BoundsCheckError is BoundsCheckError` is false. Catching
+zarr's around a call into this package therefore catches nothing but their
+shared `IndexError` base. Import these from here.
+"""
+
+from __future__ import annotations
+
+__all__ = [
+ "BoundsCheckError",
+ "VindexInvalidSelectionError",
+]
+
+
+class VindexInvalidSelectionError(IndexError):
+ """A wrapper `vindex` selection contained a slice.
+
+ Raised by `LazyArray`'s selection validation: the wrapper's vectorized
+ dialect accepts coordinate selections (integer arrays, with scalars and
+ an ellipsis) or a single boolean mask, and rejects slices with this
+ error. Other invalid entries raise plain `IndexError`, and the
+ engine-level `IndexTransform.vindex` is wider — it accepts residual
+ slice dimensions without raising.
+
+ Examples
+ --------
+ Raised by the wrapper, not the engine — a slice inside `vindex`:
+
+ >>> import numpy as np
+ >>> from zarr_indexing import LazyArray
+ >>> view = LazyArray.from_numpy(np.arange(12).reshape(3, 4))
+ >>> view.lazy.vindex[np.array([0, 2]), :]
+ Traceback (most recent call last):
+ ...
+ zarr_indexing.errors.VindexInvalidSelectionError: ...
+ """
+
+
+class BoundsCheckError(IndexError):
+ """A selection addressed coordinates outside the domain being indexed.
+
+ Raised for out-of-domain integer indices, slice bounds, index-array
+ values, and points passed to `IndexTransform.apply`. Coordinates in this
+ algebra are literal: they are never clamped, and a negative value below
+ the domain's `inclusive_min` is out of bounds rather than counted from
+ the end.
+
+ Examples
+ --------
+ >>> from zarr_indexing import IndexTransform
+ >>> IndexTransform.from_shape((8,)).apply((9,))
+ Traceback (most recent call last):
+ ...
+ zarr_indexing.errors.BoundsCheckError: ...
+
+ A negative index is a literal coordinate, not "from the end":
+
+ >>> IndexTransform.from_shape((8,))[-1]
+ Traceback (most recent call last):
+ ...
+ zarr_indexing.errors.BoundsCheckError: ...
+ """
diff --git a/packages/zarr-indexing/src/zarr_indexing/grid.py b/packages/zarr-indexing/src/zarr_indexing/grid.py
new file mode 100644
index 0000000000..20ad3f95c2
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/grid.py
@@ -0,0 +1,826 @@
+"""Compact chunk grids and the narrow planner protocol.
+
+``DimensionGridLike`` describes only the per-axis operations required by
+``plan_chunks``. The concrete compact grids below also retain enough metadata
+to describe chunk data regions and codec buffer regions without importing
+Zarr's array implementation.
+"""
+
+from __future__ import annotations
+
+import bisect
+import itertools
+import operator
+from dataclasses import dataclass, field
+from functools import reduce
+from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
+
+import numpy as np
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable, Iterator, Sequence
+
+ import numpy.typing as npt
+
+
+class DimensionGridLike(Protocol):
+ """The per-dimension chunk-mapping surface consumed by chunk resolution.
+
+ Examples
+ --------
+ `EdgeDimensionGrid` provides this surface. Chunk sizes `(2, 3)` tile
+ source coordinates `[0, 5)`, so index 4 lands in the second chunk:
+
+ >>> grid = EdgeDimensionGrid([2, 3])
+ >>> grid.index_to_chunk(4)
+ 1
+ >>> grid.chunk_offset(1), grid.chunk_size(1)
+ (2, 3)
+ """
+
+ def index_to_chunk(self, idx: int) -> int:
+ """Map a global source index to the index of the chunk that contains it.
+
+ Implementers must raise `IndexError` when `idx` lies outside `[0, extent)`.
+ """
+ ...
+
+ def chunk_offset(self, chunk_ix: int) -> int:
+ """The global source coordinate at which chunk `chunk_ix` begins."""
+ ...
+
+ def chunk_size(self, chunk_ix: int) -> int:
+ """The declared length of chunk `chunk_ix`, i.e. its codec buffer size along this axis."""
+ ...
+
+ def indices_to_chunks(self, indices: npt.NDArray[np.intp]) -> npt.NDArray[np.intp]:
+ """Vectorized `index_to_chunk`: map global source indices to chunk indices.
+
+ Implementers must raise `IndexError` if any index lies outside `[0, extent)`.
+ """
+ ...
+
+
+def _bounded_indices(indices: npt.NDArray[np.intp], extent: int) -> npt.NDArray[np.intp]:
+ """Normalize a vector lookup and enforce the scalar grid bounds."""
+ arr = np.asarray(indices, dtype=np.intp)
+ if arr.size > 0 and (int(arr.min()) < 0 or int(arr.max()) >= extent):
+ raise IndexError(
+ f"indices must lie in [0, {extent}); got [{int(arr.min())}, {int(arr.max())}]"
+ )
+ return arr
+
+
+@dataclass(frozen=True)
+class FixedDimension:
+ """Uniform chunk size with a boundary chunk clipped to the axis extent.
+
+ Examples
+ --------
+ Chunks of size 3 on an axis of extent 10 give 4 chunks. The last chunk
+ still declares a codec buffer of 3 but holds only 1 valid element:
+
+ >>> dim = FixedDimension(size=3, extent=10)
+ >>> dim.nchunks
+ 4
+ >>> dim.index_to_chunk(7)
+ 2
+ >>> dim.chunk_size(3), dim.data_size(3)
+ (3, 1)
+ """
+
+ size: int
+ """The declared chunk length along this axis; every chunk's codec buffer size."""
+
+ extent: int
+ """The axis length in global source coordinates."""
+
+ nchunks: int = field(init=False, repr=False)
+ """Derived: the number of chunks holding data within `extent`."""
+
+ ngridcells: int = field(init=False, repr=False)
+ """Derived: the number of declared grid cells; equals `nchunks` for a fixed dimension."""
+
+ def __post_init__(self) -> None:
+ if self.size < 0:
+ raise ValueError(f"FixedDimension size must be >= 0, got {self.size}")
+ if self.extent < 0:
+ raise ValueError(f"FixedDimension extent must be >= 0, got {self.extent}")
+ if self.size == 0 and self.extent > 0:
+ raise ValueError(
+ "FixedDimension size must be > 0 when extent is nonzero; "
+ f"got size {self.size} and extent {self.extent}"
+ )
+ nchunks = 0 if self.size == 0 else (self.extent + self.size - 1) // self.size
+ object.__setattr__(self, "nchunks", nchunks)
+ object.__setattr__(self, "ngridcells", nchunks)
+
+ def index_to_chunk(self, idx: int) -> int:
+ """Map a global source index to its chunk index (`idx // size`).
+
+ Raises `IndexError` when `idx` lies outside `[0, extent)`.
+ """
+ if idx < 0 or idx >= self.extent:
+ raise IndexError(f"index {idx} is out of bounds for extent {self.extent}")
+ return 0 if self.size == 0 else idx // self.size
+
+ def chunk_offset(self, chunk_ix: int) -> int:
+ """The global source coordinate where chunk `chunk_ix` begins (`chunk_ix * size`).
+
+ Not bounds-checked: chunk indices past the last chunk extrapolate linearly.
+ """
+ return chunk_ix * self.size
+
+ def chunk_size(self, chunk_ix: int) -> int:
+ """The declared chunk length, `size` for every chunk.
+
+ The boundary chunk is not clipped here; use `data_size` for the valid data length.
+ """
+ return self.size
+
+ def data_size(self, chunk_ix: int) -> int:
+ """The number of valid data elements in chunk `chunk_ix`, clipped to `extent`.
+
+ Interior chunks report `size`; the boundary chunk reports the remainder, and chunk
+ indices at or past `nchunks` report 0.
+ """
+ if self.size == 0:
+ return 0
+ return max(0, min(self.size, self.extent - chunk_ix * self.size))
+
+ def indices_to_chunks(self, indices: npt.NDArray[np.intp]) -> npt.NDArray[np.intp]:
+ """Vectorized `index_to_chunk` over an array of global source indices.
+
+ Raises `IndexError` if any index lies outside `[0, extent)`.
+ """
+ arr = _bounded_indices(indices, self.extent)
+ if self.size == 0:
+ return np.zeros_like(arr)
+ return arr // self.size
+
+ def with_extent(self, new_extent: int) -> FixedDimension:
+ """Return a copy with the same chunk size and the axis extent set to `new_extent`."""
+ return FixedDimension(size=self.size, extent=new_extent)
+
+ def resize(self, new_extent: int) -> FixedDimension:
+ """Return a copy resized to `new_extent`; the fixed chunk size covers any new extent."""
+ return FixedDimension(size=self.size, extent=new_extent)
+
+ @property
+ def size_repr(self) -> str:
+ """The chunk size rendered as a scalar for `ChunkGrid.__repr__`."""
+ return str(self.size)
+
+
+@dataclass(frozen=True, init=False)
+class VaryingDimension:
+ """Explicit chunk edge lengths, with trailing data clipped to ``extent``.
+
+ Examples
+ --------
+ Edges `(2, 3, 5)` clipped to extent 9: the last chunk declares 5 but
+ holds only 4 valid elements, and index 4 lands in the second chunk:
+
+ >>> dim = VaryingDimension(edges=(2, 3, 5), extent=9)
+ >>> dim.nchunks
+ 3
+ >>> dim.index_to_chunk(4)
+ 1
+ >>> dim.chunk_offset(2)
+ 5
+ >>> dim.chunk_size(2), dim.data_size(2)
+ (5, 4)
+ """
+
+ edges: tuple[int, ...]
+ """The declared per-chunk edge lengths, in order; codec buffer sizes, unclipped."""
+
+ cumulative: tuple[int, ...]
+ """Prefix sums of `edges`; derived, and what index lookups binary-search."""
+
+ extent: int
+ """The axis length in global source coordinates; at most the sum of `edges`."""
+ nchunks: int = field(init=False, repr=False)
+ """Derived: the number of chunks holding data within `extent`."""
+
+ ngridcells: int = field(init=False, repr=False)
+ """Derived: the number of declared edges; exceeds `nchunks` when trailing cells are empty."""
+
+ def __init__(self, edges: Sequence[int], extent: int) -> None:
+ edges_tuple = tuple(edges)
+ if not edges_tuple:
+ raise ValueError("VaryingDimension edges must not be empty")
+ if any(edge <= 0 for edge in edges_tuple):
+ raise ValueError(f"All edge lengths must be > 0, got {edges_tuple}")
+ cumulative = tuple(itertools.accumulate(edges_tuple))
+ if extent < 0:
+ raise ValueError(f"VaryingDimension extent must be >= 0, got {extent}")
+ if extent > cumulative[-1]:
+ raise ValueError(
+ f"VaryingDimension extent {extent} exceeds sum of edges {cumulative[-1]}"
+ )
+ object.__setattr__(self, "edges", edges_tuple)
+ object.__setattr__(self, "cumulative", cumulative)
+ object.__setattr__(self, "extent", extent)
+ nchunks = 0 if extent == 0 else bisect.bisect_left(cumulative, extent) + 1
+ object.__setattr__(self, "nchunks", nchunks)
+ object.__setattr__(self, "ngridcells", len(edges_tuple))
+
+ def index_to_chunk(self, idx: int) -> int:
+ """Map a global source index to the chunk whose edge interval contains it.
+
+ Raises `IndexError` when `idx` lies outside `[0, extent)`.
+ """
+ if idx < 0 or idx >= self.extent:
+ raise IndexError(f"index {idx} is out of bounds for extent {self.extent}")
+ return bisect.bisect_right(self.cumulative, idx)
+
+ def chunk_offset(self, chunk_ix: int) -> int:
+ """The global source coordinate where chunk `chunk_ix` begins (sum of prior edges)."""
+ return self.cumulative[chunk_ix - 1] if chunk_ix > 0 else 0
+
+ def chunk_size(self, chunk_ix: int) -> int:
+ """The declared edge length of chunk `chunk_ix`.
+
+ Trailing chunks are not clipped to `extent` here; use `data_size` for that.
+ """
+ return self.edges[chunk_ix]
+
+ def data_size(self, chunk_ix: int) -> int:
+ """The number of valid data elements in chunk `chunk_ix`, clipped to `extent`.
+
+ Grid cells that lie entirely at or past `extent` report 0.
+ """
+ offset = self.chunk_offset(chunk_ix)
+ return max(0, min(self.edges[chunk_ix], self.extent - offset))
+
+ def indices_to_chunks(self, indices: npt.NDArray[np.intp]) -> npt.NDArray[np.intp]:
+ """Vectorized `index_to_chunk` over an array of global source indices.
+
+ Raises `IndexError` if any index lies outside `[0, extent)`.
+ """
+ arr = _bounded_indices(indices, self.extent)
+ return np.searchsorted(self.cumulative, arr, side="right")
+
+ def with_extent(self, new_extent: int) -> VaryingDimension:
+ """Return a copy with the same edges re-clipped to `new_extent`.
+
+ The existing edges must already cover the new extent; raises `ValueError` when
+ `new_extent` exceeds the sum of edges. Use `resize` to grow past the edges.
+ """
+ if self.cumulative[-1] < new_extent:
+ raise ValueError(
+ f"VaryingDimension edge sum {self.cumulative[-1]} is less than new extent "
+ f"{new_extent}"
+ )
+ return VaryingDimension(self.edges, extent=new_extent)
+
+ def resize(self, new_extent: int) -> VaryingDimension:
+ """Return a copy resized to `new_extent`.
+
+ Shrinking (or growing within the existing edges) keeps the edges and re-clips them;
+ growing past the sum of edges appends one new trailing edge covering the remainder.
+ """
+ if new_extent == self.extent:
+ return self
+ if new_extent > self.cumulative[-1]:
+ return VaryingDimension((*self.edges, new_extent - self.cumulative[-1]), new_extent)
+ return VaryingDimension(self.edges, extent=new_extent)
+
+ @property
+ def size_repr(self) -> str:
+ """The edge lengths rendered as a tuple for `ChunkGrid.__repr__`."""
+ return repr(self.edges)
+
+
+@runtime_checkable
+class DimensionGrid(Protocol):
+ """Structural interface shared by the compact dimension grids.
+
+ Examples
+ --------
+ `FixedDimension` satisfies the protocol structurally:
+
+ >>> dim = FixedDimension(size=2, extent=5)
+ >>> isinstance(dim, DimensionGrid)
+ True
+ >>> dim.nchunks, dim.extent
+ (3, 5)
+ >>> dim.with_extent(4).nchunks
+ 2
+ """
+
+ @property
+ def nchunks(self) -> int:
+ """The number of chunks holding data within `extent`."""
+ ...
+
+ @property
+ def ngridcells(self) -> int:
+ """The number of declared grid cells; may exceed `nchunks` when trailing cells are empty."""
+ ...
+
+ @property
+ def extent(self) -> int:
+ """The axis length in global source coordinates."""
+ ...
+
+ def index_to_chunk(self, idx: int) -> int:
+ """Map a global source index to the chunk index that contains it.
+
+ Implementers must raise `IndexError` when `idx` lies outside `[0, extent)`.
+ """
+ ...
+
+ def chunk_offset(self, chunk_ix: int) -> int:
+ """The global source coordinate at which chunk `chunk_ix` begins."""
+ ...
+
+ def chunk_size(self, chunk_ix: int) -> int:
+ """The declared (codec buffer) length of chunk `chunk_ix`, never clipped to `extent`."""
+ ...
+
+ def data_size(self, chunk_ix: int) -> int:
+ """The valid data length of chunk `chunk_ix`, clipped to `extent` at the boundary."""
+ ...
+
+ def indices_to_chunks(self, indices: npt.NDArray[np.intp]) -> npt.NDArray[np.intp]:
+ """Vectorized `index_to_chunk`; must raise `IndexError` for indices outside `[0, extent)`."""
+ ...
+
+ def with_extent(self, new_extent: int) -> DimensionGrid:
+ """Return a grid with the existing chunk layout re-clipped to `new_extent`.
+
+ Implementers must not invent new grid cells: raise `ValueError` when the declared
+ layout cannot cover `new_extent`.
+ """
+ ...
+
+ def resize(self, new_extent: int) -> DimensionGrid:
+ """Return a grid covering `new_extent`, extending the chunk layout when it must grow."""
+ ...
+
+ @property
+ def size_repr(self) -> str:
+ """A compact rendering of the chunk sizes, used by `ChunkGrid.__repr__`."""
+ ...
+
+
+@dataclass(frozen=True)
+class ChunkSpec:
+ """A chunk's valid data region and its full codec buffer shape.
+
+ Examples
+ --------
+ The last chunk of a size-10 axis chunked by 3 holds one valid element
+ (`slices`), while its codec buffer still spans 3:
+
+ >>> spec = ChunkGrid.from_sizes((10,), (3,))[3]
+ >>> spec.slices
+ (slice(9, 10, 1),)
+ >>> spec.shape, spec.codec_shape
+ ((1,), (3,))
+ >>> spec.is_boundary
+ True
+ """
+
+ slices: tuple[slice, ...]
+ """Per-dimension bounds of the valid data region, in global source coordinates."""
+
+ codec_shape: tuple[int, ...]
+ """The declared (codec buffer) chunk shape, unclipped by the array extent."""
+
+ @property
+ def shape(self) -> tuple[int, ...]:
+ """The shape of the valid data region described by `slices`.
+
+ Smaller than `codec_shape` on boundary chunks, where the array extent clips the chunk.
+ """
+ return tuple(chunk_slice.stop - chunk_slice.start for chunk_slice in self.slices)
+
+ @property
+ def is_boundary(self) -> bool:
+ """Whether the valid data region is smaller than the full codec buffer on any axis."""
+ return self.shape != self.codec_shape
+
+
+@dataclass(frozen=True)
+class ChunkGrid:
+ """A concrete regular or rectilinear arrangement of chunks for one array.
+
+ Examples
+ --------
+ A `(3, 4)` array with `(2, 2)` chunks has a `(2, 2)` grid whose bottom
+ row of chunks is clipped to one valid row of data:
+
+ >>> grid = ChunkGrid.from_sizes((3, 4), (2, 2))
+ >>> grid.grid_shape
+ (2, 2)
+ >>> grid.chunk_sizes
+ ((2, 1), (2, 2))
+ >>> spec = grid[1, 0]
+ >>> spec.shape, spec.codec_shape, spec.is_boundary
+ ((1, 2), (2, 2), True)
+ """
+
+ dimensions: tuple[DimensionGrid, ...]
+ """One per-axis grid, each mapping that axis's source indices to chunks."""
+
+ _is_regular: bool = field(init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ object.__setattr__(
+ self,
+ "_is_regular",
+ all(isinstance(dimension, FixedDimension) for dimension in self.dimensions),
+ )
+
+ def __repr__(self) -> str:
+ sizes = ", ".join(dimension.size_repr for dimension in self.dimensions)
+ shape = tuple(dimension.extent for dimension in self.dimensions)
+ return f"ChunkGrid(chunk_sizes=({sizes}), array_shape={shape})"
+
+ @classmethod
+ def from_sizes(
+ cls, array_shape: Sequence[int], chunk_sizes: Sequence[int | Sequence[int]]
+ ) -> ChunkGrid:
+ """Build a grid from an array shape and one chunk-size spec per dimension.
+
+ An `int` entry gives a fixed chunk size along that axis; a sequence of ints gives
+ explicit per-chunk edge lengths. A uniform sequence consistent with the axis extent
+ collapses to a fixed dimension, so the result may report `is_regular`.
+
+ Parameters
+ ----------
+ array_shape : Sequence[int]
+ The array extent along each dimension, in global source coordinates.
+ chunk_sizes : Sequence[int | Sequence[int]]
+ Per-dimension chunk layout: a single size or explicit edge lengths.
+ """
+ extents = _shape_tuple(array_shape)
+ if len(extents) != len(chunk_sizes):
+ raise ValueError(
+ f"array_shape has {len(extents)} dimensions but chunk_sizes has "
+ f"{len(chunk_sizes)} dimensions"
+ )
+ dimensions: list[DimensionGrid] = []
+ for dimension_spec, extent in zip(chunk_sizes, extents, strict=True):
+ if isinstance(dimension_spec, int):
+ dimensions.append(FixedDimension(size=dimension_spec, extent=extent))
+ else:
+ edges = tuple(dimension_spec)
+ if not edges:
+ raise ValueError("Each dimension must have at least one chunk")
+ if (
+ edges[0] > 0
+ and all(edge == edges[0] for edge in edges)
+ and (extent == sum(edges) or len(edges) == (extent + edges[0] - 1) // edges[0])
+ ):
+ dimensions.append(FixedDimension(size=edges[0], extent=extent))
+ else:
+ dimensions.append(VaryingDimension(edges, extent=extent))
+ return cls(dimensions=tuple(dimensions))
+
+ @property
+ def ndim(self) -> int:
+ """The number of dimensions."""
+ return len(self.dimensions)
+
+ @property
+ def is_regular(self) -> bool:
+ """Whether every dimension uses a single fixed chunk size.
+
+ False when any axis carries explicit (rectilinear) per-chunk edge lengths.
+ """
+ return self._is_regular
+
+ @property
+ def grid_shape(self) -> tuple[int, ...]:
+ """The number of data-bearing chunks along each dimension."""
+ return tuple(dimension.nchunks for dimension in self.dimensions)
+
+ @property
+ def chunk_shape(self) -> tuple[int, ...]:
+ """The uniform declared chunk shape of a regular grid.
+
+ Raises `ValueError` for rectilinear grids, which have no single chunk shape;
+ use `grid[coords]` for per-chunk sizes instead.
+ """
+ if not self.is_regular:
+ raise ValueError(
+ "chunk_shape is only available for regular chunk grids. "
+ "Use grid[coords] for per-chunk sizes."
+ )
+ return tuple(
+ dimension.size for dimension in self.dimensions if isinstance(dimension, FixedDimension)
+ )
+
+ @property
+ def chunk_sizes(self) -> tuple[tuple[int, ...], ...]:
+ """Per-dimension tuples of each chunk's valid data length.
+
+ Boundary chunks report their clipped extent, not the declared codec size.
+ """
+ return tuple(
+ tuple(dimension.data_size(index) for index in range(dimension.nchunks))
+ for dimension in self.dimensions
+ )
+
+ def __getitem__(self, coords: int | tuple[int, ...]) -> ChunkSpec | None:
+ """Look up the `ChunkSpec` at the given chunk coordinates (grid cells, not indices).
+
+ Returns `None` when any coordinate falls outside the grid; raises `ValueError`
+ when the number of coordinates does not match `ndim`. The spec's slices are in
+ global source coordinates.
+ """
+ if isinstance(coords, int):
+ coords = (coords,)
+ if len(coords) != self.ndim:
+ raise ValueError(
+ f"Expected {self.ndim} coordinate(s) for a {self.ndim}-d chunk grid, "
+ f"got {len(coords)}."
+ )
+ slices: list[slice] = []
+ codec_shape: list[int] = []
+ for dimension, index in zip(self.dimensions, coords, strict=True):
+ if index < 0 or index >= dimension.nchunks:
+ return None
+ offset = dimension.chunk_offset(index)
+ slices.append(slice(offset, offset + dimension.data_size(index), 1))
+ codec_shape.append(dimension.chunk_size(index))
+ return ChunkSpec(tuple(slices), tuple(codec_shape))
+
+ def __iter__(self) -> Iterator[ChunkSpec]:
+ """Yield a `ChunkSpec` for every data-bearing chunk in row-major (C) order."""
+ for coords in itertools.product(
+ *(range(dimension.nchunks) for dimension in self.dimensions)
+ ):
+ spec = self[coords]
+ if spec is not None:
+ yield spec
+
+ def all_chunk_coords(
+ self,
+ *,
+ origin: Sequence[int] | None = None,
+ selection_shape: Sequence[int] | None = None,
+ ) -> Iterator[tuple[int, ...]]:
+ """Iterate chunk coordinates over a rectangular grid region in row-major (C) order.
+
+ `origin` defaults to the grid origin and `selection_shape` to the rest of the grid.
+ The region is not bounds-checked: an oversized region yields coordinates outside
+ the grid, which `__getitem__` resolves to `None`.
+ """
+ origin_parsed = (0,) * self.ndim if origin is None else tuple(origin)
+ selection_shape_parsed = (
+ tuple(
+ grid_size - coordinate
+ for coordinate, grid_size in zip(origin_parsed, self.grid_shape, strict=True)
+ )
+ if selection_shape is None
+ else tuple(selection_shape)
+ )
+ return itertools.product(
+ *(
+ range(coordinate, coordinate + size)
+ for coordinate, size in zip(origin_parsed, selection_shape_parsed, strict=True)
+ )
+ )
+
+ def iter_chunk_regions(
+ self,
+ *,
+ origin: Sequence[int] | None = None,
+ selection_shape: Sequence[int] | None = None,
+ ) -> Iterator[tuple[slice, ...]]:
+ """Yield each chunk's valid-data slices, in global source coordinates.
+
+ Covers the same region as `all_chunk_coords`, silently skipping coordinates
+ that fall outside the grid.
+ """
+ for coords in self.all_chunk_coords(origin=origin, selection_shape=selection_shape):
+ spec = self[coords]
+ if spec is not None:
+ yield spec.slices
+
+ def get_nchunks(self) -> int:
+ """The total number of data-bearing chunks: the product of `grid_shape` (1 if 0-d)."""
+ return reduce(operator.mul, (dimension.nchunks for dimension in self.dimensions), 1)
+
+ def update_shape(self, new_shape: tuple[int, ...]) -> ChunkGrid:
+ """Return a grid resized to `new_shape` by resizing each dimension.
+
+ Fixed axes keep their chunk size; rectilinear axes gain one trailing edge when
+ grown past their declared edges. Raises `ValueError` when `new_shape` does not
+ have `ndim` entries.
+ """
+ if len(new_shape) != self.ndim:
+ raise ValueError(
+ f"new_shape has {len(new_shape)} dimensions but chunk grid has {self.ndim} dimensions"
+ )
+ return ChunkGrid(
+ dimensions=tuple(
+ dimension.resize(new_extent)
+ for dimension, new_extent in zip(self.dimensions, new_shape, strict=True)
+ )
+ )
+
+
+class EdgeDimensionGrid:
+ """An explicitly edge-based grid for coordinate-origin examples and planners.
+
+ Examples
+ --------
+ Chunk sizes `(2, 3)` tile source coordinates `[0, 5)`; lookups outside
+ that range raise:
+
+ >>> grid = EdgeDimensionGrid([2, 3])
+ >>> grid.num_chunks, grid.extent
+ (2, 5)
+ >>> grid.index_to_chunk(2)
+ 1
+ >>> grid.index_to_chunk(5)
+ Traceback (most recent call last):
+ ...
+ IndexError: index 5 is out of bounds for an axis of extent 5
+ """
+
+ __slots__ = ("_offsets", "sizes")
+
+ sizes: tuple[int, ...]
+ """The length of each chunk along the axis, in order; every entry is positive."""
+
+ def __init__(self, sizes: Sequence[int]) -> None:
+ """Build a one-axis grid from explicit per-chunk sizes.
+
+ Every size must be positive; raises `ValueError` otherwise. A zero-length axis
+ is spelled as an empty sequence (no chunks), not as a zero size.
+
+ Parameters
+ ----------
+ sizes : Sequence[int]
+ The length of each chunk along the axis, in order.
+ """
+ normalized = tuple(int(size) for size in sizes)
+ for index, size in enumerate(normalized):
+ if size <= 0:
+ raise ValueError(
+ f"chunk sizes must be positive; got {size} at position {index} of {normalized}. "
+ "A zero-length axis is spelled as no chunks at all: EdgeDimensionGrid(())"
+ )
+ self.sizes = normalized
+ offsets: np.ndarray[Any, np.dtype[np.intp]] = np.zeros(len(normalized) + 1, dtype=np.intp)
+ if normalized:
+ np.cumsum(np.asarray(normalized, dtype=np.intp), out=offsets[1:])
+ self._offsets = offsets
+
+ @property
+ def num_chunks(self) -> int:
+ """The number of chunks along the axis."""
+ return len(self.sizes)
+
+ @property
+ def extent(self) -> int:
+ """The axis length in global source coordinates: the sum of all chunk sizes."""
+ return int(self._offsets[-1])
+
+ def __repr__(self) -> str:
+ return f"EdgeDimensionGrid(sizes={self.sizes})"
+
+ def __eq__(self, other: object) -> bool:
+ if not isinstance(other, EdgeDimensionGrid):
+ return NotImplemented
+ return self.sizes == other.sizes
+
+ def __hash__(self) -> int:
+ return hash((type(self).__name__, self.sizes))
+
+ def index_to_chunk(self, idx: int) -> int:
+ """Map a global source index to the chunk whose interval contains it.
+
+ Raises `IndexError` when `idx` lies outside `[0, extent)`.
+ """
+ if idx < 0 or idx >= self.extent:
+ raise IndexError(f"index {idx} is out of bounds for an axis of extent {self.extent}")
+ return int(np.searchsorted(self._offsets, idx, side="right")) - 1
+
+ def chunk_offset(self, chunk_ix: int) -> int:
+ """The global source coordinate where chunk `chunk_ix` begins.
+
+ Raises `IndexError` when `chunk_ix` lies outside `[0, num_chunks)`.
+ """
+ if chunk_ix < 0 or chunk_ix >= len(self.sizes):
+ raise IndexError(
+ f"chunk index {chunk_ix} is out of bounds for {len(self.sizes)} chunks"
+ )
+ return int(self._offsets[chunk_ix])
+
+ def chunk_size(self, chunk_ix: int) -> int:
+ """The length of chunk `chunk_ix`; every chunk holds data, so no boundary clipping applies.
+
+ Raises `IndexError` when `chunk_ix` lies outside `[0, num_chunks)`.
+ """
+ if chunk_ix < 0 or chunk_ix >= len(self.sizes):
+ raise IndexError(
+ f"chunk index {chunk_ix} is out of bounds for {len(self.sizes)} chunks"
+ )
+ return self.sizes[chunk_ix]
+
+ def indices_to_chunks(self, indices: npt.NDArray[np.intp]) -> npt.NDArray[np.intp]:
+ """Vectorized `index_to_chunk` over an array of global source indices.
+
+ Raises `IndexError` if any index lies outside `[0, extent)`.
+ """
+ arr = _bounded_indices(indices, self.extent)
+ return (np.searchsorted(self._offsets, arr, side="right") - 1).astype(np.intp)
+
+
+def _shape_tuple(shape: Sequence[int]) -> tuple[int, ...]:
+ result = tuple(int(size) for size in shape)
+ if any(size < 0 for size in result):
+ raise ValueError(f"shape entries must be non-negative; got {result}")
+ return result
+
+
+def _entry_kind(entry: Any) -> str:
+ if isinstance(entry, (int, np.integer)) and not isinstance(entry, bool):
+ return "int"
+ if isinstance(entry, (str, bytes)):
+ return "neither"
+ try:
+ iter(cast("Iterable[Any]", entry))
+ except TypeError:
+ return "neither"
+ return "sequence"
+
+
+def dimension_grids_from_chunks(
+ chunks: Sequence[int] | Sequence[Sequence[int]], shape: Sequence[int]
+) -> tuple[DimensionGrid, ...]:
+ """Build compact dimensions from regular sizes or explicit per-axis edges.
+
+ Examples
+ --------
+ One integer per dimension builds fixed grids, ready for `plan_chunks`:
+
+ >>> from zarr_indexing import IndexTransform, plan_chunks
+ >>> grids = dimension_grids_from_chunks((2, 2), shape=(3, 4))
+ >>> [type(grid).__name__ for grid in grids]
+ ['FixedDimension', 'FixedDimension']
+ >>> plan = plan_chunks(IndexTransform.from_shape((3, 4))[1, :], grids)
+ >>> [p.chunk_coords for p in plan]
+ [(0, 0), (0, 1)]
+ """
+ shape_t = _shape_tuple(shape)
+ entries: tuple[Any, ...] = tuple(chunks)
+ if len(entries) != len(shape_t):
+ raise ValueError(
+ f"chunks must have one entry per dimension; got {len(entries)} entries for shape {shape_t}"
+ )
+
+ conventions = (
+ "chunks must be either a uniform chunk shape (one integer per dimension) "
+ "or per-axis chunk sizes (one sequence of integers per dimension)"
+ )
+ kinds = [_entry_kind(entry) for entry in entries]
+ neither = [(axis, entries[axis]) for axis, kind in enumerate(kinds) if kind == "neither"]
+ if neither:
+ described = ", ".join(f"{entry!r} at dimension {axis}" for axis, entry in neither)
+ verb = "is" if len(neither) == 1 else "are"
+ raise ValueError(f"{conventions}; {described} {verb} neither")
+
+ integer_count = sum(kind == "int" for kind in kinds)
+ if entries and integer_count == len(entries):
+ dimensions: list[DimensionGrid] = []
+ for entry, extent in zip(entries, shape_t, strict=True):
+ size = int(entry)
+ if size <= 0:
+ raise ValueError(f"chunk shape entries must be positive; got {size}")
+ dimensions.append(FixedDimension(size=size, extent=extent))
+ return tuple(dimensions)
+ if integer_count:
+ raise ValueError(f"{conventions}, not a mixture; got {entries!r}")
+
+ dimensions = []
+ for axis, (entry, extent) in enumerate(zip(entries, shape_t, strict=True)):
+ elements: tuple[Any, ...] = tuple(cast("Iterable[Any]", entry))
+ if any(
+ not isinstance(element, (int, np.integer)) or isinstance(element, bool)
+ for element in elements
+ ):
+ raise ValueError(
+ f"per-axis chunk sizes must be integers; dimension {axis} has {entry!r}"
+ )
+ edges = tuple(int(element) for element in elements)
+ total = sum(edges)
+ if total != extent:
+ raise ValueError(
+ f"per-axis chunk sizes for dimension {axis} sum to {total}, but the array extent is {extent}"
+ )
+ if extent == 0 and all(edge == 0 for edge in edges):
+ dimensions.append(FixedDimension(size=0, extent=0))
+ continue
+ if any(edge <= 0 for edge in edges):
+ raise ValueError(f"chunk sizes must be positive; got {edges}")
+ dimensions.append(VaryingDimension(edges=edges, extent=extent))
+ return tuple(dimensions)
diff --git a/packages/zarr-indexing/src/zarr_indexing/json.py b/packages/zarr-indexing/src/zarr_indexing/json.py
new file mode 100644
index 0000000000..8bf42c74a5
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/json.py
@@ -0,0 +1,166 @@
+"""The canonical ndsel wire vocabulary, and the rules for lowering it.
+
+This is the **engine layer**. Where `messages.py` is pure JSON→JSON and imposes
+no array constraints, this module holds the JSON shapes a canonical ndsel body
+takes (spec section 4.3, as produced by `zarr_indexing.messages.normalize_ndsel`)
+together with the lowering rules that turn one into the numpy-backed engine
+representation.
+
+The conversions themselves are **methods on the types**, since each type owns
+its one serialization: `IndexTransform.to_json` / `from_json`,
+`IndexDomain.to_json` / `from_json`, and `to_json` on each output map kind,
+with `output_index_map_from_json` in `zarr_indexing.output_map` dispatching the
+wire's tagged union back to the right kind. This module is what they share.
+
+Three engine constraints live **here and only here**:
+
+- **Finite bounds.** An `IndexDomain` addresses a finite array, so a canonical
+ body carrying a `"-inf"`/`"+inf"` bound cannot be lowered; `from_json` raises.
+- **Implicit bounds lower by value.** The `[n]`-bracket implicit/explicit flag
+ is a message-layer concern; the engine keeps only the integer value.
+- **Integer `index_array` content.** The message layer carries `index_array`
+ verbatim (the spec defers its shape and type), so lowering is where a float,
+ boolean or string array is rejected — as an `NdselError`, rather than
+ truncating `[0.9, 1.9]` to cells 0 and 1 or leaking a raw NumPy error.
+
+## The `index_array` wire format (and the degenerate-collapse it documents)
+
+ndsel and TensorStore both **reject** an output map that carries *both*
+`input_dimension` and `index_array`; `input_dimension` belongs to affine
+(`single_input_dimension`) maps. The in-memory `ArrayMap` matches: what a map
+depends on is read from its full-rank array's shape (its non-singleton axes),
+so there is nothing to reconstruct on load. On serialize (`to_json`):
+
+1. An all-singleton `index_array` (size 1) selects a single coordinate
+ regardless of input, so it is **collapsed to a `constant` map**
+ `{offset: offset + stride*value}`. The size-1 input dimension stays in the
+ domain, unconsumed — a valid transform. The selection layer already builds
+ such maps as `ConstantMap` (`output_map.array_map_or_constant`); this covers
+ hand-built transforms.
+2. An **empty** `index_array` (size 0) collapses the same way, to
+ `{offset: 0}`. It names no cell, and it can only be empty because an input
+ dimension is — the full-rank invariant makes every axis either 1 or the
+ domain's extent — so nothing is ever read through it and the emptiness is
+ carried by the domain, which is emitted separately. TensorStore does the
+ same: `t[ts.d[0][[]]]` is `out[0] = 0`, emitted as `{}`. Emitting the array
+ instead would produce a document neither implementation could load, because
+ `ndarray.tolist()` renders every empty array as `[]` once the leading axis
+ is the zero-length one, and nested lists cannot spell the shape back —
+ `[[]]` is `(1, 0)` and nothing spells `(0, 1)`.
+3. Non-degenerate `index_array` maps are emitted with their array and bounds
+ only.
+
+"""
+
+from __future__ import annotations
+
+from typing import Any, Required, TypedDict
+
+# ---------------------------------------------------------------------------
+# TypedDict definitions (canonical JSON shapes)
+# ---------------------------------------------------------------------------
+
+# An `index_array` serializes via `ndarray.tolist()`, so it is a nested list of
+# ints whose nesting depth equals the array rank.
+NestedIntList = list[Any]
+
+# A canonical *lowered* body carries only finite integer bounds, but the JSON
+# shape admits the full ndsel `bound` grammar: an explicit int / sentinel, or a
+# one-element implicit `[value]` array.
+IndexValueJSON = int | str
+BoundJSON = int | str | list[IndexValueJSON]
+
+
+class IndexDomainJSON(TypedDict, total=False):
+ """Canonical JSON representation of an IndexDomain.
+
+ Examples
+ --------
+ >>> doc: IndexDomainJSON = {
+ ... "input_inclusive_min": [0],
+ ... "input_exclusive_max": [4],
+ ... "input_labels": ["x"],
+ ... }
+ >>> from zarr_indexing import IndexDomain
+ >>> IndexDomain.from_json(doc).shape
+ (4,)
+ """
+
+ input_inclusive_min: Required[list[BoundJSON]]
+ """Per-dimension lower bounds; `"-inf"` is legal on the wire but cannot be lowered."""
+
+ input_exclusive_max: Required[list[BoundJSON]]
+ """Per-dimension exclusive upper bounds; `"+inf"` is legal on the wire but cannot be lowered."""
+
+ input_labels: Required[list[str]]
+ """Per-dimension names; the empty string marks an unlabeled dimension."""
+
+
+class OutputIndexMapJSON(TypedDict, total=False):
+ """Canonical JSON representation of a single output index map.
+
+ Exactly one of three forms (distinguished by which fields are present):
+
+ - `{"offset": 5}` — constant
+ - `{"offset": 0, "stride": 1, "input_dimension": 0}` — single_input_dimension
+ - `{"offset": 0, "stride": 1, "index_array": [...],
+ "index_array_bounds": ["-inf", "+inf"]}` — index_array
+
+ Examples
+ --------
+ >>> from zarr_indexing import output_index_map_from_json
+ >>> constant: OutputIndexMapJSON = {"offset": 5}
+ >>> output_index_map_from_json(constant)
+ ConstantMap(offset=5)
+ >>> affine: OutputIndexMapJSON = {"offset": 0, "stride": 2, "input_dimension": 1}
+ >>> output_index_map_from_json(affine)
+ DimensionMap(input_dimension=1, offset=0, stride=2)
+ """
+
+ offset: int
+ """Constant term; alone it is the whole constant form."""
+
+ stride: int
+ """Multiplier applied to the input coordinate or to each `index_array` value."""
+
+ input_dimension: int
+ """The input dimension the single_input_dimension form reads."""
+
+ index_array: NestedIntList
+ """Nested lists of output coordinates, one nesting level per input dimension."""
+
+ index_array_bounds: list[IndexValueJSON]
+ """Bounds the `index_array` values are promised to lie in; `["-inf", "+inf"]` if unconstrained."""
+
+
+class IndexTransformJSON(TypedDict, total=False):
+ """Canonical JSON representation of an IndexTransform (spec section 4.3).
+
+ Examples
+ --------
+ >>> doc: IndexTransformJSON = {
+ ... "input_rank": 1,
+ ... "input_inclusive_min": [0],
+ ... "input_exclusive_max": [2],
+ ... "input_labels": [""],
+ ... "output": [{"offset": 1, "stride": 2, "input_dimension": 0}],
+ ... }
+ >>> from zarr_indexing import IndexTransform
+ >>> IndexTransform.from_json(doc).domain.shape
+ (2,)
+ """
+
+ input_rank: Required[int]
+ """The number of input dimensions; the bounds and labels lists match it in length."""
+
+ input_inclusive_min: Required[list[BoundJSON]]
+ """Per-dimension lower bounds; `"-inf"` is legal on the wire but cannot be lowered."""
+
+ input_exclusive_max: Required[list[BoundJSON]]
+ """Per-dimension exclusive upper bounds; `"+inf"` is legal on the wire but cannot be lowered."""
+
+ input_labels: Required[list[str]]
+ """Per-dimension names; the empty string marks an unlabeled dimension."""
+
+ output: Required[list[OutputIndexMapJSON]]
+ """One output map per output dimension."""
diff --git a/packages/zarr-indexing/src/zarr_indexing/lazy_array.py b/packages/zarr-indexing/src/zarr_indexing/lazy_array.py
new file mode 100644
index 0000000000..950a97b25e
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/lazy_array.py
@@ -0,0 +1,1365 @@
+"""`LazyArray` — TensorStore-style lazy indexing over array-like sources.
+
+`LazyArray` wraps a source with `shape`, `dtype`, and basic integer/slice
+`__getitem__`, whose reads can be lowered through NumPy system memory. It adds
+a `.lazy` accessor whose indexing operations build up an
+[`IndexTransform`](transform.md) instead of reading data:
+
+```python
+view = LazyArray(source).lazy[10:50, ::2].lazy.oindex[[3, 1, 1], :]
+view.shape # known without touching the data
+values = view.result()
+```
+
+Nothing is read until `result()` (or `__array__`, or an eager `__getitem__`).
+Every `.lazy` operation is metadata-only. Composition does not accumulate
+layers: a view of a view is still a single transform and retains its reader.
+
+Parts
+-----
+A `LazyArray` carries a **partitioning** of the array it wraps: a grid of boxes
+that a read is broken into. `parts()` walks those boxes as they fall through the
+view, yielding a [`Partition`](#zarr_indexing.lazy_array.Partition) per box. Its
+paired projection describes the chunk-local read and where its cells land in the
+request; `view` carries that partition's transform. `result()` allocates one
+fresh output buffer, then reads each partition once through the selected reader
+into the final buffer or an owned temporary for fancy placement.
+
+The part view's transform directly addresses its raw wrapped array. The paired
+projection deliberately retains the chunk-local frame; both travel together in
+the `ReadContext` passed to the reader.
+
+The partitioning is discovered from the wrapped array at construction — first
+`read_chunk_sizes` (zarr's clipped per-axis sizes, sharding-aware), then
+`chunks`, read as per-axis sizes if its entries are sequences and as a uniform
+box shape if they are integers. Those attribute names belong to the wrapped
+array; this API refers only to parts. An array that advertises neither gets a
+single whole-array part, and resolving it reads the whole view through its
+selected reader in one pass.
+
+`with_parts` replaces the partitioning without touching the data or the view:
+
+```python
+view.with_parts((64, 64)) # uniform boxes, tail clipped
+view.with_parts_per_axis(((3, 3, 1),)) # explicit per-axis sizes
+view.unpartitioned() # one whole-array part; resolve in one shot
+```
+
+Repartitioning changes how the read is divided, not what `result()` returns.
+Parts that do not align with the source's own boxes are permitted and can be
+useful (to bound peak memory, or to batch small reads); they cost extra I/O but
+do not affect correctness.
+
+Readers
+-------
+Every wrapper carries a reader that owns the backend-specific request. The
+transform answers **which values?** and is independent of the backend; the
+reader answers **how does this backend obtain them?** and must preserve the
+complete transform exactly. Readers do not define indexing semantics,
+partitioning, scheduling, or result ownership. The conservative
+`LazyArray(source)` uses `basic_reader`, which needs only basic slicing.
+`LazyArray.from_numpy(array)` explicitly opts into `numpy_reader` for direct
+NumPy indexing. `with_reader()` replaces the reader without reading or changing
+the view metadata. The reader object is shared by all derived views and their
+parts. Consumers may materialize part views concurrently; `LazyArray` does not
+serialize calls, so a stateful reader must synchronize its own mutable state.
+
+Both built-in readers lower through NumPy system memory. They do not implicitly
+transfer device arrays. A device source requires an explicit custom reader that
+performs any needed transfer into the supplied system-memory output buffer.
+
+Boxes and queries
+-----------------
+A selection is either **rectangular** — an interval and a stride per dimension,
+which is what basic indexing composes to at any depth — or a **query**, an
+explicit list of coordinates, which is what `oindex`, `vindex`, and masks
+produce and which subsequent basic indexing cannot undo. `is_box` reports the
+category and `bounding_box()` reports the storage region touched: the exact
+interval per dimension for a box, a hull for a query. A box is only *dense* in
+that interval when every entry of `strides()` is 1. The distinction is
+structural rather than an optimization; [the design
+notes](../design-notes.md) describe why it matters to consumers of a selection.
+
+The positional dialect
+----------------------
+Selections on `LazyArray` are **positional, NumPy-style**: index 0 is the first
+element of the current view, `-1` is the last, boolean masks must match the
+view's shape, and every index is bounds-checked against the view.
+
+This differs deliberately from `zarr.Array.lazy[...]`, which exposes the
+**literal** TensorStore dialect: a zarr view keeps the coordinate system of the
+array it came from, so after `v = arr.lazy[10:50]` the first element of `v` is
+`v[10]` and a negative index is out of bounds rather than counted from the end.
+That dialect suits zarr, where a view's coordinates stay comparable with the
+parent array's. `LazyArray` is a duck array and has to behave like the array it
+wraps to be usable as a NumPy drop-in or as a dask source, so it re-zeroes its
+coordinates on every view and uses positions. `zarr_indexing.boundary` performs
+the translation between the two.
+
+Two more NumPy rules the dialect keeps, in every mode:
+
+- A scalar integer drops its axis. Any non-boolean object implementing Python's
+ `SupportsIndex` protocol is accepted as one, including in slice bounds and
+ steps; an `__int__` method alone is deliberately not enough. A scalar is a
+ basic index wherever it appears, applied before any advanced index rather
+ than broadcast against one. So
+ `lazy.oindex[0]` has the shape of `x[0]`, `lazy.oindex[0, [1, 2], :]` means
+ `x[0][numpy.ix_([1, 2], ...)]`, and `lazy.oindex[0, 1, 2]` and
+ `lazy.vindex[0, 1, 2]` are both zero-rank. Use a length-1 list to keep an
+ axis.
+- Advanced indices are placed as NumPy places them. For a `vindex` selection
+ that leaves some axes unindexed, the gathered dimensions sit where the
+ coordinate arrays sat when those arrays are adjacent, and lead when a slice
+ separates them — so `lazy.vindex[..., i, j]` has shape
+ `(x.shape[0], *broadcast)`, matching `x[..., i, j]`.
+
+Materializing on fallback
+-------------------------
+`LazyArray` implements `__array__` but deliberately implements neither
+`__array_ufunc__` nor `__array_function__`. A NumPy *function* given a view
+therefore materializes the whole thing through
+`__array__` and works on the resulting array: `numpy.sum(view)`,
+`numpy.add(view, 1)` and `numpy.stack([view, view])` all do, and so does
+`numpy.ones(view.shape) + view`, where the ndarray on the left dispatches.
+
+Python's arithmetic *operators* do not: `view + 1` raises `TypeError`, because
+the wrapper defines no arithmetic dunders and an `int` has nothing to dispatch
+to. Both facts follow from the same intent — laziness here applies to indexing,
+not to building a deferred compute graph — and a `LazyArray` is not a drop-in
+for arithmetic on a large array either way. Use `.lazy[...]` to narrow the view
+first, or pass the wrapper to `dask.array.from_array` so that dask owns the
+compute graph.
+
+Ownership
+---------
+`result()` always allocates fresh system memory before reading through the
+selected reader. A `numpy.ma` source keeps its mask by receiving a masked
+output buffer; other source-specific array types do not survive materializing.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+import operator
+import uuid
+from collections.abc import Sequence
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any, Protocol, cast
+
+import numpy as np
+
+from zarr_indexing.boundary import (
+ SelectionMode,
+ normalize_positional_selection,
+ split_scalar_axes,
+)
+from zarr_indexing.chunk_resolution import (
+ ChunkProjection,
+ plan_chunks,
+)
+from zarr_indexing.grid import DimensionGrid, FixedDimension, dimension_grids_from_chunks
+from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap
+from zarr_indexing.reader import (
+ ReadContext,
+ Reader,
+ basic_reader,
+ numpy_reader,
+)
+from zarr_indexing.transform import (
+ IndexTransform,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Iterator
+
+ SelectFn = Callable[[Any, SelectionMode], "LazyArray"]
+
+__all__ = ["LazyArray", "Partition"]
+
+# Above this many bytes, the no-dask token fallback describes an array
+# structurally instead of digesting its contents. See `_wrapped_token`.
+_TOKEN_DIGEST_LIMIT = 1 << 20
+
+
+def _invoke_reader(
+ reader: Reader,
+ source: Any,
+ context: ReadContext,
+ out: np.ndarray[Any, Any],
+) -> None:
+ """Invoke a reader and enforce its in-place return contract."""
+ returned = reader.read_into(source, context, out)
+ if returned is not None:
+ raise TypeError(f"reader.read_into must return None, got {type(returned).__name__}")
+
+
+def _is_correlated(transform: IndexTransform) -> bool:
+ """True when the transform gathers a list of points rather than an outer product."""
+ return transform.index_array_structure == "general"
+
+
+class ArrayLike(Protocol):
+ """The surface `LazyArray` needs from the array it wraps."""
+
+ @property
+ def shape(self) -> tuple[int, ...]: ...
+ @property
+ def dtype(self) -> Any: ...
+ def __getitem__(self, key: Any) -> Any: ...
+
+
+# NumPy's dtype-specialized ``__getitem__`` overloads do not structurally match
+# the deliberately broad protocol above under strict type checking. Accept an
+# ndarray explicitly so users do not have to erase its type with ``cast(Any, …)``.
+_WrappedArray = ArrayLike | np.ndarray[Any, Any]
+
+
+# --------------------------------------------------------------------------- #
+# Partition discovery
+# --------------------------------------------------------------------------- #
+
+
+def _read_source_attribute(array: Any, name: str) -> Any:
+ """Read a partition-describing attribute, treating any failure as "absent".
+
+ Discovery inspects an object we did not write. A missing attribute is the
+ common case, but zarr raises an `AttributeError` subclass from
+ `read_chunk_sizes` on a lazy view, and other backends compute the attribute
+ lazily and may fail for their own reasons. Any failure here means "this
+ array does not advertise a partitioning", never a hard error.
+ """
+ try:
+ return getattr(array, name, None)
+ # Guarded properties (e.g. zarr's LazyViewError) and broken foreign
+ # attributes may raise anything; discovery must degrade to None.
+ except Exception:
+ return None
+
+
+def _discover_parts(array: Any, shape: tuple[int, ...]) -> tuple[DimensionGrid, ...] | None:
+ """Resolve the partitioning advertised by `array`, or None for one whole part.
+
+ Discovery parses external input: an attribute that does not describe a
+ partitioning of `shape` means "this object does not advertise one I
+ understand", and the array is treated as unpartitioned rather than rejected.
+ A partitioning is an I/O strategy, so reading the whole array is always a
+ correct fallback. `with_parts` is a public API and validates strictly.
+ """
+ declared = _read_source_attribute(array, "read_chunk_sizes")
+ if declared is None:
+ declared = _read_source_attribute(array, "chunks")
+ if declared is None:
+ return None
+ try:
+ return dimension_grids_from_chunks(declared, shape)
+ except (ValueError, TypeError):
+ return None
+
+
+def _whole_array_grids(shape: tuple[int, ...]) -> tuple[DimensionGrid, ...]:
+ """A partitioning with a single part covering the whole array."""
+ return tuple(FixedDimension(size=extent, extent=extent) for extent in shape)
+
+
+# --------------------------------------------------------------------------- #
+# The lowering engine
+# --------------------------------------------------------------------------- #
+
+
+def _is_identity_transform(transform: IndexTransform, shape: tuple[int, ...]) -> bool:
+ """True when `transform` maps every coordinate of `shape` to itself.
+
+ Structural rather than an `==` against `IndexTransform.from_shape`: an
+ `ArrayMap` holds an ndarray, so equality on two transforms that both carry
+ one would try to take the truth value of an array.
+ """
+ domain = transform.domain
+ if domain.inclusive_min != (0,) * len(shape) or domain.exclusive_max != shape:
+ return False
+ if len(transform.output) != len(shape):
+ return False
+ return all(
+ isinstance(m, DimensionMap) and m.input_dimension == i and m.offset == 0 and m.stride == 1
+ for i, m in enumerate(transform.output)
+ )
+
+
+# --------------------------------------------------------------------------- #
+# Partitions
+# --------------------------------------------------------------------------- #
+
+
+@dataclass(frozen=True, slots=True, eq=False)
+class _PartOwner:
+ """Opaque identity shared only by one view and the parts it prepared."""
+
+
+def _partition_out_selection(
+ cell_transform: IndexTransform,
+) -> tuple[Any, ...]:
+ """Lower ``cell_transform`` to NumPy selectors on the request buffer."""
+ domain = cell_transform.domain
+ if _is_correlated(cell_transform):
+ correlated_selectors: list[np.ndarray[Any, np.dtype[np.intp]]] = []
+ for output_map in cell_transform.output:
+ if isinstance(output_map, ConstantMap):
+ coordinates = np.full(domain.shape, output_map.offset, dtype=np.intp)
+ elif isinstance(output_map, DimensionMap):
+ input_dimension = output_map.input_dimension
+ axis = np.arange(
+ domain.inclusive_min[input_dimension],
+ domain.exclusive_max[input_dimension],
+ dtype=np.intp,
+ )
+ shape = (
+ (1,) * input_dimension
+ + (axis.size,)
+ + ((1,) * (domain.ndim - input_dimension - 1))
+ )
+ coordinates = np.broadcast_to(axis.reshape(shape), domain.shape)
+ coordinates = output_map.offset + output_map.stride * coordinates
+ else:
+ coordinates = output_map.offset + output_map.stride * np.broadcast_to(
+ output_map.index_array, domain.shape
+ )
+ correlated_selectors.append(np.asarray(coordinates, dtype=np.intp))
+ return tuple(correlated_selectors)
+
+ selectors: list[int | slice | np.ndarray[Any, np.dtype[np.intp]]] = []
+ n_array_maps = sum(isinstance(output_map, ArrayMap) for output_map in cell_transform.output)
+ for output_map in cell_transform.output:
+ if isinstance(output_map, ConstantMap):
+ selectors.append(output_map.offset)
+ elif isinstance(output_map, DimensionMap):
+ input_dimension = output_map.input_dimension
+ lo = domain.inclusive_min[input_dimension]
+ hi = domain.exclusive_max[input_dimension]
+ selectors.append(
+ slice(
+ output_map.offset + output_map.stride * lo,
+ output_map.offset + output_map.stride * hi,
+ output_map.stride,
+ )
+ )
+ else:
+ selectors.append(
+ (output_map.offset + output_map.stride * output_map.index_array.ravel()).astype(
+ np.intp
+ )
+ )
+ if n_array_maps > 1:
+ axes = [
+ np.asarray([selector], dtype=np.intp)
+ if isinstance(selector, int)
+ else (
+ np.arange(selector.start, selector.stop, selector.step, dtype=np.intp)
+ if isinstance(selector, slice)
+ else selector
+ )
+ for selector in selectors
+ ]
+ return np.ix_(*axes)
+ return tuple(selectors)
+
+
+def _out_selection_cell_count(selection: tuple[Any, ...], out_shape: tuple[int, ...]) -> int:
+ """How many cells of an array of shape `out_shape` a `Partition.out_selection` writes.
+
+ Counted from the selectors' own shapes, so nothing is read and no index
+ array is materialized. The selectors are slices and integer arrays: the
+ slices contribute their lengths, and the arrays broadcast against each other
+ exactly as NumPy's advanced indexing broadcasts them, whether they arrive as
+ an open mesh (`numpy.ix_`) or as parallel coordinates (`unravel_index`).
+ """
+ total = 1
+ array_shapes: list[tuple[int, ...]] = []
+ if len(selection) != len(out_shape):
+ raise AssertionError(
+ f"a partition addressed {len(selection)} of the view's {len(out_shape)} "
+ "dimensions; this is a bug in zarr-indexing's partition walk"
+ )
+ for selector, extent in zip(selection, out_shape, strict=True):
+ if isinstance(selector, slice):
+ start: Any = selector.start
+ stop: Any = selector.stop
+ step: Any = selector.step
+ if step is None and start is not None and stop is not None and 0 <= start <= stop:
+ # The shape a partition walk actually produces: a concrete,
+ # forward, in-bounds interval. Sized directly, so the common
+ # path allocates neither a tuple nor a range.
+ total *= int(stop) - int(start)
+ else:
+ total *= len(range(*selector.indices(extent)))
+ else:
+ array_shapes.append(tuple(int(s) for s in np.shape(selector)))
+ if len(array_shapes) > 0:
+ total *= math.prod(np.broadcast_shapes(*array_shapes))
+ return total
+
+
+@dataclass(frozen=True, kw_only=True)
+class Partition:
+ """One box of a `LazyArray`'s partitioning, as it falls through the view.
+
+ Yielded by [`LazyArray.parts`][zarr_indexing.lazy_array.LazyArray.parts].
+ The parts of a view tile it exactly and disjointly: assembling every
+ `view.result()` at its `out_selection` reproduces the whole view's
+ `result()`, and each part can be resolved independently and concurrently.
+ Derived parts retain the same reader object; a shared stateful reader owns
+ synchronization for concurrent calls.
+
+ A consumer that needs the plan before materialization can prepare it once
+ and reuse the same immutable parts for both scheduling and assembly:
+
+ ```python
+ parts = tuple(view.parts())
+ schedule(part.base_coords for part in parts)
+ values = view.result(parts=parts)
+ ```
+
+ Prepared parts are owned by the exact view that created them and must tile
+ it completely. Passing parts from another view, even an equivalent one, is
+ rejected without reading; omitting a part is likewise rejected rather than
+ returning a partly initialized result.
+
+ Attributes
+ ----------
+ projection
+ The source-independent description of this part. Its paired
+ `chunk_transform` and `cell_transform` share one compact synthetic
+ domain, mapping each selected cell to chunk-local storage and request
+ coordinates respectively. This is the authoritative placement model;
+ `base_coords` and `is_complete` are conveniences derived from it.
+ base_coords
+ Which box of the base partitioning this is, one coordinate per dimension
+ of the wrapped array.
+ box
+ The box itself, in the global storage coordinates of the wrapped
+ array: one `[inclusive_min, exclusive_max)` interval per dimension. It
+ describes the whole partition cell, while `view.bounding_box()` is the
+ global hull of only the selected values in that cell. For a nested or
+ repartitioned view this box may be narrower than
+ `projection.chunk_domain`.
+ view
+ A `LazyArray` covering exactly the cells of the view that live in this
+ box. Its transform directly addresses its raw wrapped `array`; only the
+ projection's `chunk_transform` is chunk-local. Resolving the view reads
+ the box once through its selected reader. Named `view` rather than
+ `array` because `LazyArray.array` is the opposite thing — the raw
+ wrapped source — and the two sat next to each other meaning inverses.
+ out_selection
+ Where `view.result()` belongs in an array of the whole view's shape — a
+ NumPy index tuple with one entry per dimension of the view, usable
+ directly as `out[part.out_selection] = ...`.
+ is_complete
+ Whether the view covers the whole box. Useful to a writer deciding
+ between a blind overwrite and a read-modify-write. Fancy projections
+ report `False` because their coverage is deliberately `unknown` until
+ duplicate-aware proof is added.
+
+ Examples
+ --------
+ Assembling every part's result at its `out_selection` reproduces the view:
+
+ >>> import numpy as np
+ >>> source = np.arange(12).reshape(3, 4)
+ >>> view = LazyArray.from_numpy(source).with_parts((2, 2))
+ >>> out = np.empty(view.shape, dtype=view.dtype)
+ >>> for part in view.parts():
+ ... out[part.out_selection] = part.view.result()
+ >>> bool((out == source).all())
+ True
+ """
+
+ projection: ChunkProjection
+ box: tuple[tuple[int, int], ...]
+ view: LazyArray
+ out_selection: tuple[Any, ...]
+ _owner: _PartOwner | None = field(default=None, repr=False, compare=False)
+
+ @property
+ def base_coords(self) -> tuple[int, ...]:
+ """Coordinates of this partition in the selected base grid."""
+ return self.projection.chunk_coords
+
+ @property
+ def is_complete(self) -> bool:
+ """Whether the projection proves it covers the entire selected cell."""
+ return self.projection.coverage == "full"
+
+
+def _validate_prepared_parts(parts: Sequence[Partition], out_shape: tuple[int, ...]) -> None:
+ """Require `parts` to address every output cell exactly once.
+
+ Prepared parts are caller-supplied input, so a plan that does not tile the
+ view is a `ValueError`, not an assertion about this library's own walk.
+ """
+ coverage = np.zeros(out_shape, dtype=np.bool_)
+ addressed = 0
+ try:
+ for part in parts:
+ # Name a rank mismatch explicitly instead of letting NumPy treat
+ # omitted selectors as implicit full slices.
+ addressed += _out_selection_cell_count(part.out_selection, out_shape)
+ if all(isinstance(selector, slice) for selector in part.out_selection):
+ # The common box part: plain assignment, no pointwise walk.
+ coverage[part.out_selection] = True
+ else:
+ # `logical_or.at` applies duplicate advanced coordinates one by
+ # one instead of buffering them as ordinary advanced indexing
+ # would.
+ np.logical_or.at(coverage, part.out_selection, True)
+ except (AssertionError, IndexError, TypeError, ValueError) as error:
+ raise ValueError("prepared parts do not tile the view exactly") from error
+ if addressed != math.prod(out_shape) or not np.all(coverage):
+ raise ValueError("prepared parts do not tile the view exactly")
+
+
+# --------------------------------------------------------------------------- #
+# Tokenization
+# --------------------------------------------------------------------------- #
+
+
+def _wrapped_token(array: Any) -> Any:
+ """A token for the wrapped array.
+
+ In order of preference: the array's own `__dask_tokenize__`;
+ `dask.base.tokenize` when dask is importable (imported lazily — this package
+ never requires it); otherwise a local fallback that digests the contents of
+ a small array.
+
+ The two environments do not agree, and neither is a translation of the
+ other: a token taken with dask installed is meaningless to a process without
+ it, and the reverse. A token is an identifier within one process, not a
+ portable name.
+
+ Above `_TOKEN_DIGEST_LIMIT` the local fallback has nothing left to identify
+ the contents with — reading them is exactly what a token call must not do —
+ so it declines to claim equality at all and returns a value that matches
+ nothing, including itself. A cache keyed on it misses; the alternative, a
+ structural description, is a cache that hands one array's result to a
+ different array of the same shape and dtype.
+ """
+ hook = getattr(array, "__dask_tokenize__", None)
+ if hook is not None:
+ try:
+ return hook()
+ # A token must never raise; fall through to the structural fallback.
+ except Exception: # pragma: no cover - a hook that refuses to run
+ pass
+ try:
+ # dask is an optional peer, never a dependency of this package, so it is
+ # imported here and its absence is ordinary.
+ from dask.base import tokenize # pyright: ignore[reportMissingImports]
+ except ImportError:
+ pass
+ else:
+ return tokenize(array)
+
+ shape = tuple(int(s) for s in getattr(array, "shape", ()))
+ dtype = getattr(array, "dtype", None)
+ structural = (type(array).__qualname__, shape, str(dtype))
+ # A token nothing can equal, for when the contents cannot be identified. It
+ # is the shape and dtype that would otherwise be mistaken for an identity,
+ # so they are kept alongside it for a reader looking at a graph.
+ unidentified = (*structural, "unidentified", uuid.uuid4().hex)
+
+ # Decide whether to digest the contents from the *declared* size. Measuring
+ # it by converting first would read the whole array — a multi-gigabyte store
+ # pulled into memory by a token call, which is the opposite of the point.
+ itemsize = getattr(dtype, "itemsize", None)
+ if not isinstance(itemsize, int) or itemsize * math.prod(shape) > _TOKEN_DIGEST_LIMIT:
+ return unidentified
+ try:
+ contents = np.ascontiguousarray(array)
+ # A token must never raise; an unreadable source is simply unidentified.
+ except Exception:
+ return unidentified
+ return (*structural, hashlib.sha256(contents.tobytes()).hexdigest())
+
+
+# --------------------------------------------------------------------------- #
+# The wrapper
+# --------------------------------------------------------------------------- #
+
+
+class LazyArray:
+ """A lazily-indexable view over a system-memory/basic-indexing source.
+
+ Wrapping neither copies nor reads the wrapped array at construction time.
+ Indexing through `.lazy` composes an `IndexTransform` and returns another
+ `LazyArray`; `result()` materializes.
+
+ Selections use the **positional NumPy dialect** and reads are broken up
+ along a **partitioning** discovered from the wrapped array. Every derived
+ view retains its reader; that reader receives the complete projected
+ transform once per part. See the module docstring, which also covers how the
+ dialect differs from `zarr.Array.lazy` and why every non-indexing NumPy
+ operation materializes the view.
+
+ This wrapper describes **reads**. It defines no `__setitem__`, so
+ assigning into a view raises `TypeError`. Writing belongs to the
+ consumer: plan the selection with
+ [`plan_chunks`][zarr_indexing.chunk_resolution.plan_chunks] and own the
+ read-modify-write, since chunk atomicity and concurrent-writer policy are
+ the backend's to decide, not an indexing plan's.
+
+ Parameters
+ ----------
+ array
+ The array to wrap. It must expose `shape`, `dtype`, and `__getitem__`
+ with basic (integer/slice) indexing; `__setitem__` is not required, so
+ a read-only source wraps as well as a writable one. Its partitioning,
+ if it advertises one, is discovered here; use `with_parts` to choose
+ a different one.
+ This conservative constructor selects `basic_reader`; use `from_numpy`
+ for a NumPy array or `with_reader` to select another backend adapter.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> source = np.arange(12).reshape(3, 4)
+ >>> view = LazyArray.from_numpy(source).with_parts((2, 2)).lazy[1:, ::2]
+ >>> view.shape
+ (2, 2)
+ >>> view.result()
+ array([[ 4, 6],
+ [ 8, 10]])
+ """
+
+ __slots__ = ("_array", "_part_owner", "_parts", "_reader", "_transform", "_window")
+
+ def __init__(self, array: _WrappedArray) -> None:
+ """Wrap `array` without reading it; parameters are documented on the class.
+
+ The only validation here is the `numpy.matrix` rejection (`TypeError`).
+ """
+ if isinstance(array, np.matrix):
+ # `np.matrix` keeps every result two-dimensional, so `m[1]` has shape
+ # `(1, n)` where every other array-like gives `(n,)`. A view's shape
+ # comes from the transform, which follows NumPy's rule, so the two
+ # disagree on every rank-reducing selection. Refused at the door
+ # rather than resolved into a shape the view did not promise.
+ raise TypeError(
+ "numpy.matrix cannot be wrapped: it never reduces rank, so a "
+ "view's shape and its result would disagree. Convert it first, "
+ "with numpy.asarray(m)."
+ )
+ shape = tuple(int(s) for s in array.shape)
+ self._array = array
+ self._window: tuple[slice, ...] | None = None
+ self._transform = IndexTransform.from_shape(shape)
+ self._parts = _discover_parts(array, shape)
+ self._reader = basic_reader
+ self._part_owner = _PartOwner()
+
+ @classmethod
+ def from_numpy(cls, array: np.ndarray[Any, Any]) -> LazyArray:
+ """Wrap a NumPy array with its explicitly selected optimized reader."""
+ if not isinstance(cast(object, array), np.ndarray):
+ raise TypeError(
+ f"LazyArray.from_numpy requires a numpy.ndarray, got {type(array).__name__}"
+ )
+ return cls(array).with_reader(numpy_reader)
+
+ @classmethod
+ def _derive(
+ cls,
+ array: _WrappedArray,
+ transform: IndexTransform,
+ parts: tuple[DimensionGrid, ...] | None,
+ window: tuple[slice, ...] | None,
+ reader: Reader,
+ ) -> LazyArray:
+ """Build a wrapper sharing `array` but carrying a new transform or partitioning."""
+ view = cls.__new__(cls)
+ view._array = array
+ # Views re-zero their coordinate system: the positional dialect means a
+ # view's first element is at position 0 whatever it was sliced from.
+ view._transform = transform.translate_domain_to((0,) * transform.input_rank)
+ view._parts = parts
+ view._window = window
+ view._reader = reader
+ view._part_owner = _PartOwner()
+ return view
+
+ @property
+ def _base_shape(self) -> tuple[int, ...]:
+ """The shape of what this wrapper treats as its base array."""
+ if self._window is None:
+ return tuple(int(s) for s in self._array.shape)
+ return tuple(s.stop - s.start for s in self._window)
+
+ # -- array-like surface -------------------------------------------------
+
+ @property
+ def array(self) -> _WrappedArray:
+ """The wrapped array."""
+ return self._array
+
+ @property
+ def base_shape(self) -> tuple[int, ...]:
+ """The shape the partitioning is expressed in — not this view's shape.
+
+ `with_parts` and `with_parts_per_axis` describe boxes of the array being
+ read, not of the view reading it, so a narrowed view still partitions
+ the extents named here. For a part's own `array`, this is the part's
+ box, which is why the same call means different sizes there. Without
+ somewhere to read it, the frame in force could only be inferred from an
+ error message.
+ """
+ return self._base_shape
+
+ @property
+ def transform(self) -> IndexTransform:
+ """The composed transform from this view's coordinates to storage."""
+ return self._transform
+
+ @property
+ def shape(self) -> tuple[int, ...]:
+ """The shape of this view — the transform's input domain, not the source's."""
+ return self._transform.domain.shape
+
+ @property
+ def ndim(self) -> int:
+ """Number of dimensions of this view — the transform's input rank."""
+ return self._transform.input_rank
+
+ @property
+ def size(self) -> int:
+ """Total number of elements in this view (the product of `shape`)."""
+ return math.prod(self.shape)
+
+ @property
+ def dtype(self) -> Any:
+ """The wrapped array's dtype; views never change it."""
+ return self._array.dtype
+
+ @property
+ def reader(self) -> Reader:
+ """The backend adapter used when this view materializes."""
+ return self._reader
+
+ def with_reader(self, reader: Reader) -> LazyArray:
+ """Return the same metadata view resolved through `reader`."""
+ if not callable(getattr(reader, "read_into", None)):
+ raise TypeError(f"reader.read_into must be callable, got {type(reader).__name__}")
+ return LazyArray._derive(
+ self._array,
+ self._transform,
+ self._parts,
+ self._window,
+ reader,
+ )
+
+ # -- shape of the selection ---------------------------------------------
+
+ @property
+ def is_box(self) -> bool:
+ """Whether this view selects a rectangular region rather than a point list.
+
+ True exactly when the composed transform's output maps are all
+ `ConstantMap` or `DimensionMap` — no `ArrayMap`. Such a selection is
+ affine and monotone along every axis, so it is described completely by
+ an interval and a stride per dimension:
+ [`bounding_box`][zarr_indexing.lazy_array.LazyArray.bounding_box]
+ together with
+ [`strides`][zarr_indexing.lazy_array.LazyArray.strides]. Basic indexing,
+ at any depth of composition, stays a box; one `oindex`, `vindex`, or
+ mask anywhere in the chain makes the selection a query permanently.
+
+ A box is dense — every cell of its bounding box selected — only when
+ every stride is 1. A strided box covers its hull sparsely:
+ `lazy[10:50, ::4]` selects 40x20 cells out of a 40x77 hull, so a
+ consumer that reads the whole hull and discards the rest transfers 3.85x
+ the data it needs. Check `strides` before treating a box as a single
+ slab read.
+
+ The distinction lets a consumer decide between a slab read and a
+ gather; see [the design notes](../design-notes.md) for why it is a
+ category rather than an optimization.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> array = LazyArray.from_numpy(np.arange(12).reshape(3, 4))
+ >>> (array.lazy[1:, ::2].is_box, array.lazy.oindex[[2, 0], :].is_box)
+ (True, False)
+ """
+ return not any(isinstance(m, ArrayMap) for m in self._transform.output)
+
+ def bounding_box(self) -> tuple[tuple[int, int], ...] | None:
+ """The storage region this view touches, one interval per storage dimension.
+
+ Defined for any selection, box or not, as the hull: the smallest
+ `[inclusive_min, exclusive_max)` interval per dimension of the array
+ this view reads from that contains every coordinate the selection
+ reaches.
+
+ The hull is dense — every cell in it selected — only for a box whose
+ every stride is 1. A strided box selects a sublattice of its hull (pair
+ this with [`strides`][zarr_indexing.lazy_array.LazyArray.strides] to
+ describe it fully), and a query's hull is a superset that can be
+ arbitrarily loose: `oindex[[0, 999]]` has a 1000-wide hull over two
+ rows.
+
+ Returns
+ -------
+ tuple of (int, int), or None
+ One interval per storage dimension, or `None` when the view is
+ empty (`size == 0`) and so touches no coordinate at all, leaving no
+ interval to report.
+
+ Notes
+ -----
+ The coordinates directly address the raw `array` this view exposes.
+ Consequently, partition views report source-global hulls;
+ [`Partition.box`][zarr_indexing.lazy_array.Partition] separately gives
+ the whole global partition cell rather than only the selected hull.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> array = LazyArray.from_numpy(np.arange(12).reshape(3, 4))
+ >>> array.lazy[1:, ::2].bounding_box()
+ ((1, 3), (0, 3))
+ >>> array.lazy.oindex[[2, 0], :].bounding_box()
+ ((0, 3), (0, 4))
+ >>> array.lazy[1:1].bounding_box() is None
+ True
+ """
+ if self.size == 0:
+ return None
+ domain = self._transform.domain
+ bounds: list[tuple[int, int]] = []
+ for m in self._transform.output:
+ if isinstance(m, ConstantMap):
+ bounds.append((m.offset, m.offset + 1))
+ elif isinstance(m, DimensionMap):
+ d = m.input_dimension
+ first = m.offset + m.stride * domain.inclusive_min[d]
+ last = m.offset + m.stride * (domain.exclusive_max[d] - 1)
+ bounds.append((min(first, last), max(first, last) + 1))
+ else:
+ coords = m.offset + m.stride * m.index_array
+ bounds.append((int(coords.min()), int(coords.max()) + 1))
+ return tuple(bounds)
+
+ def strides(self) -> tuple[int, ...] | None:
+ """The step between selected coordinates, one per storage dimension.
+
+ Together with `bounding_box()`, this fully describes a box selection:
+ `bounding_box()` gives the interval per dimension, `strides()` gives the
+ step per dimension. A stride of 1 means every cell of the hull along
+ that dimension is selected; `k` means every `k`-th. Dimensions fixed by
+ an integer index report 1 — they span a single coordinate.
+
+ Returns
+ -------
+ tuple of int, or None
+ One positive stride per storage dimension, or `None` when
+ [`is_box`][zarr_indexing.lazy_array.LazyArray.is_box] is false: a
+ query's coordinates are a lookup table and have no step. An empty
+ box still reports its strides even though
+ [`bounding_box`][zarr_indexing.lazy_array.LazyArray.bounding_box]
+ returns `None`, because the step is a property of the selection's
+ shape, not of the (empty) region it touches.
+
+ Notes
+ -----
+ Magnitudes only. A reversing view (`lazy[::-1]`) selects the same set of
+ coordinates as the equivalent forward view, so it reports the same
+ bounding box and the same strides. The traversal direction is recorded
+ in the transform, not in this description of the region touched. A
+ consumer that needs the order reads the transform, or reverses the block
+ it gets back.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> array = LazyArray.from_numpy(np.arange(24).reshape(4, 6))
+ >>> (array.lazy[1:, ::2].bounding_box(), array.lazy[1:, ::2].strides())
+ (((1, 4), (0, 5)), (1, 2))
+ >>> array.lazy[2, ::3].strides()
+ (1, 3)
+ >>> array.lazy.oindex[[2, 0], :].strides() is None
+ True
+ """
+ if not self.is_box:
+ return None
+ return tuple(
+ 1 if isinstance(m, ConstantMap) else abs(m.stride) for m in self._transform.output
+ )
+
+ # -- partitioning -------------------------------------------------------
+
+ def with_parts(self, parts: Sequence[int]) -> LazyArray:
+ """Return the same view, read in uniform boxes of shape `parts`.
+
+ One integer per dimension of `base_shape`, with the trailing box in each
+ dimension clipped to the extent. The transform, the wrapped array, and
+ therefore `result()` are all unchanged; only the boxes the read is
+ broken into differ. Nothing is copied and nothing is read.
+
+ For per-axis sizes see
+ [`with_parts_per_axis`][zarr_indexing.lazy_array.LazyArray.with_parts_per_axis],
+ and to read in one pass see
+ [`unpartitioned`][zarr_indexing.lazy_array.LazyArray.unpartitioned].
+ The three were one parameter whose meaning was decided by inspecting the
+ type of what it was given, which left no way to ask for one of them and
+ be told when you had spelled it wrong.
+
+ Parameters
+ ----------
+ parts
+ The box shape, one integer per dimension of `base_shape`.
+
+ Returns
+ -------
+ LazyArray
+ The same view with a new partitioning.
+
+ Raises
+ ------
+ ValueError
+ If `parts` has the wrong length or contains a non-positive extent.
+ Uniform part sizes must remain positive even for a zero-length
+ axis; use `with_parts_per_axis` for the accepted explicit zero-axis
+ spellings.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> view = LazyArray.from_numpy(np.arange(12).reshape(3, 4))
+ >>> [part.base_coords for part in view.with_parts((2, 3)).parts()]
+ [(0, 0), (0, 1), (1, 0), (1, 1)]
+ """
+ entries = self._part_entries(parts, "with_parts")
+ if any(isinstance(entry, Sequence) for entry in entries):
+ raise ValueError(
+ "with_parts takes one integer per dimension; for per-axis box "
+ "sizes use with_parts_per_axis"
+ )
+ return self._with_grids(dimension_grids_from_chunks(entries, self._base_shape))
+
+ def with_parts_per_axis(self, sizes: Sequence[Sequence[int]]) -> LazyArray:
+ """Return the same view, read in boxes of explicitly listed sizes.
+
+ The dask convention: one sequence of box extents per dimension of
+ `base_shape`, each summing to that dimension's extent. Use it when the
+ boxes are not uniform — a partitioning discovered from a store, or one
+ whose last box differs by more than clipping.
+
+ Parameters
+ ----------
+ sizes
+ One sequence of box extents per dimension of `base_shape`.
+
+ Returns
+ -------
+ LazyArray
+ The same view with a new partitioning.
+
+ Raises
+ ------
+ ValueError
+ If `sizes` has the wrong length, contains a negative extent, uses a
+ zero extent on a nonempty axis, or declares sizes that do not sum
+ to `base_shape`. On a zero-length axis, `()`, `(0,)`, and repeated
+ zeros all describe no chunks.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> view = LazyArray.from_numpy(np.arange(12).reshape(3, 4))
+ >>> [part.box for part in view.with_parts_per_axis(((1, 2), (4,))).parts()]
+ [((0, 1), (0, 4)), ((1, 3), (0, 4))]
+ """
+ entries = self._part_entries(sizes, "with_parts_per_axis")
+ return self._with_grids(dimension_grids_from_chunks(entries, self._base_shape))
+
+ @staticmethod
+ def _part_entries(parts: Sequence[Any], method: str) -> tuple[Any, ...]:
+ """Materialize a partitioning argument, naming a non-iterable a ValueError.
+
+ Both partitioning methods document ValueError for malformed input; a
+ bare integer would otherwise surface as a TypeError from iteration.
+ """
+ try:
+ return tuple(parts)
+ except TypeError as error:
+ raise ValueError(
+ f"{method} takes one entry per dimension of base_shape; got {parts!r}"
+ ) from error
+
+ def unpartitioned(self) -> LazyArray:
+ """Return the same view, read in one pass.
+
+ `result()` still allocates its owned output buffer first, then calls the
+ reader once with the whole projected transform. `parts()` still yields a
+ single part covering everything.
+
+ Returns
+ -------
+ LazyArray
+ The same view with no partitioning.
+ """
+ return self._with_grids(None)
+
+ def _with_grids(self, grids: tuple[DimensionGrid, ...] | None) -> LazyArray:
+ return LazyArray._derive(self._array, self._transform, grids, self._window, self._reader)
+
+ def parts(self) -> Iterator[Partition]:
+ """Iterate the base partitioning, projected through this view.
+
+ Single-use: this is a generator, so it is consumed by the first walk and
+ a second `for` over the same object yields nothing. Call `parts()` again
+ for a fresh walk, or keep a `list` of it if you need to revisit.
+
+ Yields one [`Partition`][zarr_indexing.lazy_array.Partition] per box the
+ view actually touches. The parts tile the view exactly and disjointly,
+ and each carries a `LazyArray` that can be resolved on its own: in
+ another thread, in another order, or not at all. Those views share this
+ view's reader, and `LazyArray` does not serialize calls, so a stateful
+ reader must synchronize its own mutable state.
+
+ A wrapper with no partitioning (see `with_parts`) yields a single part
+ covering the whole array.
+
+ Yields
+ ------
+ Partition
+ One per touched box, in the resolver's own order.
+
+ Examples
+ --------
+ >>> import numpy as np
+ >>> view = LazyArray.from_numpy(np.arange(12).reshape(3, 4)).with_parts((2, 2))
+ >>> part = next(view.lazy[:, 1:].parts())
+ >>> (part.base_coords, part.view.shape, part.is_complete)
+ ((0, 0), (2, 1), False)
+ """
+ base_shape = self._base_shape
+ grids = self._parts if self._parts is not None else _whole_array_grids(base_shape)
+ rank = len(base_shape)
+
+ if self._window is None:
+ plan_transform = self._transform
+ else:
+ plan_transform = self._transform.translate(tuple(-item.start for item in self._window))
+
+ for projection in plan_chunks(plan_transform, grids):
+ base_coords = projection.chunk_coords
+ local = projection.chunk_transform
+ origin = tuple(grid.chunk_offset(c) for grid, c in zip(grids, base_coords, strict=True))
+ extent = tuple(grid.data_size(c) for grid, c in zip(grids, base_coords, strict=True))
+ if origin == (0,) * rank and extent == base_shape:
+ # The part is the whole base: lowering directly against the
+ # source beats materializing a block that is the source.
+ window = self._window
+ elif self._window is None:
+ window = tuple(slice(o, o + e) for o, e in zip(origin, extent, strict=True))
+ else:
+ window = tuple(
+ slice(w.start + o, w.start + o + e)
+ for w, o, e in zip(self._window, origin, extent, strict=True)
+ )
+ # The global box, computed from the origin directly rather than from
+ # `window`: a part covering the whole base carries no window (so
+ # nothing is pre-materialized) but still sits somewhere concrete.
+ if self._window is None:
+ global_origin = origin
+ else:
+ global_origin = tuple(
+ w.start + o for w, o in zip(self._window, origin, strict=True)
+ )
+ yield Partition(
+ projection=projection,
+ box=tuple((o, o + e) for o, e in zip(global_origin, extent, strict=True)),
+ view=LazyArray._derive(
+ self._array,
+ local.translate(global_origin),
+ None,
+ window,
+ self._reader,
+ ),
+ out_selection=_partition_out_selection(projection.cell_transform),
+ _owner=self._part_owner,
+ )
+
+ # -- indexing -----------------------------------------------------------
+
+ @property
+ def lazy(self) -> _LazyIndexer:
+ """Lazy indexing: `lazy[...]`, `lazy.oindex[...]`, `lazy.vindex[...]`.
+
+ Each returns a new `LazyArray` view; no data is read.
+ """
+ return _LazyIndexer(self._select)
+
+ def _select(self, selection: Any, mode: SelectionMode) -> LazyArray:
+ transform = self._transform
+ if mode != "basic":
+ # NumPy applies scalar integers as basic indices before the advanced
+ # ones, dropping their axes. Split them into their own step.
+ scalar_selection, selection = split_scalar_axes(selection, transform.domain, mode)
+ if scalar_selection is not None:
+ transform = transform.select(scalar_selection, "basic")
+ transform = transform.translate_domain_to((0,) * transform.input_rank)
+ literal = normalize_positional_selection(selection, transform.domain, mode)
+ if mode == "basic":
+ # IndexTransform's basic path includes NumPy's `None`/newaxis.
+ # `selection_to_transform` intentionally exposes a narrower basic
+ # selection contract and rejects it.
+ composed = transform[literal]
+ else:
+ composed = transform.select(literal, mode)
+ return LazyArray._derive(self._array, composed, self._parts, self._window, self._reader)
+
+ def __getitem__(self, selection: Any) -> Any:
+ """Read a basic selection eagerly, like `numpy.ndarray.__getitem__`.
+
+ Reads here are eager, not lazy, so that a `LazyArray` works as a duck
+ array for consumers (dask's `from_array`, `numpy.asarray`) that expect
+ indexing to produce data. Use `.lazy[...]` for the lazy form.
+ """
+ return self._select(selection, "basic").result()
+
+ def result(self, *, parts: Sequence[Partition] | None = None) -> Any:
+ """Materialize this view.
+
+ Every result starts as a fresh system-memory buffer. Each touched
+ partition is read through the selected reader directly into its
+ rectangular destination, or into an owned dense temporary before fancy
+ placement. Empty views allocate without reading the source.
+
+ Parameters
+ ----------
+ parts
+ A reusable sequence previously returned by this exact view's
+ `parts()` method. Supplying it reuses that partition plan instead
+ of constructing another one. The parts must tile the view exactly.
+
+ Returns
+ -------
+ numpy.ndarray
+ An array of shape `self.shape`, identical whatever partitioning is
+ in force, always in fresh system memory. A view with a zero-rank
+ domain returns a zero-dimensional array, not a scalar.
+
+ Raises
+ ------
+ ValueError
+ If supplied parts were prepared by another view, or do not tile
+ this view exactly. The output buffer is uninitialized where nothing
+ was written, so a bad plan is reported rather than returned.
+ AssertionError
+ If this library's own partition walk fails to cover the view — a
+ bug in zarr-indexing, never a consequence of the caller's input.
+ """
+ prepared_parts = None if parts is None else tuple(parts)
+ if prepared_parts is not None and any(
+ # Module-private provenance deliberately crosses the two public
+ # wrapper types without becoming part of either public surface.
+ part._owner is not self._part_owner # pyright: ignore[reportPrivateUsage]
+ for part in prepared_parts
+ ):
+ raise ValueError("prepared parts do not belong to this view")
+
+ out_shape = self.shape
+ if prepared_parts is not None:
+ _validate_prepared_parts(prepared_parts, out_shape)
+ out = self._output_buffer(out_shape)
+ size = math.prod(out_shape)
+ if size == 0:
+ return out
+
+ if prepared_parts is None and self._parts is None:
+ _invoke_reader(self._reader, self._array, ReadContext(self._transform), out)
+ return out
+
+ written = 0
+ selected_parts = self.parts() if prepared_parts is None else prepared_parts
+ for part in selected_parts:
+ # Counted before the scatter, so a part addressing the wrong
+ # number of axes is named rather than reported as a broadcast
+ # failure against the buffer.
+ written += _out_selection_cell_count(part.out_selection, out_shape)
+ direct = all(isinstance(selector, slice) for selector in part.out_selection)
+ if direct:
+ destination = out if len(part.out_selection) == 0 else out[part.out_selection]
+ else:
+ destination = part.view._output_buffer(part.view.shape)
+ _invoke_reader(
+ self._reader,
+ self._array,
+ ReadContext(part.view.transform, part.projection),
+ destination,
+ )
+ if not direct:
+ out[part.out_selection] = destination
+ if written != size:
+ # The buffer is uninitialized where no part wrote, so a partition
+ # walk that does not tile the view exactly would otherwise hand
+ # back process memory dressed as data. The parts are disjoint by
+ # contract, so counting the cells each addresses is enough:
+ # a gap undercounts and an overlap overcounts.
+ if prepared_parts is not None:
+ raise ValueError(
+ "prepared parts do not tile the view exactly: "
+ f"they addressed {written} of the view's {size} cells"
+ )
+ raise AssertionError(
+ f"the partition walk addressed {written} of the view's {size} "
+ "cells; this is a bug in zarr-indexing's partition walk"
+ )
+ return out
+
+ def _output_buffer(self, out_shape: tuple[int, ...]) -> Any:
+ """The buffer `result()` scatters parts into.
+
+ Deliberately uninitialized: every cell is written by exactly one part,
+ and `result()` verifies that before returning. A masked source gets a
+ masked buffer so that reader writes preserve the mask; other source-
+ specific array types do not survive materializing.
+ """
+ dtype = np.dtype(self.dtype)
+ if isinstance(self._array, np.ma.MaskedArray):
+ return np.ma.masked_all(out_shape, dtype=dtype)
+ return np.empty(out_shape, dtype=dtype)
+
+ # -- protocols ----------------------------------------------------------
+
+ def __array__(self, dtype: Any = None, copy: bool | None = None) -> Any:
+ """Materialize the view as a NumPy array.
+
+ The result never shares memory with the wrapped array, whatever `copy`
+ asks for: `result()` already allocates, so `copy=True` gets an array the
+ caller owns and `copy=None` gets the same one rather than a second
+ allocation. `copy=False` is refused, because materializing means reading
+ — the values do not exist as a NumPy array until this call makes them.
+ """
+ if copy is False:
+ raise ValueError(
+ "a LazyArray cannot be converted to a NumPy array without a "
+ "copy: a view is a description of a read, and the values only "
+ "exist once the read is made"
+ )
+ return np.asarray(self.result(), dtype=dtype)
+
+ def __dask_tokenize__(self) -> Any:
+ """A deterministic token: the wrapped array and the view.
+
+ Two wrappers produce equal tokens when they wrap the same data and
+ address the same cells. The view contributes a digest of its canonical
+ ndsel body, so transforms that differ only in representation produce
+ the same token, and a fancy selection with a large index array does not
+ embed that array's JSON in the token. See `_wrapped_token` for the
+ determinism scope of the wrapped array's contribution; dask is imported
+ lazily and is never a requirement of this package.
+
+ The partitioning and reader are deliberately absent. Both decide how
+ the data is read — in which boxes, and through which request strategy —
+ and neither changes the values that come back, so two wrappers differing
+ only in those describe the same data. A token identifies data, so they
+ token alike and a consumer that caches on tokens reuses one result for
+ both.
+ """
+ canonical = json.dumps(self._transform.to_json(), sort_keys=True)
+ return (
+ type(self).__qualname__,
+ _wrapped_token(self._array),
+ hashlib.sha256(canonical.encode()).hexdigest(),
+ )
+
+ def __len__(self) -> int:
+ """The length of the first axis, as for a NumPy array; `TypeError` on a 0-d view."""
+ if self.ndim == 0:
+ raise TypeError("len() of unsized object")
+ return self.shape[0]
+
+ def __iter__(self) -> Iterator[Any]:
+ """Iterate eagerly over the first axis, like a NumPy array.
+
+ The rank check happens in `__iter__` itself rather than in the
+ generator, so `iter(view)` on a zero-rank view raises immediately as
+ NumPy's does, instead of waiting for the first `next`.
+ """
+ if self.ndim == 0:
+ raise TypeError("iteration over a 0-d array")
+ return (self[position] for position in range(self.shape[0]))
+
+ # NumPy's own conversions decide what a size-1 (or wrong-sized) view means,
+ # including which exception it raises, so these delegate rather than
+ # reimplement. Each materializes the view first.
+ def __bool__(self) -> bool:
+ return bool(self.result())
+
+ def __int__(self) -> int:
+ return int(self.result())
+
+ def __float__(self) -> float:
+ return float(self.result())
+
+ def __index__(self) -> int:
+ return operator.index(self.result())
+
+ def __repr__(self) -> str:
+ wrapped = type(self._array).__name__
+ described = [f"{wrapped} shape={self.shape} dtype={self.dtype}"]
+ if not _is_identity_transform(self._transform, self._base_shape):
+ described.append(f"view={self._transform.selection_repr}")
+ return f""
+
+
+class _LazyIndexer:
+ """The `.lazy` accessor: builds views instead of reading data.
+
+ Holds the owning view's bound `_select` rather than the view itself, so the
+ accessor classes never reach into another object's internals.
+ """
+
+ __slots__ = ("_select",)
+
+ def __init__(self, select: SelectFn) -> None:
+ self._select = select
+
+ def __getitem__(self, selection: Any) -> LazyArray:
+ """Basic (integer / slice / ellipsis) indexing, lazily."""
+ return self._select(selection, "basic")
+
+ @property
+ def oindex(self) -> _LazyOIndex:
+ """Orthogonal (outer-product) indexing, lazily."""
+ return _LazyOIndex(self._select)
+
+ @property
+ def vindex(self) -> _LazyVIndex:
+ """Vectorized (coordinate / mask) indexing, lazily."""
+ return _LazyVIndex(self._select)
+
+
+class _LazyOIndex:
+ """`lazy.oindex[...]` — one selection per axis, combined as an outer product."""
+
+ __slots__ = ("_select",)
+
+ def __init__(self, select: SelectFn) -> None:
+ self._select = select
+
+ def __getitem__(self, selection: Any) -> LazyArray:
+ return self._select(selection, "orthogonal")
+
+
+class _LazyVIndex:
+ """`lazy.vindex[...]` — correlated coordinate arrays, or a single mask."""
+
+ __slots__ = ("_select",)
+
+ def __init__(self, select: SelectFn) -> None:
+ self._select = select
+
+ def __getitem__(self, selection: Any) -> LazyArray:
+ return self._select(selection, "vectorized")
diff --git a/packages/zarr-indexing/src/zarr_indexing/messages.py b/packages/zarr-indexing/src/zarr_indexing/messages.py
new file mode 100644
index 0000000000..df0e0c87eb
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/messages.py
@@ -0,0 +1,757 @@
+"""The ndsel message layer — pure JSON in, canonical JSON out.
+
+This module implements the [ndsel](https://github.com/zarr-developers/ndsel) draft wire
+format: a JSON-serializable representation of NumPy-style n-dimensional
+selections that adapts TensorStore's `IndexTransform` model. It is a **pure
+JSON→JSON** layer: it depends on nothing but the standard library, imposes no
+engine (numpy/array) constraints, and never rounds, clamps, or drops
+information. Engine constraints (finite bounds, in-memory `IndexTransform`
+construction) live one layer up, in `json.py`.
+
+Two entry points:
+
+- `parse_ndsel(obj)` — structurally validate an ndsel message of any of the
+ five kinds (`point`/`box`/`slice`/`points`/`transform`), returning it
+ unchanged. Raises `NdselError` (carrying a spec reason code) on any defect.
+- `normalize_ndsel(obj)` — desugar and canonicalize a message to the single
+ deterministic **canonical transform body** of the spec (section 4.3): a bare
+ `IndexTransform` JSON body, without the `kind` discriminator. `normalize` is
+ idempotent when its output is re-tagged with `kind: "transform"`.
+
+The canonical body is, field-for-field, a TensorStore `IndexTransform` (minus
+`kind`), so a normalized `transform` loads directly into TensorStore once
+`kind` is stripped.
+
+Value rules enforced here: every integer is a 64-bit signed value; JSON
+booleans are **not** integers (Python's `isinstance(True, int)` is guarded
+against explicitly); the `"-inf"`/`"+inf"` sentinels are legal only in bound
+positions; an implicit bound is the one-element `[n]`-bracket form, and its
+implicit/explicit flag is preserved through normalization.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+__all__ = [
+ "NdselError",
+ "normalize_ndsel",
+ "parse_ndsel",
+]
+
+# ---------------------------------------------------------------------------
+# Error taxonomy
+# ---------------------------------------------------------------------------
+
+#: The complete set of ndsel reason codes (spec section 6).
+REASON_CODES = frozenset(
+ {
+ "invalid_json",
+ "unknown_kind",
+ "unknown_field",
+ "multiple_upper_bounds",
+ "bounds_out_of_order",
+ "output_map_conflict",
+ "rank_mismatch",
+ "step_zero",
+ # Retired in 1.0-draft.2, when negative `step` became specified. Kept in
+ # the set so a message carrying the code is still recognized, but no
+ # condition in this implementation emits it.
+ "negative_step_unsupported",
+ }
+)
+
+
+class NdselError(ValueError):
+ """An ndsel message failed validation.
+
+ Carries the spec `reason` code (one of `REASON_CODES`) so callers and the
+ conformance harness can assert on it directly, plus a human-readable
+ `detail`.
+
+ Examples
+ --------
+ >>> try:
+ ... normalize_ndsel({"kind": "bogus"})
+ ... except NdselError as error:
+ ... (error.reason, str(error))
+ ('unknown_kind', "unknown_kind: unknown kind 'bogus'")
+ """
+
+ def __init__(self, reason: str, detail: str = "") -> None:
+ """Store `reason` and `detail` and compose the message as `"reason: detail"`.
+
+ `reason` is a spec reason code (one of `REASON_CODES`); `detail` is
+ optional human-readable context, and when empty the message is the
+ bare `reason`.
+ """
+ self.reason = reason
+ self.detail = detail
+ super().__init__(f"{reason}: {detail}" if detail else reason)
+
+
+# ---------------------------------------------------------------------------
+# 64-bit signed integer range (spec section 3.5)
+# ---------------------------------------------------------------------------
+
+_I64_MIN = -(2**63)
+_I64_MAX = 2**63 - 1
+
+_KNOWN_KINDS = frozenset({"point", "box", "slice", "points", "transform"})
+
+# The two upper-bound spellings, keyed by message prefix. Only one of the three
+# per group may appear (spec section 4.1 / 5.2).
+_BOX_UPPER = ("exclusive_max", "inclusive_max", "shape")
+_TRANSFORM_UPPER = ("input_exclusive_max", "input_inclusive_max", "input_shape")
+
+_OUTPUT_MAP_FIELDS = frozenset(
+ {
+ "offset",
+ "stride",
+ "input_dimension",
+ "index_array",
+ "index_array_bounds",
+ }
+)
+
+# An upper bound on `input_rank`, because normalization allocates proportionally
+# to it — an identity `output`, a bound per dimension, a label per dimension —
+# from a document that carries no data behind the number. Matches the rank
+# TensorStore accepts, which is well above any real array.
+_MAX_RANK = 32
+
+
+# ---------------------------------------------------------------------------
+# Leaf value validators
+# ---------------------------------------------------------------------------
+
+
+def _is_int(value: Any) -> bool:
+ """True iff `value` is a JSON integer — an `int` that is not a `bool`.
+
+ JSON has no boolean-as-integer: `True`/`False` are rejected even though
+ Python makes `bool` a subclass of `int` (spec section 3.6).
+ """
+ return isinstance(value, int) and not isinstance(value, bool)
+
+
+def _check_int(value: Any, where: str) -> int:
+ """Validate a plain-integer position: an in-range i64, never a sentinel."""
+ if not _is_int(value):
+ raise NdselError("invalid_json", f"{where} must be an integer, got {value!r}")
+ if value < _I64_MIN or value > _I64_MAX:
+ raise NdselError("invalid_json", f"{where} is outside the 64-bit signed range: {value}")
+ return int(value)
+
+
+def _is_sentinel(value: Any) -> bool:
+ return value in ("-inf", "+inf")
+
+
+def _check_index_value(value: Any, where: str) -> int | str:
+ """Validate an `index-value`: an in-range i64 or a `"-inf"`/`"+inf"` sentinel."""
+ if _is_sentinel(value):
+ return str(value)
+ return _check_int(value, where)
+
+
+def _check_bound(value: Any, where: str) -> int | str | list[int | str]:
+ """Validate a `bound`: an explicit `index-value`, or a one-element implicit `[index-value]`."""
+ if isinstance(value, list):
+ if len(value) != 1:
+ raise NdselError(
+ "invalid_json",
+ f"{where} implicit bound must be a one-element array, got {value!r}",
+ )
+ return [_check_index_value(value[0], where)]
+ return _check_index_value(value, where)
+
+
+def _check_int_list(value: Any, where: str) -> list[int]:
+ if not isinstance(value, list):
+ raise NdselError("invalid_json", f"{where} must be an array, got {value!r}")
+ return [_check_int(v, f"{where}[{i}]") for i, v in enumerate(value)]
+
+
+def _check_bound_list(value: Any, where: str) -> list[Any]:
+ if not isinstance(value, list):
+ raise NdselError("invalid_json", f"{where} must be an array, got {value!r}")
+ return [_check_bound(v, f"{where}[{i}]") for i, v in enumerate(value)]
+
+
+def _check_label_list(value: Any, where: str) -> list[str]:
+ if not isinstance(value, list):
+ raise NdselError("invalid_json", f"{where} must be an array, got {value!r}")
+ for i, v in enumerate(value):
+ if not isinstance(v, str):
+ raise NdselError("invalid_json", f"{where}[{i}] must be a string, got {v!r}")
+ return list(value)
+
+
+# ---------------------------------------------------------------------------
+# Extended-integer order for bounds (spec section 4.1)
+# ---------------------------------------------------------------------------
+
+
+def _bound_value(bound: int | str | list[int | str]) -> int | str:
+ """The underlying `index-value` of a bound, dropping the implicit bracket."""
+ return bound[0] if isinstance(bound, list) else bound
+
+
+def _bound_is_implicit(bound: int | str | list[int | str]) -> bool:
+ return isinstance(bound, list)
+
+
+def _ext_key(value: int | str) -> tuple[int, int]:
+ """A sort key giving the extended-integer order `-inf < n < +inf` exactly.
+
+ Uses an integer tier plus the value, so no float rounding of near-`2**63`
+ integers can misorder the `inclusive_min <= exclusive_max` check.
+ """
+ if value == "-inf":
+ return (0, 0)
+ if value == "+inf":
+ return (2, 0)
+ assert isinstance(value, int)
+ return (1, value)
+
+
+def _rewrap(value: int | str, *, implicit: bool) -> int | str | list[int | str]:
+ return [value] if implicit else value
+
+
+# ---------------------------------------------------------------------------
+# Message-level helpers
+# ---------------------------------------------------------------------------
+
+
+def _require_object(obj: Any) -> dict[str, Any]:
+ if not isinstance(obj, dict):
+ raise NdselError("invalid_json", f"message must be a JSON object, got {type(obj).__name__}")
+ return obj
+
+
+def _message_kind(obj: dict[str, Any]) -> str:
+ kind = obj.get("kind")
+ if not isinstance(kind, str):
+ raise NdselError("invalid_json", "message must have a string 'kind' field")
+ if kind not in _KNOWN_KINDS:
+ raise NdselError("unknown_kind", f"unknown kind {kind!r}")
+ return kind
+
+
+def _check_membership(obj: dict[str, Any], allowed: frozenset[str], what: str) -> None:
+ """Strict membership (spec section 3.7): reject any undefined member."""
+ for key in obj:
+ if key not in allowed:
+ raise NdselError("unknown_field", f"{what} has undefined member {key!r}")
+
+
+def _single_upper_bound(obj: dict[str, Any], fields: tuple[str, str, str]) -> str | None:
+ present = [f for f in fields if f in obj]
+ if len(present) > 1:
+ raise NdselError(
+ "multiple_upper_bounds",
+ f"at most one of {fields} may be present; got {present}",
+ )
+ return present[0] if present else None
+
+
+def _resolve_upper_bound(
+ upper_field: str | None,
+ upper_raw: list[Any] | None,
+ inclusive_min: list[Any],
+ rank: int,
+ *,
+ kind_of: str,
+) -> list[int | str | list[int | str]]:
+ """Produce `exclusive_max` from whichever upper-bound spelling was supplied.
+
+ - `exclusive_max`/`input_exclusive_max` → used directly.
+ - `inclusive_max`/`input_inclusive_max` → each element `+1`.
+ - `shape`/`input_shape` → `inclusive_min + shape` per element.
+ - none → an **implicit `+inf`** in every dimension.
+
+ The implicit/explicit bracket travels with the extent-bearing field (the
+ upper bound, or `shape`), matching the spec's `[n]`-bracket convention.
+ """
+ if upper_field is None:
+ return [["+inf"] for _ in range(rank)]
+
+ assert upper_raw is not None
+ if kind_of == "exclusive":
+ return list(upper_raw)
+
+ result: list[int | str | list[int | str]] = []
+ for k in range(rank):
+ raw = upper_raw[k]
+ implicit = _bound_is_implicit(raw)
+ value = _bound_value(raw)
+ if kind_of == "inclusive":
+ new = _inclusive_to_exclusive(value, f"{upper_field}[{k}]")
+ else: # shape
+ new = _shape_to_exclusive(_bound_value(inclusive_min[k]), value, f"{upper_field}[{k}]")
+ result.append(_rewrap(new, implicit=implicit))
+ return result
+
+
+def _checked_i64(value: int, where: str) -> int:
+ """An arithmetic result that must still be a 64-bit signed integer.
+
+ Normalization is idempotent (spec section 4.3): whatever it emits must pass
+ the same validation on the way back in. Desugaring adds — `inclusive_max + 1`,
+ `inclusive_min + shape` — so a bound at the top of the range would otherwise
+ be emitted one past it and rejected by the next call on our own output.
+ """
+ if value < _I64_MIN or value > _I64_MAX:
+ raise NdselError(
+ "invalid_json",
+ f"{where} is {value}, which is outside the 64-bit signed range; the "
+ f"normalized form cannot represent it",
+ )
+ return value
+
+
+def _inclusive_to_exclusive(value: int | str, where: str) -> int | str:
+ if value == "+inf" or value == "-inf":
+ return value
+ assert isinstance(value, int)
+ return _checked_i64(value + 1, f"{where} converted to an exclusive bound")
+
+
+def _shape_to_exclusive(min_value: int | str, shape_value: int | str, where: str) -> int | str:
+ if shape_value == "-inf":
+ raise NdselError(
+ "invalid_json",
+ f"{where} is '-inf'; a shape counts cells and cannot be negatively infinite",
+ )
+ if shape_value == "+inf" or min_value == "+inf":
+ return "+inf"
+ if min_value == "-inf":
+ return "-inf"
+ assert isinstance(min_value, int)
+ assert isinstance(shape_value, int)
+ return _checked_i64(min_value + shape_value, f"{where} added to its inclusive_min")
+
+
+def _validate_domain(inclusive_min: list[Any], exclusive_max: list[Any], *, prefix: str) -> None:
+ """Every dimension must satisfy `inclusive_min <= exclusive_max` (empty is valid)."""
+ for k, (lo, hi) in enumerate(zip(inclusive_min, exclusive_max, strict=True)):
+ if _ext_key(_bound_value(lo)) > _ext_key(_bound_value(hi)):
+ raise NdselError(
+ "bounds_out_of_order",
+ f"{prefix}[{k}]: inclusive_min {_bound_value(lo)!r} > "
+ f"exclusive_max {_bound_value(hi)!r}",
+ )
+
+
+def _identity_output(rank: int) -> list[dict[str, Any]]:
+ return [{"offset": 0, "stride": 1, "input_dimension": k} for k in range(rank)]
+
+
+# ---------------------------------------------------------------------------
+# Per-kind desugaring
+# ---------------------------------------------------------------------------
+
+
+def _normalize_point(obj: dict[str, Any]) -> dict[str, Any]:
+ _check_membership(obj, frozenset({"kind", "coords"}), "point")
+ if "coords" not in obj:
+ raise NdselError("invalid_json", "point requires 'coords'")
+ coords = _check_int_list(obj["coords"], "coords")
+ return {
+ "input_rank": 0,
+ "input_inclusive_min": [],
+ "input_exclusive_max": [],
+ "input_labels": [],
+ "output": [{"offset": c} for c in coords],
+ }
+
+
+def _infer_rank(
+ obj: dict[str, Any],
+ named_lengths: list[tuple[str, int]],
+ *,
+ declared: int | None,
+) -> int:
+ """Reconcile a declared rank (if any) with every present array's length."""
+ rank = declared
+ for name, length in named_lengths:
+ if rank is None:
+ rank = length
+ elif rank != length:
+ raise NdselError(
+ "rank_mismatch",
+ f"{name} has length {length}, inconsistent with rank {rank}",
+ )
+ return rank if rank is not None else 0
+
+
+def _normalize_box(obj: dict[str, Any]) -> dict[str, Any]:
+ allowed = frozenset(
+ {"kind", "inclusive_min", "exclusive_max", "inclusive_max", "shape", "labels"}
+ )
+ _check_membership(obj, allowed, "box")
+
+ inclusive_min_raw = (
+ _check_bound_list(obj["inclusive_min"], "inclusive_min") if "inclusive_min" in obj else None
+ )
+ upper_field = _single_upper_bound(obj, _BOX_UPPER)
+ upper_raw = _check_bound_list(obj[upper_field], upper_field) if upper_field else None
+ labels_raw = _check_label_list(obj["labels"], "labels") if "labels" in obj else None
+
+ named_lengths: list[tuple[str, int]] = []
+ if inclusive_min_raw is not None:
+ named_lengths.append(("inclusive_min", len(inclusive_min_raw)))
+ if upper_raw is not None:
+ named_lengths.append((upper_field or "", len(upper_raw)))
+ if labels_raw is not None:
+ named_lengths.append(("labels", len(labels_raw)))
+ rank = _infer_rank(obj, named_lengths, declared=None)
+
+ inclusive_min = inclusive_min_raw if inclusive_min_raw is not None else [0] * rank
+ exclusive_max = _resolve_upper_bound(
+ upper_field, upper_raw, inclusive_min, rank, kind_of=_upper_kind(upper_field, _BOX_UPPER)
+ )
+ labels = labels_raw if labels_raw is not None else [""] * rank
+ _validate_domain(inclusive_min, exclusive_max, prefix="box")
+
+ return {
+ "input_rank": rank,
+ "input_inclusive_min": inclusive_min,
+ "input_exclusive_max": exclusive_max,
+ "input_labels": labels,
+ "output": _identity_output(rank),
+ }
+
+
+def _upper_kind(upper_field: str | None, fields: tuple[str, str, str]) -> str:
+ if upper_field is None or upper_field == fields[0]:
+ return "exclusive"
+ if upper_field == fields[1]:
+ return "inclusive"
+ return "shape"
+
+
+def _normalize_slice(obj: dict[str, Any]) -> dict[str, Any]:
+ allowed = frozenset({"kind", "start", "stop", "step", "labels"})
+ _check_membership(obj, allowed, "slice")
+ if "start" not in obj:
+ raise NdselError("invalid_json", "slice requires 'start'")
+ if "stop" not in obj:
+ raise NdselError("invalid_json", "slice requires 'stop'")
+ start = _check_int_list(obj["start"], "start")
+ stop = _check_int_list(obj["stop"], "stop")
+ step = _check_int_list(obj["step"], "step") if "step" in obj else [1] * len(start)
+ labels_raw = _check_label_list(obj["labels"], "labels") if "labels" in obj else None
+
+ n = len(start)
+ for name, arr in (("stop", stop), ("step", step)):
+ if len(arr) != n:
+ raise NdselError(
+ "rank_mismatch", f"{name} has length {len(arr)}, expected {n} (from start)"
+ )
+ if labels_raw is not None and len(labels_raw) != n:
+ raise NdselError(
+ "rank_mismatch", f"labels has length {len(labels_raw)}, expected {n} (from start)"
+ )
+
+ for k, s in enumerate(step):
+ if s == 0:
+ raise NdselError("step_zero", f"step[{k}] is zero")
+
+ inclusive_min: list[Any] = []
+ exclusive_max: list[Any] = []
+ output: list[dict[str, Any]] = []
+ for k in range(n):
+ a, b, s = start[k], stop[k], step[k]
+ # One rule for both signs (spec 5.3): the traversal runs from `a`
+ # toward `b`, so the source interval's length is `b - a` going up and
+ # `a - b` going down.
+ length = (b - a) if s > 0 else (a - b)
+ if length < 0:
+ # A reversed interval is a mistake about the direction of travel,
+ # not an empty selection. `b == a` is the way to select nothing.
+ raise NdselError(
+ "bounds_out_of_order",
+ f"start[{k}]={a} and stop[{k}]={b} with step {s} run the wrong "
+ "way; an empty selection is spelled stop == start",
+ )
+ m = -(-length // abs(s)) # ceil(length / |s|)
+ o = _trunc_div(a, s) # trunc(a / s), toward zero, both signs
+ offset = a - s * o # lattice phase, |offset| < |s|
+ inclusive_min.append(o)
+ exclusive_max.append(o + m)
+ output.append({"offset": offset, "stride": s, "input_dimension": k})
+
+ labels = labels_raw if labels_raw is not None else [""] * n
+ return {
+ "input_rank": n,
+ "input_inclusive_min": inclusive_min,
+ "input_exclusive_max": exclusive_max,
+ "input_labels": labels,
+ "output": output,
+ }
+
+
+def _normalize_points(obj: dict[str, Any]) -> dict[str, Any]:
+ _check_membership(obj, frozenset({"kind", "coords"}), "points")
+ if "coords" not in obj:
+ raise NdselError("invalid_json", "points requires 'coords'")
+ coords = obj["coords"]
+ if not isinstance(coords, list):
+ raise NdselError("invalid_json", f"points coords must be an array, got {coords!r}")
+
+ rows: list[list[int]] = []
+ n: int | None = None
+ for i, row in enumerate(coords):
+ if not isinstance(row, list):
+ raise NdselError("invalid_json", f"points coords[{i}] must be an array, got {row!r}")
+ row_ints = [_check_int(v, f"coords[{i}][{j}]") for j, v in enumerate(row)]
+ if n is None:
+ n = len(row_ints)
+ elif len(row_ints) != n:
+ raise NdselError(
+ "rank_mismatch",
+ f"points coords[{i}] has length {len(row_ints)}, expected {n} (ragged)",
+ )
+ rows.append(row_ints)
+
+ m = len(rows)
+ n = n if n is not None else 0
+ output = [
+ {
+ "offset": 0,
+ "stride": 1,
+ "index_array": [rows[i][k] for i in range(m)],
+ "index_array_bounds": ["-inf", "+inf"],
+ }
+ for k in range(n)
+ ]
+ return {
+ "input_rank": 1,
+ "input_inclusive_min": [0],
+ "input_exclusive_max": [m],
+ "input_labels": [""],
+ "output": output,
+ }
+
+
+def _normalize_output_map(raw: Any, where: str) -> dict[str, Any]:
+ if not isinstance(raw, dict):
+ raise NdselError("invalid_json", f"{where} must be a JSON object, got {raw!r}")
+ _check_membership(raw, _OUTPUT_MAP_FIELDS, where)
+
+ has_index_array = "index_array" in raw
+ has_input_dim = "input_dimension" in raw
+ if has_index_array and has_input_dim:
+ raise NdselError(
+ "output_map_conflict",
+ f"{where} carries both 'input_dimension' and 'index_array'",
+ )
+
+ offset = _check_int(raw["offset"], f"{where}.offset") if "offset" in raw else 0
+
+ if has_index_array:
+ stride = _check_int(raw["stride"], f"{where}.stride") if "stride" in raw else 1
+ bounds = (
+ _check_index_array_bounds(raw["index_array_bounds"], where)
+ if "index_array_bounds" in raw
+ else ["-inf", "+inf"]
+ )
+ # index_array is carried verbatim (spec section 7 defers shape validation).
+ normalized: dict[str, Any] = {
+ "offset": offset,
+ "stride": stride,
+ "index_array": raw["index_array"],
+ "index_array_bounds": bounds,
+ }
+ return normalized
+
+ if has_input_dim:
+ input_dim = _check_int(raw["input_dimension"], f"{where}.input_dimension")
+ if input_dim < 0:
+ raise NdselError(
+ "invalid_json", f"{where}.input_dimension must be >= 0, got {input_dim}"
+ )
+ stride = _check_int(raw["stride"], f"{where}.stride") if "stride" in raw else 1
+ return {"offset": offset, "stride": stride, "input_dimension": input_dim}
+
+ # Constant map: only offset survives. A stray `stride`/`index_array_bounds`
+ # is schema-valid (the output-map schema permits those members on any map),
+ # so it is silently dropped rather than rejected — a constant carries only
+ # `offset` in canonical form (spec section 4.3).
+ return {"offset": offset}
+
+
+def _check_index_array_bounds(value: Any, where: str) -> list[int | str]:
+ if not isinstance(value, list) or len(value) != 2:
+ raise NdselError(
+ "invalid_json",
+ f"{where}.index_array_bounds must be a two-element array, got {value!r}",
+ )
+ lo = _check_index_value(value[0], f"{where}.index_array_bounds[0]")
+ hi = _check_index_value(value[1], f"{where}.index_array_bounds[1]")
+ if _ext_key(lo) > _ext_key(hi):
+ raise NdselError(
+ "bounds_out_of_order",
+ f"{where}.index_array_bounds: lower bound {lo!r} > upper bound {hi!r}",
+ )
+ return [lo, hi]
+
+
+def _normalize_transform(obj: dict[str, Any]) -> dict[str, Any]:
+ allowed = frozenset(
+ {
+ "kind",
+ "input_rank",
+ "input_inclusive_min",
+ "input_exclusive_max",
+ "input_inclusive_max",
+ "input_shape",
+ "input_labels",
+ "output",
+ }
+ )
+ _check_membership(obj, allowed, "transform")
+
+ declared_rank: int | None = None
+ if "input_rank" in obj:
+ declared_rank = _check_int(obj["input_rank"], "input_rank")
+ if declared_rank < 0:
+ raise NdselError("invalid_json", f"input_rank must be >= 0, got {declared_rank}")
+ if declared_rank > _MAX_RANK:
+ # Normalization fills a bound, a label and an identity output map per
+ # dimension, so an unbacked rank is a request to allocate from a
+ # document that carries nothing.
+ raise NdselError(
+ "invalid_json",
+ f"input_rank must be <= {_MAX_RANK}, got {declared_rank}",
+ )
+
+ inclusive_min_raw = (
+ _check_bound_list(obj["input_inclusive_min"], "input_inclusive_min")
+ if "input_inclusive_min" in obj
+ else None
+ )
+ upper_field = _single_upper_bound(obj, _TRANSFORM_UPPER)
+ upper_raw = _check_bound_list(obj[upper_field], upper_field) if upper_field else None
+ labels_raw = (
+ _check_label_list(obj["input_labels"], "input_labels") if "input_labels" in obj else None
+ )
+
+ named_lengths: list[tuple[str, int]] = []
+ if inclusive_min_raw is not None:
+ named_lengths.append(("input_inclusive_min", len(inclusive_min_raw)))
+ if upper_raw is not None:
+ named_lengths.append((upper_field or "", len(upper_raw)))
+ if labels_raw is not None:
+ named_lengths.append(("input_labels", len(labels_raw)))
+ rank = _infer_rank(obj, named_lengths, declared=declared_rank)
+
+ inclusive_min = inclusive_min_raw if inclusive_min_raw is not None else [0] * rank
+ exclusive_max = _resolve_upper_bound(
+ upper_field,
+ upper_raw,
+ inclusive_min,
+ rank,
+ kind_of=_upper_kind(upper_field, _TRANSFORM_UPPER),
+ )
+ labels = labels_raw if labels_raw is not None else [""] * rank
+ _validate_domain(inclusive_min, exclusive_max, prefix="input")
+
+ if "output" in obj:
+ if not isinstance(obj["output"], list):
+ raise NdselError("invalid_json", f"output must be an array, got {obj['output']!r}")
+ output = [_normalize_output_map(m, f"output[{i}]") for i, m in enumerate(obj["output"])]
+ for i, m in enumerate(output):
+ # An `input_dimension` names one of *this* transform's input
+ # dimensions, so the rank is what bounds it. Checked here rather than
+ # in `_normalize_output_map`, which sees one map and not the rank.
+ if "input_dimension" in m and m["input_dimension"] >= rank:
+ raise NdselError(
+ "rank_mismatch",
+ f"output[{i}].input_dimension is {m['input_dimension']}, "
+ f"outside the valid range [0, {rank}) for input_rank {rank}",
+ )
+ else:
+ output = _identity_output(rank)
+
+ return {
+ "input_rank": rank,
+ "input_inclusive_min": inclusive_min,
+ "input_exclusive_max": exclusive_max,
+ "input_labels": labels,
+ "output": output,
+ }
+
+
+_NORMALIZERS = {
+ "point": _normalize_point,
+ "box": _normalize_box,
+ "slice": _normalize_slice,
+ "points": _normalize_points,
+ "transform": _normalize_transform,
+}
+
+
+# ---------------------------------------------------------------------------
+# trunc division (spec section 5.3 correction, matches _trunc_div in transform.py)
+# ---------------------------------------------------------------------------
+
+
+def _trunc_div(a: int, b: int) -> int:
+ """Integer division rounded toward zero (C semantics)."""
+ q = a // b
+ if q < 0 and q * b != a:
+ q += 1
+ return q
+
+
+# ---------------------------------------------------------------------------
+# Public entry points
+# ---------------------------------------------------------------------------
+
+
+def normalize_ndsel(obj: Any) -> dict[str, Any]:
+ """Desugar and canonicalize an ndsel message to its canonical transform body.
+
+ Accepts any of the five message kinds and returns the bare canonical
+ `IndexTransform` body of spec section 4.3 — no `kind` field. Raises
+ `NdselError` (carrying a reason code) for any invalid input.
+
+ Examples
+ --------
+ >>> body = normalize_ndsel({"kind": "box", "shape": [2, 3]})
+ >>> (body["input_rank"], body["input_inclusive_min"], body["input_exclusive_max"])
+ (2, [0, 0], [2, 3])
+ >>> body["output"][0]
+ {'offset': 0, 'stride': 1, 'input_dimension': 0}
+ """
+ message = _require_object(obj)
+ kind = _message_kind(message)
+ return _NORMALIZERS[kind](message)
+
+
+def parse_ndsel(obj: Any) -> dict[str, Any]:
+ """Structurally validate an ndsel message, returning it unchanged.
+
+ A lighter gate than `normalize_ndsel`: it confirms the message is a
+ well-formed ndsel message of a recognized kind (correct field membership,
+ JSON types, upper-bound exclusivity, domain ordering, step signs) and
+ raises `NdselError` otherwise, but does not desugar it. Useful for
+ validating a message you intend to keep in its compact shorthand form.
+
+ Examples
+ --------
+ >>> message = {"kind": "point", "coords": [3, 4]}
+ >>> parse_ndsel(message) is message
+ True
+ >>> normalize_ndsel(message)["output"]
+ [{'offset': 3}, {'offset': 4}]
+ """
+ message = _require_object(obj)
+ _message_kind(message)
+ # Validation and desugaring share one pass; run it and discard the body.
+ normalize_ndsel(message)
+ return message
diff --git a/packages/zarr-indexing/src/zarr_indexing/output_map.py b/packages/zarr-indexing/src/zarr_indexing/output_map.py
new file mode 100644
index 0000000000..3ee7250efa
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/output_map.py
@@ -0,0 +1,403 @@
+"""Output index maps — three ordered mappings to integer coordinates.
+
+An output index map describes how input cells address one dimension of
+the output space. Its coordinates form an **ordered, duplicate-preserving sequence**
+aligned with the input domain, never a mathematical set. Three representations
+cover the cases that arise in practice:
+
+- `ConstantMap(offset=5)` — every request cell maps to coordinate `5`
+- `DimensionMap(input_dimension=0, offset=3, stride=2)` over input `[0, 5)`
+ — the ordered arithmetic progression `[3, 5, 7, 9, 11]`
+- `ArrayMap(index_array=[5, 1, 1])` — the explicit sequence `[5, 1, 1]`,
+ preserving both order and the repeated coordinate
+
+Every output map participates in two operations defined on `IndexTransform`,
+which provides the input-domain context these maps lack:
+
+- **intersect** — retain mapped cells whose coordinates lie within a range
+ (e.g., a chunk), without changing their order or multiplicity.
+ Restricting `[3, 5, 5, 9]` to `[4, 8)` produces `[5, 5]`.
+- **translate** — shift every coordinate by a constant (e.g., make chunk-local).
+ Translating `[5, 5, 7]` by `-4` produces `[1, 1, 3]`.
+
+These two operations are the foundation of chunk resolution: for each chunk,
+intersect the map with the chunk's range, then translate to chunk-local
+coordinates.
+
+The three types exist because they trade off generality for efficiency:
+
+- `ConstantMap`: O(1) storage, O(1) intersection
+- `DimensionMap`: O(1) storage, O(1) intersection (analytical)
+- `ArrayMap`: O(n) storage, O(n) intersection (must scan the array)
+
+Collapsing everything to `ArrayMap` would be correct but wasteful — a
+billion-element slice would materialize a billion coordinates just to group
+them by chunk, when `DimensionMap` does it with three integers.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+
+from zarr_indexing._affine import checked_affine
+
+if TYPE_CHECKING:
+ import numpy.typing as npt
+
+ from zarr_indexing.json import OutputIndexMapJSON
+
+
+def _array_map_dependency_axes(index_array: np.ndarray[Any, Any]) -> tuple[int, ...]:
+ """Return the input axes on which a normalized index array varies.
+
+ Normalized `ArrayMap` index arrays carry the full input rank of their
+ enclosing transform: an axis the array varies over has its full size, while
+ an axis the array is independent of is a singleton (size 1). The dependency
+ axes are therefore exactly the axes of size 2 or more. An orthogonal
+ (`oindex`) array depends on a single axis; a vectorized (`vindex`) array
+ depends on all of the (shared) broadcast axes.
+
+ A size-**0** axis carries no dependency either: the array has no values to
+ vary, so an empty selection stays the flavor it was made as rather than
+ reading as correlated with every other axis.
+ """
+ return tuple(axis for axis, size in enumerate(index_array.shape) if size > 1)
+
+
+@dataclass(frozen=True, slots=True)
+class ConstantMap:
+ """A constant output-coordinate mapping.
+
+ Every input cell maps to `offset`. Arises from integer indexing (e.g.,
+ `arr[5]` fixes one dimension to coordinate 5).
+
+ Examples
+ --------
+ Every input cell maps to the same output coordinate — the NumPy analogy
+ is a broadcast (`np.broadcast_to(5, (3,))`), not an index:
+
+ >>> from zarr_indexing.domain import IndexDomain
+ >>> from zarr_indexing.transform import IndexTransform
+ >>> domain = IndexDomain.from_shape((3,))
+ >>> t = IndexTransform(domain=domain, output=(ConstantMap(offset=5),))
+ >>> t.apply((0,)), t.apply((1,)), t.apply((2,))
+ ((5,), (5,), (5,))
+ """
+
+ offset: int = 0
+ """The fixed output coordinate every input cell maps to."""
+
+ def to_json(self) -> OutputIndexMapJSON:
+ """Convert to the canonical wire form: the bare `constant` map.
+
+ Examples
+ --------
+ >>> ConstantMap(5).to_json()
+ {'offset': 5}
+ """
+ return {"offset": self.offset}
+
+
+@dataclass(frozen=True, slots=True)
+class DimensionMap:
+ """An ordered affine mapping to output coordinates.
+
+ Maps each input coordinate `i` to `offset + stride * i`, where the input
+ range comes from the enclosing `IndexTransform`'s domain. Arises from slice
+ indexing (e.g., `arr[2:10:3]` gives offset=2, stride=3).
+
+ Examples
+ --------
+ The slice `arr[2:11:3]` reads coordinates `2, 5, 8` — the rule
+ `offset + stride * i` with `offset=2`, `stride=3`:
+
+ >>> m = DimensionMap(input_dimension=0, offset=2, stride=3)
+ >>> [m.offset + m.stride * i for i in range(3)]
+ [2, 5, 8]
+ >>> np.arange(11)[2:11:3].tolist()
+ [2, 5, 8]
+ """
+
+ input_dimension: int
+ """The input (domain) dimension whose coordinate this map reads."""
+
+ offset: int = 0
+ """The output coordinate that input coordinate `0` maps to."""
+
+ stride: int = 1
+ """The output-coordinate step per unit input step; negative walks backward, zero repeats `offset`."""
+
+ def to_json(self) -> OutputIndexMapJSON:
+ """Convert to the canonical wire form: the `single_input_dimension` map.
+
+ Examples
+ --------
+ >>> DimensionMap(input_dimension=1, offset=0, stride=2).to_json()
+ {'offset': 0, 'stride': 2, 'input_dimension': 1}
+ """
+ return {
+ "offset": self.offset,
+ "stride": self.stride,
+ "input_dimension": self.input_dimension,
+ }
+
+
+@dataclass(frozen=True, slots=True)
+class ArrayMap:
+ """An explicit ordered, duplicate-preserving coordinate mapping.
+
+ Maps each input position `i` to `offset + stride * index_array[i]`.
+ Index-array order and repeated entries are semantic and remain present in
+ the result. Arises from fancy indexing (e.g., `arr[[5, 1, 1]]` or boolean
+ masks).
+
+ Freshly constructed maps are normalized to the **full input rank** of their
+ enclosing transform: `index_array` has the enclosing domain's rank, sized
+ fully on the axes it varies over and singleton (size 1) elsewhere. The
+ shape is the single source of truth for what the map depends on — its
+ **dependency axes** are exactly its non-singleton axes (see
+ `_array_map_dependency_axes`) — and it distinguishes the two
+ flavors of multi-array fancy indexing:
+
+ - **orthogonal** (`oindex`): each array varies along a single, *distinct*
+ axis (all others singleton); the result is their outer product.
+ - **vectorized** (`vindex`): the arrays are correlated and share the same
+ non-singleton (broadcast) axes; the result is a pointwise scatter.
+
+ A map holding exactly one coordinate carries no shape to read a dependency
+ from, and none is needed: it is the `ConstantMap` it equals, and the
+ selection layer builds that instead (see `array_map_or_constant`). A
+ hand-built all-singleton `ArrayMap` is still a valid value; resolution
+ classifies it with the correlated maps and reads it pointwise.
+
+ Examples
+ --------
+ The fancy selection `arr[[5, 1, 1]]` reads coordinate 5, then 1, then 1
+ — order and the duplicate preserved, exactly as NumPy fancy indexing:
+
+ >>> m = ArrayMap(index_array=np.array([5, 1, 1]))
+ >>> [m.offset + m.stride * c for c in m.index_array.tolist()]
+ [5, 1, 1]
+ >>> np.arange(10)[[5, 1, 1]].tolist()
+ [5, 1, 1]
+ """
+
+ index_array: npt.NDArray[np.integer[Any]]
+ """Explicit coordinates at the enclosing transform's full input rank; order and
+ duplicates are semantic. Its non-singleton axes are the map's dependency axes."""
+
+ offset: int = 0
+ """Constant term of the affine adjustment: the output coordinate is `offset + stride * index_array[i]`."""
+
+ stride: int = 1
+ """Multiplier applied to each `index_array` value before `offset` is added."""
+
+ def __post_init__(self) -> None:
+ """Own the index array and expose it read-only.
+
+ A map is frozen, but the array inside it was not: reaching through a
+ view's transform to `index_array[0] = 9` silently changed what the view
+ returned, in a package whose whole contract is that a view is a
+ description of a read and resolving it twice answers alike. Owning the
+ array also prevents the caller from changing the contents behind the
+ read-only view, which would invalidate this value object's hash.
+ """
+ # Immutable bytes are the ultimate owner so callers cannot re-enable
+ # the WRITEABLE flag, as they can on a read-only array that owns its
+ # allocation. `asarray` also accepts the NumPy scalars that reach here
+ # after indexing an array down to one element.
+ array = np.asarray(self.index_array)
+ if not np.issubdtype(array.dtype, np.integer):
+ raise TypeError(f"index_array must have an integer dtype, got {array.dtype}")
+ normalized = checked_affine(0, 1, array)
+ frozen = np.frombuffer(normalized.tobytes(), dtype=np.intp).reshape(normalized.shape)
+ object.__setattr__(self, "index_array", frozen)
+
+ def __reduce__(self) -> tuple[object, tuple[object, int, int]]:
+ """Reconstruct through `__init__`, preserving the ownership invariant."""
+ return (
+ type(self),
+ (self.index_array, self.offset, self.stride),
+ )
+
+ def __eq__(self, other: object) -> bool:
+ """Value equality, comparing index arrays element-wise.
+
+ The generated `__eq__` compares them with `==`, whose result for two
+ arrays is an array — so asking whether two maps are equal raised
+ `ValueError: the truth value of an array ... is ambiguous`. `frozen=True`
+ reads as a promise that a value can be compared and hashed, and this is
+ what makes good on it.
+ """
+ if not isinstance(other, ArrayMap):
+ return NotImplemented
+ return (
+ self.offset == other.offset
+ and self.stride == other.stride
+ and self.index_array.shape == other.index_array.shape
+ and bool(np.array_equal(self.index_array, other.index_array))
+ )
+
+ def __hash__(self) -> int:
+ """Hashed by the array's contents, so equal maps hash alike.
+
+ The generated `__hash__` hashed the ndarray itself, which is unhashable;
+ a map could therefore not go in a set, or key a cache.
+ """
+ return hash(
+ (
+ self.offset,
+ self.stride,
+ self.index_array.shape,
+ self.index_array.tobytes(),
+ )
+ )
+
+ @property
+ def dependency_axes(self) -> tuple[int, ...]:
+ """Every input axis this map varies over: its non-singleton axes.
+
+ One axis means orthogonal, several mean correlated, and none means
+ the map is degenerate — the shape is the single source of truth for
+ all three.
+
+ Examples
+ --------
+ >>> ArrayMap(index_array=np.array([[4, 0, 2]])).dependency_axes
+ (1,)
+ >>> ArrayMap(index_array=np.array([[1, 2], [3, 4]])).dependency_axes
+ (0, 1)
+ """
+ return _array_map_dependency_axes(self.index_array)
+
+ @property
+ def dependent_axis(self) -> int | None:
+ """Return the single input axis an orthogonal `ArrayMap` varies over.
+
+ This is the array's one non-singleton axis, read from the shape — the
+ single source of truth for what a map depends on. The selection layer
+ collapses a single-coordinate map to a `ConstantMap`
+ (`array_map_or_constant`), so a non-empty map built by this package always
+ has at least one dependency axis.
+
+ Returns
+ -------
+ int or None
+ The axis the map varies over, or `None` when it varies over no input
+ axis at all — an empty map, or a hand-built all-singleton one. `None`
+ is a valid result, not an error; such maps resolve through the
+ pointwise (general) path.
+
+ Raises
+ ------
+ ValueError
+ If the map varies over more than one axis, which makes it correlated
+ rather than orthogonal.
+
+ Examples
+ --------
+ An `oindex` selection on axis 1 of a rank-2 transform stores its
+ coordinates full-sized on axis 1 and singleton on axis 0, so the
+ dependency axis is read straight off the shape:
+
+ >>> m = ArrayMap(index_array=np.array([[4, 0, 2]]))
+ >>> m.index_array.shape
+ (1, 3)
+ >>> m.dependent_axis
+ 1
+ """
+ dep = self.dependency_axes
+ if len(dep) == 1:
+ return dep[0]
+ if len(dep) == 0:
+ return None
+ raise ValueError(
+ f"orthogonal ArrayMap must vary over exactly one axis; got dependency axes {dep}"
+ )
+
+ def to_json(self) -> OutputIndexMapJSON:
+ """Convert to the canonical wire form, collapsing a degenerate map.
+
+ A map holding exactly one coordinate, or none at all, is emitted as a
+ `constant` map — see the module note on the wire format in
+ [`zarr_indexing.json`][zarr_indexing.json]. Both are degenerate: the
+ first selects one coordinate whatever the input, and the second names
+ no cell and can only be empty because an input dimension is, so the
+ emptiness travels in the domain instead.
+
+ Examples
+ --------
+ >>> ArrayMap(np.array([[4], [1], [1]])).to_json()["index_array"]
+ [[4], [1], [1]]
+ >>> ArrayMap(np.array([7])).to_json() # degenerate: one coordinate
+ {'offset': 7}
+ """
+ if self.index_array.size == 1:
+ value = int(self.index_array.reshape(-1)[0])
+ return {"offset": self.offset + self.stride * value}
+ if self.index_array.size == 0:
+ return {"offset": 0}
+ return {
+ "offset": self.offset,
+ "stride": self.stride,
+ "index_array": self.index_array.tolist(),
+ "index_array_bounds": ["-inf", "+inf"],
+ }
+
+
+def output_index_map_from_json(data: OutputIndexMapJSON) -> OutputIndexMap:
+ """Construct the output map a canonical wire form names.
+
+ The wire form is a tagged union — `index_array`, then `input_dimension`,
+ else constant — so loading it dispatches to the right kind here rather
+ than on any one of them.
+
+ Examples
+ --------
+ >>> output_index_map_from_json({"offset": 5})
+ ConstantMap(offset=5)
+ >>> output_index_map_from_json({"offset": 0, "stride": 2, "input_dimension": 1})
+ DimensionMap(input_dimension=1, offset=0, stride=2)
+ """
+ from zarr_indexing._wire import lower_index_array
+
+ if "index_array" in data:
+ return ArrayMap(
+ index_array=lower_index_array(data["index_array"], "index_array"),
+ offset=data.get("offset", 0),
+ stride=data.get("stride", 1),
+ )
+ if "input_dimension" in data:
+ return DimensionMap(
+ input_dimension=data["input_dimension"],
+ offset=data.get("offset", 0),
+ stride=data.get("stride", 1),
+ )
+ return ConstantMap(offset=data.get("offset", 0))
+
+
+def array_map_or_constant(
+ index_array: npt.NDArray[np.integer[Any]],
+ offset: int = 0,
+ stride: int = 1,
+) -> ArrayMap | ConstantMap:
+ """An `ArrayMap`, collapsed to the `ConstantMap` it equals when it can be.
+
+ An index array holding exactly one coordinate maps every input cell to the
+ same place; representing it as a lookup table would leave a map whose shape
+ names no dependency axis, the one form the shape-derived classifier cannot
+ read. The selection and composition layers build their array maps through
+ this helper so that a non-empty `ArrayMap` always varies over at least one
+ axis. An empty array stays an `ArrayMap`: it maps no cell at all, and the
+ emptiness lives in the domain that accompanies it.
+ """
+ arr = np.asarray(index_array)
+ if arr.size == 1:
+ return ConstantMap(offset=checked_affine(offset, stride, int(arr.reshape(-1)[0])))
+ return ArrayMap(index_array=arr, offset=offset, stride=stride)
+
+
+OutputIndexMap = ConstantMap | DimensionMap | ArrayMap
diff --git a/packages/zarr-indexing/src/zarr_indexing/py.typed b/packages/zarr-indexing/src/zarr_indexing/py.typed
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/zarr-indexing/src/zarr_indexing/reader.py b/packages/zarr-indexing/src/zarr_indexing/reader.py
new file mode 100644
index 0000000000..8d47d51d21
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/reader.py
@@ -0,0 +1,585 @@
+"""Backend reader protocol and built-in system-memory implementations."""
+
+from __future__ import annotations
+
+import math
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Final, Protocol
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+import numpy as np
+
+from zarr_indexing._affine import checked_affine
+from zarr_indexing.chunk_resolution import ChunkProjection # noqa: TC001 (runtime annotation)
+from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap, OutputIndexMap
+from zarr_indexing.transform import (
+ IndexTransform,
+)
+
+__all__ = [
+ "BasicReader",
+ "NumPyReader",
+ "ReadContext",
+ "Reader",
+ "UnitStepReader",
+ "basic_reader",
+ "numpy_reader",
+ "unit_step_reader",
+]
+
+
+@dataclass(frozen=True, slots=True)
+class ReadContext:
+ """A source-global transform and optional projection for a partitioned read.
+
+ Examples
+ --------
+ >>> transform = IndexTransform.from_shape((6,))[1:5:2]
+ >>> context = ReadContext(transform)
+ >>> context.transform.domain.shape
+ (2,)
+ >>> context.projection is None
+ True
+ """
+
+ transform: IndexTransform
+ """Maps zero-origin output-buffer coordinates to global coordinates in the source."""
+
+ projection: ChunkProjection | None = None
+ """The partition plan when this read is one part of a partitioned view, else `None`."""
+
+
+class Reader(Protocol):
+ """Backend adapter that fills supplied system-memory result buffers.
+
+ A reader may be shared by every view and part derived from one
+ [`LazyArray`][zarr_indexing.lazy_array.LazyArray]. Part reads may run
+ concurrently, so a stateful implementation must synchronize its own
+ mutable state. `LazyArray` deliberately adds no serialization.
+
+ Examples
+ --------
+ The protocol is not `runtime_checkable`; an object satisfies it by
+ exposing a conforming `read_into`, as `basic_reader` does:
+
+ >>> transform = IndexTransform.from_shape((6,))[1:5:2]
+ >>> source = np.arange(6)
+ >>> out = np.empty(transform.domain.shape, dtype=source.dtype)
+ >>> basic_reader.read_into(source, ReadContext(transform), out)
+ >>> out.tolist()
+ [1, 3]
+ """
+
+ def read_into(
+ self,
+ source: Any,
+ context: ReadContext,
+ out: np.ndarray[Any, Any],
+ /,
+ ) -> None:
+ """Fill `out` with the exact source values selected by `context`.
+
+ `context.transform` maps zero-origin coordinates in the output buffer
+ to global coordinates in `source`, and its domain shape equals
+ `out.shape`. `context.projection`, when present, is the corresponding
+ partition plan: its `chunk_transform` is chunk-local, its
+ `cell_transform` describes result placement, and its `chunk_domain`
+ describes the grid cell. Fill every cell in place, preserving the
+ transform's exact values, order, and dtype, then return `None`. Do not
+ replace or retain `out`; it may be a strided writable view rather than
+ an owning array.
+
+ Backend exceptions propagate unchanged. Because callers may resolve
+ parts concurrently through the same reader object, stateful readers
+ are responsible for synchronizing their own state.
+ """
+ ...
+
+
+class BasicReader:
+ """Reader for system-memory sources exposing basic integer/slice indexing.
+
+ Each transform is decomposed into the smallest enclosing positive-slice
+ slab and a residual transform. The slab is read once with basic indexing,
+ so fancy or negative-step selections may over-read, and the residual is
+ then lowered through NumPy system-memory operations into the supplied
+ buffer.
+
+ Slice results must permit conversion to NumPy system memory. Device arrays
+ that reject implicit conversion require a custom reader responsible for
+ transferring values into the supplied system-memory output buffer.
+
+ Examples
+ --------
+ >>> transform = IndexTransform.from_shape((6,))[1:5:2]
+ >>> source = np.arange(6)
+ >>> out = np.empty(transform.domain.shape, dtype=source.dtype)
+ >>> BasicReader().read_into(source, ReadContext(transform), out)
+ >>> out.tolist() == source[1:5:2].tolist()
+ True
+ """
+
+ __slots__ = ()
+
+ def read_into(self, source: Any, context: ReadContext, out: Any, /) -> None:
+ """Read one transform through a positive-slice slab and residual lowering."""
+ transform = context.transform
+ key, residual = _decompose_basic(transform)
+ block = np.asanyarray(source[key])
+ out[...] = _lower(block, residual)
+
+
+class NumPyReader:
+ """Reader optimized for NumPy system-memory arrays.
+
+ This is the reader selected by
+ [`LazyArray.from_numpy`][zarr_indexing.lazy_array.LazyArray.from_numpy]. It
+ applies the complete transform with NumPy operations and is applicable to
+ `numpy.ndarray` sources, including `numpy.ma.MaskedArray`.
+
+ Examples
+ --------
+ >>> transform = IndexTransform.from_shape((3, 4))[::2, 1:3]
+ >>> source = np.arange(12).reshape(3, 4)
+ >>> out = np.empty(transform.domain.shape, dtype=source.dtype)
+ >>> NumPyReader().read_into(source, ReadContext(transform), out)
+ >>> out.tolist()
+ [[1, 2], [9, 10]]
+ """
+
+ __slots__ = ()
+
+ def read_into(self, source: Any, context: ReadContext, out: Any, /) -> None:
+ """Read one transform through a narrowed slab into `out`."""
+ transform = context.transform
+ key, residual = _decompose_basic(transform)
+ block = np.asanyarray(source[key])
+ out[...] = _lower(block, residual)
+
+
+class UnitStepReader:
+ """Reader for sources whose basic indexing accepts only step-1 slices.
+
+ Each transform is decomposed into the smallest enclosing ascending
+ unit-step slab and a residual transform, so the source only ever receives
+ `slice(start, stop, 1)` on every axis — the one form an API without
+ general strided reads (an FFI binding, an HTTP range endpoint) supports.
+ `BasicReader` instead pushes strided and reversed slices down, which
+ reads less but asks more of the source.
+
+ The residual lowering applies strides, reversals, and gathers to the
+ in-memory block, so a strided selection over-reads its cover by the
+ stride factor. Partitioning the wrapping
+ [`LazyArray`][zarr_indexing.lazy_array.LazyArray] (`with_parts`) bounds
+ each cover by a part.
+
+ Slice results must permit conversion to NumPy system memory, exactly as
+ for `BasicReader`.
+
+ Examples
+ --------
+ The source below is only ever asked for step-1 slices — here the cover
+ `slice(1, 4, 1)` — and the stride is replayed against the block:
+
+ >>> transform = IndexTransform.from_shape((6,))[1:5:2]
+ >>> source = np.arange(6)
+ >>> out = np.empty(transform.domain.shape, dtype=source.dtype)
+ >>> UnitStepReader().read_into(source, ReadContext(transform), out)
+ >>> out.tolist()
+ [1, 3]
+ """
+
+ __slots__ = ()
+
+ def read_into(self, source: Any, context: ReadContext, out: Any, /) -> None:
+ """Read one transform through an ascending unit-step slab into `out`."""
+ transform = context.transform
+ key, residual = _decompose_unit_step(transform)
+ block = np.asanyarray(source[key])
+ out[...] = _lower(block, residual)
+
+
+basic_reader: Final = BasicReader()
+numpy_reader: Final = NumPyReader()
+unit_step_reader: Final = UnitStepReader()
+
+
+def _take(array: Any, indices: np.ndarray[Any, np.dtype[np.intp]], axis: int) -> Any:
+ return np.take(array, indices, axis=axis)
+
+
+def _reshape(array: Any, shape: tuple[int, ...]) -> Any:
+ return np.reshape(array, shape)
+
+
+def _transpose(array: Any, permutation: tuple[int, ...]) -> Any:
+ return np.transpose(array, permutation)
+
+
+def _expand_dims(array: Any, axis: int) -> Any:
+ return np.expand_dims(array, axis)
+
+
+def _dimension_map_coords(
+ m: DimensionMap, transform: IndexTransform
+) -> np.ndarray[Any, np.dtype[np.intp]]:
+ """The storage coordinates a DimensionMap enumerates, in view order."""
+ d = m.input_dimension
+ lo = transform.domain.inclusive_min[d]
+ hi = transform.domain.exclusive_max[d]
+ extent = hi - lo
+ if extent == 0:
+ return np.empty((0,), dtype=np.intp)
+ first = checked_affine(m.offset, m.stride, lo)
+ return checked_affine(first, m.stride, np.arange(extent, dtype=np.intp))
+
+
+def _array_map_coords(m: ArrayMap) -> np.ndarray[Any, np.dtype[np.intp]]:
+ """The storage coordinates an ArrayMap enumerates, flattened."""
+ return checked_affine(m.offset, m.stride, m.index_array).reshape(-1)
+
+
+def _correlated_map_coords(
+ m: ArrayMap, broadcast_axes: list[int], broadcast_shape: tuple[int, ...], input_rank: int
+) -> np.ndarray[Any, np.dtype[np.intp]]:
+ """One storage coordinate per point of the correlated block, flattened.
+
+ A correlated `ArrayMap`'s index array carries the transform's full input
+ rank, with a singleton on every axis it does not vary over — including
+ broadcast axes it shares with the *other* correlated maps but is itself
+ constant along. Flattening it directly would then yield fewer coordinates
+ than there are points, so it is reduced to the broadcast block and
+ broadcast up to it explicitly.
+ """
+ coords = checked_affine(m.offset, m.stride, m.index_array)
+ if math.prod(broadcast_shape) == 0:
+ # A zero-extent broadcast axis makes the correlated block empty — for
+ # example an ArrayMap composed over an empty domain, which the package
+ # promises resolves like any other. The per-axis reshape below cannot
+ # express that block (a 0-size array does not reshape to the non-zero
+ # singleton axes), and there is no coordinate to produce anyway.
+ return np.empty(0, dtype=np.intp)
+ if coords.ndim == input_rank:
+ # Drop the axes bound by a slice, which the map is singleton along.
+ # Removing size-1 axes by reshape preserves element order wherever they
+ # sit, so no transpose is needed.
+ coords = coords.reshape(tuple(coords.shape[axis] for axis in broadcast_axes))
+ return np.ascontiguousarray(np.broadcast_to(coords, broadcast_shape)).reshape(-1)
+
+
+def _restore_domain_axis_order(
+ result: Any, axis_input_dims: list[int], domain_shape: tuple[int, ...]
+) -> Any:
+ """Permute `result`'s axes into input-domain order, restoring dropped axes.
+
+ `axis_input_dims[k]` is the input (domain) dimension that axis `k` of
+ `result` corresponds to. Axes are permuted so that they appear in increasing
+ domain-dimension order, and any domain dimension no output map depends on is
+ reinserted at **its own extent**.
+
+ An unreferenced dimension is not always a singleton. A `vindex` coordinate
+ array with a broadcast axis it does not vary over leaves that axis in the
+ domain; a later basic index that consumes the axis the array *does* vary
+ over collapses the map to a `ConstantMap` and leaves the broadcast axis
+ behind, with whatever extent the basic index gave it — including 0. Every
+ position along such an axis holds the same values, so it is restored by
+ repeating the block, and an extent of 0 restores an empty result rather than
+ fabricating a row.
+
+ This is also where NumPy's advanced-index placement rules are absorbed:
+ whatever order the gather produced, the lowered result always comes back in
+ the view's own axis order.
+ """
+ if len(set(axis_input_dims)) != len(axis_input_dims):
+ raise NotImplementedError(
+ "resolving a transform whose output maps share an input dimension "
+ "(a diagonal view) is not supported"
+ )
+ order = sorted(range(len(axis_input_dims)), key=lambda k: axis_input_dims[k])
+ if order != list(range(len(order))):
+ result = _transpose(result, tuple(order))
+ covered = set(axis_input_dims)
+ for dim, extent in enumerate(domain_shape):
+ if dim in covered:
+ continue
+ result = _expand_dims(result, dim)
+ if extent != 1:
+ result = _take(result, np.zeros(extent, dtype=np.intp), axis=dim)
+ return result
+
+
+def _lower(array: Any, transform: IndexTransform) -> Any:
+ """Lower a transform to one pass of array operations over `array`.
+
+ Every read, partitioned or not, goes through this function. The result is
+ always in the transform's own domain axis order and of exactly its domain
+ shape.
+ """
+ if math.prod(transform.domain.shape) == 0:
+ # An empty domain selects nothing, and its maps may legitimately be
+ # empty along the vanished axes (an ArrayMap composed over an empty
+ # domain, which the package promises resolves like any other). The
+ # resolvers below cannot evaluate such maps — and have no reason to.
+ return np.empty(transform.domain.shape, dtype=np.asanyarray(array).dtype)
+ if transform.index_array_structure == "general":
+ result = _lower_general(array, transform)
+ else:
+ result = _lower_orthogonal(array, transform)
+ if isinstance(result, np.generic):
+ # Basic indexing every axis of a NumPy array yields a scalar; `result()`
+ # documents a zero-dimensional array.
+ return np.asarray(result)
+ return result
+
+
+def _lower_orthogonal(array: Any, transform: IndexTransform) -> Any:
+ """Basic slicing plus one `take` per fancy-indexed axis (an outer product).
+
+ Orthogonal `ArrayMap`s vary over distinct input axes, so gathering them one
+ axis at a time is exact — a `take` along one storage axis leaves every other
+ axis's coordinates untouched.
+ """
+ outputs = transform.output
+ gathered: dict[int, np.ndarray[Any, np.dtype[np.intp]]] = {}
+ for out_dim, m in enumerate(outputs):
+ if isinstance(m, ArrayMap):
+ gathered[out_dim] = _array_map_coords(m)
+ elif isinstance(m, DimensionMap) and m.stride <= 0:
+ # Reversing and repeating maps have no positive-step slice; gather them.
+ gathered[out_dim] = _dimension_map_coords(m, transform)
+
+ result = array
+ for out_dim, coords in gathered.items():
+ result = _take(result, coords, axis=out_dim)
+
+ selection: list[Any] = []
+ axis_input_dims: list[int] = []
+ for out_dim, m in enumerate(outputs):
+ if isinstance(m, ConstantMap):
+ selection.append(m.offset)
+ continue
+ if out_dim in gathered:
+ selection.append(slice(None))
+ else:
+ assert isinstance(m, DimensionMap)
+ d = m.input_dimension
+ lo = transform.domain.inclusive_min[d]
+ hi = transform.domain.exclusive_max[d]
+ selection.append(slice(m.offset + m.stride * lo, m.offset + m.stride * hi, m.stride))
+ if isinstance(m, ArrayMap):
+ axis = m.dependent_axis
+ if axis is None:
+ raise NotImplementedError(
+ "resolving an orthogonal ArrayMap that varies over no input "
+ "dimension is not supported; such a map should have been "
+ "collapsed to a ConstantMap"
+ )
+ axis_input_dims.append(axis)
+ else:
+ axis_input_dims.append(m.input_dimension)
+ result = result[tuple(selection)]
+ return _restore_domain_axis_order(result, axis_input_dims, transform.domain.shape)
+
+
+def _lower_general(array: Any, transform: IndexTransform) -> Any:
+ """Flatten the index-array axes, gather the points once, reshape back.
+
+ The general path for every index-array structure the orthogonal resolver
+ cannot take: correlated (`vindex`) maps, maps sharing an input axis (a
+ diagonal gather), and mixtures of correlated and orthogonal maps. All
+ index arrays are treated as lookup tables over the joint block of
+ non-slice axes: the corresponding storage axes are moved to the front and
+ flattened, the per-point coordinates are converted to offsets into that
+ flat axis with row-major strides, and a single `take` collects them.
+ """
+ outputs = transform.output
+ correlated_dims = [d for d, m in enumerate(outputs) if isinstance(m, ArrayMap)]
+
+ slice_input_dims = {m.input_dimension for m in outputs if isinstance(m, DimensionMap)}
+ broadcast_axes = [d for d in range(transform.input_rank) if d not in slice_input_dims]
+ broadcast_shape = tuple(transform.domain.shape[d] for d in broadcast_axes)
+
+ for d in correlated_dims:
+ arr_map = outputs[d]
+ assert isinstance(arr_map, ArrayMap)
+ # The axes the array varies over (its non-singleton axes; see
+ # transform._array_map_dependency_axes) must all live in the block.
+ dependency = (axis for axis, size in enumerate(arr_map.index_array.shape) if size > 1)
+ if any(a not in broadcast_axes for a in dependency):
+ # Reachable only by hand-building a transform: no selection binds
+ # the same input axis to both a slice map and an index array.
+ raise NotImplementedError(
+ "resolving a transform whose index array varies over an input "
+ "dimension also bound by a slice map is not supported"
+ )
+
+ # Gather any reversing or repeating slice axis first, then take the basic-slice
+ # cut. The correlated axes keep their full extent: their coordinates are absolute.
+ gathered: dict[int, np.ndarray[Any, np.dtype[np.intp]]] = {
+ d: _dimension_map_coords(m, transform)
+ for d, m in enumerate(outputs)
+ if isinstance(m, DimensionMap) and m.stride <= 0
+ }
+ result = array
+ for out_dim, coords in gathered.items():
+ result = _take(result, coords, axis=out_dim)
+
+ selection: list[Any] = []
+ residual_axis_dims: list[int] = []
+ correlated_positions: list[int] = []
+ residual_positions: list[int] = []
+ axis = 0
+ for out_dim, m in enumerate(outputs):
+ if isinstance(m, ConstantMap):
+ selection.append(m.offset)
+ continue
+ if out_dim in correlated_dims:
+ selection.append(slice(None))
+ correlated_positions.append(axis)
+ elif out_dim in gathered:
+ selection.append(slice(None))
+ residual_positions.append(axis)
+ assert isinstance(m, DimensionMap)
+ residual_axis_dims.append(m.input_dimension)
+ else:
+ assert isinstance(m, DimensionMap)
+ d = m.input_dimension
+ lo = transform.domain.inclusive_min[d]
+ hi = transform.domain.exclusive_max[d]
+ selection.append(slice(m.offset + m.stride * lo, m.offset + m.stride * hi, m.stride))
+ residual_positions.append(axis)
+ residual_axis_dims.append(d)
+ axis += 1
+ result = result[tuple(selection)]
+
+ # Correlated axes to the front, in output order, so the flattening strides
+ # below match the order the coordinates are combined in.
+ perm = tuple(correlated_positions) + tuple(residual_positions)
+ if perm != tuple(range(len(perm))):
+ result = _transpose(result, perm)
+
+ n_corr = len(correlated_dims)
+ corr_sizes = tuple(int(s) for s in result.shape[:n_corr])
+ tail_shape = tuple(int(s) for s in result.shape[n_corr:])
+ result = _reshape(result, (math.prod(corr_sizes), *tail_shape))
+
+ flat_index = np.zeros(math.prod(broadcast_shape), dtype=np.intp)
+ stride = 1
+ for position in range(n_corr - 1, -1, -1):
+ m = outputs[correlated_dims[position]]
+ assert isinstance(m, ArrayMap)
+ flat_index = flat_index + (
+ _correlated_map_coords(m, broadcast_axes, broadcast_shape, transform.input_rank)
+ * stride
+ )
+ stride *= corr_sizes[position]
+
+ result = _take(result, flat_index, axis=0)
+ result = _reshape(result, broadcast_shape + tail_shape)
+ return _restore_domain_axis_order(
+ result, list(broadcast_axes) + residual_axis_dims, transform.domain.shape
+ )
+
+
+def _push_slice_for_dimension_map(
+ m: DimensionMap, transform: IndexTransform
+) -> tuple[slice, DimensionMap]:
+ """The positive-step slice covering a `DimensionMap`, and its block-local map.
+
+ A negative step is read forwards and reversed by the residual: a source is
+ only ever asked for a slice that walks upwards, which is the one form every
+ array-like agrees on.
+ """
+ d = m.input_dimension
+ lo = transform.domain.inclusive_min[d]
+ hi = max(transform.domain.exclusive_max[d], lo)
+ if hi == lo:
+ return slice(0, 0, 1), DimensionMap(input_dimension=d, offset=-lo, stride=1)
+ first = checked_affine(m.offset, m.stride, lo)
+ last = checked_affine(m.offset, m.stride, hi - 1)
+ if m.stride > 0:
+ return (
+ slice(first, last + 1, m.stride),
+ DimensionMap(input_dimension=d, offset=-lo, stride=1),
+ )
+ if m.stride == 0:
+ return (
+ slice(first, first + 1, 1),
+ DimensionMap(input_dimension=d, offset=0, stride=0),
+ )
+ # Descending: the block holds the same coordinates in ascending order, so
+ # the residual walks it backwards from the last block position.
+ return (
+ slice(last, first + 1, -m.stride),
+ DimensionMap(input_dimension=d, offset=hi - 1, stride=-1),
+ )
+
+
+def _push_unit_slice_for_dimension_map(
+ m: DimensionMap, transform: IndexTransform
+) -> tuple[slice, DimensionMap]:
+ """The unit-step slice covering a `DimensionMap`, and its block-local map.
+
+ Strides and reversals stay in the residual: the source is only ever asked
+ for a contiguous ascending slice, and the original stride is replayed
+ against the in-memory block. The cover therefore over-reads a strided
+ selection by its stride factor, which is the price of a source that
+ accepts nothing but `slice(start, stop, 1)`.
+ """
+ d = m.input_dimension
+ lo = transform.domain.inclusive_min[d]
+ hi = max(transform.domain.exclusive_max[d], lo)
+ if hi == lo:
+ return slice(0, 0, 1), DimensionMap(input_dimension=d, offset=-lo, stride=1)
+ first = checked_affine(m.offset, m.stride, lo)
+ if m.stride == 0:
+ return (
+ slice(first, first + 1, 1),
+ DimensionMap(input_dimension=d, offset=0, stride=0),
+ )
+ last = checked_affine(m.offset, m.stride, hi - 1)
+ origin = min(first, last)
+ return (
+ slice(origin, max(first, last) + 1, 1),
+ DimensionMap(input_dimension=d, offset=m.offset - origin, stride=m.stride),
+ )
+
+
+def _decompose_basic(transform: IndexTransform) -> tuple[tuple[slice, ...], IndexTransform]:
+ return _decompose(transform, _push_slice_for_dimension_map)
+
+
+def _decompose_unit_step(transform: IndexTransform) -> tuple[tuple[slice, ...], IndexTransform]:
+ return _decompose(transform, _push_unit_slice_for_dimension_map)
+
+
+def _decompose(
+ transform: IndexTransform,
+ push_dimension_map: Callable[[DimensionMap, IndexTransform], tuple[slice, DimensionMap]],
+) -> tuple[tuple[slice, ...], IndexTransform]:
+ key: list[slice] = []
+ residual: list[OutputIndexMap] = []
+ for output_map in transform.output:
+ if isinstance(output_map, ConstantMap):
+ coordinate = checked_affine(output_map.offset, 0, 0)
+ key.append(slice(coordinate, coordinate + 1, 1))
+ residual.append(ConstantMap(offset=0))
+ elif isinstance(output_map, DimensionMap):
+ pushed, local = push_dimension_map(output_map, transform)
+ key.append(pushed)
+ residual.append(local)
+ else:
+ coordinates = checked_affine(
+ output_map.offset, output_map.stride, output_map.index_array
+ )
+ if coordinates.size == 0:
+ key.append(slice(0, 0, 1))
+ local_index = coordinates
+ else:
+ origin = int(coordinates.min())
+ key.append(slice(origin, int(coordinates.max()) + 1, 1))
+ local_index = checked_affine(-origin, 1, coordinates)
+ residual.append(ArrayMap(index_array=local_index))
+ return tuple(key), IndexTransform(domain=transform.domain, output=tuple(residual))
diff --git a/packages/zarr-indexing/src/zarr_indexing/testing/__init__.py b/packages/zarr-indexing/src/zarr_indexing/testing/__init__.py
new file mode 100644
index 0000000000..e98d051900
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/testing/__init__.py
@@ -0,0 +1,57 @@
+"""Test support for projects whose arrays are read through `LazyArray`.
+
+A Hypothesis state machine that composes indexing steps onto a `LazyArray`
+wrapping your array and checks every step against NumPy
+([`stateful`][zarr_indexing.testing.stateful]), and the selection strategies it
+draws from, exported on their own for a project that has its own harness
+([`strategies`][zarr_indexing.testing.strategies]).
+
+```python
+from zarr_indexing.testing import ChainedIndexingStateMachine, state_machine_test
+
+class MyArrayIndexing(ChainedIndexingStateMachine):
+ def make_source(self, data):
+ array = my_format.create(shape=data.shape, dtype=data.dtype)
+ array[:] = data
+ return array
+
+TestMyArrayIndexing = state_machine_test(MyArrayIndexing)
+```
+
+This subpackage needs `hypothesis`, which the rest of `zarr_indexing` does not:
+install it with the `testing` extra (`pip install zarr-indexing[testing]`).
+"""
+
+from zarr_indexing.testing.stateful import (
+ DEFAULT_DATA,
+ DEFAULT_PARTITIONINGS,
+ DEFAULT_SETTINGS,
+ ChainedIndexingStateMachine,
+ apply_selection,
+ outer_selection,
+ repartition,
+ state_machine_test,
+)
+from zarr_indexing.testing.strategies import (
+ basic_selections,
+ masks,
+ orthogonal_selections,
+ slice_selections,
+ vectorized_selections,
+)
+
+__all__ = [
+ "DEFAULT_DATA",
+ "DEFAULT_PARTITIONINGS",
+ "DEFAULT_SETTINGS",
+ "ChainedIndexingStateMachine",
+ "apply_selection",
+ "basic_selections",
+ "masks",
+ "orthogonal_selections",
+ "outer_selection",
+ "repartition",
+ "slice_selections",
+ "state_machine_test",
+ "vectorized_selections",
+]
diff --git a/packages/zarr-indexing/src/zarr_indexing/testing/stateful.py b/packages/zarr-indexing/src/zarr_indexing/testing/stateful.py
new file mode 100644
index 0000000000..4458d7922a
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/testing/stateful.py
@@ -0,0 +1,380 @@
+"""A stateful property test for indexing an array through `LazyArray`.
+
+`ChainedIndexingStateMachine` composes indexing steps onto a `LazyArray`
+wrapping *your* array — `lazy[...]`, `lazy.oindex[...]`, `lazy.vindex[...]`,
+each step applied to the view the last one produced — while applying the same
+steps to a NumPy array holding the same values. After every step the view must
+still agree with that model three ways: its shape, its `result()`, and the
+assembly of its `parts()`.
+
+Point it at an array by subclassing and overriding `make_source`:
+
+```python
+from zarr_indexing.testing import ChainedIndexingStateMachine, state_machine_test
+
+class MyArrayIndexing(ChainedIndexingStateMachine):
+ def make_source(self, data):
+ array = my_format.create(shape=data.shape, dtype=data.dtype)
+ array[:] = data
+ return array
+
+TestMyArrayIndexing = state_machine_test(MyArrayIndexing)
+```
+
+`data`, `partitionings`, and `readers` are class attributes; override any of
+them to widen or narrow what is drawn. The base class needs no `make_source` at
+all — left alone it wraps the NumPy array itself, which is a useful smoke test
+of this package but says nothing about yours.
+
+What it is checking
+-------------------
+The parts invariant is the one with teeth.
+[`Partition`][zarr_indexing.lazy_array.Partition] documents
+`out[part.out_selection] = part.view.result()` as the assembly procedure, so
+this checks that literally: a part's values must arrive at exactly the shape its
+`out_selection` addresses — not merely a shape that broadcasts into it — land
+there, and cover the view once. Checking through `result()` alone would prove
+only that `result()` is self-consistent.
+
+The `choose_reader` rule draws a reader and applies it to the view, so the
+execution strategy becomes part of the chain. Every reader listed by a subclass
+must preserve the NumPy model for its source. The universal `basic_reader` is
+always exercised, even when a subclass lists only specialized readers; with no
+declared readers it is the sole strategy drawn.
+
+Requires the `testing` extra (`pip install zarr-indexing[testing]`).
+"""
+
+from __future__ import annotations
+
+import math
+from collections.abc import Sequence
+from typing import TYPE_CHECKING, Any, ClassVar
+
+import numpy as np
+from hypothesis import HealthCheck, settings
+from hypothesis import strategies as st
+from hypothesis.stateful import RuleBasedStateMachine, initialize, invariant, precondition, rule
+
+from zarr_indexing.lazy_array import LazyArray
+from zarr_indexing.reader import Reader, basic_reader
+from zarr_indexing.testing.strategies import (
+ basic_selections,
+ orthogonal_selections,
+ slice_selections,
+ vectorized_selections,
+)
+
+if TYPE_CHECKING:
+ from zarr_indexing.boundary import SelectionMode
+
+__all__ = [
+ "DEFAULT_DATA",
+ "DEFAULT_PARTITIONINGS",
+ "DEFAULT_SETTINGS",
+ "ChainedIndexingStateMachine",
+ "apply_selection",
+ "outer_selection",
+ "repartition",
+ "state_machine_test",
+]
+
+DEFAULT_DATA = np.arange(7 * 5 * 4, dtype=np.int64).reshape(7, 5, 4)
+"""The values the source holds by default: distinct, so a misplaced cell shows."""
+
+DEFAULT_PARTITIONINGS: tuple[Any, ...] = (
+ None,
+ (2, 2, 2),
+ (7, 5, 4),
+ (3, 2, 3),
+ ((3, 3, 1), (2, 2, 1), (3, 1)),
+ (4, 3, 3),
+)
+"""Partitionings to read under: a single whole-array part, uniform boxes of
+several shapes (some of which do not divide the extent), and explicit per-axis
+sizes. Boxes that straddle whatever the source declares are deliberate — they
+cost extra I/O but must not change an answer."""
+
+DEFAULT_SETTINGS = settings(
+ max_examples=250,
+ stateful_step_count=10,
+ deadline=None,
+ suppress_health_check=[
+ HealthCheck.data_too_large,
+ HealthCheck.filter_too_much,
+ HealthCheck.too_slow,
+ ],
+)
+"""Enough examples to find a defect reachable only through a narrow chain, at a
+few seconds per run when there is nothing to find. Every step is followed by
+checks that each materialize the whole view, so the budget buys examples rather
+than long chains — a chain runs out of axes to index within a few steps anyway.
+
+`filter_too_much` is suppressed because a chain that reaches a rank-0 view
+leaves only `repartition` enabled, so a run that opens there is discarded."""
+
+
+# --------------------------------------------------------------------------- #
+# The NumPy model
+# --------------------------------------------------------------------------- #
+
+
+def outer_selection(array: Any, selection: Sequence[Any]) -> Any:
+ """Apply an orthogonal selection to a NumPy array: the outer product of its axes.
+
+ NumPy has no operator for this, so the model is built from `numpy.ix_`.
+ Scalar integers are basic indices — NumPy applies them first and drops the
+ axis — so they are peeled off before the outer product is formed.
+ """
+
+ def is_scalar(sel: Any) -> bool:
+ return isinstance(sel, (int, np.integer)) and not isinstance(sel, bool)
+
+ scalars = tuple(sel if is_scalar(sel) else slice(None) for sel in selection)
+ reduced = array[scalars]
+ axes = [
+ np.arange(size)[sel]
+ for size, sel in zip(reduced.shape, [s for s in selection if not is_scalar(s)], strict=True)
+ ]
+ if len(axes) == 0:
+ return reduced
+ return reduced[np.ix_(*axes)]
+
+
+def apply_selection(array: Any, selection: tuple[Any, ...], mode: SelectionMode) -> Any:
+ """Apply a selection to a NumPy array in the given mode — the model a view is checked against.
+
+ NumPy's own semantics *are* basic and vectorized indexing, so only the
+ orthogonal mode needs building (see `outer_selection`).
+ """
+ if mode == "orthogonal":
+ return outer_selection(array, selection)
+ return array[selection]
+
+
+def state_machine_test(
+ machine: type[RuleBasedStateMachine], *, config: settings = DEFAULT_SETTINGS
+) -> Any:
+ """The pytest-collectable `TestCase` for a machine, with settings applied.
+
+ Hypothesis builds a fresh `TestCase` per state-machine class, so settings
+ set on a base class do not reach a subclass's; this applies them where they
+ land. Assign the result to a module-level name beginning with `Test`.
+ """
+ case = machine.TestCase
+ case.settings = config
+ return case
+
+
+def repartition(view: LazyArray, parts: Any) -> LazyArray:
+ """Apply one of `partitionings` to a view.
+
+ The three partitioning spellings are three named methods, so a list holding
+ a mix of them needs a dispatch somewhere. Choosing among them is what a test
+ harness drawing from that list is doing, so it lives here rather than being
+ pushed back into the public API as a type-inspecting parameter.
+ """
+ if parts is None:
+ return view.unpartitioned()
+ if any(isinstance(entry, Sequence) for entry in parts):
+ return view.with_parts_per_axis(parts)
+ return view.with_parts(parts)
+
+
+# --------------------------------------------------------------------------- #
+# The machine
+# --------------------------------------------------------------------------- #
+
+_SOURCE = "_zarr_indexing_cached_source"
+
+
+class ChainedIndexingStateMachine(RuleBasedStateMachine):
+ """Indexing steps composed onto one `LazyArray`, against NumPy as the model.
+
+ Subclass and override `make_source` to point it at your own array. See the
+ module docstring for the shape of that subclass and for what the invariants
+ check.
+
+ Attributes
+ ----------
+ data
+ The values the source holds, and the model every step is checked
+ against. Any shape and dtype NumPy supports; every axis must be
+ non-empty.
+ partitionings
+ Drawn once per run, before any indexing: `with_parts` is a pure setter
+ that carries through composition untouched and is read only when a view
+ resolves, so choosing it up front reaches the same states choosing it
+ mid-chain does, and spends the whole step budget on indexing.
+ readers
+ Execution strategies `choose_reader` may draw. Every listed reader must
+ preserve the model for the source. `basic_reader` is always included;
+ `None` means the reader already carried by the constructed view.
+ """
+
+ data: ClassVar[Any] = DEFAULT_DATA
+ partitionings: ClassVar[Sequence[Any]] = DEFAULT_PARTITIONINGS
+ readers: ClassVar[Sequence[Reader] | None] = None
+
+ def make_source(self, data: Any) -> Any:
+ """Build the array under test, holding `data`.
+
+ Called once per machine class and cached, not once per example: an
+ example is cheap and a source may not be. The machine only ever reads,
+ so the same object serves every run — but it must therefore not be
+ mutated by anything else while the test runs.
+
+ The default returns `data` itself, so an unsubclassed machine exercises
+ this package against NumPy.
+ """
+ return data
+
+ def __init__(self) -> None:
+ super().__init__()
+ cls = type(self)
+ self.model: Any = np.asarray(cls.data)
+ source = cls.__dict__.get(_SOURCE)
+ if source is None:
+ source = self.make_source(self.model)
+ setattr(cls, _SOURCE, source)
+ self.view = LazyArray(source)
+ self.reader_choices = _reader_set(self.view, cls.readers)
+ self.chain: list[tuple[str, Any]] = []
+
+ def _indexable(self) -> bool:
+ """Whether there is anything left to index.
+
+ A rank-0 or empty view takes no further step — NumPy would reject one
+ too — so the chain ends there, and the invariants keep checking.
+ """
+ return self.model.ndim > 0 and self.model.size > 0
+
+ def _step(self, mode: SelectionMode, selection: tuple[Any, ...]) -> None:
+ self.chain.append((mode, selection))
+ self.model = apply_selection(self.model, selection, mode)
+ if mode == "basic":
+ self.view = self.view.lazy[selection]
+ elif mode == "orthogonal":
+ self.view = self.view.lazy.oindex[selection]
+ else:
+ self.view = self.view.lazy.vindex[selection]
+
+ # -- rules --------------------------------------------------------------
+
+ @initialize(data=st.data())
+ def choose_partitioning(self, data: st.DataObject) -> None:
+ """Fix how the read is broken up, before any indexing."""
+ parts = data.draw(st.sampled_from(list(type(self).partitionings)))
+ self.view = repartition(self.view, parts)
+ self.chain.append(("parts", parts))
+
+ @precondition(lambda self: self._indexable())
+ @rule(data=st.data())
+ def basic(self, data: st.DataObject) -> None:
+ self._step("basic", data.draw(basic_selections(self.model.shape)))
+
+ @precondition(lambda self: self._indexable())
+ @rule(data=st.data())
+ def orthogonal(self, data: st.DataObject) -> None:
+ self._step("orthogonal", data.draw(orthogonal_selections(self.model.shape)))
+
+ @precondition(lambda self: self._indexable())
+ @rule(data=st.data())
+ def vectorized(self, data: st.DataObject) -> None:
+ self._step("vectorized", data.draw(vectorized_selections(self.model.shape)))
+
+ @precondition(lambda self: self._indexable())
+ @rule(data=st.data())
+ def slices_only(self, data: st.DataObject) -> None:
+ """An `oindex` step carrying only slices is not a fancy selection.
+
+ It narrows the view's own axes and composes like basic indexing. Drawn
+ as its own rule so that narrowing an existing index array by slices —
+ a distinct code path from narrowing it with coordinates — stays
+ exercised at full weight.
+ """
+ self._step("orthogonal", data.draw(slice_selections(self.model.shape)))
+
+ @rule(data=st.data())
+ def choose_reader(self, data: st.DataObject) -> None:
+ """Read the rest of the chain through another conforming strategy."""
+ reader = data.draw(st.sampled_from(list(self.reader_choices)))
+ self.view = self.view.with_reader(reader)
+ self.chain.append(("reader", type(reader).__qualname__))
+
+ @precondition(lambda self: not self._indexable())
+ @rule(data=st.data())
+ def repartition(self, data: st.DataObject) -> None:
+ """Re-box a chain that has run out of axes to index.
+
+ Something must stay enabled once the view is rank-0 or empty, or
+ Hypothesis has no move to make and abandons the run. Re-boxing is the
+ useful thing to do there: it changes nothing the invariants may see, and
+ a rank-0 view read through every partitioning is exactly the state a
+ collapsed correlated selection reaches.
+ """
+ parts = data.draw(st.sampled_from(list(type(self).partitionings)))
+ self.view = repartition(self.view, parts)
+ self.chain.append(("parts", parts))
+
+ # -- invariants ---------------------------------------------------------
+
+ @invariant()
+ def the_view_has_the_models_shape(self) -> None:
+ assert self.view.shape == self.model.shape, self.chain
+
+ @invariant()
+ def result_matches_the_model(self) -> None:
+ np.testing.assert_array_equal(
+ np.asarray(self.view.result()), self.model, err_msg=str(self.chain)
+ )
+
+ @invariant()
+ def parts_tile_the_view(self) -> None:
+ """The documented assembly, run literally.
+
+ Every part's values arrive at exactly the shape its `out_selection`
+ addresses — not merely a shape that broadcasts into it — and together
+ the parts cover the view once.
+ """
+ assembled = np.zeros(self.view.shape, dtype=self.view.dtype)
+ hits = np.zeros(self.view.shape, dtype=np.int64)
+ for part in self.view.parts():
+ value = np.asarray(part.view.result())
+ assert value.shape == assembled[part.out_selection].shape, (
+ f"part {part.base_coords} carries {value.shape} for an out_selection "
+ f"addressing {assembled[part.out_selection].shape}: {self.chain}"
+ )
+ # `is_complete` is what a consumer reads to decide it may take a
+ # whole-box read and skip assembling anything, so a wrongly-`True`
+ # one is the silent-corruption case. Asserted one way only: the flag
+ # is documented as conservative, free to say `False` about a part it
+ # does cover (a strided walk over a one-cell box, a fancy axis that
+ # happens to enumerate everything), and only the claim to cover
+ # everything has to be earned. Both quantities are already in hand
+ # here, and nothing else in the suite compares them.
+ if part.is_complete:
+ box_cells = math.prod(stop - start for start, stop in part.box)
+ assert value.size == box_cells, (
+ f"part {part.base_coords} reports is_complete but carries "
+ f"{value.size} of its box's {box_cells} cells: {self.chain}"
+ )
+ assembled[part.out_selection] = value
+ np.add.at(hits, part.out_selection, 1)
+
+ np.testing.assert_array_equal(assembled, self.model, err_msg=str(self.chain))
+ np.testing.assert_array_equal(
+ hits, np.ones(self.view.shape, dtype=np.int64), err_msg=str(self.chain)
+ )
+
+
+def _reader_set(view: LazyArray, declared: Sequence[Reader] | None) -> tuple[Reader, ...]:
+ """The readers `choose_reader` draws from, `basic_reader` always among them."""
+ readers = list(declared) if declared is not None else [view.reader]
+ if all(reader is not basic_reader for reader in readers):
+ readers.insert(0, basic_reader)
+ unique: list[Reader] = []
+ for reader in readers:
+ if all(reader is not existing for existing in unique):
+ unique.append(reader)
+ return tuple(unique)
diff --git a/packages/zarr-indexing/src/zarr_indexing/testing/strategies.py b/packages/zarr-indexing/src/zarr_indexing/testing/strategies.py
new file mode 100644
index 0000000000..63a3d351a9
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/testing/strategies.py
@@ -0,0 +1,207 @@
+"""Hypothesis strategies for the selections `LazyArray` accepts.
+
+Each strategy takes the shape of the array being indexed and generates one
+selection for it — an index tuple with one entry per axis, in the spelling its
+mode expects. They are the generators behind
+[`ChainedIndexingStateMachine`][zarr_indexing.testing.stateful.ChainedIndexingStateMachine]
+and are exported on their own for a project that has its own test harness and
+wants only the hard part.
+
+```python
+from hypothesis import given, strategies as st
+from zarr_indexing.testing.strategies import basic_selections
+
+@given(selection=basic_selections((7, 5, 4)))
+def test_my_array_slices_like_numpy(selection):
+ assert_array_equal(my_array[selection], reference[selection])
+```
+
+Every axis of `shape` must be non-empty: a selection over an axis of extent 0
+has no coordinates to draw. Filter or narrow the shape before calling.
+
+Requires the `testing` extra (`pip install zarr-indexing[testing]`).
+"""
+
+from __future__ import annotations
+
+import operator
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+from hypothesis import strategies as st
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+__all__ = [
+ "basic_selections",
+ "empty_masks",
+ "masks",
+ "orthogonal_selections",
+ "slice_selections",
+ "vectorized_selections",
+]
+
+
+def _entries(shape: tuple[int, ...], entry: Callable[[int], st.SearchStrategy[Any]]) -> Any:
+ """One `entry` strategy per axis, as an index tuple."""
+ return st.tuples(*[entry(size) for size in shape])
+
+
+def _basic_entry(size: int) -> st.SearchStrategy[Any]:
+ steps = st.integers(1, 3)
+ return st.one_of(
+ # A scalar integer drops its axis, in every mode, exactly as NumPy does.
+ st.integers(-size, size - 1),
+ st.builds(slice, st.integers(0, size), st.integers(0, size), steps),
+ # Downward. The start is drawn from below `-size` as well, where the walk
+ # begins off the front and selects nothing — a case that reads as an
+ # ordinary negative index but is empty — and a stop that falls off the
+ # front is spelled `None`.
+ st.builds(
+ slice,
+ st.integers(-2 * size - 1, size - 1),
+ st.none() | st.integers(0, size),
+ steps.map(operator.neg),
+ ),
+ st.just(slice(None)),
+ )
+
+
+def _orthogonal_entry(size: int) -> st.SearchStrategy[Any]:
+ """One axis of an `oindex` selection.
+
+ The slices carry a step and are free to stop early. Drawing them as
+ `slice(start, size)` alone meant no strided or reversed slice ever reached
+ `oindex`, and no orthogonal selection ever stopped short of the axis end.
+
+ An empty coordinate list is drawn too. It selects nothing, which is legal
+ and is exactly the shape that lost its axis on the way through JSON — but
+ with `min_size=1` no fancy selection was ever empty.
+ """
+ coordinate = st.integers(-size, size - 1)
+ return st.one_of(
+ coordinate,
+ st.lists(coordinate, min_size=1, max_size=4),
+ st.just([]),
+ masks((size,)),
+ empty_masks((size,)),
+ st.builds(
+ slice,
+ st.integers(0, size - 1),
+ st.integers(0, size) | st.none(),
+ st.integers(1, 3) | st.integers(-3, -1),
+ ),
+ )
+
+
+def _slice_entry(size: int) -> st.SearchStrategy[slice]:
+ return st.one_of(
+ st.builds(slice, st.integers(0, size - 1), st.just(size), st.integers(1, 2)),
+ st.just(slice(None, None, -1)),
+ st.just(slice(None)),
+ )
+
+
+@st.composite
+def masks(draw: st.DrawFn, shape: tuple[int, ...]) -> np.ndarray[Any, np.dtype[np.bool_]]:
+ """Boolean masks over `shape`, each selecting at least one cell.
+
+ An all-False mask is legal but is a separate concern — it empties the view,
+ and a chain of selections is more interesting when every step leaves
+ something to index — so one cell is always forced True.
+ """
+ size = int(np.prod(shape))
+ flags = np.array(draw(st.lists(st.booleans(), min_size=size, max_size=size)))
+ flags[draw(st.integers(0, size - 1))] = True
+ return flags.reshape(shape)
+
+
+def empty_masks(shape: tuple[int, ...]) -> st.SearchStrategy[np.ndarray[Any, np.dtype[np.bool_]]]:
+ """The all-False mask over `shape` — a fancy selection that empties the view.
+
+ Split out from `masks`, which forces a cell True so a chain has something
+ left to index at the next step. Drawn on its own because an empty fancy
+ selection is a shape the code paths treat separately, and nothing generated
+ one.
+ """
+ return st.just(np.zeros(shape, dtype=np.bool_))
+
+
+def basic_selections(shape: tuple[int, ...]) -> st.SearchStrategy[tuple[Any, ...]]:
+ """Basic selections: one scalar integer or slice per axis.
+
+ Slices run in both directions, including the two empty spellings — a
+ forward slice whose stop precedes its start, and a backward one whose start
+ is off the front of the axis.
+ """
+ return _entries(shape, _basic_entry)
+
+
+def orthogonal_selections(shape: tuple[int, ...]) -> st.SearchStrategy[tuple[Any, ...]]:
+ """Orthogonal (`oindex`) selections: an outer product of per-axis choices.
+
+ Each axis draws a scalar, a coordinate list (unsorted, with duplicates), a
+ boolean mask, or a slice.
+ """
+ return _entries(shape, _orthogonal_entry)
+
+
+def slice_selections(shape: tuple[int, ...]) -> st.SearchStrategy[tuple[Any, ...]]:
+ """Selections of slices alone, for the `oindex` spelling that carries no coordinates.
+
+ Such a step is not a fancy selection — it narrows the view's own axes and
+ composes like basic indexing — so it is legal after a fancy step, where
+ genuine coordinates are not. The starts reach past the origin, which is what
+ distinguishes a step that walks an existing index array's dependency axes
+ from one that walks its broadcast singletons.
+ """
+ return _entries(shape, _slice_entry)
+
+
+@st.composite
+def vectorized_selections(draw: st.DrawFn, shape: tuple[int, ...]) -> tuple[Any, ...]:
+ """Vectorized (`vindex`) selections over a leading or trailing block of axes.
+
+ `vindex` is coordinate-only — it rejects a slice outright — so a partial
+ selection names its axes by position: a leading block, or a trailing one
+ reached through an ellipsis. Either a single boolean mask spanning the whole
+ covered block, or one entry per axis, each a coordinate array or a scalar
+ (a scalar being a basic index NumPy applies before the coordinates).
+ """
+ ndim = len(shape)
+ trailing = draw(st.booleans())
+ count = draw(st.integers(1, ndim))
+ axes = range(ndim - count, ndim) if trailing else range(count)
+ sizes = [shape[axis] for axis in axes]
+
+ entries: list[Any]
+ if draw(st.booleans()):
+ entries = [draw(masks(tuple(sizes)))]
+ else:
+ # The coordinate arrays share one shape, which is what makes the
+ # selection correlated. That shape is not always one-dimensional: a
+ # vectorized read of a (2, 3) block of points is an ordinary thing to
+ # ask for and produces a result of that rank. Drawing only 1-D arrays
+ # meant no rank-raising vindex was ever generated — and a length of 0
+ # covers the empty case the same way `_orthogonal_entry` does.
+ coordinate_shape = draw(
+ st.one_of(
+ st.integers(0, 4).map(lambda length: (length,)),
+ st.tuples(st.integers(1, 2), st.integers(1, 3)),
+ )
+ )
+ entries = [
+ draw(
+ st.one_of(
+ st.integers(-size, size - 1),
+ st.lists(
+ st.integers(-size, size - 1),
+ min_size=int(np.prod(coordinate_shape)),
+ max_size=int(np.prod(coordinate_shape)),
+ ).map(lambda values: np.array(values, dtype=np.intp).reshape(coordinate_shape)),
+ )
+ )
+ for size in sizes
+ ]
+ return (Ellipsis, *entries) if trailing else tuple(entries)
diff --git a/packages/zarr-indexing/src/zarr_indexing/transform.py b/packages/zarr-indexing/src/zarr_indexing/transform.py
new file mode 100644
index 0000000000..a8a5963a26
--- /dev/null
+++ b/packages/zarr-indexing/src/zarr_indexing/transform.py
@@ -0,0 +1,1941 @@
+"""Index transforms — composable, lazy coordinate mappings.
+
+An `IndexTransform` pairs an **input domain** (the coordinates a user sees)
+with a tuple of **output maps** (the output coordinates those inputs map to).
+One output map per output dimension. See `output_map.py` for the three
+output map types.
+
+Key operations:
+
+- **Indexing** (`transform[2:8]`, `.oindex[idx]`, `.vindex[idx]`) —
+ produces a new transform with a narrower input domain and adjusted output
+ maps. No I/O occurs. This is how lazy slicing works.
+
+- **intersect(output_domain)** — restrict to output coordinates within a
+ region. This is chunk resolution: "which of my coordinates fall in this
+ chunk?"
+
+- **translate(shift)** — shift all output coordinates. This makes coordinates
+ chunk-local: "express my coordinates relative to the chunk origin."
+
+- **`transform.compose(inner)`** — chain two transforms into one.
+
+The transform is the atomic unit that connects user-facing indexing to
+chunk-level I/O. A wrapper holds one — `LazyArray` starts from the identity —
+and `.lazy[...]` composes a new transform lazily rather than reading. Reading
+resolves the transform against the chunk grid via intersect + translate.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any, Literal, cast
+
+import numpy as np
+
+from zarr_indexing._affine import checked_affine
+from zarr_indexing._selector import as_scalar_index, require_index
+from zarr_indexing.boundary import validate_advanced_selection
+from zarr_indexing.domain import IndexDomain
+from zarr_indexing.errors import BoundsCheckError, VindexInvalidSelectionError
+from zarr_indexing.output_map import (
+ ArrayMap,
+ ConstantMap,
+ DimensionMap,
+ OutputIndexMap,
+ array_map_or_constant,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+ import numpy.typing as npt
+
+ from zarr_indexing.json import IndexTransformJSON
+
+
+@dataclass(frozen=True, slots=True)
+class _PointOutOfBounds(Exception):
+ """Internal signal from the shared point kernel: one coordinate left the domain.
+
+ Never escapes this module. `apply` and `apply_many` format it as the
+ public `BoundsCheckError`, each in its own vocabulary — the kernel knows
+ batches, but a single-point caller must never hear about them.
+ """
+
+ dimension: int
+ value: int
+ lower: int
+ upper: int
+ batch_position: tuple[int, ...]
+
+
+@dataclass(frozen=True, slots=True)
+class IndexTransform:
+ """A composable mapping from input coordinates to output coordinates.
+
+ An `IndexTransform` has:
+
+ - `domain`: an `IndexDomain` describing the valid input coordinates
+ (the result's coordinate range, possibly with non-zero origin).
+ - `output`: a tuple of output maps (one per output dimension), each
+ describing which output coordinates the inputs touch.
+
+ In array-indexing terms: `domain` describes the coordinates of the result
+ array an indexing operation produces, and `output` is the rule relating
+ each result coordinate to a coordinate in the source. Note the direction —
+ the transform's input side is the result, its output side addresses the
+ source; the coordinate mapping runs opposite to the data flow.
+
+ Indexing an existing transform composes a new one without I/O.
+
+ Examples
+ --------
+ The operation "every other element of a 100-element array, starting at
+ index 0" — `array[::2]` — is a 50-cell domain whose cell `i` reads
+ output coordinate `2 * i`:
+
+ >>> domain = IndexDomain.from_shape((50,))
+ >>> output = (DimensionMap(input_dimension=0, offset=0, stride=2),)
+ >>> transform = IndexTransform(domain=domain, output=output)
+ >>> transform.apply((0,)), transform.apply((1,)), transform.apply((49,))
+ ((0,), (2,), (98,))
+
+ The selection compiler derives the identical transform from the source's
+ shape and the slice:
+
+ >>> transform == IndexTransform.from_shape((100,))[::2]
+ True
+ """
+
+ domain: IndexDomain
+ """The input domain: the request coordinates this transform accepts."""
+
+ output: tuple[OutputIndexMap, ...]
+ """One output map per output dimension, each producing that dimension's coordinate."""
+
+ def __post_init__(self) -> None:
+ for i, m in enumerate(self.output):
+ if isinstance(m, DimensionMap):
+ if m.input_dimension < 0 or m.input_dimension >= self.domain.ndim:
+ raise ValueError(
+ f"output[{i}].input_dimension = {m.input_dimension} "
+ f"is out of range for input rank {self.domain.ndim}"
+ )
+ elif isinstance(m, ArrayMap):
+ # An index array carries the transform's full input rank: the axis
+ # a map varies over is full-sized, every other axis a singleton.
+ # The rank is what makes the dependency axes readable from the
+ # shape, so a mismatch is a bug rather than a spelling. External
+ # JSON may use a lower-rank array that broadcasts against the
+ # domain; `from_json` widens those on the way in,
+ # so the invariant holds for every transform that exists.
+ if m.index_array.ndim != self.domain.ndim:
+ raise ValueError(
+ f"output[{i}].index_array has {m.index_array.ndim} dims "
+ f"but input domain has {self.domain.ndim} dims"
+ )
+ # Every axis is either the domain's extent or a singleton it
+ # broadcasts over. Any other size addresses input coordinates the
+ # array has no entry for, which reads as a smaller selection
+ # rather than as the error it is.
+ bad = [
+ (axis, size, extent)
+ for axis, (size, extent) in enumerate(
+ zip(m.index_array.shape, self.domain.shape, strict=True)
+ )
+ if size not in (1, extent)
+ ]
+ if len(bad) > 0:
+ axis, size, extent = bad[0]
+ raise ValueError(
+ f"output[{i}].index_array has {size} entries on axis {axis}, "
+ f"which is neither 1 nor the domain's extent of {extent} "
+ f"(index_array shape {m.index_array.shape}, "
+ f"domain shape {self.domain.shape})"
+ )
+
+ def __eq__(self, other: object) -> bool:
+ """Value equality. `ArrayMap` compares its index array element-wise, so
+ a transform holding one can be compared at all — the generated `__eq__`
+ raised `ValueError: the truth value of an array ... is ambiguous`."""
+ if not isinstance(other, IndexTransform):
+ return NotImplemented
+ return self.domain == other.domain and self.output == other.output
+
+ def __hash__(self) -> int:
+ """Hashed by value, so a transform can key a cache or enter a set."""
+ return hash((self.domain, self.output))
+
+ @property
+ def input_rank(self) -> int:
+ """Number of input dimensions — the rank of `domain`."""
+ return self.domain.ndim
+
+ @property
+ def output_rank(self) -> int:
+ """Number of output dimensions — one per output map."""
+ return len(self.output)
+
+ @classmethod
+ def identity(cls, domain: IndexDomain) -> IndexTransform:
+ """The identity transform over `domain`: every result cell reads the source at its own address."""
+ output = tuple(DimensionMap(input_dimension=i) for i in range(domain.ndim))
+ return cls(domain=domain, output=output)
+
+ @classmethod
+ def from_shape(cls, shape: tuple[int, ...]) -> IndexTransform:
+ """The identity transform over a zero-origin domain of the given `shape`."""
+ return cls.identity(IndexDomain.from_shape(shape))
+
+ def apply(self, point: Sequence[int]) -> tuple[int, ...]:
+ """Map one coordinate of `domain` to the source coordinate that fills it.
+
+ In array-indexing terms: `point` names a cell of the result array, and
+ the returned tuple — each `output` map evaluated at `point` — names the
+ source-array cell its value is read from: the coordinate arrow,
+ running result to source.
+
+ Parameters
+ ----------
+ point : Sequence[int]
+ One literal coordinate for each input dimension.
+
+ Returns
+ -------
+ tuple[int, ...]
+ One coordinate for each output map.
+
+ Raises
+ ------
+ ValueError
+ If ``point`` does not have exactly one coordinate per input
+ dimension.
+ TypeError
+ If the coordinates do not have an integer dtype.
+ BoundsCheckError
+ If a coordinate lies outside the input domain.
+ OverflowError
+ If a mapped output coordinate cannot be represented by
+ ``np.intp``.
+
+ Examples
+ --------
+ The `[::2]` transform reads result cell `i` from source coordinate
+ `2 * i`, so cell 3 of the result holds `source[6]`:
+
+ >>> transform = IndexTransform.from_shape((100,))[::2]
+ >>> transform.apply((3,))
+ (6,)
+ """
+ coordinates = np.asarray(point)
+ expected_shape = (self.input_rank,)
+ if coordinates.shape != expected_shape:
+ raise ValueError(f"point must have shape {expected_shape}, got {coordinates.shape}")
+ # An empty Python sequence has no elements from which NumPy can infer
+ # an integer dtype, but it is the unique point in a rank-zero domain.
+ if self.input_rank == 0 and isinstance(point, (list, tuple)):
+ coordinates = coordinates.astype(np.intp)
+ try:
+ result = self._apply_points(coordinates)
+ except _PointOutOfBounds as error:
+ raise BoundsCheckError(
+ f"coordinate {error.value} on input dimension {error.dimension} "
+ f"is outside the domain [{error.lower}, {error.upper})"
+ ) from None
+ return tuple(int(value) for value in result)
+
+ def apply_many(self, points: npt.ArrayLike) -> npt.NDArray[np.intp]:
+ """Map a batch of `domain` coordinates to the source coordinates that fill them.
+
+ The vectorized form of `apply`: each row of `points` names a result
+ cell, and the corresponding output row names the source-array cell
+ its value is read from.
+
+ Parameters
+ ----------
+ points : numpy.typing.ArrayLike
+ Integer coordinates with shape ``batch_shape + (input_rank,)``.
+
+ Returns
+ -------
+ numpy.typing.NDArray[numpy.intp]
+ An owned array with shape ``batch_shape + (output_rank,)``.
+
+ Raises
+ ------
+ ValueError
+ If ``points`` has no trailing coordinate axis or that axis does
+ not contain exactly one coordinate per input dimension.
+ TypeError
+ If the coordinates do not have an integer dtype.
+ BoundsCheckError
+ If a coordinate lies outside the input domain.
+ OverflowError
+ If a mapped output coordinate cannot be represented by
+ ``np.intp``.
+
+ Examples
+ --------
+ Three result cells of the `[::2]` transform, located in one call:
+
+ >>> transform = IndexTransform.from_shape((100,))[::2]
+ >>> transform.apply_many(np.array([[0], [1], [49]])).tolist()
+ [[0], [2], [98]]
+ """
+ coordinates = np.asarray(points)
+ if coordinates.ndim == 0 or coordinates.shape[-1] != self.input_rank:
+ raise ValueError(
+ "points must have a trailing coordinate axis of size "
+ f"{self.input_rank}, got shape {coordinates.shape}"
+ )
+ try:
+ return self._apply_points(coordinates)
+ except _PointOutOfBounds as error:
+ raise BoundsCheckError(
+ f"point at batch position {error.batch_position} has input dimension "
+ f"{error.dimension} coordinate {error.value} outside "
+ f"[{error.lower}, {error.upper})"
+ ) from None
+
+ def _apply_points(self, points: np.ndarray[Any, Any]) -> npt.NDArray[np.intp]:
+ """Vectorized implementation shared by ``apply`` and ``apply_many``.
+
+ Out-of-domain coordinates raise the internal `_PointOutOfBounds`
+ signal; each public entry point formats it in its own vocabulary —
+ `apply` never mentions a batch, `apply_many` names the batch position."""
+ if not np.issubdtype(points.dtype, np.integer):
+ raise TypeError(f"points must have an integer dtype, got {points.dtype}")
+
+ invalid = np.zeros(points.shape, dtype=np.bool_)
+ for dimension, (lower, upper) in enumerate(
+ zip(self.domain.inclusive_min, self.domain.exclusive_max, strict=True)
+ ):
+ invalid[..., dimension] = (points[..., dimension] < lower) | (
+ points[..., dimension] >= upper
+ )
+ invalid_positions = np.argwhere(invalid)
+ if invalid_positions.size > 0:
+ first = invalid_positions[0]
+ dimension = int(first[-1])
+ batch_position = tuple(int(position) for position in first[:-1])
+ point_index = tuple(int(position) for position in first)
+ value = int(points[point_index])
+ lower = self.domain.inclusive_min[dimension]
+ upper = self.domain.exclusive_max[dimension]
+ raise _PointOutOfBounds(dimension, value, lower, upper, batch_position)
+
+ batch_shape = points.shape[:-1]
+ result = np.empty(batch_shape + (self.output_rank,), dtype=np.intp)
+ for output_dimension, output_map in enumerate(self.output):
+ if isinstance(output_map, ConstantMap):
+ if result[..., output_dimension].size == 0:
+ continue
+ result[..., output_dimension] = checked_affine(output_map.offset, 0, 0)
+ elif isinstance(output_map, DimensionMap):
+ result[..., output_dimension] = checked_affine(
+ output_map.offset,
+ output_map.stride,
+ points[..., output_map.input_dimension],
+ )
+ else:
+ index = tuple(
+ np.zeros(batch_shape, dtype=np.intp)
+ if output_map.index_array.shape[axis] == 1
+ else _positions_from_origin(points[..., axis], self.domain.inclusive_min[axis])
+ for axis in range(self.input_rank)
+ )
+ result[..., output_dimension] = checked_affine(
+ output_map.offset,
+ output_map.stride,
+ np.asarray(output_map.index_array[index]),
+ )
+ return result
+
+ def inverted(self) -> IndexTransform:
+ """Return the restricted, exactly representable inverse transform.
+
+ Inversion is defined for square transforms containing only constants
+ and unique unit-stride dimension maps. Any input dimension not named by
+ a dimension map must have singleton extent, so its coordinate can be
+ recovered as a constant.
+
+ Returns
+ -------
+ IndexTransform
+ A new transform mapping output coordinates back to input
+ coordinates.
+
+ Raises
+ ------
+ ValueError
+ If this transform does not have a representable inverse, including
+ when input labels cannot be transferred to unlabeled output
+ dimensions.
+ """
+ if self.domain.labels is not None:
+ raise ValueError(
+ "cannot invert transform: input labels cannot be represented "
+ "because output dimensions do not carry labels"
+ )
+ if self.input_rank != self.output_rank:
+ raise ValueError(
+ "cannot invert transform: input rank must equal output rank, got "
+ f"{self.input_rank} and {self.output_rank}"
+ )
+
+ referenced: set[int] = set()
+ for output_dimension, output_map in enumerate(self.output):
+ if isinstance(output_map, ArrayMap):
+ raise ValueError( # noqa: TRY004 - valid map, invalid inverse
+ f"cannot invert transform: output[{output_dimension}] is an ArrayMap"
+ )
+ if isinstance(output_map, DimensionMap):
+ if output_map.stride not in (-1, 1):
+ raise ValueError(
+ "cannot invert transform: DimensionMap stride must be +1 or -1, "
+ f"got {output_map.stride} for output[{output_dimension}]"
+ )
+ if output_map.input_dimension in referenced:
+ raise ValueError(
+ "cannot invert transform: input dimension "
+ f"{output_map.input_dimension} is referenced more than once"
+ )
+ referenced.add(output_map.input_dimension)
+
+ for input_dimension, extent in enumerate(self.domain.shape):
+ if input_dimension not in referenced and extent != 1:
+ raise ValueError(
+ "cannot invert transform: unreferenced input dimension "
+ f"{input_dimension} has extent {extent}, not 1"
+ )
+
+ inverse_min: list[int] = []
+ inverse_max: list[int] = []
+ inverse_output: dict[int, OutputIndexMap] = {}
+ for output_dimension, output_map in enumerate(self.output):
+ if isinstance(output_map, ConstantMap):
+ inverse_min.append(output_map.offset)
+ inverse_max.append(output_map.offset + 1)
+ continue
+
+ assert isinstance(output_map, DimensionMap)
+ input_dimension = output_map.input_dimension
+ lower = self.domain.inclusive_min[input_dimension]
+ upper = self.domain.exclusive_max[input_dimension]
+ if output_map.stride == 1:
+ inverse_min.append(output_map.offset + lower)
+ inverse_max.append(output_map.offset + upper)
+ inverse_output[input_dimension] = DimensionMap(
+ output_dimension,
+ offset=-output_map.offset,
+ )
+ else:
+ inverse_min.append(output_map.offset - upper + 1)
+ inverse_max.append(output_map.offset - lower + 1)
+ inverse_output[input_dimension] = DimensionMap(
+ output_dimension,
+ offset=output_map.offset,
+ stride=-1,
+ )
+
+ for input_dimension, lower in enumerate(self.domain.inclusive_min):
+ if input_dimension not in referenced:
+ inverse_output[input_dimension] = ConstantMap(lower)
+
+ return IndexTransform(
+ domain=IndexDomain(tuple(inverse_min), tuple(inverse_max)),
+ output=tuple(inverse_output[dimension] for dimension in range(self.input_rank)),
+ )
+
+ @property
+ def selection_repr(self) -> str:
+ """Compact domain string, e.g. `'{ [2, 8), [0, 10) }'`.
+
+ Follows TensorStore's IndexDomain notation: each dimension shown
+ as `[inclusive_min, exclusive_max)` with stride annotation if not 1.
+ Constant (integer-indexed) dimensions show as a single value.
+ Array-indexed dimensions show the set of selected coordinates.
+ """
+ parts: list[str] = []
+ for m in self.output:
+ if isinstance(m, ConstantMap):
+ parts.append(str(m.offset))
+ elif isinstance(m, DimensionMap):
+ d = m.input_dimension
+ lo = self.domain.inclusive_min[d]
+ hi = self.domain.exclusive_max[d]
+ start = m.offset + m.stride * lo
+ stop = m.offset + m.stride * hi
+ if m.stride == 1:
+ parts.append(f"[{start}, {stop})")
+ else:
+ parts.append(f"[{start}, {stop}) step {m.stride}")
+ else:
+ # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap)
+ storage = m.offset + m.stride * m.index_array
+ n = int(storage.size) # .size, not len(): index_array may be 0-d
+ if n <= 5:
+ vals = ", ".join(str(int(v)) for v in storage.ravel())
+ parts.append("{" + vals + "}")
+ else:
+ parts.append("{" + f"array({n})" + "}")
+ return "{ " + ", ".join(parts) + " }"
+
+ def __repr__(self) -> str:
+ maps: list[str] = []
+ for i, m in enumerate(self.output):
+ if isinstance(m, ConstantMap):
+ maps.append(f"out[{i}] = {m.offset}")
+ elif isinstance(m, DimensionMap):
+ maps.append(f"out[{i}] = {m.offset} + {m.stride} * in[{m.input_dimension}]")
+ else:
+ # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap)
+ maps.append(f"out[{i}] = {m.offset} + {m.stride} * arr{m.index_array.shape}[in]")
+ maps_str = ", ".join(maps)
+ return f"IndexTransform(domain={self.domain}, {maps_str})"
+
+ def intersect(
+ self, output_domain: IndexDomain
+ ) -> (
+ tuple[
+ IndexTransform,
+ dict[int, np.ndarray[Any, np.dtype[np.intp]]]
+ | np.ndarray[Any, np.dtype[np.intp]]
+ | None,
+ ]
+ | None
+ ):
+ """Keep only the cells whose source coordinates fall inside `output_domain`.
+
+ Chunk resolution is the canonical caller: intersecting a request with
+ one chunk's box keeps the cells that chunk can serve.
+
+ Returns `(restricted_transform, out_indices)` or None if empty.
+
+ `out_indices` carries the surviving output positions: `None` when all
+ positions survive (ConstantMap/DimensionMap only), a single integer array
+ for one ArrayMap (or correlated/vectorized ArrayMaps), or a dict keyed by
+ output dimension for >= 2 orthogonal ArrayMaps (an outer product).
+ """
+ return _intersect(self, output_domain)
+
+ def translate(self, shift: tuple[int, ...]) -> IndexTransform:
+ """Shift the source coordinates every cell reads by `shift`, per dimension.
+
+ The domain is untouched: the result keeps its cells, and each one
+ reads from a shifted source address — for example, making a chunk's
+ global addresses chunk-local by translating by the chunk's negated
+ origin.
+ """
+ if len(shift) != self.output_rank:
+ raise ValueError(f"shift must have length {self.output_rank}, got {len(shift)}")
+ new_output: list[OutputIndexMap] = []
+ for m, s in zip(self.output, shift, strict=True):
+ if isinstance(m, ConstantMap):
+ new_output.append(ConstantMap(offset=m.offset + s))
+ elif isinstance(m, DimensionMap):
+ new_output.append(
+ DimensionMap(
+ input_dimension=m.input_dimension,
+ offset=m.offset + s,
+ stride=m.stride,
+ )
+ )
+ else:
+ # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap)
+ new_output.append(
+ ArrayMap(
+ index_array=m.index_array,
+ offset=m.offset + s,
+ stride=m.stride,
+ )
+ )
+ return IndexTransform(domain=self.domain, output=tuple(new_output))
+
+ def __getitem__(self, selection: Any) -> IndexTransform:
+ """Compose a basic selection (int, slice, ellipsis, newaxis) into a new transform.
+
+ No I/O occurs. Integers and slice bounds are literal domain coordinates
+ (TensorStore convention): negative values are not counted from the end,
+ and out-of-domain values raise `BoundsCheckError`. Integer indices drop
+ their input dimension; `None` inserts a size-1 dimension.
+ """
+ return _apply_basic_indexing(self, selection)
+
+ def translate_domain_by(self, shift: tuple[int, ...]) -> IndexTransform:
+ """Shift the *input* domain by `shift`, preserving which cells are addressed.
+
+ TensorStore's `translate_by`: the domain moves, and every output map is
+ re-offset so that new coordinate `c` addresses the cell that `c - shift`
+ addressed before. ArrayMaps are indexed positionally over the domain, so
+ their index arrays are unchanged.
+ """
+ if len(shift) != self.input_rank:
+ raise ValueError(f"shift must have length {self.input_rank}, got {len(shift)}")
+ new_domain = self.domain.translate(shift)
+ new_output: list[OutputIndexMap] = []
+ for m in self.output:
+ if isinstance(m, DimensionMap):
+ s = shift[m.input_dimension]
+ new_output.append(
+ DimensionMap(
+ input_dimension=m.input_dimension,
+ offset=m.offset - m.stride * s,
+ stride=m.stride,
+ )
+ )
+ else:
+ # ConstantMap: no input dependence. ArrayMap: positional over
+ # the domain, invariant under domain translation.
+ new_output.append(m)
+ return IndexTransform(domain=new_domain, output=tuple(new_output))
+
+ def translate_domain_to(self, origins: tuple[int, ...]) -> IndexTransform:
+ """Move the input domain so its per-dimension origins equal `origins`.
+
+ TensorStore's `translate_to`; `translate_domain_to((0,) * rank)`
+ re-zeros a view's coordinate system without changing which cells it
+ addresses.
+ """
+ if len(origins) != self.input_rank:
+ raise ValueError(f"origins must have length {self.input_rank}, got {len(origins)}")
+ shift = tuple(o - m for o, m in zip(origins, self.domain.inclusive_min, strict=True))
+ return self.translate_domain_by(shift)
+
+ @property
+ def oindex(self) -> _OIndexHelper:
+ """Accessor for the orthogonal (outer-product) indexing dialect.
+
+ `transform.oindex[sel]` applies each index array independently per
+ dimension and returns a new transform.
+ """
+ return _OIndexHelper(self)
+
+ @property
+ def vindex(self) -> _VIndexHelper:
+ """Accessor for the vectorized (coordinate/mask) indexing dialect.
+
+ `transform.vindex[sel]` broadcasts all index arrays together, NumPy
+ fancy-indexing style, and returns a new transform.
+ """
+ return _VIndexHelper(self)
+
+ @property
+ def index_array_structure(self) -> Literal["none", "orthogonal", "general"]:
+ """Classify how a transform's index arrays relate to its input axes.
+
+ Returns
+ -------
+ `"none"` when no output map is an `ArrayMap`; `"orthogonal"` when every
+ `ArrayMap` varies over exactly one input axis, each its own (an outer
+ product, one independent gather per axis); `"general"` otherwise —
+ correlated (`vindex`) maps sharing their non-singleton axes, maps produced
+ by composing fancy steps, maps sharing an input axis (a diagonal gather),
+ and empty or hand-built all-singleton maps whose shape names no axis. The
+ orthogonal resolvers narrow one axis at a time and are only sound for
+ `"orthogonal"`; everything else takes the pointwise path that collapses
+ the joint block. Everything is read off the index arrays' shapes.
+
+ Examples
+ --------
+ >>> t = IndexTransform.from_shape((4, 5))
+ >>> t.index_array_structure
+ 'none'
+
+ `oindex` arrays each vary over their own axis (an outer product):
+
+ >>> t.oindex[[0, 2], [1, 3]].index_array_structure
+ 'orthogonal'
+
+ `vindex` arrays are correlated — they share the broadcast axis:
+
+ >>> t.vindex[np.array([0, 2]), np.array([1, 3])].index_array_structure
+ 'general'
+ """
+ seen: set[int] = set()
+ has_array = False
+ for m in self.output:
+ if not isinstance(m, ArrayMap):
+ continue
+ has_array = True
+ dep = m.dependency_axes
+ if len(dep) != 1 or dep[0] in seen:
+ return "general"
+ seen.add(dep[0])
+ return "orthogonal" if has_array else "none"
+
+ def select(
+ self,
+ selection: Any,
+ mode: Literal["basic", "orthogonal", "vectorized"] = "basic",
+ ) -> IndexTransform:
+ """Convert a user selection into a composed IndexTransform.
+
+ Negative indices are treated as literal coordinates (TensorStore convention).
+ The caller (Array layer) is responsible for converting numpy-style negative
+ indices before calling this function.
+
+ Examples
+ --------
+ The `mode` picks the dialect; the result is the composed self the
+ corresponding accessor builds:
+
+ >>> t = IndexTransform.from_shape((10,))
+ >>> t.select(slice(2, 8)) == t[2:8]
+ True
+ >>> s = t.select(([9, 0, 0],), mode="orthogonal")
+ >>> s.apply((0,)), s.apply((1,)), s.apply((2,))
+ ((9,), (0,), (0,))
+ """
+ if mode == "basic":
+ _validate_basic_selection(selection)
+ return self[selection]
+ elif mode == "orthogonal":
+ _validate_array_selection(selection, self.domain.shape, mode)
+ return self.oindex[selection]
+ elif mode == "vectorized":
+ _validate_array_selection(selection, self.domain.shape, mode)
+ return self.vindex[selection]
+ else:
+ raise ValueError(f"Unknown mode: {mode!r}")
+
+ def compose(self, inner: IndexTransform) -> IndexTransform:
+ """Chain `inner` onto this transform, yielding one direct transform.
+
+ This transform maps its own input coordinates to `inner`'s input
+ coordinates, and `inner` maps those onward; the result maps this
+ transform's input coordinates straight to `inner`'s output
+ coordinates. Composition is what keeps a view of a view a single
+ description rather than a stack of layers, and it is exact: index
+ arrays are evaluated at the new coordinates rather than accumulated.
+
+ The precondition is that this transform's output rank equals `inner`'s
+ input rank; a mismatch, or coordinates leaving `inner`'s domain, raises.
+
+ Examples
+ --------
+ Chained indexing — `source[2:5]`, then `[::-1]` on the result —
+ collapses to one transform (a reversed axis keeps literal coordinates,
+ so the composed domain is `[-4, -1)`):
+
+ >>> inner = IndexTransform.from_shape((10,))[2:5]
+ >>> outer = IndexTransform.identity(inner.domain)[::-1]
+ >>> chained = outer.compose(inner)
+ >>> chained == inner[::-1]
+ True
+ >>> [chained.apply((i,)) for i in (-4, -3, -2)]
+ [(4,), (3,), (2,)]
+ """
+ from zarr_indexing._composition import compose
+
+ return compose(self, inner)
+
+ # -- serialization ------------------------------------------------------
+
+ def to_json(self) -> IndexTransformJSON:
+ """Convert to the canonical ndsel transform body (spec section 4.3).
+
+ The result is fully explicit: `input_rank`, fully written bounds and
+ labels, and an `output` carrying `offset`/`stride` on every affine and
+ array map. It is field-for-field a TensorStore `IndexTransform` minus
+ the `kind` discriminator, so it loads directly into
+ `tensorstore.IndexTransform(json=...)`.
+
+ Examples
+ --------
+ >>> body = IndexTransform.from_shape((6,))[1:5:2].to_json()
+ >>> (body["input_inclusive_min"], body["input_exclusive_max"])
+ ([0], [2])
+ >>> body["output"]
+ [{'offset': 1, 'stride': 2, 'input_dimension': 0}]
+ """
+ from zarr_indexing._wire import emit_labels
+
+ return {
+ "input_rank": self.domain.ndim,
+ "input_inclusive_min": list(self.domain.inclusive_min),
+ "input_exclusive_max": list(self.domain.exclusive_max),
+ "input_labels": emit_labels(self.domain.labels, self.domain.ndim),
+ "output": [m.to_json() for m in self.output],
+ }
+
+ @classmethod
+ def from_json(cls, data: IndexTransformJSON) -> IndexTransform:
+ """Construct from a canonical (or canonicalizable) ndsel transform body.
+
+ The body is first run through the message layer (`normalize_ndsel`) so
+ that omitted fields — identity `output`, default bounds and labels —
+ are filled and validated, then lowered to the engine representation.
+ Lower-rank `index_array`s are widened to the full input rank on the way
+ in.
+
+ Examples
+ --------
+ >>> body = IndexTransform.from_shape((6,))[1:5:2].to_json()
+ >>> transform = IndexTransform.from_json(body)
+ >>> transform.domain.shape
+ (2,)
+ >>> transform.to_json() == body # the round trip is exact
+ True
+ """
+ from zarr_indexing._wire import (
+ full_rank_index_array,
+ lower_bound,
+ lower_index_array,
+ lower_labels,
+ )
+ from zarr_indexing.messages import NdselError, normalize_ndsel
+
+ if not isinstance(data, dict): # pyright: ignore[reportUnnecessaryIsInstance]
+ raise NdselError(
+ "invalid_json", f"a transform body must be a JSON object, got {data!r}"
+ )
+ kind = data.get("kind", "transform")
+ if kind != "transform":
+ # Spelled before normalization so a body carrying its own `kind`
+ # cannot reinterpret the document as some other message and return
+ # a selection this constructor never promised.
+ raise NdselError("invalid_json", f"a transform body cannot carry kind {kind!r}")
+ body = normalize_ndsel({**data, "kind": "transform"})
+
+ domain = IndexDomain(
+ inclusive_min=tuple(
+ lower_bound(b, f"input_inclusive_min[{i}]")
+ for i, b in enumerate(body["input_inclusive_min"])
+ ),
+ exclusive_max=tuple(
+ lower_bound(b, f"input_exclusive_max[{i}]")
+ for i, b in enumerate(body["input_exclusive_max"])
+ ),
+ labels=lower_labels(body["input_labels"]),
+ )
+
+ output: list[OutputIndexMap] = []
+ for i, om in enumerate(body["output"]):
+ if "index_array" in om:
+ where = f"output[{i}]"
+ arr = lower_index_array(om["index_array"], f"{where}.index_array")
+ # ndsel leaves index-array rank unvalidated, so an external
+ # producer may send an array of lower rank that broadcasts
+ # against the domain. Widen it here, on the way in, so every
+ # transform that exists holds the full-rank invariant the
+ # engine reads dependency axes from.
+ output.append(
+ ArrayMap(
+ index_array=full_rank_index_array(arr, domain, where),
+ offset=om.get("offset", 0),
+ stride=om.get("stride", 1),
+ )
+ )
+ elif "input_dimension" in om:
+ output.append(
+ DimensionMap(
+ input_dimension=om["input_dimension"],
+ offset=om.get("offset", 0),
+ stride=om.get("stride", 1),
+ )
+ )
+ else:
+ output.append(ConstantMap(offset=om.get("offset", 0)))
+
+ try:
+ return cls(domain=domain, output=tuple(output))
+ except ValueError as exc:
+ # The engine's invariants are the last gate a document passes, and
+ # they speak in the engine's vocabulary. A document that fails them
+ # is invalid input, so it leaves here as one — with the engine's
+ # account of what was wrong kept, since it names the offending
+ # output map and axis.
+ raise NdselError("rank_mismatch", str(exc)) from exc
+
+
+def _positions_from_origin(coordinates: np.ndarray[Any, Any], origin: int) -> npt.NDArray[np.intp]:
+ """Convert literal coordinates to positional indices without wrapping."""
+ return checked_affine(-int(origin), 1, coordinates)
+
+
+def _intersect(
+ transform: IndexTransform, output_domain: IndexDomain
+) -> (
+ tuple[
+ IndexTransform,
+ dict[int, np.ndarray[Any, np.dtype[np.intp]]] | np.ndarray[Any, np.dtype[np.intp]] | None,
+ ]
+ | None
+):
+ """Intersect a transform with an output domain (e.g., a chunk's bounds).
+
+ For each output dimension, restrict to output coordinates within
+ `[output_domain.inclusive_min[d], output_domain.exclusive_max[d])`.
+
+ Two flavors of fancy indexing require different treatment, distinguished by
+ the ArrayMaps' dependency axes (see `ArrayMap.dependency_axes`):
+
+ - **orthogonal** (`oindex`): each ArrayMap varies over a single, distinct
+ input axis, forming an outer product. Every output dimension is intersected
+ independently and the input domain narrowed per axis.
+ - **correlated** (`vindex`): the ArrayMaps share their (broadcast) dependency
+ axes and scatter through a single flat index. A point survives only if ALL
+ its output coordinates fall within the output domain; residual slice
+ dimensions are intersected independently, as in the orthogonal case.
+
+ The routing is `index_array_structure`: only a pure per-axis outer product
+ takes the orthogonal path.
+
+ Returns `None` if the intersection is empty.
+ """
+ if output_domain.ndim != transform.output_rank:
+ raise ValueError(
+ f"output_domain rank ({output_domain.ndim}) != "
+ f"transform output rank ({transform.output_rank})"
+ )
+
+ if any(size == 0 for size in transform.domain.shape):
+ # An empty input domain addresses no coordinates at all, so it meets no
+ # output domain. Deciding it here keeps the per-flavor intersections from
+ # having to reconcile an empty domain with an index array that is *not*
+ # empty: a genuine extent-1 axis is stored as a broadcast singleton, so
+ # emptying its domain leaves the array at size 1.
+ return None
+
+ if transform.index_array_structure == "general":
+ return _intersect_general(transform, output_domain)
+ return _intersect_orthogonal(transform, output_domain)
+
+
+def _intersect_dimension_map(
+ m: DimensionMap, input_lo: int, input_hi: int, lo: int, hi: int
+) -> tuple[int, int] | None:
+ """Narrow a DimensionMap's input range to output coordinates in `[lo, hi)`.
+
+ `input_lo`/`input_hi` are the current (possibly already narrowed) input
+ range for the map's axis. Returns the new `(input_lo, input_hi)` or `None`
+ if no input produces an in-bounds output coordinate.
+ """
+ if input_lo >= input_hi:
+ return None
+ if m.stride > 0:
+ new_input_lo = max(input_lo, _ceil_div(lo - m.offset, m.stride))
+ new_input_hi = min(input_hi, _ceil_div(hi - m.offset, m.stride))
+ elif m.stride < 0:
+ new_input_lo = max(input_lo, _ceil_div(hi - 1 - m.offset, m.stride))
+ new_input_hi = min(input_hi, _ceil_div(lo - 1 - m.offset, m.stride))
+ else:
+ if lo <= m.offset < hi:
+ new_input_lo, new_input_hi = input_lo, input_hi
+ else:
+ return None
+ if new_input_lo >= new_input_hi:
+ return None
+ return new_input_lo, new_input_hi
+
+
+def _ceil_div(numerator: int, denominator: int) -> int:
+ """Return ``ceil(numerator / denominator)`` using exact integer arithmetic."""
+ return -((-numerator) // denominator)
+
+
+def _intersect_orthogonal(
+ transform: IndexTransform, output_domain: IndexDomain
+) -> (
+ tuple[
+ IndexTransform,
+ dict[int, np.ndarray[Any, np.dtype[np.intp]]] | np.ndarray[Any, np.dtype[np.intp]] | None,
+ ]
+ | None
+):
+ """Intersect a transform with no correlated ArrayMaps.
+
+ Every output dimension is intersected independently. Multiple ArrayMaps bound
+ to distinct input dimensions form an outer product, so each array's surviving
+ *output* positions are tracked separately.
+ """
+ new_min = list(transform.domain.inclusive_min)
+ new_max = list(transform.domain.exclusive_max)
+ new_output: list[OutputIndexMap] = []
+ out_positions: dict[int, np.ndarray[Any, np.dtype[np.intp]]] = {}
+
+ for out_dim, m in enumerate(transform.output):
+ lo = output_domain.inclusive_min[out_dim]
+ hi = output_domain.exclusive_max[out_dim]
+
+ if isinstance(m, ConstantMap):
+ if lo <= m.offset < hi:
+ new_output.append(m)
+ else:
+ return None
+
+ elif isinstance(m, DimensionMap):
+ d = m.input_dimension
+ narrowed = _intersect_dimension_map(m, new_min[d], new_max[d], lo, hi)
+ if narrowed is None:
+ return None
+ new_min[d], new_max[d] = narrowed
+ new_output.append(m)
+
+ else:
+ # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap)
+ # Orthogonal: the array varies over a single axis. Filter along that
+ # axis and keep the array at full input rank so the singleton axes
+ # it broadcasts over are preserved.
+ axis = m.dependent_axis
+ if axis is None:
+ raise ValueError(
+ f"output[{out_dim}] is an ArrayMap that varies over no input "
+ "dimension; a map with no dependency axis should have been "
+ "collapsed to a ConstantMap"
+ )
+ d = axis
+ storage = checked_affine(m.offset, m.stride, m.index_array)
+ mask = (storage >= lo) & (storage < hi)
+ # The array is singleton on every axis but `d`, so its mask reduces
+ # to a 1-D vector along `d`.
+ survivors = np.nonzero(mask.reshape(-1))[0].astype(np.intp)
+ if survivors.size == 0:
+ return None
+ filtered = np.take(m.index_array, survivors, axis=d)
+ new_output.append(
+ ArrayMap(
+ index_array=np.asarray(filtered, dtype=np.intp),
+ offset=m.offset,
+ stride=m.stride,
+ )
+ )
+ new_max[d] = new_min[d] + int(survivors.size)
+ out_positions[out_dim] = survivors
+
+ new_domain = IndexDomain(
+ inclusive_min=tuple(new_min),
+ exclusive_max=tuple(new_max),
+ )
+ result = IndexTransform(domain=new_domain, output=tuple(new_output))
+
+ # Hand back the surviving output positions in the shape the bridge expects:
+ # None (no arrays), a single vector (one array), or a per-output-dim dict
+ # (>= 2 orthogonal arrays → outer product).
+ out_indices: (
+ dict[int, np.ndarray[Any, np.dtype[np.intp]]] | np.ndarray[Any, np.dtype[np.intp]] | None
+ )
+ if len(out_positions) == 0:
+ out_indices = None
+ elif len(out_positions) == 1:
+ out_indices = next(iter(out_positions.values()))
+ else:
+ out_indices = out_positions
+ return (result, out_indices)
+
+
+def _intersect_general(
+ transform: IndexTransform,
+ output_domain: IndexDomain,
+) -> tuple[IndexTransform, np.ndarray[Any, np.dtype[np.intp]]] | None:
+ """Intersect a transform with any index-array structure, pointwise.
+
+ Every `ArrayMap` — correlated, orthogonal, or several sharing an axis — is
+ treated as a lookup table over the joint block of non-slice axes: a block
+ point survives only if ALL its output coordinates fall within the output
+ domain. Residual DimensionMap dimensions are intersected independently (as in
+ the orthogonal case) and preserved, so a partial vindex — e.g. two coordinate
+ arrays over a 3-D array, leaving one slice dimension — resolves correctly.
+ Treating an orthogonal map this way forfeits its per-axis independence (the
+ block enumerates the outer product), which is why the pure-orthogonal case
+ keeps its own resolver.
+
+ The surviving broadcast axes collapse to a single axis; the returned
+ `out_indices` is the flat scatter index into the (row-major flattened)
+ output buffer, of shape `(surviving_points,) + (residual slice sizes)`.
+
+ A rank-0 broadcast block — every coordinate array a scalar, as after
+ `vindex[...]` narrowed to a single point — has no axis to collapse and stays
+ rank 0: the block either survives whole or the intersection is empty. The
+ result keeps only the residual slice axes and `out_indices` loses its leading
+ points axis, so the sub-transform's rank still matches the view's.
+ """
+ correlated_dims = [i for i, m in enumerate(transform.output) if isinstance(m, ArrayMap)]
+
+ # The broadcast axes are exactly the input axes no `DimensionMap` binds: a
+ # correlated transform's input domain is its residual slice axes plus the
+ # collapsed broadcast block. Deriving them by complement rather than from the
+ # index array's non-singleton axes keeps this correct when a broadcast axis
+ # is itself size 1, and when NumPy's placement rule puts the broadcast block
+ # somewhere other than the front (see `_broadcast_insertion_point`).
+ bound_axes = {m.input_dimension for m in transform.output if isinstance(m, DimensionMap)}
+ broadcast_axes = tuple(a for a in range(transform.input_rank) if a not in bound_axes)
+ broadcast_shape = tuple(transform.domain.shape[a] for a in broadcast_axes)
+
+ for out_dim in correlated_dims:
+ arr_map = cast("ArrayMap", transform.output[out_dim])
+ if any(a not in broadcast_axes for a in arr_map.dependency_axes):
+ # Reachable only by hand-building a transform: no selection binds
+ # the same input axis to both a slice map and an index array.
+ raise NotImplementedError(
+ "intersecting a transform whose index array varies over an "
+ "input dimension also bound by a slice map is not supported"
+ )
+
+ # Joint bounds mask over the broadcast block.
+ combined: np.ndarray[Any, np.dtype[np.bool_]] | None = None
+ for out_dim in correlated_dims:
+ cm = cast("ArrayMap", transform.output[out_dim])
+ storage = checked_affine(cm.offset, cm.stride, cm.index_array)
+ lo = output_domain.inclusive_min[out_dim]
+ hi = output_domain.exclusive_max[out_dim]
+ mask = (storage >= lo) & (storage < hi)
+ combined = mask if combined is None else (combined & mask)
+ assert combined is not None
+ # Index arrays are singleton on every non-broadcast axis, so the mask
+ # collapses (C-order) to the broadcast block. A map may also be singleton
+ # along a block axis it does not vary over (an orthogonal member, or a
+ # leftover broadcast axis), so the collapsed mask is broadcast up to the
+ # full block rather than reshaped.
+ combined_block = combined.reshape(tuple(combined.shape[a] for a in broadcast_axes))
+ combined_bcast = np.broadcast_to(combined_block, broadcast_shape)
+ surviving = np.nonzero(combined_bcast.reshape(-1))[0].astype(np.intp)
+ if surviving.size == 0:
+ return None
+
+ # Intersect residual (slice / constant) dimensions independently. Slice dims
+ # are ordered by input dimension so their flat-buffer strides are row-major.
+ slice_dims: list[tuple[int, int, int, int, DimensionMap]] = [] # (in_dim, lo, hi, full, m)
+ for out_dim, m in enumerate(transform.output):
+ if out_dim in correlated_dims:
+ continue
+ lo = output_domain.inclusive_min[out_dim]
+ hi = output_domain.exclusive_max[out_dim]
+ if isinstance(m, ConstantMap):
+ if not (lo <= m.offset < hi):
+ return None
+ elif isinstance(m, DimensionMap):
+ d = m.input_dimension
+ input_lo = transform.domain.inclusive_min[d]
+ input_hi = transform.domain.exclusive_max[d]
+ narrowed = _intersect_dimension_map(m, input_lo, input_hi, lo, hi)
+ if narrowed is None:
+ return None
+ slice_dims.append((d, narrowed[0], narrowed[1], input_hi - input_lo, m))
+ slice_dims.sort(key=lambda item: item[0])
+
+ n_points = int(surviving.size)
+ n_slice = len(slice_dims)
+ corr_values: dict[int, np.ndarray[Any, np.dtype[np.intp]]] = {}
+ for out_dim in correlated_dims:
+ arr = cast("ArrayMap", transform.output[out_dim]).index_array
+ block = arr.reshape(tuple(arr.shape[a] for a in broadcast_axes))
+ corr_values[out_dim] = np.asarray(
+ np.ascontiguousarray(np.broadcast_to(block, broadcast_shape)).reshape(-1)[surviving],
+ dtype=np.intp,
+ )
+
+ # A rank-0 broadcast block contributes no axis: the leading `(n_points,)` of
+ # the domain, of every index array, and of `out_indices` is present only when
+ # there was a block to collapse.
+ points_shape = (n_points,) if len(broadcast_shape) > 0 else ()
+
+ # New domain: the collapsed broadcast axis if there is one, then one axis per
+ # residual slice.
+ new_min = [0] * len(points_shape)
+ new_max = list(points_shape)
+ new_input_dim_of = {}
+ for new_axis, (d, nlo, nhi, _full, _m) in enumerate(slice_dims, start=len(points_shape)):
+ new_min.append(nlo)
+ new_max.append(nhi)
+ new_input_dim_of[d] = new_axis
+ new_domain = IndexDomain(inclusive_min=tuple(new_min), exclusive_max=tuple(new_max))
+
+ corr_shape = points_shape + (1,) * n_slice
+ new_output: list[OutputIndexMap] = []
+ for out_dim, m in enumerate(transform.output):
+ if out_dim in correlated_dims:
+ corr = cast("ArrayMap", m)
+ new_output.append(
+ ArrayMap(
+ index_array=corr_values[out_dim].reshape(corr_shape).astype(np.intp),
+ offset=corr.offset,
+ stride=corr.stride,
+ )
+ )
+ elif isinstance(m, ConstantMap):
+ new_output.append(m)
+ else:
+ assert isinstance(m, DimensionMap)
+ new_output.append(
+ DimensionMap(
+ input_dimension=new_input_dim_of[m.input_dimension],
+ offset=m.offset,
+ stride=m.stride,
+ )
+ )
+ result = IndexTransform(domain=new_domain, output=tuple(new_output))
+
+ # Flat scatter index into the caller's row-major output buffer, whose shape
+ # is the *input* domain's shape. The buffer is addressed positionally, so
+ # this assumes a zero-origin domain — the resolvers normalize with
+ # `translate_domain_to` before resolving.
+ #
+ # Each surviving point is a flat index into the broadcast block; unravel it
+ # to per-axis coordinates so the buffer stride of each broadcast axis is
+ # applied at its real position, wherever NumPy's placement rule put it.
+ domain_shape = transform.domain.shape
+ buffer_strides = [1] * len(domain_shape)
+ for axis in range(len(domain_shape) - 2, -1, -1):
+ buffer_strides[axis] = buffer_strides[axis + 1] * domain_shape[axis + 1]
+
+ point_offsets = np.zeros(n_points, dtype=np.intp)
+ if len(broadcast_shape) > 0:
+ for axis, coords_along_axis in zip(
+ broadcast_axes, np.unravel_index(surviving, broadcast_shape), strict=True
+ ):
+ point_offsets = point_offsets + coords_along_axis.astype(np.intp) * buffer_strides[axis]
+
+ n_lead = len(points_shape)
+ out_indices: np.ndarray[Any, np.dtype[np.intp]] = point_offsets.reshape(
+ points_shape + (1,) * n_slice
+ )
+ for j in range(n_slice):
+ d, nlo, nhi, _full, _m = slice_dims[j]
+ coords = np.arange(nlo, nhi, dtype=np.intp) * buffer_strides[d]
+ shape = [1] * (n_lead + n_slice)
+ shape[n_lead + j] = coords.size
+ out_indices = out_indices + coords.reshape(shape)
+ return (result, out_indices.astype(np.intp))
+
+
+def _normalize_basic_selection(selection: Any, ndim: int) -> tuple[int | slice | None, ...]:
+ """Normalize a selection to a tuple of int, slice, or None (newaxis),
+ expanding ellipsis and padding with slice(None) as needed.
+ """
+ if not isinstance(selection, tuple):
+ selection = (selection,)
+
+ # Count non-newaxis, non-ellipsis entries to determine how many real dims are addressed
+ n_newaxis = sum(1 for s in selection if s is None)
+ has_ellipsis = any(s is Ellipsis for s in selection)
+ n_real = len(selection) - n_newaxis - (1 if has_ellipsis else 0)
+
+ if n_real > ndim:
+ raise IndexError(
+ f"too many indices for array: array has {ndim} dimensions, but {n_real} were indexed"
+ )
+
+ result: list[int | slice | None] = []
+ ellipsis_seen = False
+ for sel in selection:
+ if sel is Ellipsis:
+ if ellipsis_seen:
+ raise IndexError("an index can only have a single ellipsis ('...')")
+ ellipsis_seen = True
+ num_missing = ndim - n_real
+ result.extend([slice(None)] * num_missing)
+ elif (scalar := as_scalar_index(sel)) is not None:
+ result.append(scalar)
+ elif isinstance(sel, slice) or sel is None:
+ result.append(sel)
+ else:
+ raise IndexError(f"unsupported selection type for basic indexing: {type(sel)!r}")
+
+ # Pad remaining dimensions with slice(None)
+ while sum(1 for s in result if s is not None) < ndim:
+ result.append(slice(None))
+
+ return tuple(result)
+
+
+def _positional_slice(pos: int, size: int, step: int) -> slice:
+ """A NumPy slice selecting `size` elements from `pos`, walking by `step`.
+
+ The stop is `pos + size*step`, except in two cases. An empty selection is
+ written out explicitly, because the arithmetic form can be a negative stop
+ that NumPy would read as counting from the end. And a downward walk
+ reaching the start of the array must stop at `None`, for the same reason:
+ `slice(6, -1, -1)` selects nothing where `slice(6, None, -1)` selects the
+ first seven elements in reverse.
+ """
+ if size <= 0:
+ return slice(0, 0, 1)
+ stop = pos + size * step
+ if step < 0 and stop < 0:
+ return slice(pos, None, step)
+ return slice(pos, stop, step)
+
+
+def _reindex_array(
+ m: ArrayMap,
+ normalized: tuple[int | slice | None, ...],
+ domain: IndexDomain,
+) -> np.ndarray[Any, np.dtype[np.intp]]:
+ """Apply basic indexing operations to an ArrayMap's index_array.
+
+ The array's axes correspond to the transform's input dimensions (0-indexed
+ over the domain shape). Each axis is either a **dependency axis** — the array
+ varies with that input dimension — or a **singleton** axis it
+ broadcasts over. Integer indexing, slicing, or newaxis is applied to the
+ array only along its dependency axes; a selection on a singleton axis does not
+ touch the array's values (it just narrows or drops that broadcast axis).
+ """
+ dependent = set(m.dependency_axes)
+ arr = m.index_array
+
+ # Build a numpy indexing tuple: one entry per old input dimension
+ idx: list[Any] = []
+ old_dim = 0
+ newaxis_positions: list[int] = []
+ result_axis = 0
+
+ for sel in normalized:
+ if sel is None:
+ newaxis_positions.append(result_axis)
+ result_axis += 1
+ elif isinstance(sel, int):
+ if old_dim < arr.ndim:
+ if old_dim in dependent:
+ # Convert absolute domain coordinate to 0-based array index
+ idx.append(sel - domain.inclusive_min[old_dim])
+ else:
+ # Broadcast axis: keep the single element and drop the axis.
+ idx.append(0)
+ old_dim += 1
+ else:
+ # sel: slice (normalized: tuple[int | slice | None, ...])
+ if old_dim < arr.ndim:
+ if old_dim in dependent:
+ lo = domain.inclusive_min[old_dim]
+ hi = domain.exclusive_max[old_dim]
+ # Bounds are literal domain coordinates; the stored array is
+ # indexed positionally, so shift by the domain origin.
+ start, step, _origin, size = _resolve_slice_ts(sel, old_dim, lo, hi)
+ pos = start - lo
+ idx.append(_positional_slice(pos, size, step))
+ else:
+ # Broadcast axis: preserve the singleton (it still broadcasts
+ # over the narrowed domain), regardless of the slice bounds.
+ idx.append(slice(None))
+ old_dim += 1
+ result_axis += 1
+
+ result = arr[tuple(idx)] if idx else arr
+
+ for pos in newaxis_positions:
+ result = np.expand_dims(result, axis=pos)
+
+ return np.asarray(result, dtype=np.intp)
+
+
+def _compose_selection(
+ transform: IndexTransform,
+ selection: Any,
+ mode: Literal["orthogonal", "vectorized"],
+) -> IndexTransform:
+ """Apply an advanced selection to an array-carrying transform by composition.
+
+ The selection is applied to an identity transform over the current domain —
+ the same code path a fresh transform takes, so the dialect (placement,
+ bounds, domains) is identical by construction — and the result is chained
+ onto `transform` with `compose`, which evaluates the existing index arrays
+ at the new coordinates. This is how a second fancy step lands on *any* axis
+ of an already-fancy view: axes an existing array varies over, axes it merely
+ broadcasts along, or a mixture.
+ """
+ # Deferred import: `composition` imports this module at import time.
+
+ identity = IndexTransform.identity(transform.domain)
+ if mode == "orthogonal":
+ outer = _apply_oindex(identity, selection)
+ else:
+ outer = _apply_vindex(identity, selection)
+ return outer.compose(transform)
+
+
+def _apply_basic_indexing(transform: IndexTransform, selection: Any) -> IndexTransform:
+ """Apply basic indexing (int, slice, ellipsis, newaxis) to an IndexTransform."""
+ normalized = _normalize_basic_selection(selection, transform.domain.ndim)
+
+ new_inclusive_min: list[int] = []
+ new_exclusive_max: list[int] = []
+ old_dim = 0
+ new_dim_idx = 0
+ old_to_new_dim: dict[int, int] = {}
+ dropped_dims: set[int] = set()
+
+ # Per old-dim: the slice parameters (for computing new output maps)
+ dim_slice_params: dict[int, tuple[int, int, int]] = {} # old_dim -> (start, stop, step)
+ dim_int_val: dict[int, int] = {} # old_dim -> integer index value
+
+ for sel in normalized:
+ if sel is None:
+ # newaxis: add a size-1 dimension
+ new_inclusive_min.append(0)
+ new_exclusive_max.append(1)
+ new_dim_idx += 1
+ elif isinstance(sel, int):
+ # Integer index: drop this input dimension.
+ # Negative indices are literal coordinates (TensorStore convention),
+ # NOT "from the end" like NumPy. The Array layer handles conversion.
+ lo = transform.domain.inclusive_min[old_dim]
+ hi = transform.domain.exclusive_max[old_dim]
+ idx = sel
+ if idx < lo or idx >= hi:
+ hint = _LITERAL_HINT if sel < 0 else ""
+ raise BoundsCheckError(
+ f"index {sel} is out of bounds for dimension {old_dim} "
+ f"(valid indices [{lo}, {hi})){hint}"
+ )
+ dropped_dims.add(old_dim)
+ dim_int_val[old_dim] = idx
+ old_dim += 1
+ else:
+ # sel: slice (normalized: tuple[int | slice | None, ...])
+ lo = transform.domain.inclusive_min[old_dim]
+ hi = transform.domain.exclusive_max[old_dim]
+
+ # TensorStore semantics: bounds are literal coordinates; a step-1
+ # slice keeps them as the new domain, a strided slice's domain is
+ # [trunc(start/step), trunc(start/step) + size).
+ start, step, origin, size = _resolve_slice_ts(sel, old_dim, lo, hi)
+ new_inclusive_min.append(origin)
+ new_exclusive_max.append(origin + size)
+ dim_slice_params[old_dim] = (start, step, origin)
+ old_to_new_dim[old_dim] = new_dim_idx
+ new_dim_idx += 1
+ old_dim += 1
+
+ new_domain = IndexDomain(
+ inclusive_min=tuple(new_inclusive_min),
+ exclusive_max=tuple(new_exclusive_max),
+ )
+
+ # Now update output maps
+ new_output: list[OutputIndexMap] = []
+ for m in transform.output:
+ if isinstance(m, ConstantMap):
+ new_output.append(m)
+ elif isinstance(m, DimensionMap):
+ d = m.input_dimension
+ if d in dropped_dims:
+ # Integer index: this output becomes constant
+ new_offset = m.offset + m.stride * dim_int_val[d]
+ new_output.append(ConstantMap(offset=new_offset))
+ elif d in old_to_new_dim:
+ # Slice: new coordinate `origin + k` maps to old coordinate
+ # `start + k*step`, i.e. old = start - step*origin + step*new.
+ start, step, origin = dim_slice_params[d]
+ new_offset = m.offset + m.stride * (start - step * origin)
+ new_stride = m.stride * step
+ new_input_dim = old_to_new_dim[d]
+ new_output.append(
+ DimensionMap(
+ input_dimension=new_input_dim, offset=new_offset, stride=new_stride
+ )
+ )
+ else:
+ raise RuntimeError(f"unexpected: dimension {d} not handled")
+ else:
+ # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap).
+ # A result narrowed to a single coordinate collapses to the
+ # ConstantMap it equals — whether an integer consumed the dependency
+ # axis or a slice narrowed it to one entry — so a non-empty ArrayMap
+ # always varies over at least one axis. Nothing here renumbers: the
+ # array's axes are the new domain's axes by construction.
+ new_arr = _reindex_array(m, normalized, transform.domain)
+ new_output.append(array_map_or_constant(new_arr, offset=m.offset, stride=m.stride))
+
+ return IndexTransform(domain=new_domain, output=tuple(new_output))
+
+
+def _reshape_to_axis(
+ values: np.ndarray[Any, np.dtype[np.intp]], axis: int, ndim: int
+) -> np.ndarray[Any, np.dtype[np.intp]]:
+ """Reshape a 1-D selection to full rank `ndim` varying only along `axis`.
+
+ The result has `values` laid out along `axis` and singleton (size-1) axes
+ everywhere else, so its dependency axis is derivable from its shape.
+ """
+ flat = np.asarray(values, dtype=np.intp).ravel()
+ shape = [1] * ndim
+ shape[axis] = flat.shape[0]
+ return flat.reshape(shape)
+
+
+class _OIndexHelper:
+ """Helper that provides orthogonal (outer) indexing via `transform.oindex[...]`."""
+
+ def __init__(self, transform: IndexTransform) -> None:
+ self._transform = transform
+
+ def __getitem__(self, selection: Any) -> IndexTransform:
+ return _apply_oindex(self._transform, selection)
+
+
+def _normalize_oindex_selection(
+ selection: Any, ndim: int
+) -> tuple[np.ndarray[Any, np.dtype[np.intp]] | slice, ...]:
+ """Normalize an oindex selection: arrays, slices, booleans, integers."""
+ if not isinstance(selection, tuple):
+ selection = (selection,)
+
+ # Expand ellipsis
+ has_ellipsis = any(s is Ellipsis for s in selection)
+ n_ellipsis = 1 if has_ellipsis else 0
+ n_real = len(selection) - n_ellipsis
+
+ result: list[np.ndarray[Any, np.dtype[np.intp]] | slice] = []
+ for sel in selection:
+ if sel is Ellipsis:
+ num_missing = ndim - n_real
+ result.extend([slice(None)] * num_missing)
+ elif isinstance(sel, np.ndarray) and sel.dtype == np.bool_:
+ # Boolean array -> integer indices
+ (indices,) = np.nonzero(sel)
+ result.append(indices.astype(np.intp))
+ elif isinstance(sel, np.ndarray):
+ result.append(sel.astype(np.intp))
+ elif isinstance(sel, slice):
+ result.append(sel)
+ elif (scalar := as_scalar_index(sel)) is not None:
+ # Convert integer scalars to 1-element arrays for orthogonal indexing
+ result.append(np.array([scalar], dtype=np.intp))
+ elif isinstance(sel, (list, tuple)):
+ array = np.asarray(sel)
+ if array.dtype == np.bool_:
+ (indices,) = np.nonzero(array)
+ result.append(indices.astype(np.intp))
+ else:
+ result.append(np.asarray(sel, dtype=np.intp))
+ else:
+ result.append(sel)
+
+ # Pad with slice(None)
+ while len(result) < ndim:
+ result.append(slice(None))
+
+ return tuple(result)
+
+
+def _apply_oindex(transform: IndexTransform, selection: Any) -> IndexTransform:
+ """Apply orthogonal indexing to an IndexTransform.
+
+ Each index array is applied independently per dimension (outer product).
+
+ A transform that already carries index arrays takes the composition path
+ (`_compose_selection`) instead of being rewritten in place, so the new
+ selection may land on any axis — including axes an existing array merely
+ broadcasts along.
+ """
+ validate_advanced_selection(selection, transform.domain, "orthogonal")
+ if any(isinstance(m, ArrayMap) for m in transform.output):
+ return _compose_selection(transform, selection, "orthogonal")
+ normalized = _normalize_oindex_selection(selection, transform.domain.ndim)
+
+ new_inclusive_min: list[int] = []
+ new_exclusive_max: list[int] = []
+ new_dim_idx = 0
+ old_to_new_dim: dict[int, int] = {}
+
+ # Info per old dim
+ dim_array: dict[int, np.ndarray[Any, np.dtype[np.intp]]] = {}
+ dim_slice_params: dict[int, tuple[int, int, int]] = {}
+
+ for old_dim, sel in enumerate(normalized):
+ if isinstance(sel, np.ndarray):
+ lo = transform.domain.inclusive_min[old_dim]
+ hi = transform.domain.exclusive_max[old_dim]
+ # Index-array values are literal domain coordinates; the fancy dim
+ # they create gets a fresh zero-origin [0, n) domain (TensorStore).
+ _check_array_in_bounds(sel, lo, hi)
+ dim_array[old_dim] = sel
+ new_inclusive_min.append(0)
+ new_exclusive_max.append(len(sel))
+ old_to_new_dim[old_dim] = new_dim_idx
+ new_dim_idx += 1
+ else:
+ # sel: slice (_normalize_oindex_selection returns
+ # tuple[np.ndarray | slice, ...])
+ lo = transform.domain.inclusive_min[old_dim]
+ hi = transform.domain.exclusive_max[old_dim]
+ start, step, origin, size = _resolve_slice_ts(sel, old_dim, lo, hi)
+ new_inclusive_min.append(origin)
+ new_exclusive_max.append(origin + size)
+ dim_slice_params[old_dim] = (start, step, origin)
+ old_to_new_dim[old_dim] = new_dim_idx
+ new_dim_idx += 1
+
+ new_domain = IndexDomain(
+ inclusive_min=tuple(new_inclusive_min),
+ exclusive_max=tuple(new_exclusive_max),
+ )
+
+ new_output: list[OutputIndexMap] = []
+ for m in transform.output:
+ if isinstance(m, ConstantMap):
+ new_output.append(m)
+ elif isinstance(m, DimensionMap):
+ d = m.input_dimension
+ if d in dim_array:
+ new_axis = old_to_new_dim[d]
+ # Normalize to full input rank: the selection varies along its
+ # own new axis and is singleton on every other axis, so the
+ # dependency axis is readable from the shape. A single-entry
+ # selection holds one coordinate and collapses to the
+ # ConstantMap it equals; its length-1 axis stays in the domain.
+ full_arr = _reshape_to_axis(dim_array[d], new_axis, new_dim_idx)
+ new_output.append(array_map_or_constant(full_arr, offset=m.offset, stride=m.stride))
+ elif d in dim_slice_params:
+ start, step, origin = dim_slice_params[d]
+ new_offset = m.offset + m.stride * (start - step * origin)
+ new_stride = m.stride * step
+ new_input_dim = old_to_new_dim[d]
+ new_output.append(
+ DimensionMap(
+ input_dimension=new_input_dim, offset=new_offset, stride=new_stride
+ )
+ )
+ else:
+ raise RuntimeError(f"unexpected: dimension {d} not handled")
+ else:
+ # m: ArrayMap — unreachable: array-carrying transforms took the
+ # composition path at the top of this function.
+ raise AssertionError( # noqa: TRY004 - unreachable, not a dispatch
+ "unreachable: ArrayMap transforms are composed"
+ )
+
+ return IndexTransform(domain=new_domain, output=tuple(new_output))
+
+
+class _VIndexHelper:
+ """Helper that provides vectorized (fancy) indexing via `transform.vindex[...]`."""
+
+ def __init__(self, transform: IndexTransform) -> None:
+ self._transform = transform
+
+ def __getitem__(self, selection: Any) -> IndexTransform:
+ return _apply_vindex(self._transform, selection)
+
+
+def _broadcast_insertion_point(array_dims: Sequence[int], slice_dims: Sequence[int]) -> int:
+ """Where the broadcast dimensions land, as a count of leading slice dimensions.
+
+ NumPy's advanced-indexing placement rule: when the advanced indices are all
+ next to each other in the index tuple, the broadcast dimensions are inserted
+ at the spot they occupied; when a slice separates them, they lead. So
+ `a[:, i, j]` has shape `(len(a), *broadcast)` while `a[i, :, j]` has shape
+ `(*broadcast, a.shape[1])`.
+
+ Returns the number of slice dimensions that precede the broadcast block; `0`
+ means the broadcast dimensions lead.
+ """
+ if len(array_dims) == 0:
+ return 0
+ first, last = array_dims[0], array_dims[-1]
+ separated = any(first < d < last for d in slice_dims)
+ if separated:
+ return 0
+ return sum(1 for d in slice_dims if d < first)
+
+
+def _as_boolean_index_array(selection: Any) -> np.ndarray[Any, np.dtype[np.bool_]] | None:
+ """Return an array-like boolean index as an ndarray, else None."""
+ if not isinstance(selection, (np.ndarray, list, tuple)):
+ return None
+ array = np.asarray(selection)
+ if array.dtype != np.bool_:
+ return None
+ return array
+
+
+def _selection_axis_count(selection: Any) -> int:
+ """Return how many input axes one vectorized selection entry consumes."""
+ boolean_array = _as_boolean_index_array(selection)
+ return boolean_array.ndim if boolean_array is not None else 1
+
+
+def _apply_vindex(transform: IndexTransform, selection: Any) -> IndexTransform:
+ """Apply vectorized indexing to an IndexTransform.
+
+ All array indices are broadcast together. Broadcast dimensions are prepended,
+ followed by non-array (slice) dimensions.
+
+ A transform that already carries index arrays takes the composition path
+ (`_compose_selection`) instead of being rewritten in place; see
+ `_apply_oindex`.
+ """
+ validate_advanced_selection(selection, transform.domain, "vectorized")
+ if any(isinstance(m, ArrayMap) for m in transform.output):
+ return _compose_selection(transform, selection, "vectorized")
+ if not isinstance(selection, tuple):
+ selection = (selection,)
+
+ # Expand ellipsis and count consumed dimensions. Boolean masks consume one
+ # input axis per mask dimension, whether spelled as an ndarray or a list.
+ n_consumed = sum(_selection_axis_count(s) for s in selection if s is not Ellipsis)
+ ndim = transform.domain.ndim
+
+ expanded: list[Any] = []
+ for sel in selection:
+ if sel is Ellipsis:
+ num_missing = ndim - n_consumed
+ expanded.extend([slice(None)] * num_missing)
+ else:
+ expanded.append(sel)
+ # Count dimensions already consumed by expanded entries
+ n_expanded_dims = sum(_selection_axis_count(sel) for sel in expanded)
+ while n_expanded_dims < ndim:
+ expanded.append(slice(None))
+ n_expanded_dims += 1
+
+ # Convert booleans, lists, ints to integer arrays
+ processed: list[np.ndarray[Any, np.dtype[np.intp]] | slice] = []
+ for sel in expanded:
+ boolean_array = _as_boolean_index_array(sel)
+ if boolean_array is not None:
+ indices_tuple = np.nonzero(boolean_array)
+ processed.extend(indices.astype(np.intp) for indices in indices_tuple)
+ elif isinstance(sel, np.ndarray):
+ processed.append(sel.astype(np.intp))
+ elif isinstance(sel, (list, tuple)):
+ processed.append(np.asarray(sel, dtype=np.intp))
+ elif (scalar := as_scalar_index(sel)) is not None:
+ processed.append(np.array([scalar], dtype=np.intp))
+ else:
+ processed.append(sel)
+
+ # Separate array dims and slice dims
+ array_dims: list[int] = []
+ slice_dims: list[int] = []
+ arrays: list[np.ndarray[Any, np.dtype[np.intp]]] = []
+
+ for i, sel in enumerate(processed):
+ if isinstance(sel, np.ndarray):
+ lo = transform.domain.inclusive_min[i]
+ hi = transform.domain.exclusive_max[i]
+ _check_array_in_bounds(sel, lo, hi)
+ array_dims.append(i)
+ arrays.append(sel)
+ else:
+ slice_dims.append(i)
+
+ # Broadcast all arrays together
+ broadcast_arrays: list[np.ndarray[Any, np.dtype[np.intp]]]
+ if len(arrays) > 0:
+ broadcast_arrays = list(np.broadcast_arrays(*arrays))
+ broadcast_shape = broadcast_arrays[0].shape
+ else:
+ broadcast_arrays = []
+ broadcast_shape = ()
+
+ # Slice dimensions (preserved-domain literal semantics, like basic indexing)
+ slice_dim_params: dict[int, tuple[int, int, int]] = {}
+ slice_bounds: list[tuple[int, int]] = []
+ for old_dim in slice_dims:
+ sel = processed[old_dim]
+ assert isinstance(sel, slice)
+ lo = transform.domain.inclusive_min[old_dim]
+ hi = transform.domain.exclusive_max[old_dim]
+ start, step, origin, size = _resolve_slice_ts(sel, old_dim, lo, hi)
+ slice_bounds.append((origin, origin + size))
+ slice_dim_params[old_dim] = (start, step, origin)
+
+ n_before = _broadcast_insertion_point(array_dims, slice_dims)
+
+ # Build the new domain with NumPy's placement rule: the broadcast
+ # (correlated) dimensions sit where the advanced indices sat when those are
+ # adjacent, and lead when a slice separates them.
+ new_inclusive_min = [lo for lo, _ in slice_bounds[:n_before]]
+ new_exclusive_max = [hi for _, hi in slice_bounds[:n_before]]
+ new_inclusive_min.extend([0] * len(broadcast_shape))
+ new_exclusive_max.extend(broadcast_shape)
+ new_inclusive_min.extend(lo for lo, _ in slice_bounds[n_before:])
+ new_exclusive_max.extend(hi for _, hi in slice_bounds[n_before:])
+
+ new_domain = IndexDomain(
+ inclusive_min=tuple(new_inclusive_min),
+ exclusive_max=tuple(new_exclusive_max),
+ )
+
+ # Build output maps
+ array_dim_to_broadcast: dict[int, np.ndarray[Any, np.dtype[np.intp]]] = {}
+ for i, d in enumerate(array_dims):
+ array_dim_to_broadcast[d] = broadcast_arrays[i]
+
+ # New dim index for slice dims starts after broadcast dims
+ n_broadcast_dims = len(broadcast_shape)
+
+ new_output: list[OutputIndexMap] = []
+ for m in transform.output:
+ if isinstance(m, ConstantMap):
+ new_output.append(m)
+ elif isinstance(m, DimensionMap):
+ d = m.input_dimension
+ if d in array_dim_to_broadcast:
+ # Normalize to full input rank: the broadcast (correlated) axes
+ # come first, followed by a singleton axis per slice dimension.
+ # Every vectorized array shares the same broadcast axes, so the
+ # dependency axes derived from the shape coincide — the signature
+ # of a pointwise scatter rather than an outer product.
+ broadcast_arr = array_dim_to_broadcast[d]
+ full_arr = broadcast_arr.reshape(
+ (1,) * n_before + broadcast_shape + (1,) * (len(slice_dims) - n_before)
+ )
+ new_output.append(array_map_or_constant(full_arr, offset=m.offset, stride=m.stride))
+ else:
+ # Slice dim: new coord `origin + k` maps to old `start + k*step`
+ start, step, origin = slice_dim_params[d]
+ new_offset = m.offset + m.stride * (start - step * origin)
+ new_stride = m.stride * step
+ position = slice_dims.index(d)
+ new_input_dim = position if position < n_before else position + n_broadcast_dims
+ new_output.append(
+ DimensionMap(
+ input_dimension=new_input_dim, offset=new_offset, stride=new_stride
+ )
+ )
+ else:
+ # m: ArrayMap — unreachable: array-carrying transforms took the
+ # composition path at the top of this function.
+ raise AssertionError( # noqa: TRY004 - unreachable, not a dispatch
+ "unreachable: ArrayMap transforms are composed"
+ )
+
+ return IndexTransform(domain=new_domain, output=tuple(new_output))
+
+
+_LITERAL_HINT = (
+ "; within this transform layer, indices are literal domain coordinates (the "
+ "public Array boundary wraps NumPy-style negatives before they reach here)"
+)
+
+
+def _trunc_div(a: int, b: int) -> int:
+ """Integer division rounded toward zero (C semantics), as TensorStore uses
+ for strided-slice domain origins — distinct from Python's floor division
+ for negative operands (`trunc(-9/2) == -4` where `-9 // 2 == -5`)."""
+ q = a // b
+ if q < 0 and q * b != a:
+ q += 1
+ return q
+
+
+def _resolve_slice_ts(sel: slice, dim: int, lo: int, hi: int) -> tuple[int, int, int, int]:
+ """Resolve a slice against domain `[lo, hi)` with TensorStore semantics.
+
+ Slice bounds are **literal domain coordinates** — never from-the-end, never
+ clamped. One rule covers both signs of the step (each part verified against
+ tensorstore 0.1.84, and matching ndsel 1.0-draft.2 section 5.3):
+
+ - defaults follow the direction of travel: `start = lo`, `stop = hi` going
+ up; `start = hi - 1`, `stop = lo - 1` going down;
+ - the traversal runs from `start` toward `stop`, which is excluded, so the
+ source interval is `[start, stop)` going up and `[stop + 1, start + 1)`
+ going down;
+ - a non-empty interval must be contained in the domain (no clamping — a
+ NumPy-style out-of-range or negative bound is an error, not a shorter or
+ wrapped result);
+ - an empty interval is valid anywhere, for either sign;
+ - an interval running the wrong way (`stop` on the far side of `start` from
+ the direction of travel) is an error, not an empty result;
+ - the result's domain origin is `trunc(start/step)` — toward zero, for both
+ signs — and coordinate `origin + k` maps to input `start + k*step`.
+
+ A negative step normally produces a negative origin: reversing a
+ zero-origin axis of length 20 gives the domain `[-19, 1)`. The coordinate
+ frame stays anchored to the source; a caller that needs non-negative
+ coordinates re-bases explicitly with `translate_domain_to`.
+
+ Returns `(start, step, origin, size)` in domain coordinates.
+ """
+ start_bound = None if sel.start is None else require_index(sel.start)
+ stop_bound = None if sel.stop is None else require_index(sel.stop)
+ step = 1 if sel.step is None else require_index(sel.step)
+ if step == 0:
+ raise IndexError("slice step must not be zero")
+ if step > 0:
+ start = lo if start_bound is None else start_bound
+ stop = hi if stop_bound is None else stop_bound
+ interval_lo, interval_hi = start, stop
+ else:
+ start = hi - 1 if start_bound is None else start_bound
+ stop = lo - 1 if stop_bound is None else stop_bound
+ interval_lo, interval_hi = stop + 1, start + 1
+ length = interval_hi - interval_lo
+ if length < 0:
+ raise IndexError(
+ f"slice from {start} to {stop} with step {step} does not specify a "
+ f"valid interval for dimension {dim}: the derived interval "
+ f"[{interval_lo}, {interval_hi}) runs the wrong way. An empty "
+ "selection is spelled stop == start."
+ )
+ if length > 0 and (interval_lo < lo or interval_hi > hi):
+ hint = _LITERAL_HINT if (start < 0 or stop < 0) and lo >= 0 else ""
+ raise BoundsCheckError(
+ f"slice interval [{interval_lo}, {interval_hi}) is not contained "
+ f"within domain [{lo}, {hi}) for dimension {dim}{hint}"
+ )
+ size = -(-length // abs(step)) # ceil(length / |step|)
+ origin = _trunc_div(start, step)
+ return start, step, origin, size
+
+
+def _check_array_in_bounds(arr: np.ndarray[Any, np.dtype[np.intp]], lo: int, hi: int) -> None:
+ """Reject index-array values outside the domain `[lo, hi)`.
+
+ Index-array values are literal domain coordinates (TensorStore semantics):
+ a value below `inclusive_min` is out of bounds rather than counting from
+ the end. Out-of-range values raise instead of silently wrapping.
+ """
+ if arr.size == 0:
+ return
+ lo_val, hi_val = int(arr.min()), int(arr.max())
+ if lo_val < lo:
+ hint = _LITERAL_HINT if lo_val < 0 and lo >= 0 else ""
+ raise BoundsCheckError(
+ f"index {lo_val} is out of bounds (valid indices [{lo}, {hi})){hint}"
+ )
+ if hi_val >= hi:
+ raise BoundsCheckError(f"index {hi_val} is out of bounds (valid indices [{lo}, {hi}))")
+
+
+def _validate_array_selection(selection: Any, shape: tuple[int, ...], mode: str) -> None:
+ """Validate array-based selections (orthogonal, vectorized).
+
+ Rejects types that are not valid for coordinate/vectorized indexing.
+ Does not check bounds — the transform operations handle that.
+ """
+ items = selection if isinstance(selection, tuple) else (selection,)
+ for sel in items:
+ if isinstance(sel, slice):
+ # vindex is coordinate-only (matches eager zarr): every axis needs an
+ # integer/boolean array, never a slice. Orthogonal (oindex) allows slices.
+ if mode == "vectorized":
+ raise VindexInvalidSelectionError(
+ "unsupported selection type for vectorized indexing; only "
+ "coordinate selection (tuple of integer arrays) and mask selection "
+ f"(single Boolean array) are supported; got {selection!r}"
+ )
+ continue
+ if sel is Ellipsis or as_scalar_index(sel) is not None:
+ continue
+ if isinstance(sel, (list, np.ndarray)):
+ if mode == "orthogonal":
+ array = np.asarray(sel)
+ # An orthogonal selection is per-axis, so an integer array names
+ # coordinates along one axis and can only be one-dimensional.
+ # Left to the engine, this surfaced much later as a rank
+ # complaint about an `index_array` the caller never wrote.
+ if array.dtype.kind in "iu" and array.ndim > 1:
+ raise IndexError(
+ f"integer arrays in an orthogonal selection must be "
+ f"1-dimensional only; got one with {array.ndim} dimensions"
+ )
+ continue
+ raise IndexError(f"unsupported selection type for {mode} indexing: {type(sel)!r}")
+
+
+def _validate_basic_selection(selection: Any) -> None:
+ """Validate that a selection only contains basic indexing types (int, slice, Ellipsis).
+
+ Rejects None (newaxis), arrays, lists, floats, strings, etc.
+ """
+ items = selection if isinstance(selection, tuple) else (selection,)
+ for s in items:
+ if s is Ellipsis or isinstance(s, slice) or as_scalar_index(s) is not None:
+ continue
+ raise IndexError(f"unsupported selection type for basic indexing: {type(s)!r}")
diff --git a/packages/zarr-indexing/tests/conformance/PROVENANCE.md b/packages/zarr-indexing/tests/conformance/PROVENANCE.md
new file mode 100644
index 0000000000..749a2810e7
--- /dev/null
+++ b/packages/zarr-indexing/tests/conformance/PROVENANCE.md
@@ -0,0 +1,39 @@
+# Provenance of the ndsel conformance corpus
+
+The JSON fixtures in this directory (`point.json`, `box.json`, `slice.json`,
+`points.json`, `transform.json`, `errors.json`) and `README.md` are **vendored,
+unmodified**, from the ndsel reference repository.
+
+- **Source:**
+- **Branch:** `main` (merge of d-v-b/ndsel#3, empty `index_array` serialization)
+- **Commit:** `49b9e1db1ca93c55f320b025a666367de87a9014` (previously vendored:
+ `92d6a32df0cd1ac47d548f14f42909a95997cf19`, before that `c59bc556c`, itself
+ byte-identical to `c132b4c1caa3205830ce35a42502363171f650a7`)
+- **Path in source:** `conformance/`
+
+**Do not edit these files.** They are vendored as-is so that
+`zarr_indexing`' ndsel message layer can be checked against the same
+language-agnostic corpus every other ndsel implementation runs. To update the
+corpus, re-vendor from a newer ndsel commit and update the commit SHA above.
+
+ndsel PR #1 (merged) corrected the `slice` desugaring origin from
+`floor(a/s)` to `trunc(a/s)` (rounding toward zero), which matches
+`zarr_indexing`' existing `_trunc_div` semantics.
+
+ndsel PR #2 (merged) specified negative `step`, which changed two fixtures:
+
+- `slice.json` gained the negative-step cases (full reverse, `|s| > 1`
+ non-divisible, negative-coordinate intervals, empty-at-any-coordinate).
+- `errors.json` retired `error/negative-step` — the reason code
+ `negative_step_unsupported` is retired with it — and replaced it with three
+ `bounds_out_of_order` fixtures pinning that a reversed interval is an error
+ for either sign of the step, rather than being clamped to empty.
+
+Re-vendoring those two files and teaching `zarr_indexing.messages` the new
+desugaring are one change: the corpus is the definition of correct here, so it
+lands in the same commit as the code that satisfies it.
+
+ndsel PR #3 (merged) specified empty `index_array` serialization, adding two
+fixtures to `transform.json`: `normalize` carries an empty `index_array`
+verbatim (it is not rewritten to a constant map), while a producer SHOULD
+collapse it to a constant output map — which `zarr_indexing.json` already does.
diff --git a/packages/zarr-indexing/tests/conformance/README.md b/packages/zarr-indexing/tests/conformance/README.md
new file mode 100644
index 0000000000..ecb0c57ca2
--- /dev/null
+++ b/packages/zarr-indexing/tests/conformance/README.md
@@ -0,0 +1,16 @@
+# ndsel conformance corpus
+
+Language-agnostic fixtures. Each file is a JSON array of cases.
+
+A **success** case:
+ { "name": "...", "input": , "normalized": }
+
+An **error** case:
+ { "name": "...", "input": , "error": "" }
+
+An implementation is conformant iff, for every success case,
+`normalize(input)` equals `normalized` by structural JSON equality, and for
+every error case, `normalize(input)` is rejected with the given reason code.
+
+The `normalized` value is a canonical `transform` body (the `kind` field is
+omitted; implementations compare the transform structure).
diff --git a/packages/zarr-indexing/tests/conformance/box.json b/packages/zarr-indexing/tests/conformance/box.json
new file mode 100644
index 0000000000..e847872f86
--- /dev/null
+++ b/packages/zarr-indexing/tests/conformance/box.json
@@ -0,0 +1,50 @@
+[
+ {
+ "name": "box/2d-min-max",
+ "input": { "kind": "box", "inclusive_min": [0, 0], "exclusive_max": [3, 4] },
+ "normalized": {
+ "input_rank": 2,
+ "input_inclusive_min": [0, 0],
+ "input_exclusive_max": [3, 4],
+ "input_labels": ["", ""],
+ "output": [
+ { "offset": 0, "stride": 1, "input_dimension": 0 },
+ { "offset": 0, "stride": 1, "input_dimension": 1 }
+ ]
+ }
+ },
+ {
+ "name": "box/shape-only-origin-zero",
+ "input": { "kind": "box", "shape": [5] },
+ "normalized": {
+ "input_rank": 1,
+ "input_inclusive_min": [0],
+ "input_exclusive_max": [5],
+ "input_labels": [""],
+ "output": [ { "offset": 0, "stride": 1, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "box/inclusive-max",
+ "input": { "kind": "box", "inclusive_min": [2], "inclusive_max": [9] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [2], "input_exclusive_max": [10],
+ "input_labels": [""],
+ "output": [ { "offset": 0, "stride": 1, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "box/implicit-and-infinite-bounds",
+ "input": { "kind": "box", "inclusive_min": [["-inf"], 0], "exclusive_max": [["+inf"], 4], "labels": ["t", ""] },
+ "normalized": {
+ "input_rank": 2,
+ "input_inclusive_min": [["-inf"], 0],
+ "input_exclusive_max": [["+inf"], 4],
+ "input_labels": ["t", ""],
+ "output": [
+ { "offset": 0, "stride": 1, "input_dimension": 0 },
+ { "offset": 0, "stride": 1, "input_dimension": 1 }
+ ]
+ }
+ }
+]
diff --git a/packages/zarr-indexing/tests/conformance/errors.json b/packages/zarr-indexing/tests/conformance/errors.json
new file mode 100644
index 0000000000..072acd0af5
--- /dev/null
+++ b/packages/zarr-indexing/tests/conformance/errors.json
@@ -0,0 +1,25 @@
+[
+ { "name": "error/step-zero", "input": { "kind": "slice", "start": [0], "stop": [4], "step": [0] }, "error": "step_zero" },
+ { "name": "error/slice-reversed-interval-unit-step", "input": { "kind": "slice", "start": [9], "stop": [0] }, "error": "bounds_out_of_order" },
+ { "name": "error/slice-reversed-interval-positive-step", "input": { "kind": "slice", "start": [9], "stop": [0], "step": [2] }, "error": "bounds_out_of_order" },
+ { "name": "error/slice-reversed-interval-negative-step", "input": { "kind": "slice", "start": [5], "stop": [6], "step": [-1] }, "error": "bounds_out_of_order" },
+ { "name": "error/multiple-upper-bounds", "input": { "kind": "box", "shape": [3], "exclusive_max": [3] }, "error": "multiple_upper_bounds" },
+ { "name": "error/rank-mismatch", "input": { "kind": "slice", "start": [0, 0], "stop": [4] }, "error": "rank_mismatch" },
+ { "name": "error/unknown-kind", "input": { "kind": "bogus" }, "error": "unknown_kind" },
+ { "name": "error/transform-multiple-upper-bounds", "input": { "kind": "transform", "input_shape": [3], "input_exclusive_max": [3] }, "error": "multiple_upper_bounds" },
+ { "name": "error/transform-rank-mismatch", "input": { "kind": "transform", "input_rank": 2, "input_inclusive_min": [0] }, "error": "rank_mismatch" },
+ { "name": "error/missing-kind", "input": { "coords": [1, 2] }, "error": "invalid_json" },
+ { "name": "error/point-missing-coords", "input": { "kind": "point" }, "error": "invalid_json" },
+ { "name": "error/point-bool-coord", "input": { "kind": "point", "coords": [true] }, "error": "invalid_json" },
+ { "name": "error/slice-missing-stop", "input": { "kind": "slice", "start": [0] }, "error": "invalid_json" },
+ { "name": "error/box-non-list-bound", "input": { "kind": "box", "inclusive_min": 5 }, "error": "invalid_json" },
+ { "name": "error/points-bool-coord", "input": { "kind": "points", "coords": [[true]] }, "error": "invalid_json" },
+ { "name": "error/integer-out-of-i64-range", "input": { "kind": "point", "coords": [99999999999999999999] }, "error": "invalid_json" },
+ { "name": "error/box-inverted-bounds", "input": { "kind": "box", "inclusive_min": [5], "exclusive_max": [3] }, "error": "bounds_out_of_order" },
+ { "name": "error/box-negative-shape", "input": { "kind": "box", "shape": [-3] }, "error": "bounds_out_of_order" },
+ { "name": "error/transform-inverted-bounds", "input": { "kind": "transform", "input_inclusive_min": [0], "input_exclusive_max": [-1] }, "error": "bounds_out_of_order" },
+ { "name": "error/output-map-conflict", "input": { "kind": "transform", "output": [{ "input_dimension": 0, "index_array": [1, 2] }] }, "error": "output_map_conflict" },
+ { "name": "error/box-unknown-field", "input": { "kind": "box", "shapee": [3] }, "error": "unknown_field" },
+ { "name": "error/point-unknown-field", "input": { "kind": "point", "coords": [1], "extra": true }, "error": "unknown_field" },
+ { "name": "error/output-map-unknown-field", "input": { "kind": "transform", "output": [{ "offset": 0, "bogus": 1 }] }, "error": "unknown_field" }
+]
diff --git a/packages/zarr-indexing/tests/conformance/point.json b/packages/zarr-indexing/tests/conformance/point.json
new file mode 100644
index 0000000000..99a5ea16d8
--- /dev/null
+++ b/packages/zarr-indexing/tests/conformance/point.json
@@ -0,0 +1,30 @@
+[
+ {
+ "name": "point/2d",
+ "input": { "kind": "point", "coords": [4, 7] },
+ "normalized": {
+ "input_rank": 0,
+ "input_inclusive_min": [],
+ "input_exclusive_max": [],
+ "input_labels": [],
+ "output": [ { "offset": 4 }, { "offset": 7 } ]
+ }
+ },
+ {
+ "name": "point/scalar-0d",
+ "input": { "kind": "point", "coords": [] },
+ "normalized": {
+ "input_rank": 0, "input_inclusive_min": [], "input_exclusive_max": [],
+ "input_labels": [], "output": []
+ }
+ },
+ {
+ "name": "point/large-i64",
+ "input": { "kind": "point", "coords": [1152921504606846976] },
+ "normalized": {
+ "input_rank": 0, "input_inclusive_min": [], "input_exclusive_max": [],
+ "input_labels": [],
+ "output": [ { "offset": 1152921504606846976 } ]
+ }
+ }
+]
diff --git a/packages/zarr-indexing/tests/conformance/points.json b/packages/zarr-indexing/tests/conformance/points.json
new file mode 100644
index 0000000000..1ad92e12b1
--- /dev/null
+++ b/packages/zarr-indexing/tests/conformance/points.json
@@ -0,0 +1,34 @@
+[
+ {
+ "name": "points/three-2d",
+ "input": { "kind": "points", "coords": [[1, 10], [2, 20], [3, 30]] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [0], "input_exclusive_max": [3],
+ "input_labels": [""],
+ "output": [
+ { "offset": 0, "stride": 1, "index_array": [1, 2, 3], "index_array_bounds": ["-inf", "+inf"] },
+ { "offset": 0, "stride": 1, "index_array": [10, 20, 30], "index_array_bounds": ["-inf", "+inf"] }
+ ]
+ }
+ },
+ {
+ "name": "points/1d",
+ "input": { "kind": "points", "coords": [[5], [9], [2]] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [0], "input_exclusive_max": [3],
+ "input_labels": [""],
+ "output": [
+ { "offset": 0, "stride": 1, "index_array": [5, 9, 2], "index_array_bounds": ["-inf", "+inf"] }
+ ]
+ }
+ },
+ {
+ "name": "points/empty",
+ "input": { "kind": "points", "coords": [] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [0], "input_exclusive_max": [0],
+ "input_labels": [""],
+ "output": []
+ }
+ }
+]
diff --git a/packages/zarr-indexing/tests/conformance/slice.json b/packages/zarr-indexing/tests/conformance/slice.json
new file mode 100644
index 0000000000..959ebc6247
--- /dev/null
+++ b/packages/zarr-indexing/tests/conformance/slice.json
@@ -0,0 +1,156 @@
+[
+ {
+ "name": "slice/unit-step-preserves-frame",
+ "input": { "kind": "slice", "start": [5], "stop": [10] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [5], "input_exclusive_max": [10],
+ "input_labels": [""],
+ "output": [ { "offset": 0, "stride": 1, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/divisible-stride",
+ "input": { "kind": "slice", "start": [4], "stop": [10], "step": [2] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [2], "input_exclusive_max": [5],
+ "input_labels": [""],
+ "output": [ { "offset": 0, "stride": 2, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/nondivisible-stride-phase-offset",
+ "input": { "kind": "slice", "start": [5], "stop": [10], "step": [2] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [2], "input_exclusive_max": [5],
+ "input_labels": [""],
+ "output": [ { "offset": 1, "stride": 2, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/2d-mixed-step",
+ "input": { "kind": "slice", "start": [0, 5], "stop": [10, 10], "step": [2, 1] },
+ "normalized": {
+ "input_rank": 2,
+ "input_inclusive_min": [0, 5],
+ "input_exclusive_max": [5, 10],
+ "input_labels": ["", ""],
+ "output": [
+ { "offset": 0, "stride": 2, "input_dimension": 0 },
+ { "offset": 0, "stride": 1, "input_dimension": 1 }
+ ]
+ }
+ },
+ {
+ "name": "slice/negative-start-trunc-origin",
+ "input": { "kind": "slice", "start": [-9], "stop": [5], "step": [2] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [-4], "input_exclusive_max": [3],
+ "input_labels": [""],
+ "output": [ { "offset": -1, "stride": 2, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/negative-start-trunc-origin-step3",
+ "input": { "kind": "slice", "start": [-8], "stop": [6], "step": [3] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [-2], "input_exclusive_max": [3],
+ "input_labels": [""],
+ "output": [ { "offset": -2, "stride": 3, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/negative-step-full-reverse",
+ "input": { "kind": "slice", "start": [19], "stop": [-1], "step": [-1] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [-19], "input_exclusive_max": [1],
+ "input_labels": [""],
+ "output": [ { "offset": 0, "stride": -1, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/negative-step-divisible-span",
+ "input": { "kind": "slice", "start": [15], "stop": [5], "step": [-2] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [-7], "input_exclusive_max": [-2],
+ "input_labels": [""],
+ "output": [ { "offset": 1, "stride": -2, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/negative-step-nondivisible-span",
+ "input": { "kind": "slice", "start": [15], "stop": [5], "step": [-4] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [-3], "input_exclusive_max": [0],
+ "input_labels": [""],
+ "output": [ { "offset": 3, "stride": -4, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/negative-step-down-to-zero",
+ "input": { "kind": "slice", "start": [9], "stop": [0], "step": [-2] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [-4], "input_exclusive_max": [1],
+ "input_labels": [""],
+ "output": [ { "offset": 1, "stride": -2, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/negative-step-negative-interval",
+ "input": { "kind": "slice", "start": [-1], "stop": [-6], "step": [-2] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [0], "input_exclusive_max": [3],
+ "input_labels": [""],
+ "output": [ { "offset": -1, "stride": -2, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/negative-step-negative-interval-step3",
+ "input": { "kind": "slice", "start": [-2], "stop": [-9], "step": [-3] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [0], "input_exclusive_max": [3],
+ "input_labels": [""],
+ "output": [ { "offset": -2, "stride": -3, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/negative-step-single-point",
+ "input": { "kind": "slice", "start": [5], "stop": [4], "step": [-3] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [-1], "input_exclusive_max": [0],
+ "input_labels": [""],
+ "output": [ { "offset": 2, "stride": -3, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/negative-step-empty",
+ "input": { "kind": "slice", "start": [5], "stop": [5], "step": [-1] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [-5], "input_exclusive_max": [-5],
+ "input_labels": [""],
+ "output": [ { "offset": 0, "stride": -1, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/negative-step-keeps-labels",
+ "input": { "kind": "slice", "start": [19], "stop": [-1], "step": [-1], "labels": ["x"] },
+ "normalized": {
+ "input_rank": 1, "input_inclusive_min": [-19], "input_exclusive_max": [1],
+ "input_labels": ["x"],
+ "output": [ { "offset": 0, "stride": -1, "input_dimension": 0 } ]
+ }
+ },
+ {
+ "name": "slice/2d-mixed-sign-step",
+ "input": { "kind": "slice", "start": [19, 0], "stop": [-1, 10], "step": [-1, 2] },
+ "normalized": {
+ "input_rank": 2,
+ "input_inclusive_min": [-19, 0],
+ "input_exclusive_max": [1, 5],
+ "input_labels": ["", ""],
+ "output": [
+ { "offset": 0, "stride": -1, "input_dimension": 0 },
+ { "offset": 0, "stride": 2, "input_dimension": 1 }
+ ]
+ }
+ }
+]
diff --git a/packages/zarr-indexing/tests/conformance/transform.json b/packages/zarr-indexing/tests/conformance/transform.json
new file mode 100644
index 0000000000..3d12fe3352
--- /dev/null
+++ b/packages/zarr-indexing/tests/conformance/transform.json
@@ -0,0 +1,94 @@
+[
+ {
+ "name": "transform/omitted-output-identity",
+ "input": { "kind": "transform", "input_inclusive_min": [0, 0], "input_exclusive_max": [3, 4] },
+ "normalized": {
+ "input_rank": 2, "input_inclusive_min": [0, 0], "input_exclusive_max": [3, 4],
+ "input_labels": ["", ""],
+ "output": [
+ { "offset": 0, "stride": 1, "input_dimension": 0 },
+ { "offset": 0, "stride": 1, "input_dimension": 1 }
+ ]
+ }
+ },
+ {
+ "name": "transform/implicit-bounds-and-labels",
+ "input": {
+ "kind": "transform",
+ "input_inclusive_min": [["-inf"], 7],
+ "input_exclusive_max": [["+inf"], 11],
+ "input_labels": ["x", "y"]
+ },
+ "normalized": {
+ "input_rank": 2,
+ "input_inclusive_min": [["-inf"], 7],
+ "input_exclusive_max": [["+inf"], 11],
+ "input_labels": ["x", "y"],
+ "output": [
+ { "offset": 0, "stride": 1, "input_dimension": 0 },
+ { "offset": 0, "stride": 1, "input_dimension": 1 }
+ ]
+ }
+ },
+ {
+ "name": "transform/explicit-output-all-three-map-kinds",
+ "input": {
+ "kind": "transform",
+ "input_inclusive_min": [0],
+ "input_exclusive_max": [3],
+ "output": [
+ { "offset": 7 },
+ { "input_dimension": 0, "stride": 2 },
+ { "index_array": [1, 2, 3] }
+ ]
+ },
+ "normalized": {
+ "input_rank": 1,
+ "input_inclusive_min": [0],
+ "input_exclusive_max": [3],
+ "input_labels": [""],
+ "output": [
+ { "offset": 7 },
+ { "offset": 0, "stride": 2, "input_dimension": 0 },
+ { "offset": 0, "stride": 1, "index_array": [1, 2, 3], "index_array_bounds": ["-inf", "+inf"] }
+ ]
+ }
+ },
+ {
+ "name": "transform/empty-index-array-carried-verbatim",
+ "input": {
+ "kind": "transform",
+ "input_inclusive_min": [0, 0],
+ "input_exclusive_max": [0, 3],
+ "output": [{ "index_array": [] }, { "input_dimension": 1 }]
+ },
+ "normalized": {
+ "input_rank": 2, "input_inclusive_min": [0, 0], "input_exclusive_max": [0, 3],
+ "input_labels": ["", ""],
+ "output": [
+ { "offset": 0, "stride": 1, "index_array": [], "index_array_bounds": ["-inf", "+inf"] },
+ { "offset": 0, "stride": 1, "input_dimension": 1 }
+ ]
+ }
+ },
+ {
+ "name": "transform/empty-index-array-is-idempotent",
+ "input": {
+ "input_rank": 2, "input_inclusive_min": [0, 0], "input_exclusive_max": [0, 3],
+ "input_labels": ["", ""],
+ "kind": "transform",
+ "output": [
+ { "offset": 0, "stride": 1, "index_array": [], "index_array_bounds": ["-inf", "+inf"] },
+ { "offset": 0, "stride": 1, "input_dimension": 1 }
+ ]
+ },
+ "normalized": {
+ "input_rank": 2, "input_inclusive_min": [0, 0], "input_exclusive_max": [0, 3],
+ "input_labels": ["", ""],
+ "output": [
+ { "offset": 0, "stride": 1, "index_array": [], "index_array_bounds": ["-inf", "+inf"] },
+ { "offset": 0, "stride": 1, "input_dimension": 1 }
+ ]
+ }
+ }
+]
diff --git a/packages/zarr-indexing/tests/test_chunk_resolution.py b/packages/zarr-indexing/tests/test_chunk_resolution.py
new file mode 100644
index 0000000000..fbd779f463
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_chunk_resolution.py
@@ -0,0 +1,598 @@
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+import pytest
+from hypothesis import assume, given
+from hypothesis import strategies as st
+
+import zarr_indexing
+from zarr_indexing import (
+ ChunkGrid,
+ ChunkPlan,
+ ChunkProjection,
+ FixedDimension,
+ VaryingDimension,
+ chunk_resolution,
+ plan_chunks,
+)
+from zarr_indexing.domain import IndexDomain
+from zarr_indexing.grid import dimension_grids_from_chunks
+from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap
+from zarr_indexing.transform import IndexTransform
+
+
+def _storage_of(transform: IndexTransform, point: tuple[int, ...]) -> tuple[int, ...]:
+ """Evaluate the three map forms at one point, independently of planning."""
+ result: list[int] = []
+ for output_map in transform.output:
+ if isinstance(output_map, ConstantMap):
+ result.append(output_map.offset)
+ elif isinstance(output_map, DimensionMap):
+ result.append(output_map.offset + output_map.stride * point[output_map.input_dimension])
+ else:
+ index = tuple(
+ 0
+ if output_map.index_array.shape[axis] == 1
+ else point[axis] - transform.domain.inclusive_min[axis]
+ for axis in range(output_map.index_array.ndim)
+ )
+ result.append(
+ output_map.offset + output_map.stride * int(output_map.index_array[index])
+ )
+ return tuple(result)
+
+
+def _points(domain: IndexDomain) -> list[tuple[int, ...]]:
+ """Enumerate a small finite domain in its own coordinates."""
+ return [
+ tuple(
+ coordinate + origin
+ for coordinate, origin in zip(position, domain.inclusive_min, strict=True)
+ )
+ for position in np.ndindex(*domain.shape)
+ ]
+
+
+def _count_intersect_calls(monkeypatch: pytest.MonkeyPatch) -> dict[str, int]:
+ """Count real intersections to protect touched-only candidate enumeration."""
+ calls = {"n": 0}
+ original = IndexTransform.intersect
+
+ def counting(self: IndexTransform, output_domain: IndexDomain) -> object:
+ calls["n"] += 1
+ return original(self, output_domain)
+
+ monkeypatch.setattr(IndexTransform, "intersect", counting)
+ return calls
+
+
+def test_basic_plan_is_reiterable_and_projects_both_spaces() -> None:
+ """A plan can be revisited without losing either side of each projection."""
+ transform = IndexTransform.from_shape((6,))[1:6]
+ grids = dimension_grids_from_chunks((3,), (6,))
+
+ plan = plan_chunks(transform, grids)
+ first = list(plan)
+ second = list(plan.projections())
+
+ assert isinstance(plan, ChunkPlan)
+ assert all(isinstance(projection, ChunkProjection) for projection in first)
+ assert [projection.chunk_coords for projection in first] == [(0,), (1,)]
+ assert [projection.chunk_domain for projection in first] == [
+ IndexDomain((0,), (3,)),
+ IndexDomain((3,), (6,)),
+ ]
+ assert [projection.coverage for projection in first] == ["partial", "full"]
+ assert first == second
+ assert all(
+ projection.chunk_transform.domain == projection.cell_transform.domain
+ for projection in first
+ )
+ assert all(projection.chunk_transform.domain.origin == (0,) for projection in first)
+
+
+def test_projection_requires_one_shared_synthetic_domain() -> None:
+ """Paired transforms with different cell domains are rejected as incoherent."""
+ with pytest.raises(ValueError, match="must share an input domain"):
+ ChunkProjection(
+ chunk_coords=(0,),
+ chunk_domain=IndexDomain.from_shape((3,)),
+ chunk_transform=IndexTransform.identity(IndexDomain.from_shape((2,))),
+ cell_transform=IndexTransform.identity(IndexDomain.from_shape((1,))),
+ coverage="partial",
+ )
+
+
+def test_projection_plan_is_the_only_public_chunk_resolution_surface() -> None:
+ """The greenfield API does not retain tuple or NumPy-selector bridges."""
+ assert {"ChunkCoverage", "ChunkPlan", "ChunkProjection", "plan_chunks"} <= set(
+ zarr_indexing.__all__
+ )
+ assert "iter_chunk_transforms" not in zarr_indexing.__all__
+ assert "sub_transform_to_selections" not in zarr_indexing.__all__
+
+
+def test_plan_rejects_grid_rank_different_from_transform_output_rank() -> None:
+ """A missing storage grid dimension is rejected before iteration."""
+ transform = IndexTransform.from_shape((2, 3))
+
+ with pytest.raises(ValueError, match="1 grids for output rank 2"):
+ plan_chunks(transform, dimension_grids_from_chunks((2,), (2,)))
+
+
+@pytest.mark.parametrize(
+ ("transform", "expected"),
+ [
+ (IndexTransform.from_shape((5,)), ["full", "full"]),
+ (IndexTransform.from_shape((5,))[::-1], ["full", "full"]),
+ (IndexTransform.from_shape((5,))[::2], ["partial", "partial"]),
+ (IndexTransform.from_shape((5,))[2], ["partial"]),
+ (
+ IndexTransform.from_shape((5,)).oindex[np.array([0, 1, 2, 3, 4])],
+ ["unknown", "unknown"],
+ ),
+ ],
+ ids=["clipped-edge", "reverse", "strided", "scalar", "fancy-is-conservative"],
+)
+def test_coverage_classification(transform: IndexTransform, expected: list[str]) -> None:
+ """Coverage is exact for affine requests and conservative for gathers."""
+ grids = dimension_grids_from_chunks((3,), (5,))
+
+ assert [projection.coverage for projection in plan_chunks(transform, grids)] == expected
+
+
+def test_repeated_input_dependency_is_not_full_coverage() -> None:
+ transform = IndexTransform(
+ domain=IndexDomain.from_shape((2,)),
+ output=(DimensionMap(input_dimension=0), DimensionMap(input_dimension=0)),
+ )
+ grids = dimension_grids_from_chunks((2, 2), (2, 2))
+
+ assert [projection.coverage for projection in plan_chunks(transform, grids)] == ["partial"]
+
+
+def test_unused_input_axis_is_not_full_coverage() -> None:
+ transform = IndexTransform(
+ domain=IndexDomain.from_shape((2, 2)),
+ output=(DimensionMap(input_dimension=0),),
+ )
+ grids = dimension_grids_from_chunks((2,), (2,))
+
+ assert [projection.coverage for projection in plan_chunks(transform, grids)] == ["partial"]
+
+
+@pytest.mark.parametrize(
+ ("transform", "grids"),
+ [
+ (
+ IndexTransform.from_shape((2, 3)),
+ dimension_grids_from_chunks((2, 3), (2, 3)),
+ ),
+ (
+ IndexTransform(
+ domain=IndexDomain.from_shape((2, 3)),
+ output=(DimensionMap(input_dimension=1), DimensionMap(input_dimension=0)),
+ ),
+ dimension_grids_from_chunks((3, 2), (3, 2)),
+ ),
+ (
+ IndexTransform.from_shape((2, 3))[::-1, ::-1],
+ dimension_grids_from_chunks((2, 3), (2, 3)),
+ ),
+ (
+ IndexTransform(
+ domain=IndexDomain((4, 7), (6, 10)),
+ output=(
+ DimensionMap(input_dimension=0, offset=-4),
+ DimensionMap(input_dimension=1, offset=-7),
+ ),
+ ),
+ dimension_grids_from_chunks((2, 3), (2, 3)),
+ ),
+ ],
+ ids=["identity", "axis-permutation", "reversal", "translated-unit-affine"],
+)
+def test_bijective_unit_affine_transforms_retain_full_coverage(
+ transform: IndexTransform, grids: tuple[Any, ...]
+) -> None:
+ assert [projection.coverage for projection in plan_chunks(transform, grids)] == ["full"]
+
+
+def test_rank_zero_transform_has_full_coverage() -> None:
+ transform = IndexTransform.identity(IndexDomain((), ()))
+
+ assert [projection.coverage for projection in plan_chunks(transform, ())] == ["full"]
+
+
+@pytest.mark.parametrize(
+ ("transform", "grids", "expected_coords"),
+ [
+ (
+ IndexTransform.from_shape((30,)),
+ dimension_grids_from_chunks((10,), (30,)),
+ [(0,), (1,), (2,)],
+ ),
+ (
+ IndexTransform.from_shape((20, 30)),
+ dimension_grids_from_chunks((10, 10), (20, 30)),
+ [(i, j) for i in range(2) for j in range(3)],
+ ),
+ (
+ IndexTransform.from_shape((100, 100))[25, :],
+ dimension_grids_from_chunks((10, 10), (100, 100)),
+ [(2, j) for j in range(10)],
+ ),
+ (
+ IndexTransform.from_shape((100,))[8:15],
+ dimension_grids_from_chunks((10,), (100,)),
+ [(0,), (1,)],
+ ),
+ ],
+ ids=["one-dimensional", "two-dimensional", "constant-map", "slice"],
+)
+def test_affine_plans_touch_the_expected_chunks(
+ transform: IndexTransform,
+ grids: tuple[Any, ...],
+ expected_coords: list[tuple[int, ...]],
+) -> None:
+ """Identity, constant, and sliced transforms enumerate literal grid cells."""
+ assert [projection.chunk_coords for projection in plan_chunks(transform, grids)] == (
+ expected_coords
+ )
+
+
+@pytest.mark.parametrize(
+ ("transform", "grids"),
+ [
+ (
+ IndexTransform.from_shape((6,)).oindex[np.array([4, 0, 4, 2])],
+ dimension_grids_from_chunks((3,), (6,)),
+ ),
+ (
+ IndexTransform.from_shape((4, 5)).oindex[np.array([3, 0]), np.array([4, 1, 1])],
+ dimension_grids_from_chunks(((1, 3), (2, 3)), (4, 5)),
+ ),
+ (
+ IndexTransform.from_shape((2, 4, 5)).vindex[
+ ..., np.array([3, 0, 3]), np.array([4, 1, 1])
+ ],
+ dimension_grids_from_chunks((1, 2, 3), (2, 4, 5)),
+ ),
+ ],
+ ids=["repeated-oindex", "irregular-oindex", "vindex-with-residual"],
+)
+def test_projection_invariants_for_fancy_selections(
+ transform: IndexTransform, grids: tuple[Any, ...]
+) -> None:
+ """Both transforms agree pointwise and cell ranges tile request space once."""
+ plan = plan_chunks(transform, grids)
+ request_points: list[tuple[int, ...]] = []
+
+ for projection in plan:
+ assert projection.coverage == "unknown"
+ assert projection.chunk_transform.domain == projection.cell_transform.domain
+ for cell_point in _points(projection.cell_transform.domain):
+ request_point = _storage_of(projection.cell_transform, cell_point)
+ chunk_point = _storage_of(projection.chunk_transform, cell_point)
+ storage_point = _storage_of(plan.transform, request_point)
+ chunk_origin = projection.chunk_domain.inclusive_min
+ assert chunk_point == tuple(
+ value - origin for value, origin in zip(storage_point, chunk_origin, strict=True)
+ )
+ assert all(
+ 0 <= value < extent
+ for value, extent in zip(chunk_point, projection.chunk_domain.shape, strict=True)
+ )
+ request_points.append(request_point)
+
+ assert sorted(request_points) == sorted(_points(transform.domain))
+
+
+@pytest.mark.parametrize(
+ "grid",
+ [
+ pytest.param(FixedDimension(size=2, extent=4), id="fixed"),
+ pytest.param(VaryingDimension(edges=(1, 3), extent=4), id="varying"),
+ ],
+)
+def test_orthogonal_array_map_plan_rejects_coordinate_below_grid(grid: Any) -> None:
+ transform = IndexTransform(
+ domain=IndexDomain.from_shape((2,)),
+ output=(ArrayMap(np.array([-1, 1], dtype=np.intp)),),
+ )
+
+ # The sorted 1-D fast path reports the first offending coordinate.
+ with pytest.raises(IndexError, match=r"index -1 is out of bounds"):
+ list(plan_chunks(transform, (grid,)))
+
+
+@pytest.mark.parametrize(
+ "grid",
+ [
+ pytest.param(FixedDimension(size=2, extent=4), id="fixed"),
+ pytest.param(VaryingDimension(edges=(1, 3), extent=4), id="varying"),
+ ],
+)
+def test_orthogonal_array_map_plan_rejects_coordinate_above_grid(grid: Any) -> None:
+ transform = IndexTransform(
+ domain=IndexDomain.from_shape((2,)),
+ output=(ArrayMap(np.array([1, 4], dtype=np.intp)),),
+ )
+
+ # The sorted 1-D fast path reports the first offending coordinate.
+ with pytest.raises(IndexError, match=r"index 4 is out of bounds"):
+ list(plan_chunks(transform, (grid,)))
+
+
+def test_nonempty_identity_plan_rejects_zero_size_fixed_dimension() -> None:
+ transform = IndexTransform.from_shape((4,))
+
+ with pytest.raises(ValueError, match="size must be > 0 when extent is nonzero"):
+ list(plan_chunks(transform, (FixedDimension(size=0, extent=4),)))
+
+
+@given(
+ origin=st.integers(min_value=-4, max_value=4),
+ extent=st.integers(min_value=0, max_value=8),
+ stride=st.integers(min_value=-3, max_value=3),
+)
+def test_affine_projection_pairs_reconstruct_independent_source_coordinates(
+ origin: int, extent: int, stride: int
+) -> None:
+ """Bounded literal-domain examples preserve every request/storage pair."""
+ anchor = extent - 1 if stride < 0 else 0
+ offset = anchor - stride * origin
+ expected_pairs = [
+ ((coordinate,), (source_coordinate,))
+ for coordinate in range(origin, origin + extent)
+ if 0 <= (source_coordinate := offset + stride * coordinate) < extent
+ ]
+ assume(expected_pairs)
+
+ unrestricted = IndexTransform(
+ domain=IndexDomain((origin,), (origin + extent,)),
+ output=(DimensionMap(input_dimension=0, offset=offset, stride=stride),),
+ )
+ intersection = unrestricted.intersect(IndexDomain.from_shape((extent,)))
+ assume(intersection is not None)
+ transform, _ = intersection
+ grids = dimension_grids_from_chunks((min(3, extent),), (extent,))
+
+ reconstructed_pairs = [
+ (
+ _storage_of(projection.cell_transform, cell_coordinate),
+ tuple(
+ local_coordinate + chunk_origin
+ for local_coordinate, chunk_origin in zip(
+ _storage_of(projection.chunk_transform, cell_coordinate),
+ projection.chunk_domain.inclusive_min,
+ strict=True,
+ )
+ ),
+ )
+ for projection in plan_chunks(transform, grids)
+ for cell_coordinate in _points(projection.cell_transform.domain)
+ ]
+
+ assert sorted(reconstructed_pairs) == sorted(expected_pairs)
+
+
+def test_correlated_projection_preserves_nonzero_request_coordinates() -> None:
+ base = IndexTransform.identity(IndexDomain((2, 5), (4, 8)))
+ transform = base.vindex[np.array([2, 3], dtype=np.intp), :]
+ grids = dimension_grids_from_chunks((2, 4), (4, 8))
+
+ points = [
+ transform.apply(projection.cell_transform.apply(cell))
+ for projection in plan_chunks(transform, grids)
+ for cell in _points(projection.cell_transform.domain)
+ ]
+
+ assert sorted(points) == [(2, 5), (2, 6), (2, 7), (3, 5), (3, 6), (3, 7)]
+
+
+def test_correlated_projection_preserves_translated_advanced_axis_coordinates() -> None:
+ transform = (
+ IndexTransform.from_shape((4,))
+ .vindex[np.array([0, 3], dtype=np.intp)]
+ .translate_domain_by((5,))
+ )
+ grids = dimension_grids_from_chunks((2,), (4,))
+
+ request_points = [
+ projection.cell_transform.apply(cell)
+ for projection in plan_chunks(transform, grids)
+ for cell in _points(projection.cell_transform.domain)
+ ]
+
+ assert sorted(request_points) == [(5,), (6,)]
+
+
+def test_empty_request_has_no_projections() -> None:
+ """An empty fancy selection does not fabricate a touched chunk."""
+ transform = IndexTransform.from_shape((10,)).oindex[np.array([], dtype=np.intp)]
+ grids = dimension_grids_from_chunks((3,), (10,))
+
+ assert list(plan_chunks(transform, grids)) == []
+
+
+class TestSortedOneDimensionalPlan:
+ def test_matches_general_resolution_for_randomized_selections(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """The direct sorted path has the same paired transforms as intersection."""
+ rng = np.random.default_rng(0)
+ grids = (
+ ChunkGrid(dimensions=(FixedDimension(size=7, extent=30),)),
+ ChunkGrid(dimensions=(VaryingDimension(edges=(3, 4, 8, 5, 10), extent=30),)),
+ )
+ for grid in grids:
+ for _ in range(50):
+ indices = np.sort(rng.integers(0, 30, size=int(rng.integers(1, 80)))).astype(
+ np.intp
+ )
+ transform = IndexTransform.from_shape((30,)).vindex[indices]
+ direct = list(plan_chunks(transform, grid.dimensions))
+ with monkeypatch.context() as context:
+ context.setattr(
+ chunk_resolution,
+ "_one_dimensional_array_map",
+ lambda _transform: None,
+ )
+ general = list(plan_chunks(transform, grid.dimensions))
+ assert direct == general
+
+ def test_sorted_coordinates_bypass_intersection(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Sorted coordinates partition directly at touched chunk boundaries."""
+ transform = IndexTransform.from_shape((12,)).vindex[
+ np.array([0, 3, 4, 4, 9, 11], dtype=np.intp)
+ ]
+ grid = ChunkGrid(dimensions=(FixedDimension(size=4, extent=12),))
+ calls = _count_intersect_calls(monkeypatch)
+
+ projections = list(plan_chunks(transform, grid.dimensions))
+
+ assert [projection.chunk_coords for projection in projections] == [(0,), (1,), (2,)]
+ assert calls["n"] == 0
+ assert [
+ [
+ _storage_of(projection.cell_transform, point)[0]
+ for point in _points(projection.cell_transform.domain)
+ ]
+ for projection in projections
+ ] == [[0, 1], [2, 3], [4, 5]]
+
+ def test_unsorted_coordinates_use_intersection(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Unsorted coordinates retain the general intersection path."""
+ transform = IndexTransform.from_shape((12,)).vindex[np.array([9, 0, 4], dtype=np.intp)]
+ grid = ChunkGrid(dimensions=(FixedDimension(size=4, extent=12),))
+ calls = _count_intersect_calls(monkeypatch)
+
+ projections = list(plan_chunks(transform, grid.dimensions))
+
+ assert [projection.chunk_coords for projection in projections] == [(0,), (1,), (2,)]
+ assert calls["n"] == 3
+
+
+class CountingUnitGrid:
+ """A real unit grid that counts every planner-grid operation."""
+
+ def __init__(self, extent: int) -> None:
+ self._grid = FixedDimension(size=1, extent=extent)
+ self.calls = 0
+
+ def index_to_chunk(self, idx: int) -> int:
+ self.calls += 1
+ return self._grid.index_to_chunk(idx)
+
+ def chunk_offset(self, chunk_ix: int) -> int:
+ self.calls += 1
+ return self._grid.chunk_offset(chunk_ix)
+
+ def chunk_size(self, chunk_ix: int) -> int:
+ self.calls += 1
+ return self._grid.chunk_size(chunk_ix)
+
+ def indices_to_chunks(
+ self, indices: np.ndarray[Any, np.dtype[np.intp]]
+ ) -> np.ndarray[Any, np.dtype[np.intp]]:
+ self.calls += 1
+ return self._grid.indices_to_chunks(indices)
+
+
+def test_sparse_affine_plan_does_not_visit_intervening_chunks() -> None:
+ grid = CountingUnitGrid(extent=100_001)
+ transform = IndexTransform.from_shape((100_001,))[::100_000]
+
+ assert [projection.chunk_coords for projection in plan_chunks(transform, (grid,))] == [
+ (0,),
+ (100_000,),
+ ]
+ assert grid.calls <= 12
+
+
+def test_sparse_affine_plan_handles_large_origin_cancellation() -> None:
+ origin = int(np.iinfo(np.intp).max)
+ transform = IndexTransform(
+ domain=IndexDomain((origin,), (origin + 2,)),
+ output=(DimensionMap(input_dimension=0, offset=-2 * origin, stride=2),),
+ )
+ grids = dimension_grids_from_chunks((1,), (3,))
+
+ assert [projection.chunk_coords for projection in plan_chunks(transform, grids)] == [
+ (0,),
+ (2,),
+ ]
+
+
+class TestTouchedOnlyCandidateEnumeration:
+ def test_sparse_one_dimensional_selection_skips_the_dense_span(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """Two sorted points on a 1000-cell grid require no intersections."""
+ transform = IndexTransform.from_shape((4000,)).vindex[np.array([1, 3997], dtype=np.intp)]
+ grid = ChunkGrid(dimensions=(FixedDimension(size=4, extent=4000),))
+ calls = _count_intersect_calls(monkeypatch)
+
+ projections = list(plan_chunks(transform, grid.dimensions))
+
+ assert [projection.chunk_coords for projection in projections] == [(0,), (999,)]
+ assert calls["n"] == 0
+
+ @pytest.mark.parametrize(
+ ("mode", "expected_coords", "expected_calls"),
+ [
+ ("orthogonal", [(0, 0), (0, 999), (999, 0), (999, 999)], 4),
+ ("correlated", [(0, 0), (999, 999)], 2),
+ ],
+ )
+ def test_sparse_two_dimensional_selection_uses_only_touched_combinations(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ mode: str,
+ expected_coords: list[tuple[int, int]],
+ expected_calls: int,
+ ) -> None:
+ """Orthogonal points use their outer product; correlated points remain paired."""
+ base = IndexTransform.from_shape((4000, 4000))
+ first = np.array([1, 3997], dtype=np.intp)
+ second = np.array([2, 3998], dtype=np.intp)
+ transform = (
+ base.oindex[first, second] if mode == "orthogonal" else base.vindex[first, second]
+ )
+ grid = ChunkGrid(
+ dimensions=(
+ FixedDimension(size=4, extent=4000),
+ FixedDimension(size=4, extent=4000),
+ )
+ )
+ calls = _count_intersect_calls(monkeypatch)
+
+ projections = list(plan_chunks(transform, grid.dimensions))
+
+ assert sorted(projection.chunk_coords for projection in projections) == expected_coords
+ assert calls["n"] == expected_calls
+
+ def test_correlated_diagonal_scales_with_points_not_their_product(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ """Fifty diagonal points require fifty, rather than 2500, intersections."""
+ n_points = 50
+ coordinates = np.arange(n_points, dtype=np.intp) * 8
+ transform = IndexTransform.from_shape((4000, 4000)).vindex[coordinates, coordinates]
+ grid = ChunkGrid(
+ dimensions=(
+ FixedDimension(size=4, extent=4000),
+ FixedDimension(size=4, extent=4000),
+ )
+ )
+ calls = _count_intersect_calls(monkeypatch)
+
+ projections = list(plan_chunks(transform, grid.dimensions))
+
+ assert sorted(projection.chunk_coords for projection in projections) == [
+ (2 * index, 2 * index) for index in range(n_points)
+ ]
+ assert calls["n"] == n_points
diff --git a/packages/zarr-indexing/tests/test_composition.py b/packages/zarr-indexing/tests/test_composition.py
new file mode 100644
index 0000000000..0cb6155344
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_composition.py
@@ -0,0 +1,474 @@
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from zarr_indexing.domain import IndexDomain
+from zarr_indexing.errors import BoundsCheckError
+from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap
+from zarr_indexing.transform import IndexTransform
+
+
+class TestComposeConstantInner:
+ """Inner = constant. Result is always constant."""
+
+ def test_constant_inner_any_outer(self) -> None:
+ outer = IndexTransform.from_shape((5,))
+ inner = IndexTransform(
+ domain=IndexDomain.from_shape((5,)),
+ output=(ConstantMap(offset=42),),
+ )
+ result = outer.compose(inner)
+ assert isinstance(result.output[0], ConstantMap)
+ assert result.output[0].offset == 42
+
+
+class TestComposeDimensionInner:
+ """Inner = DimensionMap."""
+
+ def test_dimension_inner_constant_outer(self) -> None:
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((10,)),
+ output=(ConstantMap(offset=5),),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain.from_shape((10,)),
+ output=(DimensionMap(input_dimension=0, offset=10, stride=3),),
+ )
+ result = outer.compose(inner)
+ assert isinstance(result.output[0], ConstantMap)
+ assert result.output[0].offset == 25
+
+ def test_dimension_inner_constant_outer_rejects_affine_overflow(self) -> None:
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((1,)),
+ output=(ConstantMap(offset=2**62),),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain((2**62,), (2**62 + 1,)),
+ output=(DimensionMap(input_dimension=0, stride=4),),
+ )
+
+ with pytest.raises(OverflowError, match="outside np.intp"):
+ outer.compose(inner)
+
+ def test_dimension_inner_dimension_outer(self) -> None:
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(DimensionMap(input_dimension=0, offset=5, stride=2),),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain.from_shape((10,)),
+ output=(DimensionMap(input_dimension=0, offset=10, stride=3),),
+ )
+ result = outer.compose(inner)
+ assert isinstance(result.output[0], DimensionMap)
+ assert result.output[0].offset == 25
+ assert result.output[0].stride == 6
+ assert result.output[0].input_dimension == 0
+
+ def test_dimension_inner_array_outer(self) -> None:
+ arr = np.array([0, 1, 2], dtype=np.intp)
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(ArrayMap(index_array=arr, offset=5, stride=2),),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain.from_shape((10,)),
+ output=(DimensionMap(input_dimension=0, offset=10, stride=3),),
+ )
+ result = outer.compose(inner)
+ assert isinstance(result.output[0], ArrayMap)
+ assert result.output[0].offset == 25
+ assert result.output[0].stride == 6
+ np.testing.assert_array_equal(result.output[0].index_array, arr)
+
+
+class TestComposeArrayInner:
+ """Inner = ArrayMap."""
+
+ def test_array_inner_constant_outer(self) -> None:
+ inner_arr = np.array([10, 20, 30], dtype=np.intp)
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((5,)),
+ output=(ConstantMap(offset=1),),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(ArrayMap(index_array=inner_arr, offset=0, stride=1),),
+ )
+ result = outer.compose(inner)
+ assert isinstance(result.output[0], ConstantMap)
+ assert result.output[0].offset == 20
+
+ def test_array_inner_constant_outer_rejects_affine_overflow(self) -> None:
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((1,)),
+ output=(ConstantMap(offset=0),),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain.from_shape((1,)),
+ output=(ArrayMap(np.array([2**62], dtype=np.intp), stride=4),),
+ )
+
+ with pytest.raises(OverflowError, match="outside np.intp"):
+ outer.compose(inner)
+
+ def test_array_inner_array_outer(self) -> None:
+ outer_arr = np.array([0, 2, 1], dtype=np.intp)
+ inner_arr = np.array([10, 20, 30], dtype=np.intp)
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(ArrayMap(index_array=outer_arr, offset=0, stride=1),),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(ArrayMap(index_array=inner_arr, offset=0, stride=1),),
+ )
+ result = outer.compose(inner)
+ assert isinstance(result.output[0], ArrayMap)
+ expected = np.array([10, 30, 20], dtype=np.intp)
+ np.testing.assert_array_equal(result.output[0].index_array, expected)
+
+
+def _storage_of(transform: IndexTransform, point: tuple[int, ...]) -> tuple[int, ...]:
+ """The storage coordinates a transform assigns to one input point.
+
+ The point is given in the transform's own domain coordinates; an index array
+ carries the full input rank, singleton on the axes it does not vary over, and
+ is addressed positionally from the domain origin.
+ """
+ coords: list[int] = []
+ for m in transform.output:
+ if isinstance(m, ConstantMap):
+ coords.append(m.offset)
+ elif isinstance(m, DimensionMap):
+ coords.append(m.offset + m.stride * point[m.input_dimension])
+ else:
+ origin = transform.domain.inclusive_min
+ idx = tuple(
+ 0 if m.index_array.shape[axis] == 1 else point[axis] - origin[axis]
+ for axis in range(m.index_array.ndim)
+ )
+ coords.append(m.offset + m.stride * int(m.index_array[idx]))
+ return tuple(coords)
+
+
+def _assert_composes_pointwise(outer: IndexTransform, inner: IndexTransform) -> None:
+ """`outer.compose(inner)` must agree with running the two in sequence."""
+ composed = outer.compose(inner)
+ assert composed.domain == outer.domain
+ lo = outer.domain.inclusive_min
+ hi = outer.domain.exclusive_max
+ for coord in np.ndindex(*outer.domain.shape):
+ point = tuple(int(c) + int(o) for c, o in zip(coord, lo, strict=True))
+ assert all(point[d] < hi[d] for d in range(len(hi)))
+ intermediate = _storage_of(outer, point)
+ assert _storage_of(composed, point) == _storage_of(inner, intermediate)
+
+
+class TestComposeOverANonZeroOriginDomain:
+ """The outer domain need not start at 0 — a step-1 slice preserves its
+ literal bounds and a negative step produces a negative origin — so the inner
+ map has to be evaluated over the outer domain's real range."""
+
+ def test_array_inner_sliced_outer(self) -> None:
+ inner = IndexTransform.from_shape((10,)).oindex[np.array([3, 1, 4, 1, 5])]
+ outer = IndexTransform.identity(IndexDomain.from_shape((5,)))[1:4]
+ assert outer.domain.inclusive_min == (1,)
+ result = outer.compose(inner)
+ assert isinstance(result.output[0], ArrayMap)
+ np.testing.assert_array_equal(
+ result.output[0].index_array, np.array([1, 4, 1], dtype=np.intp)
+ )
+ _assert_composes_pointwise(outer, inner)
+
+ def test_array_inner_reversed_outer(self) -> None:
+ inner = IndexTransform.from_shape((10,)).oindex[np.array([3, 1, 4, 1, 5])]
+ outer = IndexTransform.identity(IndexDomain.from_shape((5,)))[::-1]
+ assert outer.domain.inclusive_min == (-4,)
+ result = outer.compose(inner)
+ assert isinstance(result.output[0], ArrayMap)
+ np.testing.assert_array_equal(
+ result.output[0].index_array, np.array([5, 1, 4, 1, 3], dtype=np.intp)
+ )
+ _assert_composes_pointwise(outer, inner)
+
+ def test_array_inner_strided_outer(self) -> None:
+ inner = IndexTransform.from_shape((10,)).oindex[np.array([3, 1, 4, 1, 5, 9])]
+ outer = IndexTransform.identity(IndexDomain.from_shape((6,)))[1::2]
+ _assert_composes_pointwise(outer, inner)
+
+ def test_array_inner_translated_outer(self) -> None:
+ inner = IndexTransform.from_shape((10,)).oindex[np.array([3, 1, 4, 1, 5])]
+ outer = IndexTransform.identity(IndexDomain.from_shape((5,))).translate_domain_to((-2,))
+ _assert_composes_pointwise(outer, inner)
+
+ def test_array_inner_array_outer_over_a_shifted_domain(self) -> None:
+ inner = IndexTransform.from_shape((10,)).oindex[np.array([3, 1, 4, 1, 5])]
+ outer = IndexTransform.from_shape((5,)).oindex[np.array([4, 0, 2])]
+ _assert_composes_pointwise(outer, inner)
+
+ def test_array_inner_constant_outer_over_a_shifted_domain(self) -> None:
+ inner = IndexTransform.from_shape((10,)).oindex[np.array([3, 1, 4, 1, 5])]
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((4,)),
+ output=(ConstantMap(offset=3),),
+ )
+ _assert_composes_pointwise(outer, inner)
+
+
+class TestComposeMultiDim:
+ def test_2d_identity_compose(self) -> None:
+ a = IndexTransform.from_shape((10, 20))
+ b = IndexTransform.from_shape((10, 20))
+ result = a.compose(b)
+ assert result.domain.shape == (10, 20)
+ for i in range(2):
+ m = result.output[i]
+ assert isinstance(m, DimensionMap)
+ assert m.input_dimension == i
+ assert m.offset == 0
+ assert m.stride == 1
+
+ def test_mixed_map_types(self) -> None:
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((10,)),
+ output=(
+ ConstantMap(offset=5),
+ DimensionMap(input_dimension=0, offset=0, stride=1),
+ ),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain.from_shape((10, 10)),
+ output=(
+ DimensionMap(input_dimension=0, offset=2, stride=3),
+ DimensionMap(input_dimension=1, offset=0, stride=1),
+ ),
+ )
+ result = outer.compose(inner)
+ assert isinstance(result.output[0], ConstantMap)
+ assert result.output[0].offset == 17
+ assert isinstance(result.output[1], DimensionMap)
+ assert result.output[1].input_dimension == 0
+ assert result.output[1].offset == 0
+ assert result.output[1].stride == 1
+
+ def test_rank_mismatch_raises(self) -> None:
+ outer = IndexTransform.from_shape((10,))
+ inner = IndexTransform.from_shape((10, 20))
+ with pytest.raises(ValueError, match="rank"):
+ outer.compose(inner)
+
+
+class TestComposeInnerDomainValidation:
+ @pytest.mark.parametrize(
+ ("outer_map", "inner_lower"),
+ [
+ (ConstantMap(offset=10), 10),
+ (ConstantMap(offset=14), 10),
+ (DimensionMap(input_dimension=0, offset=10, stride=1), 10),
+ (DimensionMap(input_dimension=0, offset=14, stride=-1), 10),
+ (DimensionMap(input_dimension=0, offset=10**100, stride=1), 10**100),
+ ],
+ ids=["lower-bound", "upper-bound", "forward", "reverse", "arbitrary-integer"],
+ )
+ def test_valid_constant_and_affine_boundaries(
+ self, outer_map: ConstantMap | DimensionMap, inner_lower: int
+ ) -> None:
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((5,)),
+ output=(outer_map,),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain((inner_lower,), (inner_lower + 5,)),
+ output=(DimensionMap(input_dimension=0),),
+ )
+
+ result = outer.compose(inner)
+
+ assert result.domain == outer.domain
+ _assert_composes_pointwise(outer, inner)
+
+ def test_rejects_constant_outer_coordinate_outside_inner_domain(self) -> None:
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((1,)),
+ output=(ConstantMap(offset=2),),
+ )
+ inner = IndexTransform.identity(IndexDomain((0,), (2,)))
+
+ with pytest.raises(BoundsCheckError, match="outside.*inner.*domain"):
+ outer.compose(inner)
+
+ def test_rejects_affine_outer_range_crossing_inner_domain(self) -> None:
+ outer = IndexTransform(
+ domain=IndexDomain((10**100,), (10**100 + 3,)),
+ output=(DimensionMap(input_dimension=0),),
+ )
+ inner = IndexTransform.identity(IndexDomain((10**100,), (10**100 + 2,)))
+
+ with pytest.raises(BoundsCheckError, match="outside.*inner.*domain"):
+ outer.compose(inner)
+
+ def test_empty_outer_domain_does_not_evaluate_nonexistent_points(self) -> None:
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((0,)),
+ output=(ConstantMap(offset=99),),
+ )
+ inner = IndexTransform.identity(IndexDomain((0,), (2,)))
+
+ result = outer.compose(inner)
+
+ assert result.domain.shape == (0,)
+ assert result.output == (ConstantMap(offset=99),)
+
+
+class TestComposeMultidimensionalArrayInner:
+ def test_identity_gathers_a_two_dimensional_array_map(self) -> None:
+ inner = IndexTransform(
+ domain=IndexDomain.from_shape((2, 3)),
+ output=(
+ ArrayMap(
+ index_array=np.array([[11, 13, 17], [19, 23, 29]], dtype=np.intp),
+ offset=5,
+ stride=2,
+ ),
+ ),
+ )
+ outer = IndexTransform.identity(inner.domain)
+
+ result = outer.compose(inner)
+
+ assert result == inner
+ _assert_composes_pointwise(outer, inner)
+
+ def test_mixed_affine_and_constant_outputs_gather_with_metadata(self) -> None:
+ inner = IndexTransform(
+ domain=IndexDomain((4, 8), (7, 10)),
+ output=(
+ ArrayMap(
+ index_array=np.array([[2, 3], [5, 7], [11, 13]], dtype=np.intp),
+ offset=-3,
+ stride=4,
+ ),
+ ),
+ )
+ outer = IndexTransform(
+ domain=IndexDomain((-2,), (0,)),
+ output=(
+ DimensionMap(input_dimension=0, offset=7, stride=1),
+ ConstantMap(offset=9),
+ ),
+ )
+
+ result = outer.compose(inner)
+
+ assert result.domain == outer.domain
+ assert len(result.output) == 1
+ array_map = result.output[0]
+ assert isinstance(array_map, ArrayMap)
+ assert array_map.index_array.shape == (2,)
+ assert array_map.offset == -3
+ assert array_map.stride == 4
+ np.testing.assert_array_equal(array_map.index_array, np.array([7, 13], dtype=np.intp))
+ _assert_composes_pointwise(outer, inner)
+
+ def test_out_of_bounds_affine_output_is_rejected_before_gather(self) -> None:
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(
+ DimensionMap(input_dimension=0, offset=1, stride=1),
+ ConstantMap(offset=0),
+ ),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain.from_shape((3, 2)),
+ output=(ArrayMap(np.arange(6, dtype=np.intp).reshape(3, 2)),),
+ )
+
+ with pytest.raises(BoundsCheckError, match="outside.*inner.*domain"):
+ outer.compose(inner)
+
+ def test_rank_zero_array_map_composition(self) -> None:
+ domain = IndexDomain((), ())
+ outer = IndexTransform.identity(domain)
+ inner = IndexTransform(
+ domain=domain,
+ output=(ArrayMap(np.array(7, dtype=np.intp), offset=2, stride=3),),
+ )
+
+ result = outer.compose(inner)
+
+ assert result.domain == domain
+ assert result.output == (ConstantMap(offset=23),)
+
+
+class TestComposeChain:
+ def test_three_transforms(self) -> None:
+ a = IndexTransform.from_shape((100,))
+ b = IndexTransform(
+ domain=IndexDomain.from_shape((100,)),
+ output=(DimensionMap(input_dimension=0, offset=10, stride=1),),
+ )
+ c = IndexTransform(
+ domain=IndexDomain.from_shape((110,)),
+ output=(DimensionMap(input_dimension=0, offset=5, stride=2),),
+ )
+ bc = b.compose(c)
+ abc = a.compose(bc)
+ assert isinstance(abc.output[0], DimensionMap)
+ assert abc.output[0].offset == 25
+ assert abc.output[0].stride == 2
+
+
+def test_composing_an_inner_array_with_a_broadcast_axis_wider_than_one_cell() -> None:
+ """A non-dependency axis is a singleton that broadcasts, so its coordinate is 0.
+
+ Indexing it by the raw intermediate coordinate walked off the end of an axis
+ the array only has one entry for.
+ """
+ outer = IndexTransform(
+ domain=IndexDomain(inclusive_min=(-2,), exclusive_max=(0,)),
+ output=(ConstantMap(offset=1), ConstantMap(offset=0)),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain(inclusive_min=(0, 0), exclusive_max=(2, 1)),
+ output=(ArrayMap(index_array=np.array([[13]], dtype=np.intp), offset=-2, stride=2),),
+ )
+ composed = outer.compose(inner)
+ assert composed.output[0] == ConstantMap(offset=24)
+
+
+def test_composing_a_one_dimensional_inner_array_under_a_higher_rank_outer() -> None:
+ """The shortcut gated on the output rank but sized by the input rank.
+
+ A rank-2 outer therefore built a rank-1 array for a rank-2 domain, which the
+ engine's own invariant then rejected.
+ """
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((2, 3)),
+ output=(DimensionMap(input_dimension=0, offset=1, stride=1),),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain.from_shape((4,)),
+ output=(ArrayMap(index_array=np.array([7, 3, 5, 1], dtype=np.intp)),),
+ )
+ composed = outer.compose(inner)
+ array_map = composed.output[0]
+ assert isinstance(array_map, ArrayMap)
+ assert array_map.index_array.shape == (2, 1)
+ np.testing.assert_array_equal(array_map.index_array, np.array([[3], [5]]))
+
+
+def test_composing_out_of_the_inner_domain_is_refused() -> None:
+ """An intermediate outside the inner domain wrapped NumPy-style and read a cell."""
+ outer = IndexTransform(
+ domain=IndexDomain.from_shape((1,)),
+ output=(ConstantMap(offset=-3),),
+ )
+ inner = IndexTransform(
+ domain=IndexDomain.from_shape((2,)),
+ output=(ArrayMap(index_array=np.array([7, 3], dtype=np.intp)),),
+ )
+ with pytest.raises(BoundsCheckError, match="outside.*inner.*domain"):
+ outer.compose(inner)
diff --git a/packages/zarr-indexing/tests/test_conformance.py b/packages/zarr-indexing/tests/test_conformance.py
new file mode 100644
index 0000000000..207a9d8236
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_conformance.py
@@ -0,0 +1,55 @@
+"""ndsel conformance corpus harness.
+
+Runs the vendored, language-agnostic ndsel fixtures (see
+`tests/conformance/PROVENANCE.md`) against this package's message layer
+(`zarr_indexing.messages`). An implementation is conformant iff:
+
+- for every *success* fixture, `normalize_ndsel(input)` equals the fixture's
+ `normalized` value by structural JSON equality;
+- for every *error* fixture, `normalize_ndsel(input)` is rejected with an
+ `NdselError` carrying the fixture's `error` reason code.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from zarr_indexing.messages import NdselError, normalize_ndsel
+
+_CONFORMANCE_DIR = Path(__file__).parent / "conformance"
+
+
+def _load_cases() -> list[tuple[str, dict[str, Any]]]:
+ cases: list[tuple[str, dict[str, Any]]] = []
+ for path in sorted(_CONFORMANCE_DIR.glob("*.json")):
+ data = json.loads(path.read_text())
+ cases.extend((f"{path.stem}::{case['name']}", case) for case in data)
+ return cases
+
+
+_CASES = _load_cases()
+_SUCCESS = [(name, c) for name, c in _CASES if "normalized" in c]
+_ERROR = [(name, c) for name, c in _CASES if "error" in c]
+
+
+def test_corpus_is_present() -> None:
+ # Guard against an empty/missing vendored corpus silently passing.
+ assert len(_SUCCESS) > 0
+ assert len(_ERROR) > 0
+
+
+@pytest.mark.parametrize(("name", "case"), _SUCCESS, ids=[name for name, _ in _SUCCESS])
+def test_success_fixture(name: str, case: dict[str, Any]) -> None:
+ result = normalize_ndsel(case["input"])
+ assert result == case["normalized"]
+
+
+@pytest.mark.parametrize(("name", "case"), _ERROR, ids=[name for name, _ in _ERROR])
+def test_error_fixture(name: str, case: dict[str, Any]) -> None:
+ with pytest.raises(NdselError) as excinfo:
+ normalize_ndsel(case["input"])
+ assert excinfo.value.reason == case["error"]
diff --git a/packages/zarr-indexing/tests/test_doc_examples.py b/packages/zarr-indexing/tests/test_doc_examples.py
new file mode 100644
index 0000000000..240bd48531
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_doc_examples.py
@@ -0,0 +1,488 @@
+"""Executable documentation contracts.
+
+Two kinds of test live here, and nothing else:
+
+1. **Structural.** Every snippet include in the rendered docs resolves to a
+ real file and a balanced, non-empty region — discovered by scanning the
+ markdown, never hand-registered — and every example file executes. The
+ examples carry their own inline assertions, so executing one *is* the
+ value check; expected values are stated once, in the example, beside the
+ prose that narrates them.
+2. **Behavioral.** Documented classes are exercised where the example cannot
+ assert the behavior itself: error paths, cache lifecycle invariants, and
+ contracts stated in prose about types the docs define.
+
+Editorial choices — section order, exact wording, teaching progression — are
+deliberately not pinned here; they belong to review. Structural breakage
+belongs to `mkdocs build --strict`, whose configuration makes it actually
+fail on the relevant classes: `check_paths: true` for unresolvable includes,
+and `validation` set to warn (strict turns warnings into errors) for broken
+link anchors and nav-omitted pages.
+"""
+
+from __future__ import annotations
+
+import re
+import runpy
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import pytest
+
+import zarr_indexing
+import zarr_indexing.lazy_array as lazy_array_module
+from zarr_indexing import IndexTransform, LazyArray, ReadContext
+
+DOCS = Path(__file__).parents[1] / "docs"
+PACKAGE_ROOT = DOCS.parent
+STANDALONE_EXAMPLES = PACKAGE_ROOT / "examples"
+DOC_SNIPPETS_DIR = DOCS / "snippets"
+CACHE_EXAMPLE = STANDALONE_EXAMPLES / "system_memory_chunk_cache" / "system_memory_chunk_cache.py"
+
+# Mirrors `pymdownx.snippets: base_path` in mkdocs.yml. If that list changes,
+# change this one in the same commit.
+SNIPPET_BASE_PATHS = (DOCS, STANDALONE_EXAMPLES)
+
+_INCLUDE = re.compile(r'--8<--\s+"(?P[^":\n]+?)(?::(?P[^"\n]+))?"')
+
+
+def _markdown_includes() -> tuple[tuple[str, str, str | None], ...]:
+ """Every snippet include in the rendered docs: (page, target, region)."""
+ return tuple(
+ (str(page.relative_to(DOCS)), match["target"], match["region"])
+ for page in sorted(DOCS.rglob("*.md"))
+ for match in _INCLUDE.finditer(page.read_text())
+ )
+
+
+INCLUDES = _markdown_includes()
+# In-process executables: every snippet, plus the one standalone example that
+# is importable as a module. The lazy_indexing_* examples are CLI scripts
+# (they parse argv and call sys.exit), so they run as subprocesses below —
+# no other test in the repository executes them.
+EXECUTABLES = (*sorted(DOC_SNIPPETS_DIR.glob("*.py")), CACHE_EXAMPLE)
+CLI_EXAMPLES = tuple(
+ script for script in sorted(STANDALONE_EXAMPLES.glob("*/*.py")) if script not in EXECUTABLES
+)
+
+PATTERN_NAMESPACE: dict[str, Any] = runpy.run_path(str(DOC_SNIPPETS_DIR / "indexing_patterns.py"))
+PATTERN_CASES: tuple[dict[str, Any], ...] = PATTERN_NAMESPACE["PATTERN_CASES"]
+CACHE_NAMESPACE: dict[str, Any] = runpy.run_path(str(CACHE_EXAMPLE))
+
+# The documented pattern matrix must keep covering every selection family.
+REQUIRED_PATTERNS = {
+ "basic-slice",
+ "integer-axis-removal",
+ "negative-stride",
+ "empty-selection",
+ "boolean-mask",
+ "orthogonal",
+ "vectorized",
+ "broadcasting",
+ "repeated-out-of-order",
+}
+
+
+# --------------------------------------------------------------------------- #
+# Structural: the include graph and the executable examples
+# --------------------------------------------------------------------------- #
+
+
+def test_docs_reference_snippets_at_all() -> None:
+ """An empty scan means the include regex rotted, not that the docs did."""
+ assert len(INCLUDES) >= 10
+ assert any(region for _, _, region in INCLUDES)
+
+
+@pytest.mark.parametrize(
+ ("page", "target", "region"),
+ INCLUDES,
+ ids=[
+ f"{page}->{target}" + (f":{region}" if region else "") for page, target, region in INCLUDES
+ ],
+)
+def test_markdown_include_resolves(page: str, target: str, region: str | None) -> None:
+ """Each include names exactly one real file; each region is balanced and non-empty."""
+ resolved = [base / target for base in SNIPPET_BASE_PATHS if (base / target).is_file()]
+ assert len(resolved) == 1, f"{page} includes {target!r}: resolved to {resolved or 'nothing'}"
+ if region is None:
+ return
+ source = resolved[0].read_text()
+ starts = re.findall(rf"^\s*# --8<-- \[start:{re.escape(region)}\]$", source, re.MULTILINE)
+ ends = re.findall(rf"^\s*# --8<-- \[end:{re.escape(region)}\]$", source, re.MULTILINE)
+ assert len(starts) == 1, f"{target}:{region} needs exactly one start marker, has {len(starts)}"
+ assert len(ends) == 1, f"{target}:{region} needs exactly one end marker, has {len(ends)}"
+ body = source.split(starts[0], maxsplit=1)[1].split(ends[0], maxsplit=1)[0]
+ assert body.strip(), f"{target}:{region} is empty"
+
+
+def test_snippet_directories_hold_only_their_kind() -> None:
+ """Rendered pages are markdown; executable snippets are Python."""
+ assert all(p.suffix == ".md" for p in (DOCS / "examples").iterdir() if p.is_file())
+ assert all(p.suffix == ".py" for p in DOC_SNIPPETS_DIR.iterdir() if p.is_file())
+
+
+@pytest.mark.parametrize("example", EXECUTABLES, ids=lambda path: path.stem)
+def test_documentation_example_executes(example: Path) -> None:
+ """Examples are executable contracts; their inline asserts are the values check."""
+ runpy.run_path(str(example), run_name="__main__")
+
+
+@pytest.mark.parametrize("script", CLI_EXAMPLES, ids=lambda path: path.stem)
+def test_cli_example_runs_as_a_subprocess(script: Path) -> None:
+ """The CLI examples exit 0 when run the way their READMEs instruct."""
+ if "dask" in script.stem:
+ pytest.importorskip("dask.array")
+ completed = subprocess.run(
+ [sys.executable, str(script)],
+ capture_output=True,
+ text=True,
+ cwd=script.parent,
+ check=False,
+ )
+ assert completed.returncode == 0, completed.stderr[-2000:]
+
+
+@pytest.mark.parametrize(
+ "example_dir",
+ sorted(p for p in STANDALONE_EXAMPLES.iterdir() if p.is_dir()),
+ ids=lambda path: path.name,
+)
+def test_standalone_example_is_a_documented_script(example_dir: Path) -> None:
+ """Each standalone example ships a README and a same-named runnable script."""
+ script = example_dir / f"{example_dir.name}.py"
+ assert script.is_file()
+ assert (example_dir / "README.md").is_file()
+ assert (DOCS / "examples" / f"{example_dir.name}.md").is_file()
+ assert script.read_text().startswith("# /// script\n"), "examples stay PEP 723 runnable"
+
+
+# --------------------------------------------------------------------------- #
+# Behavioral: the documented pattern matrix
+# --------------------------------------------------------------------------- #
+
+
+def test_indexing_pattern_matrix_is_complete() -> None:
+ assert {case["name"] for case in PATTERN_CASES} == REQUIRED_PATTERNS
+
+
+def test_pattern_page_tabs_are_the_models() -> None:
+ """Both tabs of every matrix entry are the executable model, in order.
+
+ The JSON tab must be the model's canonical wire form; the Python tab
+ must evaluate (in the executable matrix's namespace) to the model
+ itself. Either tab drifting from the snippet fails here.
+ """
+ import json
+ import textwrap
+
+ page = (DOCS / "guide" / "patterns.md").read_text()
+
+ json_blocks = re.findall(r"```json\n(.*?)```", page, re.DOTALL)
+ assert len(json_blocks) == len(PATTERN_CASES)
+ for block, case in zip(json_blocks, PATTERN_CASES, strict=True):
+ assert json.loads(block) == case["transform"].to_json(), case["name"]
+
+ python_blocks = [
+ textwrap.dedent(block)
+ for block in re.findall(r"```python\n(.*?)```", page, re.DOTALL)
+ if "--8<--" not in block
+ ]
+ assert len(python_blocks) == len(PATTERN_CASES)
+ for block, case in zip(python_blocks, PATTERN_CASES, strict=True):
+ constructed = eval(block, dict(PATTERN_NAMESPACE))
+ assert constructed == case["transform"], case["name"]
+
+
+@pytest.mark.parametrize("case", PATTERN_CASES, ids=lambda case: case["name"])
+def test_indexing_pattern_matrix_matches_numpy(case: dict[str, Any]) -> None:
+ """The wrapper agrees with the matrix the snippet proves at the transform level."""
+ image = PATTERN_NAMESPACE["image"]
+ lazy = LazyArray.from_numpy(image)
+ accessor = {
+ "basic": lazy.lazy,
+ "oindex": lazy.lazy.oindex,
+ "vindex": lazy.lazy.vindex,
+ }[case["mode"]]
+ view = accessor[case["selection"]]
+ result = view.result()
+
+ np.testing.assert_array_equal(result, case["expected"])
+ assert result.shape == case["shape"]
+ assert ("box" if view.is_box else "query") == case["category"]
+
+
+# --------------------------------------------------------------------------- #
+# Behavioral: contracts the guide states about wrapped sources and parts
+# --------------------------------------------------------------------------- #
+
+
+def test_default_source_contract_converts_basic_selected_slabs_to_system_memory() -> None:
+ """A source may return Python slabs as long as NumPy can convert each selected slab."""
+
+ class ListSlabSource:
+ def __init__(self) -> None:
+ self.data = np.arange(20).reshape(4, 5)
+ self.keys: list[tuple[Any, ...]] = []
+
+ @property
+ def shape(self) -> tuple[int, ...]:
+ return self.data.shape
+
+ @property
+ def dtype(self) -> np.dtype[Any]:
+ return self.data.dtype
+
+ def __getitem__(self, key: tuple[Any, ...]) -> object:
+ assert all(isinstance(item, (int, slice)) for item in key)
+ self.keys.append(key)
+ return self.data[key].tolist()
+
+ source = ListSlabSource()
+ result = LazyArray(source).with_parts((2, 3)).lazy.oindex[[3, 1, 1], 1:5:2].result()
+
+ np.testing.assert_array_equal(result, np.array([[16, 18], [6, 8], [6, 8]]))
+ assert len(source.keys) > 0
+ assert all(all(isinstance(item, (int, slice)) for item in key) for key in source.keys)
+
+
+def test_coordinate_array_example_preserves_order_and_duplicates() -> None:
+ """Coordinate arrays are ordered sequences, not mathematical sets."""
+ view = LazyArray.from_numpy(np.arange(6)).with_parts((2,)).lazy.oindex[[4, 1, 1, 3]]
+
+ np.testing.assert_array_equal(view.result(), np.array([4, 1, 1, 3]))
+ assembled = np.empty(view.shape, dtype=view.dtype)
+ for part in view.parts():
+ assembled[part.out_selection] = part.view.result()
+ np.testing.assert_array_equal(assembled, np.array([4, 1, 1, 3]))
+
+
+def test_documented_partition_transform_is_global_and_projection_is_chunk_local() -> None:
+ source = np.arange(8)
+ part = tuple(LazyArray.from_numpy(source).with_parts((4,)).parts())[1]
+
+ assert part.view.transform.apply((0,)) == (4,)
+ assert part.view.array[part.view.transform.apply((0,))] == 4
+ assert part.projection.chunk_transform.apply((0,)) == (0,)
+
+
+@pytest.mark.parametrize(
+ ("mode", "parts"),
+ [
+ ("per-axis", ((0,), (3,))),
+ ("per-axis", ((), (3,))),
+ ("uniform", (1, 1)),
+ ("per-axis", ((0, 0), (3,))),
+ ],
+ ids=["single-zero", "empty-sequence", "positive-uniform", "repeated-zero"],
+)
+def test_documented_zero_length_axis_partition_spellings_execute(mode: str, parts: Any) -> None:
+ data = np.zeros((0, 3))
+ base = LazyArray.from_numpy(data)
+ view = base.with_parts(parts) if mode == "uniform" else base.with_parts_per_axis(parts)
+
+ assert view.result().shape == (0, 3)
+ assert tuple(view.parts()) == ()
+
+
+def test_projection_example_exposes_paired_directions() -> None:
+ """Both transforms of every projection keep their documented output ranks."""
+ namespace = runpy.run_path(str(DOC_SNIPPETS_DIR / "chunk_projection.py"))
+ np.testing.assert_array_equal(namespace["ADVANCED_RESULT"], namespace["ADVANCED_EXPECTED"])
+ assert all(p.chunk_transform.output_rank == 2 for p in namespace["PROJECTIONS"])
+ assert all(p.cell_transform.output_rank == 1 for p in namespace["PROJECTIONS"])
+
+
+@pytest.mark.parametrize(
+ "example",
+ [DOC_SNIPPETS_DIR / "integrations.py", CACHE_EXAMPLE],
+ ids=lambda path: path.stem,
+)
+def test_projection_examples_assemble_rank_zero_domains(example: Path) -> None:
+ """The examples' shared assembly helpers handle a zero-rank cell domain."""
+ namespace = runpy.run_path(str(example))
+ domain = zarr_indexing.IndexDomain((), ())
+ cell_points = namespace["_domain_points"](domain)
+ destination = np.empty((), dtype=np.intp)
+
+ values = namespace["_gather_and_scatter"](
+ destination,
+ np.array(7, dtype=np.intp),
+ cell_points,
+ cell_points,
+ )
+
+ assert cell_points.shape == (1, 0)
+ assert values.shape == (1,)
+ assert destination[()] == 7
+
+
+# --------------------------------------------------------------------------- #
+# Behavioral: the documented chunk cache (error paths the example cannot show)
+# --------------------------------------------------------------------------- #
+
+
+def make_documented_cache() -> tuple[Any, Any]:
+ source_type = CACHE_NAMESPACE["RecordingChunkSource"]
+ cache_type = CACHE_NAMESPACE["SystemMemoryChunkCache"]
+ source = source_type(np.arange(48).reshape(6, 8), chunks=(3, 4))
+ return source, cache_type(source, capacity=2)
+
+
+def test_system_memory_cache_assembles_and_deduplicates_public_projections() -> None:
+ source, cache = make_documented_cache()
+
+ np.testing.assert_array_equal(cache.oindex[[1, 1], 2], np.array([10, 10]))
+ assert tuple(source.reads) == ((0, 0),)
+ assert cache.projection_uses == (("chunk_transform", "context.transform"),)
+
+
+def test_chunk_cache_queues_all_parts_before_loading(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ source, cache = make_documented_cache()
+ planning_count = 0
+ original_plan_chunks = lazy_array_module.plan_chunks
+
+ def counted_plan_chunks(*args: Any, **kwargs: Any) -> Any:
+ nonlocal planning_count
+ planning_count += 1
+ return original_plan_chunks(*args, **kwargs)
+
+ with monkeypatch.context() as request_patch:
+ request_patch.setattr(lazy_array_module, "plan_chunks", counted_plan_chunks)
+ result = cache[1:5, 2]
+
+ np.testing.assert_array_equal(result, np.array([10, 18, 26, 34]))
+ assert planning_count == 1
+ assert source.reads == [(0, 0), (1, 0)]
+
+ assert [
+ (event.chunk_coords, event.previous.value, event.current.value) for event in cache.events
+ ] == [
+ ((0, 0), "new", "queued"),
+ ((1, 0), "new", "queued"),
+ ((0, 0), "queued", "loading"),
+ ((0, 0), "loading", "ready"),
+ ((1, 0), "queued", "loading"),
+ ((1, 0), "loading", "ready"),
+ ]
+
+
+def test_chunk_cache_reader_resolves_a_transform_from_cached_chunks() -> None:
+ """The reader boundary consumes the prepared projections of real parts."""
+ source_type = CACHE_NAMESPACE["RecordingChunkSource"]
+ reader_type = CACHE_NAMESPACE["SystemMemoryChunkReader"]
+ source = source_type(np.arange(48).reshape(6, 8), chunks=(3, 4))
+ reader = reader_type(capacity=2)
+ view = LazyArray(source).with_reader(reader).lazy[1:5, 2]
+ parts = tuple(view.parts())
+ out = np.empty(view.shape, dtype=source.dtype)
+ for part in parts:
+ destination = out[part.out_selection]
+ assert (
+ reader.read_into(
+ source,
+ ReadContext(part.view.transform, part.projection),
+ destination,
+ )
+ is None
+ )
+
+ np.testing.assert_array_equal(out, np.array([10, 18, 26, 34]))
+ assert source.reads == [(0, 0), (1, 0)]
+ assert reader.projection_uses == [
+ ("chunk_transform", "context.transform"),
+ ("chunk_transform", "context.transform"),
+ ]
+
+
+def test_chunk_cache_reader_requires_a_prepared_projection() -> None:
+ source_type = CACHE_NAMESPACE["RecordingChunkSource"]
+ reader_type = CACHE_NAMESPACE["SystemMemoryChunkReader"]
+ source = source_type(np.arange(48).reshape(6, 8), chunks=(3, 4))
+ transform = IndexTransform.from_shape(source.shape)[1:3, 2].translate_domain_to((0,))
+ out = np.empty(transform.domain.shape, dtype=source.dtype)
+
+ with pytest.raises(ValueError, match="requires context.projection"):
+ reader_type(capacity=2).read_into(source, ReadContext(transform), out)
+
+
+def test_system_memory_cache_separates_basic_and_orthogonal_indexing() -> None:
+ source_type = CACHE_NAMESPACE["RecordingChunkSource"]
+ cache_type = CACHE_NAMESPACE["SystemMemoryChunkCache"]
+ data = np.arange(48).reshape(6, 8)
+ cache = cache_type(source_type(data, chunks=(3, 4)), capacity=4)
+ row = np.array([0, 2])
+ column = np.array([1, 3])
+
+ np.testing.assert_array_equal(cache[0:3, 1:4], data[0:3, 1:4])
+ np.testing.assert_array_equal(
+ cache.oindex[row, column],
+ data[np.ix_(row, column)],
+ )
+
+
+def test_system_memory_cache_does_not_treat_array_keys_as_orthogonal() -> None:
+ source, cache = make_documented_cache()
+ row = np.array([0, 2])
+ column = np.array([1, 3])
+
+ with pytest.raises(IndexError, match="unsupported selection type for basic indexing"):
+ cache[row, column]
+
+ assert tuple(source.reads) == ()
+
+
+def test_system_memory_cache_assembles_a_scalar_selection() -> None:
+ source, cache = make_documented_cache()
+
+ result = cache[1, 2]
+
+ assert result.shape == ()
+ assert result[()] == 10
+ assert tuple(source.reads) == ((0, 0),)
+ assert cache.projection_uses == (("chunk_transform", "context.transform"),)
+
+
+def test_system_memory_cache_is_documentation_only() -> None:
+ assert not hasattr(zarr_indexing, "SystemMemoryChunkCache")
+ assert not hasattr(zarr_indexing, "ChunkState")
+
+
+def test_chunk_source_failure_is_retained_as_failed_with_its_cause() -> None:
+ source, cache = make_documented_cache()
+ source.failures.add((1, 1))
+
+ with pytest.raises(CACHE_NAMESPACE["ChunkLoadError"]) as error:
+ cache[3:5, 4:6]
+
+ assert isinstance(error.value.__cause__, OSError)
+ assert cache.state((1, 1)).value == "failed"
+ assert tuple(source.reads) == ((1, 1),)
+
+
+def test_failed_chunk_is_not_retried_implicitly() -> None:
+ source, cache = make_documented_cache()
+ source.failures.add((1, 1))
+ with pytest.raises(CACHE_NAMESPACE["ChunkLoadError"]):
+ cache[3:5, 4:6]
+ with pytest.raises(CACHE_NAMESPACE["ChunkLoadError"]):
+ cache[3:5, 4:6]
+
+ assert tuple(source.reads) == ((1, 1),)
+
+
+def test_retry_requires_a_failed_chunk() -> None:
+ _, cache = make_documented_cache()
+ with pytest.raises(ValueError, match="retry requires failed chunk .*new"):
+ cache.retry((0, 0))
+
+
+def test_illegal_chunk_transition_is_rejected() -> None:
+ _, cache = make_documented_cache()
+ with pytest.raises(ValueError, match="illegal chunk transition new -> ready"):
+ cache.reader._transition((0, 0), CACHE_NAMESPACE["ChunkState"].READY, "test")
diff --git a/packages/zarr-indexing/tests/test_domain.py b/packages/zarr-indexing/tests/test_domain.py
new file mode 100644
index 0000000000..0278ecd714
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_domain.py
@@ -0,0 +1,218 @@
+from __future__ import annotations
+
+import pytest
+
+from zarr_indexing.domain import IndexDomain
+from zarr_indexing.errors import BoundsCheckError
+
+
+class TestIndexDomainConstruction:
+ def test_from_shape(self) -> None:
+ d = IndexDomain.from_shape((10, 20))
+ assert d.inclusive_min == (0, 0)
+ assert d.exclusive_max == (10, 20)
+ assert d.ndim == 2
+ assert d.origin == (0, 0)
+ assert d.shape == (10, 20)
+
+ def test_from_shape_0d(self) -> None:
+ d = IndexDomain.from_shape(())
+ assert d.ndim == 0
+ assert d.shape == ()
+
+ def test_non_zero_origin(self) -> None:
+ d = IndexDomain(inclusive_min=(5, 10), exclusive_max=(15, 30))
+ assert d.origin == (5, 10)
+ assert d.shape == (10, 20)
+ assert d.ndim == 2
+
+ def test_validation_mismatched_lengths(self) -> None:
+ with pytest.raises(ValueError, match="same length"):
+ IndexDomain(inclusive_min=(0,), exclusive_max=(10, 20))
+
+ def test_validation_min_greater_than_max(self) -> None:
+ with pytest.raises(ValueError, match="inclusive_min must be <="):
+ IndexDomain(inclusive_min=(10,), exclusive_max=(5,))
+
+ def test_empty_domain(self) -> None:
+ d = IndexDomain(inclusive_min=(5,), exclusive_max=(5,))
+ assert d.shape == (0,)
+
+ def test_labels(self) -> None:
+ d = IndexDomain(inclusive_min=(0, 0), exclusive_max=(10, 20), labels=("x", "y"))
+ assert d.labels == ("x", "y")
+
+ def test_labels_none(self) -> None:
+ d = IndexDomain.from_shape((10,))
+ assert d.labels is None
+
+
+class TestIndexDomainContains:
+ def test_contains_inside(self) -> None:
+ d = IndexDomain.from_shape((10, 20))
+ assert d.contains((0, 0)) is True
+ assert d.contains((9, 19)) is True
+ assert d.contains((5, 10)) is True
+
+ def test_contains_outside(self) -> None:
+ d = IndexDomain.from_shape((10, 20))
+ assert d.contains((10, 0)) is False
+ assert d.contains((-1, 0)) is False
+ assert d.contains((0, 20)) is False
+
+ def test_contains_non_zero_origin(self) -> None:
+ d = IndexDomain(inclusive_min=(5,), exclusive_max=(10,))
+ assert d.contains((5,)) is True
+ assert d.contains((9,)) is True
+ assert d.contains((4,)) is False
+ assert d.contains((10,)) is False
+
+ def test_contains_wrong_ndim(self) -> None:
+ d = IndexDomain.from_shape((10, 20))
+ assert d.contains((5,)) is False
+
+ def test_contains_domain_inside(self) -> None:
+ outer = IndexDomain.from_shape((10, 20))
+ inner = IndexDomain(inclusive_min=(2, 3), exclusive_max=(8, 15))
+ assert outer.contains_domain(inner) is True
+
+ def test_contains_domain_outside(self) -> None:
+ outer = IndexDomain.from_shape((10, 20))
+ inner = IndexDomain(inclusive_min=(2, 3), exclusive_max=(11, 15))
+ assert outer.contains_domain(inner) is False
+
+ def test_contains_domain_wrong_ndim(self) -> None:
+ outer = IndexDomain.from_shape((10, 20))
+ inner = IndexDomain.from_shape((5,))
+ assert outer.contains_domain(inner) is False
+
+
+class TestIndexDomainIntersect:
+ def test_overlapping(self) -> None:
+ a = IndexDomain(inclusive_min=(0, 0), exclusive_max=(10, 10))
+ b = IndexDomain(inclusive_min=(5, 5), exclusive_max=(15, 15))
+ result = a.intersect(b)
+ assert result is not None
+ assert result.inclusive_min == (5, 5)
+ assert result.exclusive_max == (10, 10)
+
+ def test_disjoint(self) -> None:
+ a = IndexDomain(inclusive_min=(0,), exclusive_max=(5,))
+ b = IndexDomain(inclusive_min=(10,), exclusive_max=(15,))
+ assert a.intersect(b) is None
+
+ def test_touching_boundary(self) -> None:
+ a = IndexDomain(inclusive_min=(0,), exclusive_max=(5,))
+ b = IndexDomain(inclusive_min=(5,), exclusive_max=(10,))
+ assert a.intersect(b) is None
+
+ def test_contained(self) -> None:
+ a = IndexDomain.from_shape((20,))
+ b = IndexDomain(inclusive_min=(5,), exclusive_max=(10,))
+ result = a.intersect(b)
+ assert result is not None
+ assert result.inclusive_min == (5,)
+ assert result.exclusive_max == (10,)
+
+ def test_wrong_ndim(self) -> None:
+ a = IndexDomain.from_shape((10,))
+ b = IndexDomain.from_shape((10, 20))
+ with pytest.raises(ValueError, match="different ranks"):
+ a.intersect(b)
+
+
+class TestIndexDomainTranslate:
+ def test_translate_positive(self) -> None:
+ d = IndexDomain.from_shape((10, 20))
+ result = d.translate((5, 10))
+ assert result.inclusive_min == (5, 10)
+ assert result.exclusive_max == (15, 30)
+
+ def test_translate_negative(self) -> None:
+ d = IndexDomain(inclusive_min=(10, 20), exclusive_max=(30, 40))
+ result = d.translate((-10, -20))
+ assert result.inclusive_min == (0, 0)
+ assert result.exclusive_max == (20, 20)
+
+ def test_translate_wrong_length(self) -> None:
+ d = IndexDomain.from_shape((10,))
+ with pytest.raises(ValueError, match="same length"):
+ d.translate((1, 2))
+
+
+class TestIndexDomainNarrow:
+ def test_narrow_slice(self) -> None:
+ d = IndexDomain.from_shape((10, 20))
+ result = d.narrow((slice(2, 8), slice(5, 15)))
+ assert result.inclusive_min == (2, 5)
+ assert result.exclusive_max == (8, 15)
+
+ def test_narrow_int(self) -> None:
+ d = IndexDomain.from_shape((10, 20))
+ result = d.narrow((3, slice(None)))
+ assert result.inclusive_min == (3, 0)
+ assert result.exclusive_max == (4, 20)
+
+ def test_narrow_ellipsis(self) -> None:
+ d = IndexDomain.from_shape((10, 20, 30))
+ result = d.narrow((slice(1, 5), ...))
+ assert result.inclusive_min == (1, 0, 0)
+ assert result.exclusive_max == (5, 20, 30)
+
+ def test_narrow_slice_none(self) -> None:
+ d = IndexDomain.from_shape((10,))
+ result = d.narrow((slice(None),))
+ assert result == d
+
+ def test_narrow_non_zero_origin(self) -> None:
+ d = IndexDomain(inclusive_min=(10,), exclusive_max=(20,))
+ result = d.narrow((slice(12, 18),))
+ assert result.inclusive_min == (12,)
+ assert result.exclusive_max == (18,)
+
+ def test_narrow_int_out_of_bounds(self) -> None:
+ d = IndexDomain.from_shape((10,))
+ with pytest.raises(BoundsCheckError, match="out of bounds"):
+ d.narrow((10,))
+
+ def test_narrow_int_below_origin(self) -> None:
+ d = IndexDomain(inclusive_min=(5,), exclusive_max=(10,))
+ with pytest.raises(BoundsCheckError, match="out of bounds"):
+ d.narrow((4,))
+
+ def test_narrow_refuses_a_bound_outside_the_domain(self) -> None:
+ """Clamping made two different requests answer alike, and neither well.
+
+ Indices here are absolute coordinates, so `-5` is a coordinate this
+ domain does not contain rather than NumPy's "five from the end" — and
+ clamping returned the whole axis for it, which is what a caller writing
+ the NumPy spelling would least expect. A stop past the end produced a
+ domain the parent did not contain.
+ """
+ d = IndexDomain.from_shape((10,))
+ with pytest.raises(BoundsCheckError, match="absolute coordinates"):
+ d.narrow((slice(-5, 100),))
+ with pytest.raises(BoundsCheckError, match="out of bounds"):
+ d.narrow((slice(20, 30),))
+
+ def test_narrow_accepts_the_bounds_of_the_domain_itself(self) -> None:
+ d = IndexDomain.from_shape((10,))
+ assert d.narrow((slice(0, 10),)) == d
+
+ def test_narrow_bare_slice(self) -> None:
+ d = IndexDomain.from_shape((10,))
+ result = d.narrow(slice(2, 8))
+ assert result.inclusive_min == (2,)
+ assert result.exclusive_max == (8,)
+
+ def test_narrow_too_many_indices(self) -> None:
+ d = IndexDomain.from_shape((10,))
+ with pytest.raises(IndexError, match="too many indices"):
+ d.narrow((1, 2))
+
+ def test_narrow_step_not_one(self) -> None:
+ """A stride is not a bounds failure — the rest of the algebra raises
+ `ValueError` for a request it does not implement, and so does this."""
+ d = IndexDomain.from_shape((10,))
+ with pytest.raises(ValueError, match="step=1"):
+ d.narrow((slice(0, 10, 2),))
diff --git a/packages/zarr-indexing/tests/test_json.py b/packages/zarr-indexing/tests/test_json.py
new file mode 100644
index 0000000000..9848c6801f
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_json.py
@@ -0,0 +1,586 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+import numpy as np
+import pytest
+
+from zarr_indexing.domain import IndexDomain
+from zarr_indexing.messages import NdselError
+from zarr_indexing.output_map import (
+ ArrayMap,
+ ConstantMap,
+ DimensionMap,
+ output_index_map_from_json,
+)
+from zarr_indexing.transform import IndexTransform
+
+if TYPE_CHECKING:
+ from zarr_indexing.json import IndexTransformJSON
+
+
+def _maps_equal(a: object, b: object) -> bool:
+ if type(a) is not type(b):
+ return False
+ if isinstance(a, ConstantMap):
+ assert isinstance(b, ConstantMap)
+ return a.offset == b.offset
+ if isinstance(a, DimensionMap):
+ assert isinstance(b, DimensionMap)
+ return (a.input_dimension, a.offset, a.stride) == (b.input_dimension, b.offset, b.stride)
+ assert isinstance(a, ArrayMap)
+ assert isinstance(b, ArrayMap)
+ return (
+ a.offset == b.offset
+ and a.stride == b.stride
+ and np.array_equal(a.index_array, b.index_array)
+ )
+
+
+def _transforms_equal(a: IndexTransform, b: IndexTransform) -> bool:
+ """Structural equality that compares `ArrayMap` index arrays element-wise
+ (`IndexTransform`'s dataclass `__eq__` cannot, as numpy `==` is ambiguous)."""
+ return (
+ a.domain == b.domain
+ and len(a.output) == len(b.output)
+ and all(_maps_equal(x, y) for x, y in zip(a.output, b.output, strict=True))
+ )
+
+
+class TestIndexDomainJSON:
+ def test_roundtrip(self) -> None:
+ domain = IndexDomain(inclusive_min=(2, 5), exclusive_max=(10, 20))
+ json = domain.to_json()
+ assert json == {
+ "input_inclusive_min": [2, 5],
+ "input_exclusive_max": [10, 20],
+ "input_labels": ["", ""],
+ }
+ restored = IndexDomain.from_json(json)
+ assert restored == domain
+
+ def test_with_labels(self) -> None:
+ domain = IndexDomain(inclusive_min=(0, 0), exclusive_max=(10, 20), labels=("x", "y"))
+ json = domain.to_json()
+ assert json["input_labels"] == ["x", "y"]
+ restored = IndexDomain.from_json(json)
+ assert restored.labels == ("x", "y")
+
+ def test_without_labels_emits_empty_and_round_trips_to_none(self) -> None:
+ domain = IndexDomain.from_shape((5,))
+ json = domain.to_json()
+ # Canonical form always writes labels; an unlabeled domain gets [""]*rank.
+ assert json["input_labels"] == [""]
+ restored = IndexDomain.from_json(json)
+ assert restored.labels is None
+
+ def test_zero_origin(self) -> None:
+ domain = IndexDomain.from_shape((10, 20, 30))
+ json = domain.to_json()
+ assert json == {
+ "input_inclusive_min": [0, 0, 0],
+ "input_exclusive_max": [10, 20, 30],
+ "input_labels": ["", "", ""],
+ }
+ assert IndexDomain.from_json(json) == domain
+
+
+class TestOutputIndexMapJSON:
+ def test_constant(self) -> None:
+ m = ConstantMap(offset=42)
+ json = m.to_json()
+ assert json == {"offset": 42}
+ restored = output_index_map_from_json(json)
+ assert isinstance(restored, ConstantMap)
+ assert restored.offset == 42
+
+ def test_constant_zero(self) -> None:
+ m = ConstantMap(offset=0)
+ json = m.to_json()
+ assert json == {"offset": 0}
+ restored = output_index_map_from_json(json)
+ assert isinstance(restored, ConstantMap)
+ assert restored.offset == 0
+
+ def test_dimension(self) -> None:
+ m = DimensionMap(input_dimension=1, offset=10, stride=3)
+ json = m.to_json()
+ assert json == {"offset": 10, "stride": 3, "input_dimension": 1}
+ restored = output_index_map_from_json(json)
+ assert isinstance(restored, DimensionMap)
+ assert restored.input_dimension == 1
+ assert restored.offset == 10
+ assert restored.stride == 3
+
+ def test_dimension_stride_1_written(self) -> None:
+ """Canonical form writes stride even at its default of 1."""
+ m = DimensionMap(input_dimension=0)
+ json = m.to_json()
+ assert json == {"offset": 0, "stride": 1, "input_dimension": 0}
+ restored = output_index_map_from_json(json)
+ assert isinstance(restored, DimensionMap)
+ assert restored.stride == 1
+
+ def test_array(self) -> None:
+ arr = np.array([1, 5, 9], dtype=np.intp)
+ m = ArrayMap(index_array=arr, offset=2, stride=3)
+ json = m.to_json()
+ # Canonical: stride/offset present, index_array_bounds present, and
+ # no input_dimension (ndsel/TensorStore reject it beside index_array).
+ assert json == {
+ "offset": 2,
+ "stride": 3,
+ "index_array": [1, 5, 9],
+ "index_array_bounds": ["-inf", "+inf"],
+ }
+ restored = output_index_map_from_json(json)
+ assert isinstance(restored, ArrayMap)
+ np.testing.assert_array_equal(restored.index_array, arr)
+ assert restored.offset == 2
+ assert restored.stride == 3
+
+ def test_array_stride_1_written(self) -> None:
+ arr = np.array([0, 1, 2], dtype=np.intp)
+ m = ArrayMap(index_array=arr)
+ json = m.to_json()
+ assert json["stride"] == 1
+ restored = output_index_map_from_json(json)
+ assert isinstance(restored, ArrayMap)
+ assert restored.stride == 1
+
+ def test_array_2d(self) -> None:
+ arr = np.array([[1, 2], [3, 4]], dtype=np.intp)
+ m = ArrayMap(index_array=arr)
+ json = m.to_json()
+ assert json["index_array"] == [[1, 2], [3, 4]]
+ restored = output_index_map_from_json(json)
+ assert isinstance(restored, ArrayMap)
+ np.testing.assert_array_equal(restored.index_array, arr)
+
+ def test_degenerate_singleton_array_collapses_to_constant(self) -> None:
+ """An all-singleton index_array selects one coordinate -> constant map."""
+ m = ArrayMap(index_array=np.array([[4]], dtype=np.intp), offset=1, stride=2)
+ json = m.to_json()
+ assert json == {"offset": 1 + 2 * 4}
+ restored = output_index_map_from_json(json)
+ assert isinstance(restored, ConstantMap)
+ assert restored.offset == 9
+
+
+class TestIndexTransformJSON:
+ def test_identity(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ json = t.to_json()
+ assert json == {
+ "input_rank": 2,
+ "input_inclusive_min": [0, 0],
+ "input_exclusive_max": [10, 20],
+ "input_labels": ["", ""],
+ "output": [
+ {"offset": 0, "stride": 1, "input_dimension": 0},
+ {"offset": 0, "stride": 1, "input_dimension": 1},
+ ],
+ }
+ restored = IndexTransform.from_json(json)
+ assert restored.domain == t.domain
+ assert len(restored.output) == 2
+ for orig, rest in zip(t.output, restored.output, strict=True):
+ assert type(orig) is type(rest)
+
+ def test_sliced(self) -> None:
+ t = IndexTransform.from_shape((100,))[10:50:2]
+ json = t.to_json()
+ restored = IndexTransform.from_json(json)
+ assert restored.domain.shape == t.domain.shape
+ assert isinstance(restored.output[0], DimensionMap)
+ orig = t.output[0]
+ assert isinstance(orig, DimensionMap)
+ assert restored.output[0].offset == orig.offset
+ assert restored.output[0].stride == orig.stride
+
+ def test_with_constant(self) -> None:
+ t = IndexTransform.from_shape((10, 20))[3]
+ json = t.to_json()
+ restored = IndexTransform.from_json(json)
+ assert isinstance(restored.output[0], ConstantMap)
+ assert restored.output[0].offset == 3
+ assert isinstance(restored.output[1], DimensionMap)
+
+ def test_with_array(self) -> None:
+ idx = np.array([1, 5, 9], dtype=np.intp)
+ t = IndexTransform.from_shape((10, 20)).oindex[idx, :]
+ json = t.to_json()
+ # The oindex array must not carry input_dimension on the wire.
+ assert "input_dimension" not in json["output"][0]
+ restored = IndexTransform.from_json(json)
+ assert isinstance(restored.output[0], ArrayMap)
+ # Orthogonal arrays are normalized to full input rank with a singleton
+ # axis on the dimension they do not vary over.
+ assert restored.output[0].index_array.shape == (3, 1)
+ np.testing.assert_array_equal(restored.output[0].index_array, idx.reshape(3, 1))
+ assert isinstance(restored.output[1], DimensionMap)
+
+ def test_roundtrip_preserves_singleton_axes(self) -> None:
+ """Full-rank orthogonal arrays keep their singleton axes across JSON."""
+ t = IndexTransform.from_shape((10, 20)).oindex[np.array([1, 3]), np.array([2, 4, 6])]
+ restored = IndexTransform.from_json(t.to_json())
+ orig0, orig1 = t.output[0], t.output[1]
+ rest0, rest1 = restored.output[0], restored.output[1]
+ assert isinstance(orig0, ArrayMap)
+ assert isinstance(orig1, ArrayMap)
+ assert isinstance(rest0, ArrayMap)
+ assert isinstance(rest1, ArrayMap)
+ assert rest0.index_array.shape == (2, 1)
+ assert rest1.index_array.shape == (1, 3)
+ np.testing.assert_array_equal(rest0.index_array, orig0.index_array)
+ np.testing.assert_array_equal(rest1.index_array, orig1.index_array)
+
+ def test_with_labels(self) -> None:
+ domain = IndexDomain(inclusive_min=(0, 0), exclusive_max=(10, 20), labels=("x", "y"))
+ t = IndexTransform.identity(domain)
+ json = t.to_json()
+ assert json["input_labels"] == ["x", "y"]
+ restored = IndexTransform.from_json(json)
+ assert restored.domain.labels == ("x", "y")
+
+ def test_tensorstore_compatible_format(self) -> None:
+ """A canonical body loads and round-trips through the engine layer."""
+ json: IndexTransformJSON = {
+ "input_rank": 3,
+ "input_inclusive_min": [0, 0, 0],
+ "input_exclusive_max": [100, 200, 3],
+ "input_labels": ["x", "y", "channel"],
+ "output": [
+ {"offset": 5},
+ {"offset": 10, "stride": 2, "input_dimension": 1},
+ # Full input rank, which is what TensorStore itself requires:
+ # it rejects a rank-1 array over a rank-3 domain outright.
+ {"offset": 0, "stride": 1, "index_array": [[[1, 2, 0]]]},
+ ],
+ }
+ t = IndexTransform.from_json(json)
+ assert t.domain.shape == (100, 200, 3)
+ assert t.domain.labels == ("x", "y", "channel")
+ assert isinstance(t.output[0], ConstantMap)
+ assert t.output[0].offset == 5
+ assert isinstance(t.output[1], DimensionMap)
+ assert t.output[1].offset == 10
+ assert t.output[1].stride == 2
+ assert t.output[1].input_dimension == 1
+ assert isinstance(t.output[2], ArrayMap)
+ np.testing.assert_array_equal(t.output[2].index_array, [[[1, 2, 0]]])
+
+ # Roundtrip
+ json_rt = t.to_json()
+ t_rt = IndexTransform.from_json(json_rt)
+ assert t_rt.domain == t.domain
+
+
+class TestCanonicalRoundTrips:
+ """Round-trip `transform == from(to(transform))`, up to the documented
+ degenerate-collapse (all-singleton ArrayMap -> ConstantMap)."""
+
+ def test_oindex_multi_axis(self) -> None:
+ t = IndexTransform.from_shape((10, 20, 30)).oindex[np.array([1, 3]), :, np.array([2, 4, 6])]
+ rt = IndexTransform.from_json(t.to_json())
+ assert _transforms_equal(rt, t)
+
+ def test_oindex_with_slice(self) -> None:
+ t = IndexTransform.from_shape((10, 20))[2:8].oindex[np.array([3, 5, 7]), :]
+ rt = IndexTransform.from_json(t.to_json())
+ assert _transforms_equal(rt, t)
+
+ def test_vindex(self) -> None:
+ t = IndexTransform.from_shape((10, 20)).vindex[np.array([1, 3, 5]), np.array([2, 4, 6])]
+ rt = IndexTransform.from_json(t.to_json())
+ assert _transforms_equal(rt, t)
+
+ def test_vindex_with_residual_slice(self) -> None:
+ t = IndexTransform.from_shape((10, 20, 30)).vindex[np.array([1, 3]), np.array([2, 4]), :]
+ rt = IndexTransform.from_json(t.to_json())
+ assert _transforms_equal(rt, t)
+
+ def test_length1_degenerate_oindex_collapses(self) -> None:
+ """A length-1 oindex selection is the ConstantMap it equals.
+
+ The selection layer collapses it at construction; a hand-built
+ all-singleton ArrayMap still collapses on serialize, so the canonical
+ wire form is a `constant` map either way.
+ """
+ t = IndexTransform.from_shape((10, 20)).oindex[np.array([7]), :]
+ m = t.output[0]
+ assert isinstance(m, ConstantMap)
+ assert m.offset == 7
+
+ hand_built = IndexTransform(
+ domain=t.domain,
+ output=(ArrayMap(index_array=np.array([[7]], dtype=np.intp)), t.output[1]),
+ )
+ rt = IndexTransform.from_json(hand_built.to_json())
+ rm = rt.output[0]
+ assert isinstance(rm, ConstantMap)
+ assert rm.offset == 7
+ # The size-1 input dimension survives, unconsumed, in the domain.
+ assert rt.domain == t.domain
+
+ def test_slices_and_constants(self) -> None:
+ t = IndexTransform.from_shape((10, 20, 30))[2:8:2, 5, :]
+ rt = IndexTransform.from_json(t.to_json())
+ assert _transforms_equal(rt, t)
+
+
+def _index_array_body(index_array: Any, rank: int = 1, extent: int = 2) -> IndexTransformJSON:
+ return {
+ "input_rank": rank,
+ "input_inclusive_min": [0] * rank,
+ "input_exclusive_max": [extent] * rank,
+ "input_labels": [""] * rank,
+ "output": [{"offset": 0, "stride": 1, "index_array": index_array}],
+ }
+
+
+@pytest.mark.parametrize(
+ ("index_array", "detail"),
+ [
+ ([0.9, 1.9], "float64"),
+ ([0, 1.5], "float64"),
+ ([True, False], "bool"),
+ (["a", "b"], "str"),
+ # Not lists at all, so they are turned away before their content is
+ # looked at: a bare string would be iterated into characters, and a bare
+ # integer would become a rank-0 array and then a length-1 map, so a
+ # document naming no cells would select one.
+ ("abc", "must be an array of integers"),
+ (5, "must be an array of integers"),
+ ([None, None], "object"),
+ ],
+ ids=["floats", "mixed", "bools", "strings", "string", "scalar", "nulls"],
+)
+def test_a_non_integer_index_array_is_rejected(index_array: Any, detail: str) -> None:
+ """An `index_array` addresses output coordinates, so it must be integral.
+
+ Lowering a float array silently truncated it (`[0.9, 1.9]` selected cells 0
+ and 1), a bool array coerced to 0/1, and a string array leaked a raw NumPy
+ `ValueError` from the middle of the conversion.
+ """
+ with pytest.raises(NdselError) as excinfo:
+ IndexTransform.from_json(_index_array_body(index_array))
+ assert excinfo.value.reason == "invalid_json"
+ assert "index_array" in str(excinfo.value)
+ assert detail in str(excinfo.value)
+
+
+def test_a_ragged_index_array_is_rejected() -> None:
+ """A nested list that is not rectangular is not an array at all."""
+ with pytest.raises(NdselError) as excinfo:
+ IndexTransform.from_json(_index_array_body([[0, 1], [2]]))
+ assert excinfo.value.reason == "invalid_json"
+
+
+@pytest.mark.parametrize(
+ ("index_array", "rank", "extent"),
+ [([0, 1], 1, 2), ([[0], [1]], 2, 2), ([], 1, 0)],
+ ids=["1d", "2d", "empty"],
+)
+def test_an_integer_index_array_is_accepted(index_array: Any, rank: int, extent: int) -> None:
+ """Integers of any nesting still lower, including an empty selection.
+
+ An empty array selects nothing, so the domain it is read over is empty too;
+ a domain with room for coordinates the array does not supply is rejected
+ (see `test_an_index_array_that_does_not_span_its_domain_is_rejected`).
+ """
+ t = IndexTransform.from_json(_index_array_body(index_array, rank, extent))
+ m = t.output[0]
+ assert isinstance(m, ArrayMap)
+ assert m.index_array.dtype == np.intp
+
+
+def test_a_non_integer_index_array_is_rejected_by_the_map_loader() -> None:
+ """The single-map loader enforces the same constraint as the transform one."""
+ with pytest.raises(NdselError) as excinfo:
+ output_index_map_from_json({"index_array": [0.5, 1.5]})
+ assert excinfo.value.reason == "invalid_json"
+
+
+def test_infinite_bound_rejected_on_lowering() -> None:
+ body: IndexTransformJSON = {
+ "input_rank": 1,
+ "input_inclusive_min": [0],
+ "input_exclusive_max": [["+inf"]],
+ "input_labels": [""],
+ "output": [{"offset": 0, "stride": 1, "input_dimension": 0}],
+ }
+ with pytest.raises(ValueError, match="infinite"):
+ IndexTransform.from_json(body)
+
+
+def test_a_lower_rank_index_array_is_widened_on_the_way_in() -> None:
+ """External JSON may broadcast a lower-rank array; the engine never holds one.
+
+ ndsel leaves index-array rank unvalidated, so a conformant producer may send
+ an array of lower rank. It is widened at the boundary, which keeps the
+ full-rank invariant true of every transform the engine builds.
+ """
+ # A rank-1 array widens into the trailing axis, so it spans that axis's
+ # extent of four.
+ body: IndexTransformJSON = {
+ "input_inclusive_min": [0, 0],
+ "input_exclusive_max": [3, 4],
+ "output": [{"index_array": [1, 2, 0, 2]}, {"input_dimension": 1}],
+ }
+ transform = IndexTransform.from_json(body)
+ array_map = transform.output[0]
+ assert isinstance(array_map, ArrayMap)
+ assert array_map.index_array.shape == (1, 4)
+ assert array_map.index_array.ndim == transform.domain.ndim
+
+
+def test_an_index_array_of_the_wrong_rank_is_rejected() -> None:
+ """Inside the engine, a rank that does not match the domain is a bug."""
+ with pytest.raises(ValueError, match="index_array has 1 dims"):
+ IndexTransform(
+ domain=IndexDomain.from_shape((3, 4)),
+ output=(ArrayMap(index_array=np.array([1, 2, 0], dtype=np.intp)),),
+ )
+
+
+def test_an_index_array_that_does_not_span_its_domain_is_rejected() -> None:
+ """An array with entries for only part of an axis is not a smaller selection.
+
+ Reading it that way is how a truncated index array turned into a partially
+ written result rather than an error.
+ """
+ with pytest.raises(ValueError, match="neither 1 nor the domain's extent"):
+ IndexTransform(
+ domain=IndexDomain.from_shape((3, 2)),
+ output=(
+ ArrayMap(index_array=np.zeros((3, 0), dtype=np.intp)),
+ DimensionMap(input_dimension=1),
+ ),
+ )
+
+
+def test_an_empty_index_array_collapses_to_a_constant() -> None:
+ """Selecting nothing must survive a trip through JSON.
+
+ An empty index array names no cell, and can only be empty because an input
+ dimension is, so nothing is ever read through it. It is degenerate in
+ exactly the way a size-1 array is, and collapses the same way — which is
+ also what TensorStore emits for `t[ts.d[0][[]]]`.
+
+ Emitting the array instead produced a document nothing could load:
+ `tolist()` renders every empty array as `[]` once the leading axis is the
+ zero-length one, so the rank went with it, and the loader put the dependency
+ back on a different axis by prepending singletons.
+ """
+ for shape, selection in (
+ ((5, 3), (np.array([], dtype=np.intp), slice(None))),
+ ((5, 5), (np.array([], dtype=np.intp), np.array([], dtype=np.intp))),
+ ):
+ transform = IndexTransform.from_shape(shape).oindex[selection]
+ body = transform.to_json()
+
+ assert all("index_array" not in m for m in body["output"])
+ reloaded = IndexTransform.from_json(body)
+ assert reloaded.domain == transform.domain
+ assert reloaded.to_json() == body
+
+
+def test_an_empty_index_array_from_elsewhere_is_recovered_from_the_domain() -> None:
+ """A producer that does emit one is still readable when the domain settles it.
+
+ This package never writes such a document, but ndsel does not forbid it, and
+ the domain names the axis unambiguously when exactly one dimension is empty.
+ """
+ body: IndexTransformJSON = {
+ "input_inclusive_min": [0, 0],
+ "input_exclusive_max": [0, 4],
+ "output": [{"index_array": []}, {"input_dimension": 1}],
+ }
+ array_map = IndexTransform.from_json(body).output[0]
+ assert isinstance(array_map, ArrayMap)
+ assert array_map.index_array.shape == (0, 1)
+
+
+def test_an_ambiguous_empty_index_array_is_rejected() -> None:
+ """Two zero-length dimensions leave nothing to recover the axis from."""
+ body: IndexTransformJSON = {
+ "input_inclusive_min": [0, 0],
+ "input_exclusive_max": [0, 0],
+ "output": [{"index_array": []}, {"input_dimension": 1}],
+ }
+ with pytest.raises(NdselError) as excinfo:
+ IndexTransform.from_json(body)
+ assert excinfo.value.reason == "invalid_json"
+ assert "zero-length" in str(excinfo.value)
+
+
+@pytest.mark.parametrize(
+ ("document", "reason", "detail"),
+ [
+ (
+ {"input_inclusive_min": [0.0], "input_exclusive_max": [3], "input_labels": [""]},
+ "invalid_json",
+ "must be an integer",
+ ),
+ (
+ {"input_inclusive_min": [0], "input_exclusive_max": ["3"], "input_labels": [""]},
+ "invalid_json",
+ "must be an integer",
+ ),
+ (
+ {"input_inclusive_min": [False], "input_exclusive_max": [True], "input_labels": [""]},
+ "invalid_json",
+ "must be an integer",
+ ),
+ (
+ {"input_inclusive_min": [0], "input_exclusive_max": [3], "input_labels": [5]},
+ "invalid_json",
+ "must be a string",
+ ),
+ (
+ {"input_inclusive_min": [0], "input_exclusive_max": [2**200], "input_labels": [""]},
+ "invalid_json",
+ "64-bit signed range",
+ ),
+ ],
+ ids=["float", "string", "bool", "non-string-label", "out-of-range"],
+)
+def test_a_malformed_domain_document_is_rejected(document: Any, reason: str, detail: str) -> None:
+ """The domain loader validates what the message layer validates.
+
+ Reading the keys directly was a second, undefended way into the same
+ objects: a bare `int()` truncated `3.9` to 3, coerced `"3"` and `True`, and
+ let a non-string label into a `tuple[str, ...]` — each building a domain
+ that was not the document's, and re-dumping as a different document.
+ """
+ with pytest.raises(NdselError) as excinfo:
+ IndexDomain.from_json(document)
+ assert excinfo.value.reason == reason
+ assert detail in str(excinfo.value)
+
+
+def test_a_transform_body_cannot_reinterpret_itself_as_another_message() -> None:
+ """A `kind` inside the body must not change which message is being read."""
+ with pytest.raises(NdselError) as excinfo:
+ IndexTransform.from_json({"kind": "points", "coords": [[1, 2], [3, 4]]})
+ assert excinfo.value.reason == "invalid_json"
+ assert "kind" in str(excinfo.value)
+
+
+def test_an_engine_invariant_failure_leaves_the_loader_as_a_typed_error() -> None:
+ """A document is invalid input however deep the check that catches it lives.
+
+ The engine's rank and span invariants are the last gate a document passes,
+ and they raised a bare `ValueError` written in the engine's vocabulary.
+ """
+ body: IndexTransformJSON = {
+ "input_rank": 1,
+ "input_inclusive_min": [0],
+ "input_exclusive_max": [5],
+ "input_labels": [""],
+ "output": [{"index_array": [[1, 2], [3, 4]]}],
+ }
+ with pytest.raises(NdselError) as excinfo:
+ IndexTransform.from_json(body)
+ assert excinfo.value.reason == "rank_mismatch"
diff --git a/packages/zarr-indexing/tests/test_lazy_array.py b/packages/zarr-indexing/tests/test_lazy_array.py
new file mode 100644
index 0000000000..ab94be1ae5
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_lazy_array.py
@@ -0,0 +1,2618 @@
+"""Tests for `zarr_indexing.grid` grids and the `LazyArray` wrapper.
+
+The happy-path suite is a single oracle test: every selection case is applied
+both to a `LazyArray` and to the NumPy array it wraps, and the results must
+match. The case list is crossed with four source flavors — an unchunked NumPy
+array, NumPy with each of the two declared chunk conventions, and a zarr array
+whose chunking is auto-discovered — so the chunked and unchunked resolution
+strategies are held to the same answers.
+"""
+
+from __future__ import annotations
+
+import operator
+import pickle
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import replace
+from typing import TYPE_CHECKING, Any, cast
+
+import numpy as np
+import pytest
+
+import zarr_indexing.lazy_array as lazy_array_module
+from zarr_indexing import (
+ ArrayMap,
+ ChunkGrid,
+ ChunkProjection,
+ ConstantMap,
+ DimensionMap,
+ EdgeDimensionGrid,
+ FixedDimension,
+ IndexTransform,
+ LazyArray,
+ ReadContext,
+ VaryingDimension,
+ dimension_grids_from_chunks,
+)
+from zarr_indexing.lazy_array import _out_selection_cell_count, _validate_prepared_parts
+from zarr_indexing.reader import Reader, basic_reader, numpy_reader
+from zarr_indexing.testing import repartition
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Sequence
+
+SHAPE = (7, 5, 4)
+PART_SHAPE = (3, 2, 3)
+EXPLICIT_PARTS = ((3, 3, 1), (2, 2, 1), (3, 1))
+
+
+class IndexLike:
+ """A scalar integer selector implemented only through `__index__`."""
+
+ def __init__(self, value: int) -> None:
+ self.value = value
+
+ def __index__(self) -> int:
+ return self.value
+
+
+class IntOnly:
+ def __int__(self) -> int:
+ return 2
+
+
+class BadIndex:
+ """An `__index__` that lies: the protocol requires an integer."""
+
+ def __index__(self) -> int:
+ return cast("int", 2.5)
+
+
+def reference() -> np.ndarray[Any, np.dtype[np.int64]]:
+ """The array every source flavor holds, and the oracle for every case."""
+ return np.arange(int(np.prod(SHAPE)), dtype=np.int64).reshape(SHAPE)
+
+
+class DelegatingReader:
+ def __init__(self, inner: Reader) -> None:
+ self.inner = inner
+
+ def read_into(self, source: Any, context: ReadContext, out: Any, /) -> None:
+ self.inner.read_into(source, context, out)
+
+
+def outer(ref: np.ndarray[Any, Any], selections: Sequence[Any]) -> np.ndarray[Any, Any]:
+ """NumPy oracle for orthogonal indexing: the outer product of per-axis selections.
+
+ Scalar integers are basic indices — NumPy applies them first and drops the
+ axis — so they are peeled off before the outer product is formed.
+ """
+ scalars = tuple(
+ sel if isinstance(sel, (int, np.integer)) and not isinstance(sel, bool) else slice(None)
+ for sel in selections
+ )
+ reduced = ref[scalars]
+ axes = [
+ np.arange(size)[sel]
+ for size, sel in zip(
+ reduced.shape,
+ [
+ s
+ for s in selections
+ if not (isinstance(s, (int, np.integer)) and not isinstance(s, bool))
+ ],
+ strict=True,
+ )
+ ]
+ if len(axes) == 0:
+ return reduced
+ return reduced[np.ix_(*axes)]
+
+
+# ---------------------------------------------------------------------------
+# EdgeDimensionGrid
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize(
+ ("sizes", "expected_index_to_chunk", "expected_offsets", "expected_sizes"),
+ [
+ # A clipped edge chunk.
+ ((3, 3, 1), [0, 0, 0, 1, 1, 1, 2], [0, 3, 6], [3, 3, 1]),
+ # A single chunk covering the whole axis.
+ ((4,), [0, 0, 0, 0], [0], [4]),
+ # A size-1 axis.
+ ((1,), [0], [0], [1]),
+ # Irregular sizes: the grid does not have to be regular.
+ ((1, 2, 1), [0, 1, 1, 2], [0, 1, 3], [1, 2, 1]),
+ ],
+)
+def test_edge_dimension_grid(
+ sizes: tuple[int, ...],
+ expected_index_to_chunk: list[int],
+ expected_offsets: list[int],
+ expected_sizes: list[int],
+) -> None:
+ """All four `DimensionGridLike` methods agree with hand-computed values."""
+ grid = EdgeDimensionGrid(sizes)
+ extent = sum(sizes)
+
+ assert grid.num_chunks == len(sizes)
+ assert grid.extent == extent
+ assert [grid.index_to_chunk(i) for i in range(extent)] == expected_index_to_chunk
+ assert [grid.chunk_offset(c) for c in range(len(sizes))] == expected_offsets
+ assert [grid.chunk_size(c) for c in range(len(sizes))] == expected_sizes
+ np.testing.assert_array_equal(
+ grid.indices_to_chunks(np.arange(extent, dtype=np.intp)),
+ np.asarray(expected_index_to_chunk, dtype=np.intp),
+ )
+
+
+def test_edge_dimension_grid_rejects_nonpositive_size() -> None:
+ with pytest.raises(ValueError, match="chunk sizes must be positive"):
+ EdgeDimensionGrid((3, 0, 2))
+
+
+def test_edge_dimension_grid_rejects_out_of_bounds_index() -> None:
+ with pytest.raises(IndexError, match="out of bounds for an axis of extent 4"):
+ EdgeDimensionGrid((3, 1)).index_to_chunk(4)
+
+
+def test_edge_dimension_grid_rejects_out_of_bounds_chunk() -> None:
+ with pytest.raises(IndexError, match="chunk index 2 is out of bounds"):
+ EdgeDimensionGrid((3, 1)).chunk_offset(2)
+
+
+@pytest.mark.parametrize(
+ "grid",
+ [
+ pytest.param(FixedDimension(size=2, extent=4), id="fixed"),
+ pytest.param(VaryingDimension(edges=(1, 3), extent=4), id="varying"),
+ ],
+)
+def test_compact_dimension_grid_rejects_vector_below_extent(grid: Any) -> None:
+ with pytest.raises(IndexError, match=r"indices must lie in \[0, 4\); got \[-1, 1\]"):
+ grid.indices_to_chunks(np.array([-1, 1], dtype=np.intp))
+
+
+@pytest.mark.parametrize(
+ "grid",
+ [
+ pytest.param(FixedDimension(size=2, extent=4), id="fixed"),
+ pytest.param(VaryingDimension(edges=(1, 3), extent=4), id="varying"),
+ ],
+)
+def test_compact_dimension_grid_rejects_vector_above_extent(grid: Any) -> None:
+ with pytest.raises(IndexError, match=r"indices must lie in \[0, 4\); got \[1, 4\]"):
+ grid.indices_to_chunks(np.array([1, 4], dtype=np.intp))
+
+
+def test_fixed_dimension_rejects_zero_size_for_nonempty_extent() -> None:
+ with pytest.raises(ValueError, match="size must be > 0 when extent is nonzero"):
+ FixedDimension(size=0, extent=4)
+
+
+def test_fixed_dimension_retains_zero_size_for_zero_extent() -> None:
+ grid = FixedDimension(size=0, extent=0)
+
+ assert grid.nchunks == 0
+ assert grid.ngridcells == 0
+
+
+@pytest.mark.parametrize(
+ ("chunks", "shape", "expected"),
+ [
+ # Uniform chunk shape, tail clipped.
+ ((3, 4), (7, 4), (FixedDimension(size=3, extent=7), FixedDimension(size=4, extent=4))),
+ # Dask-convention per-axis sizes, passed through.
+ (
+ ((3, 3, 1), (2, 2)),
+ (7, 4),
+ (VaryingDimension(edges=(3, 3, 1), extent=7), VaryingDimension(edges=(2, 2), extent=4)),
+ ),
+ # A chunk longer than the axis collapses to one clipped chunk.
+ ((10,), (4,), (FixedDimension(size=10, extent=4),)),
+ # A zero-length axis has no chunks at all.
+ ((3,), (0,), (FixedDimension(size=3, extent=0),)),
+ ],
+)
+def test_dimension_grids_from_chunks(
+ chunks: Any, shape: tuple[int, ...], expected: tuple[Any, ...]
+) -> None:
+ grids = dimension_grids_from_chunks(chunks, shape)
+ assert grids == expected
+
+
+def test_regular_dimension_metadata_is_constant_in_chunk_count() -> None:
+ dimensions = dimension_grids_from_chunks((1,), (1_000_000,))
+
+ assert dimensions == (FixedDimension(size=1, extent=1_000_000),)
+ assert dimensions[0].nchunks == 1_000_000
+ assert dimensions[0].index_to_chunk(999_999) == 999_999
+
+
+def test_chunk_grid_distinguishes_codec_and_data_shape_at_the_edge() -> None:
+ grid = ChunkGrid(dimensions=(FixedDimension(size=3, extent=7),))
+
+ edge = grid[(2,)]
+
+ assert edge is not None
+ assert edge.slices == (slice(6, 7, 1),)
+ assert edge.shape == (1,)
+ assert edge.codec_shape == (3,)
+
+
+def test_dimension_grids_reject_negative_shape() -> None:
+ with pytest.raises(ValueError, match="shape entries must be non-negative"):
+ dimension_grids_from_chunks((3,), (-1,))
+
+
+def test_dimension_grids_from_chunks_rejects_wrong_length() -> None:
+ with pytest.raises(ValueError, match="one entry per dimension"):
+ dimension_grids_from_chunks((3,), (7, 4))
+
+
+def test_dimension_grids_from_chunks_rejects_mixed_conventions() -> None:
+ with pytest.raises(ValueError, match="not a mixture"):
+ dimension_grids_from_chunks((3, (2, 2)), (7, 4))
+
+
+def test_dimension_grids_from_chunks_rejects_wrong_total() -> None:
+ with pytest.raises(ValueError, match="sum to 5, but the array extent is 7"):
+ dimension_grids_from_chunks(((3, 2), (4,)), (7, 4))
+
+
+def test_dimension_grids_from_chunks_rejects_non_sequence_entry() -> None:
+ """A float entry is neither convention, and says so instead of raising TypeError."""
+ with pytest.raises(ValueError, match=r"3\.5 at dimension 0 is neither"):
+ dimension_grids_from_chunks((3.5, (4,)), (7, 4))
+
+
+def test_array_map_dependent_axis_reports_no_axis() -> None:
+ """A map varying over nothing answers None rather than a stale binding."""
+ correlated = ArrayMap(index_array=np.array([[1], [2]], dtype=np.intp))
+ assert correlated.dependent_axis == 0
+ assert ArrayMap(index_array=np.array([[3]], dtype=np.intp)).dependent_axis is None
+
+
+def test_scalar_on_a_fancy_axis_collapses_to_a_constant() -> None:
+ """The degenerate-collapse rule: an all-singleton ArrayMap becomes a ConstantMap.
+
+ Without it the map keeps an `input_dimension` naming an axis the integer
+ index just removed, which after renumbering aliases a different axis.
+ """
+ view = IndexTransform.from_shape((7, 5)).oindex[np.array([3, 1]), slice(None)][0]
+ assert view.output[0] == ConstantMap(offset=3)
+ assert isinstance(view.output[1], DimensionMap)
+ assert view.domain.shape == (5,)
+
+
+# ---------------------------------------------------------------------------
+# Sources
+# ---------------------------------------------------------------------------
+
+
+def make_zarr_source() -> Any:
+ """A zarr array holding `reference()`, whose parts are auto-discovered."""
+ zarr = pytest.importorskip("zarr")
+ array = zarr.create_array({}, shape=SHAPE, chunks=PART_SHAPE, dtype="int64")
+ array[:] = reference()
+ return array
+
+
+def make_source(flavor: str) -> LazyArray:
+ """Build a `LazyArray` over `reference()` with the requested partitioning."""
+ data = reference()
+ if flavor == "numpy-whole":
+ return LazyArray(data)
+ if flavor == "numpy-explicit-parts":
+ return LazyArray(data).with_parts_per_axis(EXPLICIT_PARTS)
+ if flavor == "numpy-uniform-parts":
+ return LazyArray(data).with_parts(PART_SHAPE)
+ if flavor == "zarr":
+ return LazyArray(make_zarr_source())
+ if flavor == "zarr-misaligned":
+ # Parts that deliberately straddle the zarr array's own chunks.
+ return LazyArray(make_zarr_source()).with_parts((4, 3, 3))
+ raise TypeError(f"unknown source flavor {flavor!r}")
+
+
+FLAVORS = [
+ "numpy-whole",
+ "numpy-explicit-parts",
+ "numpy-uniform-parts",
+ "zarr",
+ "zarr-misaligned",
+]
+
+
+@pytest.fixture(params=FLAVORS)
+def source(request: pytest.FixtureRequest) -> LazyArray:
+ return make_source(request.param)
+
+
+# ---------------------------------------------------------------------------
+# The oracle
+# ---------------------------------------------------------------------------
+
+MASK = (reference() % 11) == 0
+# A mask over the two trailing axes, for the `vindex[..., mask]` idiom.
+TRAILING_MASK = (reference()[0] % 3) == 0
+
+# (id, lazy view builder, NumPy oracle). Integer scalars are deliberately absent
+# from the orthogonal cases: the transform algebra keeps an int-selected axis as
+# a length-1 axis under `oindex`, which `np.ix_` cannot express.
+CASES: list[tuple[str, Callable[[LazyArray], LazyArray], Callable[[Any], Any]]] = [
+ (
+ "basic-strided-and-int-drop",
+ lambda a: a.lazy[1:6:2, :, -1],
+ lambda r: r[1:6:2, :, -1],
+ ),
+ ("basic-ellipsis", lambda a: a.lazy[..., -2], lambda r: r[..., -2]),
+ ("basic-negative-scalar", lambda a: a.lazy[-3], lambda r: r[-3]),
+ ("basic-newaxis", lambda a: a.lazy[None, :, :, None], lambda r: r[None, :, :, None]),
+ ("basic-empty", lambda a: a.lazy[:, 2:2, :], lambda r: r[:, 2:2, :]),
+ ("basic-all-scalars", lambda a: a.lazy[-1, 0, 2], lambda r: r[-1, 0, 2]),
+ (
+ "oindex-unsorted-duplicates-multi-axis",
+ lambda a: a.lazy.oindex[[4, 0, 0, 2], :, [3, 1]],
+ lambda r: outer(r, ([4, 0, 0, 2], slice(None), [3, 1])),
+ ),
+ (
+ "oindex-negative-and-slice",
+ lambda a: a.lazy.oindex[:, [-1, 0], 1:4],
+ lambda r: outer(r, (slice(None), [-1, 0], slice(1, 4))),
+ ),
+ (
+ "oindex-boolean-axis",
+ lambda a: a.lazy.oindex[np.array([True, False, True, False, False, False, True]), :, :],
+ lambda r: outer(
+ r,
+ (np.array([True, False, True, False, False, False, True]), slice(None), slice(None)),
+ ),
+ ),
+ (
+ "vindex-coordinates",
+ lambda a: a.lazy.vindex[np.array([0, 6, 3]), np.array([1, 4, 0]), np.array([2, 0, 1])],
+ lambda r: r[np.array([0, 6, 3]), np.array([1, 4, 0]), np.array([2, 0, 1])],
+ ),
+ (
+ "vindex-broadcast-pair",
+ lambda a: a.lazy.vindex[np.array([[0], [6]]), np.array([1, 4]), np.array([2, 0])],
+ lambda r: r[np.array([[0], [6]]), np.array([1, 4]), np.array([2, 0])],
+ ),
+ (
+ "vindex-negative-coordinates",
+ lambda a: a.lazy.vindex[np.array([-1, -7]), np.array([-2, 0]), np.array([0, -1])],
+ lambda r: r[np.array([-1, -7]), np.array([-2, 0]), np.array([0, -1])],
+ ),
+ ("vindex-mask", lambda a: a.lazy.vindex[MASK], lambda r: r[MASK]),
+ (
+ "compose-basic-then-oindex",
+ lambda a: a.lazy[1:6].lazy.oindex[[3, 0, 0], [4, 1], :],
+ lambda r: outer(r[1:6], ([3, 0, 0], [4, 1], slice(None))),
+ ),
+ (
+ "compose-oindex-then-basic-other-axis",
+ lambda a: a.lazy.oindex[[4, 0, 2], :, :].lazy[:, 1:4, ::2],
+ lambda r: outer(r, ([4, 0, 2], slice(None), slice(None)))[:, 1:4, ::2],
+ ),
+ (
+ "compose-basic-then-basic",
+ lambda a: a.lazy[2:, 1:].lazy[::2, -1],
+ lambda r: r[2:, 1:][::2, -1],
+ ),
+ (
+ "compose-basic-then-vindex",
+ lambda a: a.lazy[1:6, :, 1:].lazy.vindex[
+ np.array([0, 4]), np.array([2, 0]), np.array([1, 2])
+ ],
+ lambda r: r[1:6, :, 1:][np.array([0, 4]), np.array([2, 0]), np.array([1, 2])],
+ ),
+ # Scalar integers are basic indices in the positional dialect: they drop the
+ # axis, in every mode, exactly as NumPy does.
+ ("oindex-scalar-drops-axis", lambda a: a.lazy.oindex[0], lambda r: r[0]),
+ (
+ "oindex-scalar-with-arrays",
+ lambda a: a.lazy.oindex[0, [1, 2], :],
+ lambda r: outer(r, (0, [1, 2], slice(None))),
+ ),
+ (
+ "oindex-scalar-middle-axis",
+ lambda a: a.lazy.oindex[[3, 1], -1, :],
+ lambda r: outer(r, ([3, 1], -1, slice(None))),
+ ),
+ ("oindex-all-scalars", lambda a: a.lazy.oindex[0, 1, 2], lambda r: r[0, 1, 2]),
+ ("vindex-all-scalars", lambda a: a.lazy.vindex[0, 1, 2], lambda r: r[0, 1, 2]),
+ (
+ "vindex-scalar-with-arrays",
+ lambda a: a.lazy.vindex[0, [1, 2], [3, 0]],
+ lambda r: r[0, [1, 2], [3, 0]],
+ ),
+ (
+ "vindex-scalar-on-middle-axis",
+ lambda a: a.lazy.vindex[[1, 2], 0, [3, 0]],
+ lambda r: r[[1, 2], 0, [3, 0]],
+ ),
+ # Scalar applied to a previously fancy-indexed axis, both orders.
+ (
+ "compose-oindex-then-scalar",
+ lambda a: a.lazy.oindex[[3, 1], :, :].lazy[0],
+ lambda r: outer(r, ([3, 1], slice(None), slice(None)))[0],
+ ),
+ (
+ "compose-oindex-then-scalar-negative",
+ lambda a: a.lazy.oindex[[3, 1, 1], :, :].lazy[-1, 2],
+ lambda r: outer(r, ([3, 1, 1], slice(None), slice(None)))[-1, 2],
+ ),
+ (
+ "compose-scalar-then-oindex",
+ lambda a: a.lazy[0].lazy.oindex[[3, 1], :],
+ lambda r: outer(r[0], ([3, 1], slice(None))),
+ ),
+ (
+ "compose-vindex-then-scalar",
+ lambda a: a.lazy.vindex[np.array([0, 6, 3]), np.array([1, 4, 0]), np.array([2, 0, 1])].lazy[
+ 1
+ ],
+ lambda r: r[np.array([0, 6, 3]), np.array([1, 4, 0]), np.array([2, 0, 1])][1],
+ ),
+ # Partial vindex whose coordinate arrays are NOT on the leading axes: NumPy
+ # inserts the gathered axis where the (adjacent) advanced indices sat.
+ (
+ "vindex-trailing-arrays",
+ lambda a: a.lazy.vindex[..., np.array([1, 4, 0]), np.array([2, 0, 1])],
+ lambda r: r[..., np.array([1, 4, 0]), np.array([2, 0, 1])],
+ ),
+ (
+ "vindex-single-trailing-array",
+ lambda a: a.lazy.vindex[..., np.array([3, 0, 1])],
+ lambda r: r[..., np.array([3, 0, 1])],
+ ),
+ (
+ "vindex-trailing-mask",
+ lambda a: a.lazy.vindex[..., TRAILING_MASK],
+ lambda r: r[..., TRAILING_MASK],
+ ),
+ (
+ "vindex-leading-partial",
+ lambda a: a.lazy.vindex[np.array([1, 2, 2])],
+ lambda r: r[np.array([1, 2, 2])],
+ ),
+ (
+ "compose-basic-then-vindex-trailing",
+ lambda a: a.lazy[2:, 1:].lazy.vindex[..., np.array([1, 3, 0])],
+ lambda r: r[2:, 1:][..., np.array([1, 3, 0])],
+ ),
+ # A ConstantMap sitting between a slice and the coordinate arrays: NumPy
+ # counts the integer as an advanced index, so the gathered axis moves to the
+ # front of the chunk block even though the arrays are trailing.
+ (
+ "compose-vindex-trailing-then-scalar",
+ lambda a: a.lazy.vindex[..., np.array([3, 0])].lazy[2],
+ lambda r: r[..., np.array([3, 0])][2],
+ ),
+ # Two fancy axes, then a scalar on the first: the surviving ArrayMap ends up
+ # behind a ConstantMap and a slice, which is where NumPy's integer-counts-as-
+ # advanced rule bites.
+ (
+ "compose-oindex-two-axes-then-scalar",
+ lambda a: a.lazy.oindex[[3, 1, 0], 3:5, [2, 0, 2]].lazy[0],
+ lambda r: outer(r, ([3, 1, 0], slice(3, 5), [2, 0, 2]))[0],
+ ),
+ # Negative steps: the positional dialect is NumPy's, including the empty
+ # cases NumPy allows where the transform algebra alone would object.
+ ("reverse", lambda a: a.lazy[::-1], lambda r: r[::-1]),
+ ("reverse-every-axis", lambda a: a.lazy[::-1, ::-1, ::-1], lambda r: r[::-1, ::-1, ::-1]),
+ ("reverse-strided", lambda a: a.lazy[::-2], lambda r: r[::-2]),
+ ("reverse-nondivisible", lambda a: a.lazy[::-3], lambda r: r[::-3]),
+ ("reverse-bounded", lambda a: a.lazy[5:1:-1], lambda r: r[5:1:-1]),
+ ("reverse-negative-start", lambda a: a.lazy[-1:None:-1], lambda r: r[-1:None:-1]),
+ ("reverse-past-the-start", lambda a: a.lazy[:-8:-1], lambda r: r[:-8:-1]),
+ ("reverse-empty", lambda a: a.lazy[2:2:-1], lambda r: r[2:2:-1]),
+ # NumPy reads a reversed *positional* interval as empty; only the literal
+ # layer calls it a direction error.
+ ("reverse-inverted-is-empty", lambda a: a.lazy[2:5:-1], lambda r: r[2:5:-1]),
+ ("reverse-with-int-drop", lambda a: a.lazy[::-2, 2, ::-1], lambda r: r[::-2, 2, ::-1]),
+ ("reverse-trailing-axis", lambda a: a.lazy[..., ::-1], lambda r: r[..., ::-1]),
+ (
+ "compose-reverse-then-reverse",
+ lambda a: a.lazy[::-1].lazy[::-1],
+ lambda r: r[::-1][::-1],
+ ),
+ (
+ "compose-strided-then-reverse",
+ lambda a: a.lazy[::2].lazy[::-1],
+ lambda r: r[::2][::-1],
+ ),
+ (
+ "compose-reverse-then-strided",
+ lambda a: a.lazy[::-1].lazy[::2],
+ lambda r: r[::-1][::2],
+ ),
+ (
+ "compose-reverse-then-oindex",
+ lambda a: a.lazy[::-1].lazy.oindex[[3, 0, 0], :, :],
+ lambda r: outer(r[::-1], ([3, 0, 0], slice(None), slice(None))),
+ ),
+ (
+ "compose-oindex-then-reverse",
+ lambda a: a.lazy.oindex[[3, 1, 2], :, :].lazy[::-1],
+ lambda r: outer(r, ([3, 1, 2], slice(None), slice(None)))[::-1],
+ ),
+ (
+ "compose-reverse-then-vindex",
+ lambda a: a.lazy[::-1].lazy.vindex[..., np.array([1, 3, 0])],
+ lambda r: r[::-1][..., np.array([1, 3, 0])],
+ ),
+ (
+ "compose-vindex-trailing-then-scalar-and-slice",
+ lambda a: a.lazy.vindex[..., np.array([3, 0, 1])].lazy[-1, 1:4],
+ lambda r: r[..., np.array([3, 0, 1])][-1, 1:4],
+ ),
+ # A downward walk that begins off the front of the axis selects nothing.
+ # Written against an oindex axis and a vindex axis, where the selection is
+ # carried by an index array rather than by the domain.
+ (
+ "compose-oindex-then-empty-downward-walk",
+ lambda a: a.lazy.oindex[np.array([3, 1, 4]), :, :].lazy[-8::-1],
+ lambda r: r[np.array([3, 1, 4])][-8::-1],
+ ),
+ (
+ "compose-vindex-then-empty-downward-walk",
+ lambda a: a.lazy.vindex[np.array([3, 1]), np.array([2, 0])].lazy[-9::-2],
+ lambda r: r[np.array([3, 1]), np.array([2, 0])][-9::-2],
+ ),
+ (
+ "empty-downward-walk-on-a-plain-axis",
+ lambda a: a.lazy[-11::-1],
+ lambda r: r[-11::-1],
+ ),
+ # A fancy *spelling* whose entries are all slices is not a fancy selection:
+ # it narrows the view's own axes and must compose exactly like basic
+ # indexing. The slices start past 0, so a step that applied them to the
+ # broadcast (singleton) axes of the existing index array would truncate it.
+ (
+ "compose-oindex-then-oindex-slices-only",
+ lambda a: a.lazy.oindex[[4, 0, 0], :, :].lazy.oindex[:, 2:5, 1:],
+ lambda r: outer(r, ([4, 0, 0], slice(None), slice(None)))[:, 2:5, 1:],
+ ),
+ (
+ "compose-oindex-then-oindex-slices-only-strided",
+ lambda a: a.lazy.oindex[:, [3, 1, 1], :].lazy.oindex[1::2, :, ::-1],
+ lambda r: outer(r, (slice(None), [3, 1, 1], slice(None)))[1::2, :, ::-1],
+ ),
+ (
+ "compose-vindex-then-oindex-slices-only",
+ lambda a: a.lazy.vindex[np.array([4, 0, 2]), np.array([1, 3, 0])].lazy.oindex[1:, 2:],
+ lambda r: r[np.array([4, 0, 2]), np.array([1, 3, 0])][1:, 2:],
+ ),
+ (
+ "compose-oindex-then-oindex-array-on-its-own-axis",
+ lambda a: a.lazy.oindex[[4, 0, 0], :, :].lazy.oindex[[2, 0], 3:, :],
+ lambda r: outer(
+ outer(r, ([4, 0, 0], slice(None), slice(None))),
+ ([2, 0], slice(3, None), slice(None)),
+ ),
+ ),
+]
+
+
+@pytest.mark.parametrize(("build", "oracle"), [c[1:] for c in CASES], ids=[c[0] for c in CASES])
+def test_selection_matches_numpy(
+ source: LazyArray,
+ build: Callable[[LazyArray], LazyArray],
+ oracle: Callable[[Any], Any],
+) -> None:
+ """Every selection resolves to what NumPy computes positionally on the same data."""
+ view = build(source)
+ expected = np.asarray(oracle(reference()))
+
+ assert view.shape == expected.shape
+ assert view.ndim == expected.ndim
+ np.testing.assert_array_equal(np.asarray(view.result()), expected)
+ # `__getitem__` is eager, and `__array__` routes through `result()`.
+ np.testing.assert_array_equal(np.asarray(view), expected)
+
+
+# ---------------------------------------------------------------------------
+# Randomized chain sweep
+# ---------------------------------------------------------------------------
+
+
+def _random_basic(rng: np.random.Generator, shape: tuple[int, ...]) -> tuple[Any, ...]:
+ selection: list[Any] = []
+ for size in shape:
+ roll = rng.random()
+ if roll < 0.3:
+ selection.append(int(rng.integers(-size, size)))
+ elif roll < 0.55:
+ start = int(rng.integers(0, size))
+ stop = int(rng.integers(start, size + 1))
+ selection.append(slice(start, stop, int(rng.integers(1, 4))))
+ elif roll < 0.8:
+ # Downward: `start >= stop` and the stop may fall off the front,
+ # which is spelled `None`. The start is drawn from below `-size` as
+ # well, where the walk begins off the front and selects nothing —
+ # a case that reads as an ordinary negative index but is empty.
+ start = int(rng.integers(-2 * size - 1, size)) if size else 0
+ stop_choice = int(rng.integers(-1, max(start, 0) + 1))
+ stop = None if stop_choice < 0 else stop_choice
+ selection.append(slice(start, stop, -int(rng.integers(1, 4))))
+ else:
+ selection.append(slice(None))
+ return tuple(selection)
+
+
+def _random_oindex(rng: np.random.Generator, shape: tuple[int, ...]) -> tuple[Any, ...]:
+ selection: list[Any] = []
+ for size in shape:
+ roll = rng.random()
+ if roll < 0.25:
+ selection.append(int(rng.integers(-size, size)))
+ elif roll < 0.65:
+ count = int(rng.integers(1, 5))
+ selection.append(rng.integers(-size, size, size=count).tolist())
+ elif roll < 0.8:
+ mask = rng.random(size) < 0.5
+ mask[int(rng.integers(0, size))] = True
+ selection.append(mask)
+ else:
+ start = int(rng.integers(0, size))
+ selection.append(slice(start, size))
+ return tuple(selection)
+
+
+def _broadcast_singleton_axes(
+ rng: np.random.Generator, entries: list[Any], length: int
+) -> list[Any]:
+ """Reshape 1-D coordinate arrays so the selection carries singleton axes.
+
+ A coordinate array of shape `(1, n)` or `(n, 1)` contributes a broadcast axis
+ it does not vary over. That axis stays in the view's domain, and a later
+ basic index that consumes its partner leaves it referenced by no output map
+ at all — the shape that makes a broadcast axis and a genuine extent-1 axis
+ indistinguishable from the index array alone.
+ """
+ rank = int(rng.integers(2, 4))
+ reshaped: list[Any] = []
+ for entry in entries:
+ if not isinstance(entry, np.ndarray):
+ reshaped.append(entry)
+ continue
+ varying = int(rng.integers(0, rank))
+ reshaped.append(
+ entry.reshape(tuple(length if axis == varying else 1 for axis in range(rank)))
+ )
+ return reshaped
+
+
+def _random_vindex(rng: np.random.Generator, shape: tuple[int, ...]) -> tuple[Any, ...]:
+ ndim = len(shape)
+ count = int(rng.integers(1, ndim + 1))
+ trailing = bool(rng.random() < 0.5)
+ axes = range(ndim - count, ndim) if trailing else range(count)
+ sizes = [shape[axis] for axis in axes]
+
+ entries: list[Any]
+ if rng.random() < 0.2:
+ # A single boolean mask spanning the whole covered block.
+ mask = rng.random(tuple(sizes)) < 0.5
+ mask.flat[int(rng.integers(0, mask.size))] = True
+ entries = [mask]
+ else:
+ length = int(rng.integers(1, 5))
+ entries = [
+ int(rng.integers(-size, size))
+ if rng.random() < 0.25
+ else rng.integers(-size, size, size=length)
+ for size in sizes
+ ]
+ if rng.random() < 0.35:
+ entries = _broadcast_singleton_axes(rng, entries, length)
+ return (Ellipsis, *entries) if trailing else tuple(entries)
+
+
+def _apply_oracle(
+ ref: np.ndarray[Any, Any], mode: str, selection: tuple[Any, ...]
+) -> np.ndarray[Any, Any]:
+ if mode == "orthogonal":
+ return outer(ref, selection)
+ # NumPy's own semantics *are* basic and vectorized indexing.
+ return ref[selection]
+
+
+def _apply_view(view: LazyArray, mode: str, selection: tuple[Any, ...]) -> LazyArray:
+ if mode == "basic":
+ return view.lazy[selection]
+ if mode == "orthogonal":
+ return view.lazy.oindex[selection]
+ return view.lazy.vindex[selection]
+
+
+def _random_slices_only(rng: np.random.Generator, shape: tuple[int, ...]) -> tuple[Any, ...]:
+ """A selection of slices alone, spelled through `oindex`.
+
+ `oindex` entries that are all slices are not a fancy selection — they narrow
+ the view's own axes and must compose like basic indexing. A start past 0 is
+ what distinguishes a step that walks the index array's dependency axes from
+ one that walks its broadcast singletons, so slices are drawn to reach past
+ the origin. (`vindex` is coordinate-only and rejects a slice outright, so
+ this spelling has no vectorized counterpart.)
+ """
+ selection: list[Any] = []
+ for size in shape:
+ roll = rng.random()
+ if roll < 0.4:
+ start = int(rng.integers(0, size)) if size else 0
+ selection.append(slice(start, size))
+ elif roll < 0.7:
+ start = int(rng.integers(0, size)) if size else 0
+ selection.append(slice(start, size, int(rng.integers(1, 3))))
+ elif roll < 0.85:
+ selection.append(slice(None, None, -1))
+ else:
+ selection.append(slice(None))
+ return tuple(selection)
+
+
+def _random_chain(rng: np.random.Generator) -> list[tuple[str, tuple[Any, ...]]]:
+ """A chain of 2-4 steps, any of which may be fancy.
+
+ A step spelled through `oindex` but carrying only slices is drawn separately
+ (`slices-only`): it narrows an existing index array by a *slice* rather than
+ by coordinates, a distinct code path kept at full weight.
+ """
+ n_steps = int(rng.integers(2, 5))
+ fancy_steps = {int(rng.integers(0, n_steps)) for _ in range(2)}
+ slices_only_at = int(rng.integers(0, n_steps)) if rng.random() < 0.4 else -1
+
+ chain: list[tuple[str, tuple[Any, ...]]] = []
+ running = reference()
+ for step in range(n_steps):
+ if running.ndim == 0 or running.size == 0:
+ break
+ if step in fancy_steps:
+ mode = "orthogonal" if rng.random() < 0.5 else "vectorized"
+ else:
+ mode = "basic"
+ if step == slices_only_at and step not in fancy_steps:
+ mode = "orthogonal"
+ selection = _random_slices_only(rng, running.shape)
+ elif mode == "basic":
+ selection = _random_basic(rng, running.shape)
+ elif mode == "orthogonal":
+ selection = _random_oindex(rng, running.shape)
+ else:
+ selection = _random_vindex(rng, running.shape)
+ chain.append((mode, selection))
+ running = _apply_oracle(running, mode, selection)
+ return chain
+
+
+@pytest.mark.parametrize("flavor", FLAVORS)
+def test_random_chains_match_numpy(flavor: str) -> None:
+ """A seeded sweep of composed chains, under every partitioning.
+
+ The partitioning invariant as a property: `result()` is identical whatever
+ boxes the read is broken into, including boxes deliberately misaligned with
+ the source's own.
+ """
+ rng = np.random.default_rng(20260730)
+ source = make_source(flavor)
+ partitionings: list[Any] = [None, (2, 2, 2), (7, 5, 4), ((4, 3), (1, 3, 1), (3, 1))]
+ # Reads against a real store cost more per chain; the NumPy flavors carry
+ # the bulk of the sweep and exercise the identical code path.
+ n_chains = 120 if flavor.startswith("zarr") else 400
+
+ for _ in range(n_chains):
+ chain = _random_chain(rng)
+ expected = reference()
+ for mode, selection in chain:
+ expected = _apply_oracle(expected, mode, selection)
+
+ view = source
+ for mode, selection in chain:
+ view = _apply_view(view, mode, selection)
+
+ assert view.shape == expected.shape, f"{flavor}: {chain}"
+ np.testing.assert_array_equal(
+ np.asarray(view.result()), np.asarray(expected), err_msg=f"{flavor}: {chain}"
+ )
+ for parts in partitionings:
+ np.testing.assert_array_equal(
+ np.asarray(repartition(view, parts).result()),
+ np.asarray(expected),
+ err_msg=f"{flavor} parts={parts}: {chain}",
+ )
+
+
+# `result()` can absorb a defect that `parts()` cannot — an empty view assembles
+# correctly from no parts at all, and a rank-0 one can be reshaped into place —
+# so the iteration contract needs its own sweep, asserting the documented
+# assembly literally rather than through `result()`. That sweep is the
+# `ChainedIndexing` state machine in `test_lazy_array_stateful.py`, which drives
+# the same operations and shrinks a failure to the chain that caused it.
+
+
+PARTITIONINGS_1D: list[Any] = [None, (1,), (2,), (5,)]
+
+
+def test_a_slice_only_fancy_step_after_a_fancy_step_reads_real_data() -> None:
+ """`oindex[:, 2:8]` after an `oindex` narrows the view, it does not re-index it.
+
+ The second step carries no coordinates, so it is not fancy-after-fancy: it
+ must compose like basic indexing. Applying its slices to the *broadcast*
+ axes of the first step's index array instead truncates that array to size 0,
+ which leaves the resolver with no parts to read and `result()` handing back
+ an unwritten buffer.
+ """
+ base = np.arange(24).reshape(3, 8)
+ expected = base[np.ix_([0, 2], range(8))][:, 2:8]
+
+ for parts in (None, (2, 4), (1, 8), (3, 3)):
+ view = (
+ repartition(LazyArray(base), parts).lazy.oindex[np.array([0, 2]), :].lazy.oindex[:, 2:8]
+ )
+ assert view.shape == expected.shape, f"parts={parts}"
+ np.testing.assert_array_equal(np.asarray(view.result()), expected, err_msg=f"{parts}")
+
+
+def test_a_fancy_step_composes_onto_any_axis_of_a_fancy_view() -> None:
+ """A second fancy step may land on axes the first one merely broadcasts along.
+
+ Composition evaluates the existing index arrays at the new coordinates, so
+ `oindex` after `oindex`, `vindex` after `oindex`, and both orders around a
+ correlated gather all resolve — under every partitioning.
+ """
+ base = np.arange(24).reshape(3, 8)
+ rows, cols = np.array([0, 2]), np.array([1, 3, 3])
+
+ for parts in (None, (2, 4), (1, 8), (3, 3)):
+ view = repartition(LazyArray(base), parts).lazy.oindex[rows, :]
+
+ composed = view.lazy.oindex[:, cols]
+ expected = base[np.ix_(rows, cols)]
+ assert composed.shape == expected.shape, f"parts={parts}"
+ np.testing.assert_array_equal(np.asarray(composed.result()), expected, err_msg=f"{parts}")
+
+ gathered = view.lazy.vindex[np.array([0, 1]), np.array([7, 0])]
+ np.testing.assert_array_equal(
+ np.asarray(gathered.result()),
+ base[np.ix_(rows, range(8))][[0, 1], [7, 0]],
+ err_msg=f"{parts}",
+ )
+
+ pointwise = repartition(LazyArray(base), parts).lazy.vindex[[0, 1, 2], [0, 2, 3]]
+ np.testing.assert_array_equal(
+ np.asarray(pointwise.lazy.oindex[[2, 0]].result()),
+ base[[0, 1, 2], [0, 2, 3]][[2, 0]],
+ err_msg=f"{parts}",
+ )
+ np.testing.assert_array_equal(
+ np.asarray(pointwise.lazy.vindex[[1, 1, 0]].result()),
+ base[[0, 1, 2], [0, 2, 3]][[1, 1, 0]],
+ err_msg=f"{parts}",
+ )
+
+
+def test_a_partial_vindex_after_an_oindex_resolves_the_mixed_transform() -> None:
+ """A composed transform can mix correlated and orthogonal index arrays.
+
+ `oindex` on the last axis then `vindex` on the first two leaves the
+ orthogonal gather in place while the new coordinate arrays are correlated;
+ resolution takes the pointwise path.
+ """
+ base = np.arange(60).reshape(3, 4, 5)
+ expected = base[:, :, [4, 0]][[0, 2], [1, 3]]
+
+ for parts in (None, (2, 2, 2), (3, 4, 5), (1, 1, 1)):
+ view = repartition(LazyArray(base), parts).lazy.oindex[:, :, [4, 0]]
+ composed = view.lazy.vindex[np.array([0, 2]), np.array([1, 3])]
+ assert composed.shape == expected.shape, f"parts={parts}"
+ np.testing.assert_array_equal(np.asarray(composed.result()), expected, err_msg=f"{parts}")
+
+
+def test_a_boolean_mask_composes_onto_a_fancy_view() -> None:
+ base = np.arange(60).reshape(3, 4, 5)
+ mask = np.array([True, False, True])
+ expected = base[[0, 1, 2]][mask]
+
+ for parts in (None, (2, 2, 2), (1, 4, 5)):
+ view = repartition(LazyArray(base), parts).lazy.oindex[[0, 1, 2], :, :]
+ np.testing.assert_array_equal(
+ np.asarray(view.lazy.oindex[mask].result()), expected, err_msg=f"{parts}"
+ )
+
+
+def test_an_ellipsis_only_vindex_step_preserves_a_correlated_gather() -> None:
+ """Regression: a slice-only vindex step misread correlated maps as orthogonal.
+
+ `vindex[...]` (and `vindex[..., scalar]`, whose remainder after the scalar
+ is split off is ellipsis-only) used to stamp each correlated map with its
+ block axis as an orthogonal binding. Two "orthogonal" maps then shared one
+ input axis, and the partition walk rejected its own transform mid-read.
+ """
+ base = np.arange(16).reshape(4, 4)
+
+ for parts in (None, (2, 2), (4, 4), (1, 3)):
+ pointwise = repartition(LazyArray(base), parts).lazy.vindex[[0, 1, 2], [0, 2, 3]]
+ np.testing.assert_array_equal(
+ np.asarray(pointwise.lazy.vindex[...].result()),
+ base[[0, 1, 2], [0, 2, 3]],
+ err_msg=f"{parts}",
+ )
+
+ planar = repartition(LazyArray(base), parts).lazy.vindex[
+ np.array([[0], [1]]), np.array([[1], [3]])
+ ]
+ np.testing.assert_array_equal(
+ np.asarray(planar.lazy.vindex[..., np.array(0)].result()),
+ base[[0, 1], [1, 3]],
+ err_msg=f"{parts}",
+ )
+
+
+# ---------------------------------------------------------------------------
+# Domain dimensions no output map depends on
+# ---------------------------------------------------------------------------
+#
+# A `vindex` coordinate array with a *singleton* broadcast axis leaves that axis
+# in the view's domain while the map varies only over its partner. A later basic
+# index that consumes the partner collapses the map to a `ConstantMap`, and the
+# singleton axis survives with nothing referencing it. Every stage of resolution
+# has to keep counting it: the lowered block needs the axis back at its true
+# extent, and a part has to say where its values belong along it.
+
+UNREFERENCED_AXIS_PARTITIONINGS: list[Any] = [None, (1, 1, 1), (2, 2, 2), (3, 4, 5), (3, 1, 2)]
+
+# (id, view builder, NumPy oracle) over `np.arange(60).reshape(3, 4, 5)`.
+UNREFERENCED_AXIS_CASES: list[
+ tuple[str, Callable[[LazyArray], LazyArray], Callable[[Any], Any]]
+] = [
+ (
+ "leading-singleton-row",
+ lambda a: a.lazy.vindex[np.array([[2, 0]])].lazy[:, 0],
+ lambda r: r[np.array([[2, 0]])][:, 0],
+ ),
+ (
+ "trailing-singleton-column",
+ lambda a: a.lazy.vindex[np.array([[2], [0]])].lazy[0],
+ lambda r: r[np.array([[2], [0]])][0],
+ ),
+ (
+ "repeated-coordinates-over-a-singleton",
+ lambda a: a.lazy.vindex[np.array([[1, 1]]), np.array([[3, 3]])].lazy[:, 0],
+ lambda r: r[np.array([[1, 1]]), np.array([[3, 3]])][:, 0],
+ ),
+ (
+ "unreferenced-axis-emptied",
+ lambda a: a.lazy.vindex[np.array([[2, 0]])].lazy[0:0, 0],
+ lambda r: r[np.array([[2, 0]])][0:0, 0],
+ ),
+ (
+ "partial-vindex-with-a-residual-slice",
+ lambda a: a.lazy.vindex[np.array([[2, 0]]), np.array([[1, 3]])].lazy[:, 0, 1:4],
+ lambda r: r[np.array([[2, 0]]), np.array([[1, 3]])][:, 0, 1:4],
+ ),
+]
+
+
+def unreferenced_axis_reference() -> np.ndarray[Any, np.dtype[np.int64]]:
+ return np.arange(60, dtype=np.int64).reshape(3, 4, 5)
+
+
+@pytest.mark.parametrize(
+ ("build", "oracle"),
+ [case[1:] for case in UNREFERENCED_AXIS_CASES],
+ ids=[case[0] for case in UNREFERENCED_AXIS_CASES],
+)
+@pytest.mark.parametrize("parts", UNREFERENCED_AXIS_PARTITIONINGS)
+def test_a_domain_axis_no_output_map_depends_on_still_resolves(
+ build: Callable[[LazyArray], LazyArray],
+ oracle: Callable[[Any], Any],
+ parts: Any,
+) -> None:
+ """The value, the shape and the tiling all hold when an axis is unreferenced."""
+ data = unreferenced_axis_reference()
+ expected = np.asarray(oracle(data))
+ view = build(repartition(LazyArray(data), parts))
+
+ assert view.shape == expected.shape
+ np.testing.assert_array_equal(np.asarray(view.result()), expected)
+
+ hits = np.zeros(view.shape, dtype=np.int64)
+ assembled = np.zeros(view.shape, dtype=view.dtype)
+ for part in view.parts():
+ assembled[part.out_selection] = np.asarray(part.view.result())
+ np.add.at(hits, part.out_selection, 1)
+ np.testing.assert_array_equal(assembled, expected)
+ np.testing.assert_array_equal(hits, np.ones(view.shape, dtype=np.int64))
+
+
+def test_an_unreferenced_domain_axis_of_extent_zero_stays_empty() -> None:
+ """An emptied broadcast axis must not be restored as a fabricated row.
+
+ The lowered block has no axis for a dimension nothing depends on, so the
+ resolver puts one back. Putting it back at extent 1 invents a row of data
+ for a selection whose own `shape` says it is empty.
+ """
+ data = np.arange(140, dtype=np.int64).reshape(7, 5, 4)
+ coords = np.array([[6], [3], [0]])
+ for parts in (None, (1, 1, 1), (3, 2, 3), (7, 5, 4)):
+ view = repartition(LazyArray(data), parts).lazy.vindex[coords, -4].lazy[0, 0:0]
+ expected = data[coords, -4][0, 0:0]
+ assert view.shape == expected.shape == (0, 4), f"parts={parts}"
+ np.testing.assert_array_equal(np.asarray(view.result()), expected, err_msg=f"{parts}")
+ assert list(view.parts()) == []
+
+
+def test_a_zero_length_axis_resolves_the_same_way_under_every_partitioning() -> None:
+ """A size-0 axis carries no dependency, so it cannot make a map correlated."""
+ base = np.zeros((3, 0))
+ expected = base[np.ix_([1, 0, 0], np.arange(0, dtype=int))]
+
+ for parts in (None, ((1, 1, 1), ()), ((3,), ())):
+ view = (
+ repartition(LazyArray(base), parts)
+ .lazy.oindex[np.array([1, -3, -3]), :]
+ .lazy.oindex[:, :]
+ )
+ assert view.shape == expected.shape, f"parts={parts}"
+ np.testing.assert_array_equal(np.asarray(view.result()), expected, err_msg=f"{parts}")
+ assert list(view.parts()) == []
+
+
+def test_an_empty_slice_of_a_length_one_correlated_axis_has_no_parts() -> None:
+ """`parts()` agrees with `result()` that an emptied view selects nothing.
+
+ A correlated selection of exactly one point normalizes to an all-singleton
+ index array, indistinguishable by shape from an axis the map broadcasts
+ over — so a later slice that empties the domain leaves the array at size 1.
+ """
+ base = np.arange(5)
+ mask = np.array([False, True, False, False, False])
+
+ for parts in PARTITIONINGS_1D:
+ view = repartition(LazyArray(base), parts).lazy.vindex[mask].lazy[1:-2]
+ assert view.shape == (0,), f"parts={parts}"
+ assert list(view.parts()) == [], f"parts={parts}"
+ np.testing.assert_array_equal(np.asarray(view.result()), base[mask][1:-2])
+
+
+def test_an_empty_slice_of_a_length_one_vindex_pair_has_no_parts() -> None:
+ """The same emptied view, spelled with explicit coordinates over two axes."""
+ base = np.arange(35).reshape(7, 5)
+
+ for parts in (None, (2, 2), (7, 5)):
+ view = (
+ repartition(LazyArray(base), parts).lazy.vindex[np.array([6]), np.array([0])].lazy[0:0]
+ )
+ assert view.shape == (0,), f"parts={parts}"
+ assert list(view.parts()) == [], f"parts={parts}"
+ np.testing.assert_array_equal(
+ np.asarray(view.result()), base[np.array([6]), np.array([0])][0:0]
+ )
+
+
+def test_a_correlated_view_narrowed_to_one_point_has_parts_of_the_views_rank() -> None:
+ """A rank-0 broadcast block survives intersection without gaining an axis.
+
+ `Partition` documents `out[part.out_selection] = part.view.result()` as the
+ assembly, so a part's values must arrive at the rank the view has — including
+ the rank 0 a correlated selection reaches once every coordinate is a scalar.
+ """
+ base = np.arange(140).reshape(7, 5, 4)
+ cases = [
+ # No residual slice: the whole view is the one gathered point.
+ ((np.array([-1]), np.array([-1]), np.array([2])), 0),
+ # One residual slice dimension survives alongside the collapsed block.
+ ((np.array([6, 1]), np.array([4, 0])), 1),
+ ]
+
+ for parts in (None, (2, 2, 2), (3, 3, 3), (7, 5, 4)):
+ for selection, tail in cases:
+ expected = base[selection][tail]
+ view = repartition(LazyArray(base), parts).lazy.vindex[selection].lazy[tail]
+ assert view.shape == expected.shape, f"parts={parts}, {selection}"
+
+ assembled = np.zeros(view.shape, dtype=view.dtype)
+ hits = np.zeros(view.shape, dtype=np.int64)
+ for part in view.parts():
+ assembled[part.out_selection] = np.asarray(part.view.result())
+ np.add.at(hits, part.out_selection, 1)
+
+ err = f"parts={parts}, {selection}"
+ np.testing.assert_array_equal(assembled, expected, err_msg=err)
+ np.testing.assert_array_equal(hits, np.ones(view.shape, dtype=np.int64), err_msg=err)
+ np.testing.assert_array_equal(np.asarray(view.result()), expected, err_msg=err)
+
+
+def test_eager_getitem_returns_data(source: LazyArray) -> None:
+ """`arr[...]` reads immediately, like `numpy.ndarray.__getitem__`."""
+ np.testing.assert_array_equal(np.asarray(source[1:3, ::2, -1]), reference()[1:3, ::2, -1])
+
+
+# ---------------------------------------------------------------------------
+# Attribute forwarding
+# ---------------------------------------------------------------------------
+
+
+def test_forwards_array_attributes(source: LazyArray) -> None:
+ assert source.shape == SHAPE
+ assert source.ndim == len(SHAPE)
+ assert source.size == int(np.prod(SHAPE))
+ assert source.dtype == np.dtype("int64")
+ assert len(source) == SHAPE[0]
+ assert "LazyArray" in repr(source)
+
+
+def test_view_shape_comes_from_the_transform(source: LazyArray) -> None:
+ """A view reports its own shape, not the wrapped array's."""
+ view = source.lazy[1:6:2, :, -1]
+ assert view.shape == (3, 5)
+ assert view.ndim == 2
+ assert view.size == 15
+ assert view.dtype == source.dtype
+ assert "view=" in repr(view)
+
+
+def test_the_wrapper_has_no_chunks_vocabulary() -> None:
+ """`chunks` is the *source's* word, never the wrapper's."""
+ assert not hasattr(make_source("numpy-whole"), "chunks")
+ assert not hasattr(make_source("zarr"), "chunks")
+ with pytest.raises(TypeError):
+ LazyArray(reference(), chunks=PART_SHAPE) # type: ignore[call-arg]
+
+
+def test_scalar_is_basic_even_when_a_slice_separates_it_from_the_arrays() -> None:
+ """A documented, deliberate departure from one NumPy corner.
+
+ NumPy counts an integer as an *advanced* index for its placement rule, so
+ `x[0, :, i]` has shape `(len(i), x.shape[1])` — the arrays lead because the
+ slice separates the integer from them. The positional dialect instead treats
+ every scalar as a basic index applied first, which is the rule the rest of
+ the surface follows and the only one `oindex` can express, so the same
+ selection reads as `x[0][:, i]`.
+ """
+ data = reference()
+ view = make_source("numpy-uniform-parts").lazy.vindex[0, ..., np.array([3, 0])]
+ np.testing.assert_array_equal(np.asarray(view.result()), data[0][..., np.array([3, 0])])
+ assert view.shape == data[0][..., np.array([3, 0])].shape
+ assert view.shape != data[0, ..., np.array([3, 0])].shape
+
+
+def test_zero_dimensional_result_is_an_array(source: LazyArray) -> None:
+ """Both resolvers agree on the kind of a zero-rank result."""
+ result = source.lazy[0, 1, 2].result()
+ assert isinstance(result, np.ndarray)
+ assert result.ndim == 0
+ assert result[()] == reference()[0, 1, 2]
+
+
+def test_construction_selects_readers_explicitly() -> None:
+ data = reference()
+ assert LazyArray(data).reader is basic_reader
+ assert LazyArray.from_numpy(data).reader is numpy_reader
+
+
+def test_reader_wrappers_forward_the_read_contract_unchanged() -> None:
+ events: list[tuple[str, Any, ReadContext, Any]] = []
+
+ class RecordingDelegatingReader:
+ def __init__(self, name: str, inner: Reader) -> None:
+ self.name = name
+ self.inner = inner
+
+ def read_into(self, source: Any, context: ReadContext, out: Any, /) -> None:
+ events.append((self.name, source, context, out))
+ self.inner.read_into(source, context, out)
+
+ data = reference()
+ inner = RecordingDelegatingReader("inner", numpy_reader)
+ outer = RecordingDelegatingReader("outer", inner)
+ view = LazyArray(data).with_reader(outer).lazy[1:6:2, ::-1, 1].unpartitioned()
+
+ result = view.result()
+
+ assert [name for name, _, _, _ in events] == ["outer", "inner"]
+ outer_call, inner_call = events
+ assert outer_call[1] is inner_call[1] is data
+ assert outer_call[2] is inner_call[2]
+ assert outer_call[3] is inner_call[3] is result
+ np.testing.assert_array_equal(result, data[1:6:2, ::-1, 1])
+
+
+@pytest.mark.parametrize("value", [object(), [1, 2, 3], "array"])
+def test_from_numpy_rejects_non_ndarrays(value: Any) -> None:
+ with pytest.raises(TypeError, match="from_numpy requires a numpy.ndarray"):
+ LazyArray.from_numpy(value)
+
+
+class RecordingReader:
+ def __init__(self) -> None:
+ self.calls: list[tuple[Any, ReadContext, Any]] = []
+ self.contexts: list[ReadContext] = []
+
+ def read_into(self, source: Any, context: ReadContext, out: Any, /) -> None:
+ self.calls.append((source, context, out))
+ self.contexts.append(context)
+ basic_reader.read_into(source, context, out)
+
+
+def test_view_operations_preserve_the_reader_without_reading() -> None:
+ data = reference()
+ reader = RecordingReader()
+ base = LazyArray(data).with_reader(reader)
+ views = (
+ base.lazy[1:5],
+ base.with_parts((2, 2, 2)),
+ base.with_parts_per_axis(((3, 3, 1), (2, 3), (1, 3))),
+ base.unpartitioned(),
+ )
+ assert reader.calls == []
+ assert all(view.reader is reader for view in views)
+ assert all(part.view.reader is reader for part in views[1].parts())
+
+
+@pytest.mark.parametrize("value", [object(), None, lambda: None])
+def test_with_reader_requires_callable_read_into(value: Any) -> None:
+ with pytest.raises(TypeError, match="reader.read_into must be callable"):
+ LazyArray(reference()).with_reader(value)
+
+
+class ReturningReader:
+ def read_into(self, source: Any, context: ReadContext, out: Any, /) -> Any:
+ return np.empty(context.transform.domain.shape, dtype=source.dtype)
+
+
+def test_result_rejects_a_reader_that_returns_a_value() -> None:
+ view = LazyArray(reference()).with_reader(ReturningReader())
+ with pytest.raises(TypeError, match="must return None"):
+ view.result()
+
+
+class BufferRecordingReader(RecordingReader):
+ def __init__(self) -> None:
+ super().__init__()
+ self.owns_data: list[bool] = []
+
+ def read_into(self, source: Any, context: ReadContext, out: Any, /) -> None:
+ self.owns_data.append(bool(out.flags.owndata))
+ super().read_into(source, context, out)
+
+
+def test_reader_is_called_once_per_touched_part() -> None:
+ data = np.arange(48).reshape(6, 8)
+ reader = RecordingReader()
+ view = LazyArray(data).with_reader(reader).with_parts((3, 4)).lazy[1:5, 2]
+ np.testing.assert_array_equal(view.result(), data[1:5, 2])
+ assert len(reader.calls) == len(tuple(view.parts())) == 2
+ assert all(
+ call[1].transform.domain.inclusive_min == (0,) * call[1].transform.input_rank
+ for call in reader.calls
+ )
+
+
+def test_partition_reader_receives_global_transform_and_existing_projection() -> None:
+ reader = RecordingReader()
+ view = LazyArray(np.arange(8)).with_reader(reader).with_parts((4,))
+ expected = [part.projection for part in view.parts()]
+
+ np.testing.assert_array_equal(view.result(), np.arange(8))
+
+ assert [context.projection for context in reader.contexts] == expected
+ assert [context.transform.apply((0,)) for context in reader.contexts] == [(0,), (4,)]
+
+
+def test_an_empty_result_does_not_call_the_reader() -> None:
+ reader = RecordingReader()
+ result = LazyArray(reference()).with_reader(reader).lazy[:, 0:0, :].result()
+ assert result.shape == (7, 0, 4)
+ assert reader.calls == []
+
+
+def test_rectangular_parts_write_into_result_views() -> None:
+ reader = BufferRecordingReader()
+ view = LazyArray(reference()).with_reader(reader).with_parts((2, 2, 2)).lazy[1:6, 1:4]
+ view.result()
+ assert reader.owns_data
+ assert not any(reader.owns_data)
+
+
+def test_fancy_part_placement_uses_owned_dense_temporaries() -> None:
+ reader = BufferRecordingReader()
+ view = (
+ LazyArray(reference())
+ .with_reader(reader)
+ .with_parts((2, 2, 2))
+ .lazy.oindex[[6, 1, 1], :, :]
+ )
+ np.testing.assert_array_equal(view.result(), reference()[np.ix_([6, 1, 1], range(5), range(4))])
+ assert any(reader.owns_data)
+
+
+def test_reader_exception_propagates_unchanged() -> None:
+ error = RuntimeError("backend failed")
+
+ class FailingReader:
+ def read_into(self, source: Any, context: ReadContext, out: Any, /) -> None:
+ raise error
+
+ with pytest.raises(RuntimeError) as caught:
+ LazyArray(reference()).with_reader(FailingReader()).result()
+ assert caught.value is error
+
+
+class ForeignArray:
+ """A minimal array-like whose advertised `chunks` we do not control."""
+
+ def __init__(self, data: np.ndarray[Any, Any], chunks: Any) -> None:
+ self._data = data
+ self.chunks = chunks
+
+ @property
+ def shape(self) -> tuple[int, ...]:
+ return self._data.shape
+
+ @property
+ def dtype(self) -> Any:
+ return self._data.dtype
+
+ def __getitem__(self, key: Any) -> Any:
+ return self._data[key]
+
+
+def test_malformed_discovered_parts_are_ignored() -> None:
+ """A foreign object's unusable `chunks` falls back to one whole-array part.
+
+ Discovery parses external input, so an attribute that does not describe a
+ partitioning of the shape means "none I understand", not an error.
+ """
+ data = reference()
+ for bogus in (((3, 3), (5,), (4,)), (3, 2), "nope", (3.5, 2, 2)):
+ wrapped = LazyArray(ForeignArray(data, bogus))
+ assert len(list(wrapped.parts())) == 1
+ np.testing.assert_array_equal(np.asarray(wrapped.lazy[1:3].result()), data[1:3])
+
+
+def test_discovered_parts_come_from_the_source_vocabulary() -> None:
+ """`read_chunk_sizes` wins over `chunks`, and both are read as parts."""
+ data = reference()
+ wrapped = LazyArray(ForeignArray(data, PART_SHAPE))
+ assert [part.base_coords for part in wrapped.parts()][:3] == [(0, 0, 0), (0, 0, 1), (0, 1, 0)]
+ assert len(list(wrapped.parts())) == 3 * 3 * 2
+
+
+# ---------------------------------------------------------------------------
+# Boxes
+# ---------------------------------------------------------------------------
+
+# (id, build, expected is_box, expected bounding_box). The bounds are storage
+# intervals of the wrapped (7, 5, 4) array, computed by hand.
+BOX_CASES: list[tuple[str, Callable[[LazyArray], LazyArray], bool, Any]] = [
+ ("identity", lambda a: a, True, ((0, 7), (0, 5), (0, 4))),
+ ("basic-slice", lambda a: a.lazy[1:6, :, 1:3], True, ((1, 6), (0, 5), (1, 3))),
+ # Stride 2 over axis 1 touches 0, 2, 4; the hull is the closed span.
+ ("strided", lambda a: a.lazy[::3, ::2, :], True, ((0, 7), (0, 5), (0, 4))),
+ ("int-drop", lambda a: a.lazy[2, :, -1], True, ((2, 3), (0, 5), (3, 4))),
+ ("all-scalars", lambda a: a.lazy[0, 1, 2], True, ((0, 1), (1, 2), (2, 3))),
+ ("negative-and-open", lambda a: a.lazy[-2:], True, ((5, 7), (0, 5), (0, 4))),
+ (
+ "oindex",
+ lambda a: a.lazy.oindex[[4, 0, 0], :, [3, 1]],
+ False,
+ ((0, 5), (0, 5), (1, 4)),
+ ),
+ (
+ "vindex",
+ lambda a: a.lazy.vindex[np.array([0, 6, 3]), np.array([1, 4, 0]), np.array([2, 0, 1])],
+ False,
+ ((0, 7), (0, 5), (0, 3)),
+ ),
+ ("mask", lambda a: a.lazy.vindex[MASK], False, ((0, 7), (0, 5), (0, 4))),
+ # Composition preserves the category in both directions.
+ ("box-of-box", lambda a: a.lazy[1:6].lazy[:, 1:3], True, ((1, 6), (1, 3), (0, 4))),
+ (
+ "box-after-fancy",
+ lambda a: a.lazy.oindex[[4, 0, 2], :, :].lazy[0:2],
+ False,
+ ((0, 5), (0, 5), (0, 4)),
+ ),
+ ("empty", lambda a: a.lazy[2:2], True, None),
+ (
+ "empty-fancy",
+ lambda a: a.lazy.oindex[np.array([], dtype=np.intp), :, :],
+ False,
+ None,
+ ),
+]
+
+
+@pytest.mark.parametrize(
+ ("build", "expected_is_box", "expected_bounds"),
+ [case[1:] for case in BOX_CASES],
+ ids=[case[0] for case in BOX_CASES],
+)
+def test_is_box_and_bounding_box(
+ source: LazyArray,
+ build: Callable[[LazyArray], LazyArray],
+ expected_is_box: bool,
+ expected_bounds: Any,
+) -> None:
+ """The box/query taxonomy, and the hull every selection has either way."""
+ view = build(source)
+ assert view.is_box is expected_is_box
+ assert view.bounding_box() == expected_bounds
+
+
+def test_a_unit_stride_box_is_dense_in_its_bounding_box() -> None:
+ """Stride 1 everywhere: the hull is exactly what the view selects."""
+ data = reference()
+ view = make_source("numpy-uniform-parts").lazy[1:6, :, 1:3]
+ assert view.is_box
+ assert view.strides() == (1, 1, 1)
+ bounds = view.bounding_box()
+ assert bounds is not None
+ np.testing.assert_array_equal(
+ np.asarray(view.result()),
+ data[tuple(slice(lo, hi) for lo, hi in bounds)],
+ )
+
+
+def test_a_strided_box_is_sparse_in_its_bounding_box() -> None:
+ """Stride > 1: the hull is a superset, and `strides()` is what says by how much."""
+ data = reference()
+ view = make_source("numpy-uniform-parts").lazy[1:6, ::2, :]
+ assert view.is_box
+ assert view.strides() == (1, 2, 1)
+ bounds = view.bounding_box()
+ assert bounds is not None
+ assert bounds == ((1, 6), (0, 5), (0, 4))
+
+ hull = data[tuple(slice(lo, hi) for lo, hi in bounds)]
+ assert hull.size == 5 * 5 * 4
+ assert view.size == 5 * 3 * 4
+ # A consumer that slabbed the hull and threw the rest away would over-read.
+ assert hull.size > view.size
+ # Applying the strides to the hull recovers the selection exactly.
+ np.testing.assert_array_equal(
+ np.asarray(view.result()),
+ hull[tuple(slice(None, None, step) for step in view.strides() or ())],
+ )
+
+
+def test_strides_are_none_for_a_query() -> None:
+ view = make_source("numpy-uniform-parts").lazy.oindex[[5, 1], :, :]
+ assert not view.is_box
+ assert view.strides() is None
+
+
+def test_an_integer_indexed_dimension_has_stride_one() -> None:
+ view = make_source("numpy-uniform-parts").lazy[2, ::3, :]
+ assert view.strides() == (1, 3, 1)
+ assert view.bounding_box() == ((2, 3), (0, 4), (0, 4))
+
+
+def test_a_query_bounding_box_is_only_a_hull() -> None:
+ """For a fancy selection the box is a superset, and `is_box` says so."""
+ view = make_source("numpy-uniform-parts").lazy.oindex[[5, 1], :, :]
+ assert not view.is_box
+ assert view.bounding_box() == ((1, 6), (0, 5), (0, 4))
+ # The hull spans 5 rows; the selection touches 2 of them.
+ assert view.shape[0] == 2
+
+
+# ---------------------------------------------------------------------------
+# Parts
+# ---------------------------------------------------------------------------
+
+
+class _ReadMustNotRun:
+ def read_into(self, source: Any, context: ReadContext, out: Any, /) -> None:
+ raise AssertionError("prepared-plan validation must happen before any read")
+
+
+def test_prepared_part_validation_allocates_boolean_coverage_bitmap(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ view = LazyArray.from_numpy(reference()).with_parts(PART_SHAPE).lazy[1:6, ::2, 1:]
+ parts = tuple(view.parts())
+ allocations: list[tuple[tuple[int, ...], np.dtype[Any]]] = []
+ real_zeros = np.zeros
+
+ def recording_zeros(shape: Any, *args: Any, **kwargs: Any) -> np.ndarray[Any, Any]:
+ result = real_zeros(shape, *args, **kwargs)
+ allocations.append((tuple(shape), result.dtype))
+ return result
+
+ monkeypatch.setattr(lazy_array_module.np, "zeros", recording_zeros)
+
+ _validate_prepared_parts(parts, view.shape)
+
+ assert allocations == [(view.shape, np.dtype(np.bool_))]
+
+
+@pytest.mark.parametrize(
+ ("data", "part_shape", "build", "expected"),
+ [
+ pytest.param(
+ np.arange(8),
+ (3,),
+ lambda array: array.lazy[1:7:2],
+ np.array([1, 3, 5]),
+ id="basic-reordered-parts",
+ ),
+ pytest.param(
+ np.arange(8),
+ (3,),
+ lambda array: array.lazy[3],
+ np.array(3),
+ id="scalar",
+ ),
+ pytest.param(
+ np.arange(20).reshape(4, 5),
+ (2, 3),
+ lambda array: array.lazy.oindex[[3, 1, 1], [4, 0]],
+ np.array([[19, 15], [9, 5], [9, 5]]),
+ id="orthogonal-fancy",
+ ),
+ pytest.param(
+ np.arange(20).reshape(4, 5),
+ (2, 3),
+ lambda array: array.lazy.vindex[[3, 1, 1], [4, 0, 4]],
+ np.array([19, 5, 9]),
+ id="correlated-fancy",
+ ),
+ pytest.param(
+ np.arange(8),
+ (3,),
+ lambda array: array.lazy[2:2],
+ np.array([], dtype=np.int64),
+ id="empty",
+ ),
+ ],
+)
+def test_result_accepts_prepared_parts_from_the_same_view(
+ monkeypatch: pytest.MonkeyPatch,
+ data: np.ndarray[Any, Any],
+ part_shape: tuple[int, ...],
+ build: Callable[[LazyArray], LazyArray],
+ expected: np.ndarray[Any, Any],
+) -> None:
+ view = build(LazyArray.from_numpy(data).with_parts(part_shape))
+ parts = tuple(reversed(tuple(view.parts())))
+
+ def unexpected_replan(self: LazyArray) -> Any:
+ raise AssertionError("result(parts=...) must not construct another partition plan")
+
+ monkeypatch.setattr(LazyArray, "parts", unexpected_replan)
+
+ np.testing.assert_array_equal(view.result(parts=parts), expected)
+
+
+def test_result_rejects_prepared_parts_owned_by_another_view() -> None:
+ data = reference()
+ base = LazyArray.from_numpy(data).with_reader(_ReadMustNotRun()).with_parts(PART_SHAPE)
+ view = base.lazy[1:6, ::2, 1:]
+ owned_parts = tuple(view.parts())
+ foreign_parts = tuple(base.lazy[1:6, ::2, 1:].parts())
+ mixed_parts = (owned_parts[0], *foreign_parts[1:])
+
+ with pytest.raises(ValueError, match="prepared parts do not belong to this view"):
+ view.result(parts=mixed_parts)
+
+
+def test_result_rejects_prepared_parts_that_do_not_tile_the_view() -> None:
+ data = reference()
+ view = (
+ LazyArray.from_numpy(data)
+ .with_reader(_ReadMustNotRun())
+ .with_parts(PART_SHAPE)
+ .lazy[1:6, ::2, 1:]
+ )
+ parts = tuple(view.parts())
+
+ with pytest.raises(ValueError, match="prepared parts do not tile the view exactly"):
+ view.result(parts=parts[:-1])
+
+
+def test_result_rejects_prepared_parts_with_a_duplicate_and_omission() -> None:
+ view = LazyArray.from_numpy(np.arange(8)).with_reader(_ReadMustNotRun()).with_parts((4,))
+ first, _second = tuple(view.parts())
+
+ with pytest.raises(ValueError, match="prepared parts do not tile the view exactly"):
+ view.result(parts=(first, first))
+
+
+def test_result_rejects_complete_prepared_parts_plus_a_duplicate() -> None:
+ view = LazyArray.from_numpy(np.arange(8)).with_reader(_ReadMustNotRun()).with_parts((4,))
+ first, second = tuple(view.parts())
+
+ with pytest.raises(ValueError, match="prepared parts do not tile the view exactly"):
+ view.result(parts=(first, second, first))
+
+
+def test_result_rejects_overlapping_prepared_parts() -> None:
+ view = LazyArray.from_numpy(np.arange(8)).with_reader(_ReadMustNotRun()).with_parts((4,))
+ first, second = tuple(view.parts())
+ overlapping = replace(second, out_selection=(slice(2, 6),))
+
+ with pytest.raises(ValueError, match="prepared parts do not tile the view exactly"):
+ view.result(parts=(first, overlapping))
+
+
+def test_result_rejects_wrong_rank_prepared_parts() -> None:
+ view = LazyArray.from_numpy(np.arange(8)).with_reader(_ReadMustNotRun()).with_parts((4,))
+ first, second = tuple(view.parts())
+ wrong_rank = replace(first, out_selection=())
+
+ with pytest.raises(ValueError, match="prepared parts do not tile the view exactly"):
+ view.result(parts=(wrong_rank, second))
+
+
+def test_result_rejects_empty_prepared_parts_for_a_nonempty_view() -> None:
+ view = LazyArray.from_numpy(np.arange(8)).with_reader(_ReadMustNotRun()).with_parts((4,))
+
+ with pytest.raises(ValueError, match="prepared parts do not tile the view exactly"):
+ view.result(parts=())
+
+
+def test_result_accepts_empty_prepared_parts_for_an_empty_view() -> None:
+ view = (
+ LazyArray.from_numpy(np.arange(8)).with_reader(_ReadMustNotRun()).with_parts((4,)).lazy[0:0]
+ )
+
+ np.testing.assert_array_equal(view.result(parts=()), np.array([], dtype=np.int64))
+
+
+@pytest.mark.parametrize("flavor", FLAVORS)
+@pytest.mark.parametrize(
+ "build",
+ [
+ lambda a: a,
+ lambda a: a.lazy[1:6, :, 1:],
+ lambda a: a.lazy.oindex[[4, 0, 0], :, [3, 1]],
+ lambda a: a.lazy.vindex[..., np.array([1, 4, 0]), np.array([2, 0, 1])],
+ # A reversing view drives the negative-stride branches of
+ # `_intersect_dimension_map` and chunk projection, which were
+ # written defensively long before anything could reach them.
+ lambda a: a.lazy[::-1, ::-2, :],
+ lambda a: a.lazy[5:1:-1, :, ::-1],
+ ],
+ ids=["identity", "basic", "oindex", "vindex", "reversed", "reversed-bounded"],
+)
+def test_parts_tile_the_view_exactly_and_disjointly(
+ flavor: str, build: Callable[[LazyArray], LazyArray]
+) -> None:
+ """Assembling the parts reproduces `result()`; the placements cover with no overlap."""
+ view = build(make_source(flavor))
+ expected = np.asarray(view.result())
+
+ assembled = np.zeros(view.shape, dtype=view.dtype)
+ hits = np.zeros(view.shape, dtype=np.int64)
+ for part in view.parts():
+ assembled[part.out_selection] = np.asarray(part.view.result())
+ # `np.add.at` accumulates per element; `+= 1` on a fancy index would
+ # count a repeated position once and hide a real overlap.
+ np.add.at(hits, part.out_selection, 1)
+
+ np.testing.assert_array_equal(assembled, expected)
+ np.testing.assert_array_equal(hits, np.ones(view.shape, dtype=np.int64))
+
+
+def _transform_point(transform: IndexTransform, point: tuple[int, ...]) -> tuple[int, ...]:
+ """Evaluate a transform pointwise for projection placement assertions."""
+ result: list[int] = []
+ for output_map in transform.output:
+ if isinstance(output_map, ConstantMap):
+ result.append(output_map.offset)
+ elif isinstance(output_map, DimensionMap):
+ result.append(output_map.offset + output_map.stride * point[output_map.input_dimension])
+ else:
+ index = tuple(
+ 0
+ if output_map.index_array.shape[axis] == 1
+ else point[axis] - transform.domain.inclusive_min[axis]
+ for axis in range(output_map.index_array.ndim)
+ )
+ result.append(
+ output_map.offset + output_map.stride * int(output_map.index_array[index])
+ )
+ return tuple(result)
+
+
+@pytest.mark.parametrize(
+ "build",
+ [
+ lambda array: array.lazy.oindex[[6, 0, 2], :, [3, 1]],
+ lambda array: array.lazy.vindex[..., np.array([4, 0, 4]), np.array([3, 1, 1])],
+ ],
+ ids=["orthogonal", "vectorized"],
+)
+def test_partition_exposes_projection_as_its_placement_authority(
+ build: Callable[[LazyArray], LazyArray],
+) -> None:
+ """A part's NumPy placement addresses exactly its cell-transform range."""
+ view = build(LazyArray(reference()).with_parts(PART_SHAPE))
+ assembled = np.zeros(view.shape, dtype=view.dtype)
+
+ for part in view.parts():
+ projection = part.projection
+ assert isinstance(projection, ChunkProjection)
+ assert part.view.transform == projection.chunk_transform.translate(
+ tuple(lo for lo, _ in part.box)
+ )
+ assert part.base_coords == projection.chunk_coords
+ assert part.box == tuple(
+ zip(
+ projection.chunk_domain.inclusive_min,
+ projection.chunk_domain.exclusive_max,
+ strict=True,
+ )
+ )
+
+ expected_hits = np.zeros(view.shape, dtype=np.int8)
+ cell_domain = projection.cell_transform.domain
+ for positional_point in np.ndindex(*cell_domain.shape):
+ cell_point = tuple(
+ coordinate + origin
+ for coordinate, origin in zip(
+ positional_point, cell_domain.inclusive_min, strict=True
+ )
+ )
+ expected_hits[_transform_point(projection.cell_transform, cell_point)] = 1
+ actual_hits = np.zeros(view.shape, dtype=np.int8)
+ actual_hits[part.out_selection] = 1
+ np.testing.assert_array_equal(actual_hits, expected_hits)
+
+ assembled[part.out_selection] = np.asarray(part.view.result())
+
+ np.testing.assert_array_equal(assembled, np.asarray(view.result()))
+
+
+def test_nonfirst_partition_transform_directly_addresses_its_array() -> None:
+ source = np.arange(8)
+ part = list(LazyArray.from_numpy(source).with_parts((4,)).parts())[1]
+
+ assert part.box == ((4, 8),)
+ assert part.view.transform.apply((0,)) == (4,)
+ assert part.view.array[part.view.transform.apply((0,))] == 4
+ assert part.view.result()[0] == 4
+ assert part.projection.chunk_transform.apply((0,)) == (0,)
+
+
+def test_partition_token_encodes_its_public_global_transform() -> None:
+ source = np.arange(8)
+ base = LazyArray.from_numpy(source)
+ partition_view = list(base.with_parts((4,)).parts())[1].view
+ direct_view = base.lazy[4:8]
+
+ assert partition_view.__dask_tokenize__() == direct_view.__dask_tokenize__()
+
+
+def test_parts_resolve_independently_and_concurrently() -> None:
+ """Each part's `array` is a standalone `LazyArray` with no shared mutable state."""
+ view = make_source("zarr").lazy[1:7, :, 1:].with_parts((2, 2, 2))
+ parts = list(view.parts())
+ assert len(parts) > 1
+
+ with ThreadPoolExecutor(max_workers=4) as pool:
+ values = list(pool.map(lambda part: np.asarray(part.view.result()), parts))
+
+ assembled = np.zeros(view.shape, dtype=view.dtype)
+ for part, value in zip(parts, values, strict=True):
+ assembled[part.out_selection] = value
+ np.testing.assert_array_equal(assembled, np.asarray(view.result()))
+
+
+def test_parts_report_completeness() -> None:
+ """`is_complete` distinguishes a fully-covered box from a partial one."""
+ array = make_source("numpy-uniform-parts")
+ assert all(part.is_complete for part in array.parts())
+ # Boxes along axis 2 are [0, 3) and [3, 4); dropping column 0 leaves the
+ # first partially covered and the second whole.
+ trimmed = {part.base_coords[2]: part.is_complete for part in array.lazy[:, :, 1:].parts()}
+ assert trimmed == {0: False, 1: True}
+ # A fancy axis is always reported incomplete.
+ assert not any(part.is_complete for part in array.lazy.oindex[[4, 0, 0], :, :].parts())
+
+
+def test_partition_boxes_are_global_and_tile_the_base() -> None:
+ """Both the selected hull and the whole partition box use global coordinates."""
+ view = make_source("numpy-uniform-parts").lazy[1:6, :, 1:]
+ parts = {part.base_coords: part for part in view.parts()}
+
+ # The reviewer's repro: two parts of the same view now expose distinct
+ # source-global selected hulls as well as distinct whole partition boxes.
+ first, second = parts[0, 0, 0], parts[0, 1, 0]
+ assert first.view.bounding_box() == ((1, 3), (0, 2), (1, 3))
+ assert second.view.bounding_box() == ((1, 3), (2, 4), (1, 3))
+ assert first.box != second.box
+ assert first.box == ((0, 3), (0, 2), (0, 3))
+ assert second.box == ((0, 3), (2, 4), (0, 3))
+
+ # Every box is the base partitioning's own box for those coordinates, and
+ # the touched boxes tile the region the view reads without overlapping.
+ grids = dimension_grids_from_chunks(PART_SHAPE, SHAPE)
+ for coords, part in parts.items():
+ expected = tuple(
+ (grid.chunk_offset(c), grid.chunk_offset(c) + grid.data_size(c))
+ for grid, c in zip(grids, coords, strict=True)
+ )
+ assert part.box == expected
+ covered = np.zeros(SHAPE, dtype=np.int64)
+ for part in parts.values():
+ covered[tuple(slice(lo, hi) for lo, hi in part.box)] += 1
+ assert covered.max() == 1
+ # The view reads rows 1..5 and columns 1.., so every box it touches
+ # intersects that region and no box outside it is visited.
+ assert covered[1:6, :, 1:].sum() > 0
+ assert covered[6:, :, :].sum() == 0
+
+
+def test_partition_box_of_a_whole_array_part() -> None:
+ part = next(iter(make_source("numpy-whole").parts()))
+ assert part.box == tuple((0, extent) for extent in SHAPE)
+
+
+def test_an_unpartitioned_wrapper_has_a_single_whole_array_part() -> None:
+ view = make_source("numpy-whole")
+ parts = list(view.parts())
+ assert len(parts) == 1
+ assert parts[0].base_coords == (0, 0, 0)
+ assert parts[0].view.shape == SHAPE
+ assert parts[0].is_complete
+ np.testing.assert_array_equal(np.asarray(parts[0].view.result()), reference())
+
+
+def test_with_parts_keeps_the_view_and_the_base() -> None:
+ view = make_source("zarr").lazy[1:6, ::2]
+ repartitioned = view.with_parts((2, 1, 4))
+ assert repartitioned.shape == view.shape
+ assert repartitioned.array is view.array
+ assert repartitioned.transform == view.transform
+ # A different partitioning really is a different set of boxes.
+ assert [part.base_coords for part in repartitioned.parts()] != [
+ part.base_coords for part in view.parts()
+ ]
+ np.testing.assert_array_equal(np.asarray(repartitioned.result()), np.asarray(view.result()))
+
+
+def test_with_parts_none_forces_one_shot_resolution() -> None:
+ view = make_source("zarr").lazy.oindex[[4, 0, 0], :, :]
+ whole = view.unpartitioned()
+ assert len(list(whole.parts())) == 1
+ np.testing.assert_array_equal(np.asarray(whole.result()), np.asarray(view.result()))
+
+
+@pytest.mark.parametrize(
+ ("parts", "match"),
+ [
+ ((3,), "one entry per dimension"),
+ ((3, (2, 2), 4), "not a mixture"),
+ (((3, 3), (2, 2, 1), (3, 1)), "sum to 6, but the array extent is 7"),
+ ((3, 0, 3), "chunk shape entries must be positive"),
+ # A float or a None belongs to neither convention; saying "not a
+ # mixture" would send the reader looking for the wrong mistake.
+ ((3.5, 2, 2), r"3\.5 at dimension 0 is neither"),
+ ((None, 5, 4), "None at dimension 0 is neither"),
+ ((3.5, 2.5, 2.5), r"3\.5 at dimension 0, 2\.5 at dimension 1, .* are neither"),
+ (((1.5, 5.5), (5,), (4,)), "per-axis chunk sizes must be integers; dimension 0"),
+ ],
+)
+def test_with_parts_validates_strictly(parts: Any, match: str) -> None:
+ """`with_parts` is our own API, so a malformed partitioning raises."""
+ with pytest.raises(ValueError, match=match):
+ repartition(make_source("numpy-whole"), parts)
+
+
+# ---------------------------------------------------------------------------
+# Protocols
+# ---------------------------------------------------------------------------
+
+
+def test_dask_token_is_deterministic_and_discriminating() -> None:
+ """Same data and same view token alike; a different selection differs."""
+ data = reference()
+ base = LazyArray(data)
+ assert base.__dask_tokenize__() == LazyArray(reference()).__dask_tokenize__()
+
+ tokens = {
+ "base": base.__dask_tokenize__(),
+ "view": base.lazy[1:3].__dask_tokenize__(),
+ "other view": base.lazy[2:4].__dask_tokenize__(),
+ "other data": LazyArray(data + 1).__dask_tokenize__(),
+ }
+ assert len({repr(token) for token in tokens.values()}) == len(tokens)
+ # Equivalent transforms reached different ways still token alike.
+ assert base.lazy[1:5].lazy[0:2].__dask_tokenize__() == base.lazy[1:3].__dask_tokenize__()
+
+
+def test_reader_and_partitioning_do_not_change_dask_identity() -> None:
+ base = LazyArray(reference())
+ token = base.__dask_tokenize__()
+ assert base.with_reader(numpy_reader).__dask_tokenize__() == token
+ assert base.with_reader(basic_reader).__dask_tokenize__() == token
+ assert base.with_parts((2, 2, 2)).__dask_tokenize__() == token
+
+
+@pytest.mark.parametrize("reader", [basic_reader, numpy_reader, DelegatingReader(numpy_reader)])
+def test_reader_survives_pickle(reader: Reader) -> None:
+ view = LazyArray(reference()).with_reader(reader).lazy[1:5, ::2]
+ restored = pickle.loads(pickle.dumps(view))
+ assert type(restored.reader) is type(reader)
+ np.testing.assert_array_equal(restored.result(), view.result())
+
+
+def test_iteration_yields_eager_slices(source: LazyArray) -> None:
+ rows = list(source.lazy[2:5])
+ assert len(rows) == 3
+ for row, expected in zip(rows, reference()[2:5], strict=True):
+ np.testing.assert_array_equal(np.asarray(row), expected)
+
+
+def test_iteration_over_a_zero_dimensional_view_is_rejected() -> None:
+ with pytest.raises(TypeError, match="iteration over a 0-d array"):
+ iter(make_source("numpy-uniform-parts").lazy[0, 0, 0])
+
+
+def test_len_of_a_zero_dimensional_view_is_rejected() -> None:
+ with pytest.raises(TypeError, match="len\\(\\) of unsized object"):
+ len(make_source("numpy-uniform-parts").lazy[0, 0, 0])
+
+
+@pytest.mark.parametrize(
+ ("convert", "selection"),
+ [
+ (bool, (1, 1, 1)),
+ (int, (1, 1, 1)),
+ (float, (1, 1, 1)),
+ (operator.index, (1, 1, 1)),
+ (bool, (1, 1, slice(0, 1))),
+ ],
+ ids=["bool-0d", "int-0d", "float-0d", "index-0d", "bool-size-1"],
+)
+def test_scalar_conversions_match_numpy(convert: Any, selection: Any) -> None:
+ """Size-1 conversions delegate to NumPy, values and all."""
+ data = reference()
+ view = make_source("numpy-uniform-parts").lazy[selection]
+ assert convert(view) == convert(data[selection])
+
+
+@pytest.mark.parametrize(
+ ("convert", "selection", "error"),
+ [
+ (bool, (slice(0, 2), 0, 0), ValueError),
+ (bool, (slice(0, 0), 0, 0), ValueError),
+ (int, (slice(0, 1), 0, 0), TypeError),
+ (float, (slice(0, 2), 0, 0), TypeError),
+ (operator.index, (slice(0, 1), 0, 0), TypeError),
+ ],
+ ids=["bool-many", "bool-empty", "int-1d", "float-many", "index-1d"],
+)
+def test_scalar_conversions_raise_what_numpy_raises(
+ convert: Any, selection: Any, error: type[Exception]
+) -> None:
+ data = reference()
+ view = make_source("numpy-uniform-parts").lazy[selection]
+ with pytest.raises(error):
+ convert(view)
+ with pytest.raises(error):
+ convert(data[selection])
+
+
+def test_pickle_round_trip() -> None:
+ """A wrapper over a picklable base survives a round trip, view and parts intact."""
+ view = LazyArray(reference()).with_parts((2, 2, 2)).lazy[1:6, ::2].lazy.oindex[[3, 0, 0], :, :]
+ restored = pickle.loads(pickle.dumps(view))
+ assert restored.shape == view.shape
+ assert restored.__dask_tokenize__() == view.__dask_tokenize__()
+ np.testing.assert_array_equal(np.asarray(restored.result()), np.asarray(view.result()))
+
+
+# ---------------------------------------------------------------------------
+# dask interop
+# ---------------------------------------------------------------------------
+
+
+def test_dask_from_array_roundtrip() -> None:
+ """A `LazyArray` is a drop-in dask source — no translation ceremony."""
+ da = pytest.importorskip("dask.array")
+ source = make_source("zarr")
+
+ lazy = da.from_array(source)
+ np.testing.assert_array_equal(lazy.compute(), reference())
+
+ # dask chooses its own blocks; the wrapper reads each of them through its
+ # own parts, so the two partitionings need not agree.
+ blocked = da.from_array(source, chunks=(4, 3, 3))
+ assert blocked.chunks == ((4, 3), (3, 2), (3, 1))
+ np.testing.assert_array_equal(blocked[2:, ::2].compute(), reference()[2:, ::2])
+
+
+# ---------------------------------------------------------------------------
+# Errors
+# ---------------------------------------------------------------------------
+
+
+def test_boolean_scalar_is_rejected() -> None:
+ with pytest.raises(IndexError, match="boolean scalars are not valid indices"):
+ make_source("numpy-uniform-parts").lazy[True]
+
+
+@pytest.mark.parametrize(
+ ("mode", "selection", "expected"),
+ [
+ pytest.param("basic", IndexLike(2), np.array(2), id="basic-scalar"),
+ pytest.param(
+ "basic",
+ slice(IndexLike(1), IndexLike(7), IndexLike(2)),
+ np.array([1, 3, 5]),
+ id="basic-slice-components",
+ ),
+ pytest.param("orthogonal", IndexLike(2), np.array(2), id="orthogonal-scalar"),
+ pytest.param("vectorized", IndexLike(2), np.array(2), id="vectorized-scalar"),
+ ],
+)
+def test_positional_selectors_support_the_index_protocol(
+ mode: str, selection: Any, expected: np.ndarray[Any, Any]
+) -> None:
+ source = LazyArray.from_numpy(np.arange(8))
+ if mode == "basic":
+ view = source.lazy[selection]
+ else:
+ view = getattr(source.lazy, "oindex" if mode == "orthogonal" else "vindex")[selection]
+
+ result = np.asarray(view.result())
+ assert result.shape == expected.shape
+ np.testing.assert_array_equal(result, expected)
+
+
+def test_positional_selector_rejects_int_only_objects() -> None:
+ with pytest.raises(IndexError, match="unsupported selection type"):
+ LazyArray.from_numpy(np.arange(8)).lazy[IntOnly()]
+
+
+def test_positional_selector_propagates_malformed_index_protocol() -> None:
+ with pytest.raises(TypeError, match="__index__ returned non-int"):
+ LazyArray.from_numpy(np.arange(8)).lazy[BadIndex()]
+
+
+def test_positional_slice_propagates_malformed_index_protocol() -> None:
+ with pytest.raises(TypeError, match="__index__ returned non-int"):
+ LazyArray.from_numpy(np.arange(8)).lazy[:: BadIndex()]
+
+
+def test_protocol_objects_inside_an_index_array_remain_invalid() -> None:
+ selection = np.array([IndexLike(2)], dtype=object)
+ with pytest.raises(IndexError, match="integer or boolean"):
+ LazyArray.from_numpy(np.arange(8)).lazy.oindex[selection]
+
+
+def test_mask_shape_must_match_the_view() -> None:
+ array = make_source("numpy-uniform-parts")
+ with pytest.raises(IndexError, match="boolean index has shape"):
+ array.lazy.vindex[np.ones((2, 2, 2), dtype=bool)]
+
+
+def test_scalar_index_out_of_bounds() -> None:
+ with pytest.raises(IndexError, match="index 7 is out of bounds for axis 0 with size 7"):
+ make_source("numpy-uniform-parts").lazy[7]
+
+
+def test_scalar_index_out_of_bounds_in_a_view() -> None:
+ """Bounds are the *view's*, not the wrapped array's."""
+ array = make_source("numpy-uniform-parts").lazy[1:4]
+ with pytest.raises(IndexError, match="index 3 is out of bounds for axis 0 with size 3"):
+ array.lazy[3]
+
+
+def test_index_array_out_of_bounds() -> None:
+ array = make_source("numpy-uniform-parts")
+ with pytest.raises(IndexError, match="index 99 is out of bounds for axis 0 with size 7"):
+ array.lazy.oindex[[0, 99], :, :]
+
+
+def test_too_many_indices() -> None:
+ with pytest.raises(IndexError, match="too many indices"):
+ make_source("numpy-uniform-parts").lazy[0, 0, 0, 0]
+
+
+def test_copy_false_conversion_is_rejected() -> None:
+ array = make_source("numpy-uniform-parts")
+ with pytest.raises(ValueError, match="cannot be converted to a NumPy array without a copy"):
+ np.array(array, copy=False)
+
+
+# ---------------------------------------------------------------------------
+# Negative steps
+# ---------------------------------------------------------------------------
+
+
+def test_reversed_box_reports_a_positive_stride(source: LazyArray) -> None:
+ """A reversal is still a box; `strides()` is magnitudes, so it matches the forward twin."""
+ reversed_view = source.lazy[::-2]
+ forward = source.lazy[::2]
+ assert reversed_view.is_box
+ assert reversed_view.strides() == forward.strides() == (2, 1, 1)
+ assert reversed_view.bounding_box() == ((0, 7), (0, 5), (0, 4))
+
+
+def test_a_reversed_view_is_re_based_to_origin_zero() -> None:
+ """The literal domain of a reversal is negative; the positional dialect hides it."""
+ view = make_source("numpy-whole").lazy[::-1]
+ # The algebra's own answer keeps the source frame.
+ assert IndexTransform.from_shape(SHAPE)[::-1].domain.inclusive_min[0] == -6
+ # The wrapper re-bases, so positions start at 0 as NumPy expects.
+ assert view.transform.domain.inclusive_min == (0, 0, 0)
+ assert view.shape == SHAPE
+ np.testing.assert_array_equal(np.asarray(view.result()), reference()[::-1])
+
+
+def test_zero_step_is_rejected() -> None:
+ with pytest.raises(ValueError, match="step cannot be zero"):
+ make_source("numpy-whole").lazy[::0]
+
+
+def test_reversed_positional_interval_is_empty_not_an_error() -> None:
+ """NumPy's rule at the boundary; the literal layer keeps TensorStore's."""
+ view = make_source("numpy-uniform-parts").lazy[2:5:-1]
+ assert view.shape == (0, 5, 4)
+ np.testing.assert_array_equal(np.asarray(view.result()), reference()[2:5:-1])
+ # Literal coordinates, on the other hand, call it a direction error.
+ with pytest.raises(IndexError, match="valid interval"):
+ IndexTransform.from_shape(SHAPE)[2:5:-1]
+
+
+def test_negative_step_over_a_fancy_axis_reverses_the_coordinates(source: LazyArray) -> None:
+ """Reversing a gathered axis materializes, rather than attaching a stride."""
+ view = source.lazy.oindex[[3, 1, 2], :, :].lazy[::-1]
+ expected = outer(reference(), ([3, 1, 2], slice(None), slice(None)))[::-1]
+ np.testing.assert_array_equal(np.asarray(view.result()), expected)
+ m = view.transform.output[0]
+ assert isinstance(m, ArrayMap)
+ np.testing.assert_array_equal(m.index_array.reshape(-1), np.array([2, 1, 3]))
+
+
+# ---------------------------------------------------------------------------
+# Minimal sources
+# ---------------------------------------------------------------------------
+
+
+class MinimalSource:
+ """The floor of the wrapped-array protocol: `shape`, `dtype`, `__getitem__`."""
+
+ def __init__(self, data: np.ndarray[Any, Any]) -> None:
+ self._data = data
+
+ @property
+ def shape(self) -> tuple[int, ...]:
+ return self._data.shape
+
+ @property
+ def dtype(self) -> Any:
+ return self._data.dtype
+
+ def __getitem__(self, key: Any) -> Any:
+ return self._data[key]
+
+
+class RecordingSource(MinimalSource):
+ """A minimal source that records every attempted data read."""
+
+ def __init__(self, data: np.ndarray[Any, Any]) -> None:
+ super().__init__(data)
+ self.reads: list[Any] = []
+
+ def __getitem__(self, key: Any) -> Any:
+ self.reads.append(key)
+ return super().__getitem__(key)
+
+
+@pytest.mark.parametrize(
+ "build",
+ [
+ lambda a: a.lazy[:, 2:2, :],
+ lambda a: a.lazy.vindex[np.array([[6], [3], [0]]), -4].lazy[0, 0:0],
+ ],
+ ids=["ordinary", "unreferenced-axis"],
+)
+def test_an_empty_view_does_not_read_its_source(
+ build: Callable[[LazyArray], LazyArray],
+) -> None:
+ source = RecordingSource(reference())
+ result = build(LazyArray(source)).result()
+
+ assert result.size == 0
+ assert source.reads == []
+
+
+# ---------------------------------------------------------------------------
+# Materializing
+# ---------------------------------------------------------------------------
+
+
+class NumpyBackedSource:
+ """A duck array that stores its data in NumPy and returns views from reads.
+
+ Not an `np.ndarray`, so nothing about the source can be compared against the
+ result's memory — but its blocks are NumPy views of storage the caller must
+ not be handed. The `BASIC` source this package invites people to wrap.
+ """
+
+ def __init__(self, data: np.ndarray[Any, Any]) -> None:
+ self.data = data
+
+ @property
+ def shape(self) -> tuple[int, ...]:
+ return self.data.shape
+
+ @property
+ def dtype(self) -> Any:
+ return self.data.dtype
+
+ def __getitem__(self, selection: Any) -> Any:
+ return self.data[selection]
+
+
+@pytest.mark.parametrize("parts", [None, (2, 2, 2), SHAPE])
+@pytest.mark.parametrize("wrap", [lambda d: d, NumpyBackedSource], ids=["ndarray", "duck"])
+@pytest.mark.parametrize(
+ ("build", "description"),
+ [
+ (lambda a: a.lazy[1:3, :, :], "a basic slice"),
+ (lambda a: a.lazy[:, :, :], "the whole array"),
+ (lambda a: a.lazy[::-1, :, :], "a reversal"),
+ (lambda a: a.lazy.oindex[[2, 0], :, :], "a gather"),
+ ],
+)
+def test_materializing_never_hands_back_the_wrapped_array(
+ parts: Any,
+ wrap: Callable[[np.ndarray[Any, Any]], Any],
+ build: Callable[[LazyArray], LazyArray],
+ description: str,
+) -> None:
+ """Writing to a materialized result must never reach the source.
+
+ An unpartitioned read of a basic selection can be answered with a *view* of
+ the wrapped array, and NumPy 2 hands whatever `__array__` returns straight to
+ the caller. Every route out of the wrapper detaches, so the answer does not
+ depend on how the read happened to be divided.
+
+ Run over both a raw `ndarray` and a duck array that merely stores its data in
+ NumPy: the second is the case where the source cannot be compared against the
+ result, so detaching has to decide from the result's own buffer instead.
+ """
+ data = reference()
+ view = build(repartition(LazyArray(wrap(data)), parts))
+
+ for materialize in (
+ lambda v: v.result(),
+ lambda v: np.array(v, copy=True),
+ lambda v: np.asarray(v),
+ lambda v: np.array(v),
+ ):
+ before = data.copy()
+ materialized = np.asarray(materialize(view))
+ assert not np.shares_memory(materialized, data), description
+ materialized[...] = -1
+ np.testing.assert_array_equal(data, before, err_msg=description)
+
+
+def test_an_eager_getitem_never_hands_back_the_wrapped_array() -> None:
+ data = reference()
+ block = LazyArray(data)[1:3]
+ block[...] = -1
+ np.testing.assert_array_equal(data, reference())
+
+
+def test_result_refuses_to_return_a_partly_written_buffer(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A partition walk that leaves a gap must raise, not return process memory.
+
+ `result()` scatters into an uninitialized buffer, which is only safe because
+ the parts tile the view. This is the guard that turns any future break of
+ that contract into a failure instead of into plausible-looking numbers.
+ """
+ view = LazyArray(reference()).with_parts(PART_SHAPE).lazy[:, 1:, :]
+ complete = LazyArray.parts
+
+ def drop_one(self: LazyArray) -> Any:
+ return list(complete(self))[:-1]
+
+ monkeypatch.setattr(LazyArray, "parts", drop_one)
+ with pytest.raises(AssertionError, match="partition walk addressed"):
+ view.result()
+
+
+def test_result_refuses_a_partition_of_the_wrong_rank(monkeypatch: pytest.MonkeyPatch) -> None:
+ """A part addressing fewer axes than the view has is caught by name."""
+ view = LazyArray(reference()).with_parts(PART_SHAPE).lazy[:, 1:, :]
+ complete = LazyArray.parts
+
+ def truncate(self: LazyArray) -> Any:
+ return [replace(part, out_selection=part.out_selection[:-1]) for part in complete(self)]
+
+ monkeypatch.setattr(LazyArray, "parts", truncate)
+ with pytest.raises(AssertionError, match="of the view's 3 dimensions"):
+ view.result()
+
+
+class DuckBlock:
+ """An array-like meeting exactly the documented `BASIC` floor and no more.
+
+ `shape`, `dtype`, and a `__getitem__` that understands integers and slices.
+ Anything else — an integer array, `take`, `reshape` — raises, and indexing it
+ yields another one of itself, so a block that comes back from it is as
+ limited as the source was.
+ """
+
+ def __init__(self, data: np.ndarray[Any, Any]) -> None:
+ self._data = data
+
+ @property
+ def shape(self) -> tuple[int, ...]:
+ return self._data.shape
+
+ @property
+ def dtype(self) -> Any:
+ return self._data.dtype
+
+ def __array__(self, dtype: Any = None, copy: bool | None = None) -> Any:
+ return np.array(self._data, dtype=dtype, copy=True if copy is None else copy)
+
+ def __getitem__(self, key: Any) -> DuckBlock:
+ selectors = key if isinstance(key, tuple) else (key,)
+ for selector in selectors:
+ if not isinstance(selector, (int, np.integer, slice)):
+ raise TypeError(f"basic indexing only, got {selector!r}")
+ return DuckBlock(self._data[key])
+
+
+@pytest.mark.parametrize(
+ ("build", "oracle"),
+ [
+ (lambda a: a.lazy[1:5, ::2, :], lambda r: r[1:5, ::2, :]),
+ (
+ lambda a: a.lazy.oindex[[4, 0, 0], :, :],
+ lambda r: r[np.ix_([4, 0, 0], range(5), range(4))],
+ ),
+ (lambda a: a.lazy.vindex[[4, 0], [1, 1]], lambda r: r[[4, 0], [1, 1]]),
+ (lambda a: a.lazy[::-1, :, :], lambda r: r[::-1, :, :]),
+ ],
+ ids=["basic", "oindex", "vindex", "reversal"],
+)
+@pytest.mark.parametrize("parts", [None, PART_SHAPE])
+def test_a_source_meeting_only_the_basic_floor_resolves_any_selection(
+ build: Callable[[LazyArray], LazyArray], oracle: Callable[[Any], Any], parts: Any
+) -> None:
+ """The floor is a promise about the source; its blocks are coerced, not trusted."""
+ expected = np.asarray(oracle(reference()))
+ view = build(repartition(LazyArray(DuckBlock(reference())), parts))
+ assert view.shape == expected.shape
+ np.testing.assert_array_equal(np.asarray(view.result()), expected)
+
+
+def test_the_duck_block_double_refuses_a_fancy_key() -> None:
+ """A negative control: the floor test only means something if the double bites."""
+ with pytest.raises(TypeError, match="basic indexing only"):
+ DuckBlock(reference())[np.array([1, 0])]
+
+
+# ---------------------------------------------------------------------------
+# Sources with their own opinions
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.filterwarnings("ignore::PendingDeprecationWarning")
+def test_numpy_matrix_is_refused() -> None:
+ """`np.matrix` never reduces rank, so a view's shape could not be honored."""
+ with pytest.raises(TypeError, match="numpy.matrix cannot be wrapped"):
+ LazyArray(np.matrix(np.arange(12).reshape(3, 4)))
+
+
+@pytest.mark.parametrize("parts", [None, (2, 2), (1, 4), (3, 4)])
+def test_a_masked_source_keeps_its_mask_under_every_partitioning(parts: Any) -> None:
+ data = np.ma.masked_greater(np.arange(12).reshape(3, 4), 7)
+ got = repartition(LazyArray(data), parts).lazy[:, 1:].result()
+ expected = data[:, 1:]
+ assert isinstance(got, np.ma.MaskedArray), parts
+ np.testing.assert_array_equal(np.ma.getmaskarray(got), np.ma.getmaskarray(expected))
+ np.testing.assert_array_equal(np.ma.filled(got, 0), np.ma.filled(expected, 0))
+
+
+@pytest.mark.parametrize("parts", [None, (2, 2), (3, 4)])
+def test_a_masked_source_keeps_its_mask_when_the_view_is_empty(parts: Any) -> None:
+ """An empty result is still a result, and its type must not depend on the parts.
+
+ An empty view is answered without reading the source at all, and that
+ shortcut reached for the array namespace's own `empty` — which knows nothing
+ about masks — so an unpartitioned empty view came back a plain array while
+ the same view partitioned came back masked. No cells either way, so nothing
+ about the values changed; the caller just got a different type depending on
+ how the read had been divided.
+ """
+ data = np.ma.masked_greater(np.arange(12).reshape(3, 4), 7)
+ got = repartition(LazyArray(data), parts).lazy[:, 2:2].result()
+ assert isinstance(got, np.ma.MaskedArray), parts
+ assert np.asarray(got).shape == (3, 0), parts
+
+
+def test_a_large_array_without_dask_refuses_to_claim_equality(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Above the digest limit the fallback must miss a cache rather than lie.
+
+ Two arrays differing in one element used to token identically, because the
+ fallback described the shape and dtype and gave up on the contents.
+ """
+ import sys
+
+ monkeypatch.setitem(sys.modules, "dask.base", None)
+ big = np.zeros(1 << 19, dtype=np.int64)
+ other = big.copy()
+ other[0] = 1
+
+ assert LazyArray(big).__dask_tokenize__() != LazyArray(other).__dask_tokenize__()
+ assert LazyArray(big).__dask_tokenize__() != LazyArray(big).__dask_tokenize__()
+
+ # Below the limit the contents are digested, so equal data still tokens alike.
+ small = np.zeros(8, dtype=np.int64)
+ assert LazyArray(small).__dask_tokenize__() == LazyArray(small.copy()).__dask_tokenize__()
+
+
+# ---------------------------------------------------------------------------
+# Completeness and partition spellings
+# ---------------------------------------------------------------------------
+
+
+def test_a_reversing_view_covers_its_parts() -> None:
+ """A reversal reads every cell of every box, back to front."""
+ data = np.arange(48).reshape(8, 6)
+ forward = LazyArray(data).with_parts((2, 2)).lazy[:, :]
+ reversed_view = LazyArray(data).with_parts((2, 2)).lazy[::-1, ::-1]
+ assert [part.is_complete for part in reversed_view.parts()] == [
+ part.is_complete for part in forward.parts()
+ ]
+ assert all(part.is_complete for part in reversed_view.parts())
+
+
+def test_a_strided_reversal_is_still_incomplete() -> None:
+ data = np.arange(48).reshape(8, 6)
+ view = LazyArray(data).with_parts((2, 2)).lazy[::-2, :]
+ assert not any(part.is_complete for part in view.parts())
+
+
+@pytest.mark.parametrize("parts", [((0,), (3,)), ((), (3,)), (1, 1), ((0, 0), (3,))])
+def test_a_zero_length_axis_accepts_every_spelling_of_no_chunks(parts: Any) -> None:
+ """`(0,)`, `(0, 0)`, `()` and a uniform shape all describe an axis with no cells."""
+ data = np.zeros((0, 3))
+ view = repartition(LazyArray(data), parts)
+ assert view.result().shape == (0, 3)
+ assert list(view.parts()) == []
+
+
+def test_a_zero_chunk_on_a_nonempty_axis_is_still_rejected() -> None:
+ with pytest.raises(ValueError, match="chunk sizes must be positive"):
+ LazyArray(np.zeros((4, 3))).with_parts_per_axis(((0, 4), (3,)))
+
+
+@pytest.mark.parametrize(
+ "selection",
+ [
+ (slice(None, None, -1), slice(None), slice(None)),
+ (slice(3, 1, -1), slice(None), slice(None)),
+ (slice(None, None, -2), slice(None, None, -1), slice(None)),
+ ],
+ ids=["reversed", "reversed-partial", "reversed-strided"],
+)
+def test_the_coverage_count_agrees_with_numpy_for_reversed_selections(
+ selection: tuple[Any, ...],
+) -> None:
+ """The safety net behind `result()`'s coverage assertion, checked on its own.
+
+ `_out_selection_cell_count` sizes a partition's `out_selection` without
+ materializing it, and `result()` trusts that count to decide whether the
+ walk covered the view. Nothing pinned it for a reversed slice, so dropping
+ its `start <= stop` guard — or wrapping the subtraction in `abs()` — left
+ the suite green. A net nobody tests only matters once something else breaks,
+ which is exactly when it needs to be right.
+ """
+ data = reference()
+ view = LazyArray(data).with_parts((2, 2, 2)).lazy[selection]
+ out_shape = view.shape
+ for part in view.parts():
+ counted = _out_selection_cell_count(part.out_selection, out_shape)
+ assert counted == np.empty(out_shape)[part.out_selection].size
+
+
+@pytest.mark.parametrize(
+ ("selection", "out_shape", "expected"),
+ [
+ (((slice(2, 5)),), (10,), 3),
+ # A backwards interval selects nothing. The fast path subtracts, which
+ # would make this negative and let an incomplete walk sum to the view's
+ # own size — so the count falls back to `range` whenever the interval is
+ # not a forward, in-bounds one.
+ (((slice(5, 2)),), (10,), 0),
+ # Counts from the end, to index 8 — past the stop, so nothing.
+ (((slice(-2, 5)),), (10,), 0),
+ ((slice(5, 2), slice(0, 3)), (10, 10), 0),
+ ],
+ ids=["forward", "backwards", "negative-start", "backwards-in-a-pair"],
+)
+def test_the_coverage_count_matches_numpy_for_intervals_the_fast_path_declines(
+ selection: tuple[Any, ...], out_shape: tuple[int, ...], expected: int
+) -> None:
+ """The guard on `result()`'s safety net, exercised where the walk cannot reach it.
+
+ A partition walk only ever produces concrete forward in-bounds intervals, so
+ the guard that keeps everything else off the subtraction fast path is not
+ reachable through `parts()` at all — which is why removing it left the whole
+ suite green. It is the net's own contract, so it is checked directly.
+ """
+ counted = _out_selection_cell_count(selection, out_shape)
+ assert counted == np.empty(out_shape)[selection].size
+ assert counted == expected
+
+
+def test_a_zero_dimensional_index_array_drops_its_axis_like_a_scalar() -> None:
+ """`a[np.array(2), :]` is `a[2, :]` in NumPy, and now here too.
+
+ Only Python and NumPy integers counted as scalars, so a 0-d array fell
+ through to the fancy path and was widened into a length-1 index array —
+ keeping an axis NumPy drops. That was a third answer, agreeing with neither
+ NumPy nor eager zarr, which rejects it.
+ """
+ data = np.arange(20).reshape(4, 5)
+ for mode, expected in (
+ ("oindex", data[np.array(2), :]),
+ ("vindex", data[np.array(2), np.array(3)]),
+ ):
+ view = (
+ LazyArray(data).lazy.oindex[np.array(2), slice(None)]
+ if mode == "oindex"
+ else LazyArray(data).lazy.vindex[np.array(2), np.array(3)]
+ )
+ assert view.shape == expected.shape, mode
+ np.testing.assert_array_equal(np.asarray(view.result()), expected, err_msg=mode)
+
+
+def test_a_multidimensional_array_in_an_orthogonal_selection_is_refused() -> None:
+ """The rule belongs to the selection, so the message speaks its vocabulary.
+
+ Left to the engine, this surfaced as a rank complaint about an `index_array`
+ the caller never wrote — the transform layer's words for a mistake made two
+ layers above it.
+ """
+ with pytest.raises(IndexError, match="must be 1-dimensional"):
+ LazyArray(np.arange(20).reshape(4, 5)).lazy.oindex[[[0, 1], [2, 3]], slice(None)]
+
+
+def test_with_parts_rejects_a_bare_integer() -> None:
+ """Both partitioning methods document ValueError for malformed input."""
+ view = LazyArray.from_numpy(np.arange(12).reshape(3, 4))
+ with pytest.raises(ValueError, match="one entry per dimension"):
+ view.with_parts(3) # type: ignore[arg-type]
+ with pytest.raises(ValueError, match="one entry per dimension"):
+ view.with_parts_per_axis(3) # type: ignore[arg-type]
+
+
+def test_fancy_composition_over_an_empty_axis() -> None:
+ """Regression: composing fancy steps over an empty axis stays unpinned.
+
+ The empty-domain branch of `compose` produces index arrays that are
+ singleton on every non-empty axis; pinning one to an axis it merely
+ broadcasts along made a later basic step index a size-1 axis positionally
+ and raise, deep inside a legal chain.
+ """
+ base = np.empty((3, 0, 6), dtype=np.int64)
+ view = LazyArray(base).lazy.oindex[[2, 1], :, [5, 0, 3]]
+ assert view.shape == (2, 0, 3)
+ composed = view.lazy.oindex[[1, 0], :, [2, 2]]
+ assert composed.shape == (2, 0, 2)
+ scalar = composed.lazy.vindex[..., np.array(1)]
+ assert scalar.shape == (2, 0)
+ assert np.asarray(scalar.result()).shape == (2, 0)
diff --git a/packages/zarr-indexing/tests/test_lazy_array_stateful.py b/packages/zarr-indexing/tests/test_lazy_array_stateful.py
new file mode 100644
index 0000000000..4f5dadc1e4
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_lazy_array_stateful.py
@@ -0,0 +1,114 @@
+"""This package's own use of the state machine it exports.
+
+`ChainedIndexingStateMachine` composes indexing steps onto a `LazyArray` and
+checks each step against NumPy — see
+`zarr_indexing.testing.stateful` for what the invariants assert and why.
+
+Two sources: a NumPy array, which exercises both built-in readers, and a real
+zarr array, whose partitioning is discovered from the store rather than
+declared. The zarr case runs a smaller budget: it reads through a store, and it
+exercises the same code paths.
+
+This replaces a seeded `_random_chain` sweep in `test_lazy_array` that read
+chained selections through `parts()`. That sweep did reach the states it was
+meant to, but a rank-0 correlated view was absorbed by a reshape in `result()`
+and mirrored into the sweep rather than read as a failure; asserting the
+documented assembly literally makes that impossible to paper over.
+`test_lazy_array` keeps its `result()`-based sweep, which is the deterministic
+cross-flavor coverage this does not attempt.
+"""
+
+from __future__ import annotations
+
+from typing import Any, ClassVar
+
+import numpy as np
+import pytest
+from hypothesis import settings
+
+from zarr_indexing import LazyArray, ReadContext
+from zarr_indexing.reader import basic_reader, numpy_reader
+from zarr_indexing.testing import (
+ DEFAULT_SETTINGS,
+ ChainedIndexingStateMachine,
+ state_machine_test,
+ stateful,
+)
+
+
+class NumpyIndexing(ChainedIndexingStateMachine):
+ readers = (numpy_reader,)
+
+
+TestNumpyIndexing = state_machine_test(NumpyIndexing)
+
+
+class UnhashableReader:
+ __hash__ = None
+
+ def read_into(self, source: Any, context: ReadContext, out: Any, /) -> None:
+ basic_reader.read_into(source, context, out)
+
+ def __eq__(self, other: object) -> bool:
+ return isinstance(other, UnhashableReader)
+
+
+def test_reader_set_deduplicates_by_identity_without_hashing() -> None:
+ first = UnhashableReader()
+ second = UnhashableReader()
+
+ readers = stateful._reader_set(LazyArray(np.arange(3)), (first, first, second))
+
+ assert readers[0] is basic_reader
+ assert readers[1] is first
+ assert readers[2] is second
+ assert len(readers) == 3
+
+
+class OneDimensionalIndexing(ChainedIndexingStateMachine):
+ """The same chains over a rank-1 source.
+
+ The sorted one-dimensional fancy path in `chunk_resolution` is entered only
+ when both the input and output ranks are 1, and the output rank is the
+ *source's* — so the rank-3 default walls that path off from the machine
+ entirely, and from every downstream project told to subclass it. That path
+ is where reordering and duplicate coordinates are partitioned, which is the
+ corruption class this whole harness exists to catch.
+ """
+
+ data = np.arange(30, dtype=np.int64)
+ partitionings: ClassVar[tuple[Any, ...]] = (None, (4,), (30,), ((7, 8, 15),))
+
+
+TestOneDimensionalIndexing = state_machine_test(OneDimensionalIndexing)
+
+
+class SingletonAxisIndexing(ChainedIndexingStateMachine):
+ """A source with an extent-1 axis, which the code must not read as a broadcast one.
+
+ An index array's axis is a singleton either because the map broadcasts over
+ it or because the domain is genuinely one cell wide there, and the two are
+ told apart by the domain rather than the array. Nothing generated the second
+ kind.
+ """
+
+ data = np.arange(2 * 1 * 3, dtype=np.int64).reshape(2, 1, 3)
+ partitionings: ClassVar[tuple[Any, ...]] = (None, (1, 1, 1), (2, 1, 2))
+
+
+TestSingletonAxisIndexing = state_machine_test(SingletonAxisIndexing)
+
+
+class ZarrIndexing(ChainedIndexingStateMachine):
+ """The same chains against a zarr array through the universal basic reader."""
+
+ def make_source(self, data: Any) -> Any:
+ zarr = pytest.importorskip("zarr")
+ array = zarr.create_array({}, shape=data.shape, chunks=(3, 2, 3), dtype=data.dtype)
+ array[:] = data
+ return array
+
+
+TestZarrIndexing = state_machine_test(
+ ZarrIndexing, config=settings(DEFAULT_SETTINGS, max_examples=50)
+)
diff --git a/packages/zarr-indexing/tests/test_messages.py b/packages/zarr-indexing/tests/test_messages.py
new file mode 100644
index 0000000000..2ebd41a263
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_messages.py
@@ -0,0 +1,133 @@
+"""Message-layer tests beyond the vendored conformance corpus.
+
+The corpus (see `test_conformance.py`) covers the desugaring matrix and error
+codes. These tests pin behaviors the corpus does not: `normalize` idempotence,
+`parse_ndsel`, 64-bit boundary handling, and schema-valid-but-redundant maps.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from zarr_indexing.messages import NdselError, normalize_ndsel, parse_ndsel
+
+_MESSAGES = [
+ {"kind": "point", "coords": [4, 7]},
+ {"kind": "box", "inclusive_min": [0, 0], "exclusive_max": [3, 4]},
+ {"kind": "box", "inclusive_min": [["-inf"], 0], "exclusive_max": [["+inf"], 4]},
+ {"kind": "slice", "start": [5], "stop": [10], "step": [2]},
+ {"kind": "points", "coords": [[1, 10], [2, 20]]},
+ {
+ "kind": "transform",
+ "input_inclusive_min": [0],
+ "input_exclusive_max": [3],
+ "output": [{"offset": 7}, {"input_dimension": 0, "stride": 2}, {"index_array": [1, 2, 3]}],
+ },
+]
+
+
+@pytest.mark.parametrize("message", _MESSAGES)
+def test_normalize_is_idempotent(message: dict[str, Any]) -> None:
+ once = normalize_ndsel(message)
+ twice = normalize_ndsel({"kind": "transform", **once})
+ assert twice == once
+
+
+@pytest.mark.parametrize("message", _MESSAGES)
+def test_parse_returns_message_unchanged(message: dict[str, Any]) -> None:
+ assert parse_ndsel(message) == message
+
+
+def test_parse_rejects_invalid() -> None:
+ with pytest.raises(NdselError) as excinfo:
+ parse_ndsel({"kind": "slice", "start": [0]})
+ assert excinfo.value.reason == "invalid_json"
+
+
+def test_constant_map_drops_redundant_stride() -> None:
+ # A constant map (no input_dimension, no index_array) is schema-valid even
+ # with a stray stride; it canonicalizes to offset-only.
+ result = normalize_ndsel(
+ {"kind": "transform", "input_rank": 0, "output": [{"offset": 5, "stride": 9}]}
+ )
+ assert result["output"] == [{"offset": 5}]
+
+
+def test_i64_min_and_max_round_trip() -> None:
+ i64_min, i64_max = -(2**63), 2**63 - 1
+ result = normalize_ndsel({"kind": "point", "coords": [i64_min, i64_max]})
+ assert result["output"] == [{"offset": i64_min}, {"offset": i64_max}]
+
+
+def test_i64_overflow_rejected() -> None:
+ with pytest.raises(NdselError) as excinfo:
+ normalize_ndsel({"kind": "point", "coords": [2**63]})
+ assert excinfo.value.reason == "invalid_json"
+
+
+def test_bool_in_output_offset_rejected() -> None:
+ with pytest.raises(NdselError) as excinfo:
+ normalize_ndsel({"kind": "transform", "input_rank": 0, "output": [{"offset": True}]})
+ assert excinfo.value.reason == "invalid_json"
+
+
+def test_sentinel_not_allowed_in_plain_integer_position() -> None:
+ with pytest.raises(NdselError) as excinfo:
+ normalize_ndsel({"kind": "point", "coords": ["+inf"]})
+ assert excinfo.value.reason == "invalid_json"
+
+
+def test_not_an_object_rejected() -> None:
+ with pytest.raises(NdselError) as excinfo:
+ normalize_ndsel([1, 2, 3])
+ assert excinfo.value.reason == "invalid_json"
+
+
+def test_empty_string_kind_is_unknown_kind() -> None:
+ with pytest.raises(NdselError) as excinfo:
+ normalize_ndsel({"kind": ""})
+ assert excinfo.value.reason == "unknown_kind"
+
+
+class TestNegativeStep:
+ """ndsel 1.0-draft.2 section 5.3: one desugaring rule, both signs."""
+
+ def test_full_reverse(self) -> None:
+ """The spec's own worked example: reversing a length-20 axis."""
+ body = normalize_ndsel({"kind": "slice", "start": [19], "stop": [-1], "step": [-1]})
+ assert body["input_inclusive_min"] == [-19]
+ assert body["input_exclusive_max"] == [1]
+ assert body["output"] == [{"offset": 0, "stride": -1, "input_dimension": 0}]
+
+ def test_trunc_origin_for_a_negative_step(self) -> None:
+ """`trunc(15 / -2) == -7`; `floor` would give -8."""
+ body = normalize_ndsel({"kind": "slice", "start": [15], "stop": [5], "step": [-2]})
+ assert body["input_inclusive_min"] == [-7]
+ assert body["input_exclusive_max"] == [-2]
+ assert body["output"] == [{"offset": 1, "stride": -2, "input_dimension": 0}]
+
+ def test_empty_is_legal_at_any_coordinate(self) -> None:
+ body = normalize_ndsel({"kind": "slice", "start": [5], "stop": [5], "step": [-1]})
+ assert body["input_inclusive_min"] == body["input_exclusive_max"] == [-5]
+
+ @pytest.mark.parametrize(
+ "message",
+ [
+ {"kind": "slice", "start": [9], "stop": [0]},
+ {"kind": "slice", "start": [9], "stop": [0], "step": [2]},
+ {"kind": "slice", "start": [5], "stop": [6], "step": [-1]},
+ ],
+ ids=["unit-step", "positive-step", "negative-step"],
+ )
+ def test_a_reversed_interval_is_an_error(self, message: dict[str, object]) -> None:
+ """Not clamped to empty: travelling the wrong way is a mistake, either sign."""
+ with pytest.raises(NdselError) as excinfo:
+ normalize_ndsel(message)
+ assert excinfo.value.reason == "bounds_out_of_order"
+
+ def test_zero_step_still_errors(self) -> None:
+ with pytest.raises(NdselError) as excinfo:
+ normalize_ndsel({"kind": "slice", "start": [0], "stop": [4], "step": [0]})
+ assert excinfo.value.reason == "step_zero"
diff --git a/packages/zarr-indexing/tests/test_ndsel_tensorstore.py b/packages/zarr-indexing/tests/test_ndsel_tensorstore.py
new file mode 100644
index 0000000000..ab9465c8d1
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_ndsel_tensorstore.py
@@ -0,0 +1,51 @@
+"""Cross-check canonical ndsel bodies against a real TensorStore.
+
+A normalized ndsel `transform` body is, field-for-field, a TensorStore
+`IndexTransform` (minus the `kind` discriminator, which the canonical body never
+carries). This test loads a handful of finite-bound canonical bodies into
+`tensorstore.IndexTransform(json=...)` and confirms that TensorStore's own
+`to_json()` re-loads, through our engine layer, into an equivalent transform.
+
+Skipped when tensorstore is not installed. Run it explicitly with:
+
+ uv run --with tensorstore pytest \
+ packages/zarr-indexing/tests/test_ndsel_tensorstore.py -q
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pytest
+
+from zarr_indexing.transform import IndexTransform
+
+ts = pytest.importorskip("tensorstore")
+
+
+def _canonical_transforms() -> list[IndexTransform]:
+ base = IndexTransform.from_shape((10, 20))
+ return [
+ base, # identity
+ base[2:8:2, :], # strided DimensionMap + identity
+ base[3, :], # integer index -> ConstantMap + DimensionMap
+ base.oindex[np.array([1, 5, 9]), :], # orthogonal index_array
+ IndexTransform.from_shape((10, 20, 30)).vindex[
+ np.array([1, 3]), np.array([2, 4]), :
+ ], # correlated index_arrays + residual slice
+ ]
+
+
+@pytest.mark.parametrize("transform", _canonical_transforms())
+def test_body_loads_in_tensorstore_and_round_trips(transform: IndexTransform) -> None:
+ body = transform.to_json()
+
+ # (1) The canonical body loads directly as a TensorStore IndexTransform.
+ ts_transform = ts.IndexTransform(json=body)
+
+ # (2) TensorStore's own JSON re-loads, through our engine, to an equivalent
+ # transform. Comparing via our canonical form normalizes away
+ # representational choices (index_array_bounds, default omissions) that
+ # both sides make differently but that denote the same selection.
+ ts_json = ts_transform.to_json()
+ reloaded = IndexTransform.from_json(ts_json)
+ assert reloaded.to_json() == transform.to_json()
diff --git a/packages/zarr-indexing/tests/test_output_map.py b/packages/zarr-indexing/tests/test_output_map.py
new file mode 100644
index 0000000000..d1e21efaa7
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_output_map.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import pickle
+
+import numpy as np
+import pytest
+
+from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap
+
+
+class TestConstantMap:
+ def test_construction(self) -> None:
+ m = ConstantMap(offset=42)
+ assert m.offset == 42
+
+ def test_default_offset(self) -> None:
+ m = ConstantMap()
+ assert m.offset == 0
+
+ def test_frozen(self) -> None:
+ m = ConstantMap(offset=5)
+ assert isinstance(m, ConstantMap)
+
+
+class TestDimensionMap:
+ def test_construction(self) -> None:
+ m = DimensionMap(input_dimension=3, offset=5, stride=2)
+ assert m.input_dimension == 3
+ assert m.offset == 5
+ assert m.stride == 2
+
+ def test_defaults(self) -> None:
+ m = DimensionMap(input_dimension=0)
+ assert m.offset == 0
+ assert m.stride == 1
+
+ def test_frozen(self) -> None:
+ m = DimensionMap(input_dimension=0)
+ assert isinstance(m, DimensionMap)
+
+
+class TestArrayMap:
+ def test_construction(self) -> None:
+ arr = np.array([1, 3, 5], dtype=np.intp)
+ m = ArrayMap(index_array=arr, offset=10, stride=2)
+ assert m.offset == 10
+ assert m.stride == 2
+ np.testing.assert_array_equal(m.index_array, arr)
+
+ def test_defaults(self) -> None:
+ arr = np.array([0, 1], dtype=np.intp)
+ m = ArrayMap(index_array=arr)
+ assert m.offset == 0
+ assert m.stride == 1
+
+ def test_frozen(self) -> None:
+ arr = np.array([0], dtype=np.intp)
+ m = ArrayMap(index_array=arr)
+ assert isinstance(m, ArrayMap)
+
+ def test_owns_index_array_and_keeps_hash_stable(self) -> None:
+ arr = np.array([1, 3, 5], dtype=np.intp)
+ m = ArrayMap(index_array=arr)
+ lookup = {m: "value"}
+
+ arr[:] = 9
+
+ np.testing.assert_array_equal(m.index_array, [1, 3, 5])
+ assert lookup[m] == "value"
+
+ def test_pickle_round_trip_keeps_index_array_read_only(self) -> None:
+ restored = pickle.loads(pickle.dumps(ArrayMap(index_array=np.array([1, 3, 5]))))
+
+ assert not restored.index_array.flags.writeable
+ with pytest.raises(ValueError, match="read-only"):
+ restored.index_array[0] = 9
+
+ def test_index_array_cannot_be_made_writeable(self) -> None:
+ m = ArrayMap(index_array=np.array([1, 3, 5]))
+
+ with pytest.raises(ValueError):
+ m.index_array.flags.writeable = True
+
+ def test_equal_array_maps_have_equal_hashes_across_integer_dtypes(self) -> None:
+ left = ArrayMap(np.array([1, 2], dtype=np.int32))
+ right = ArrayMap(np.array([1, 2], dtype=np.int64))
+ assert left == right
+ assert hash(left) == hash(right)
+ assert left.index_array.dtype == np.dtype(np.intp)
+
+ def test_rejects_non_integer_index_array(self) -> None:
+ with pytest.raises(TypeError, match="index_array must have an integer dtype"):
+ ArrayMap(np.array([1.5, 2.0], dtype=np.float64))
+
+ def test_rejects_unsigned_index_array_value_outside_intp(self) -> None:
+ outside_intp = np.array([np.iinfo(np.uint64).max], dtype=np.uint64)
+
+ with pytest.raises(OverflowError, match="outside np.intp range"):
+ ArrayMap(outside_intp)
diff --git a/packages/zarr-indexing/tests/test_reader.py b/packages/zarr-indexing/tests/test_reader.py
new file mode 100644
index 0000000000..11241a316c
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_reader.py
@@ -0,0 +1,514 @@
+from __future__ import annotations
+
+import math
+from typing import TYPE_CHECKING, Any, get_type_hints
+
+import numpy as np
+import numpy.typing as npt
+import pytest
+
+import zarr_indexing.reader as reader_module
+from zarr_indexing import (
+ ArrayMap,
+ ChunkProjection,
+ ConstantMap,
+ DimensionMap,
+ IndexDomain,
+ IndexTransform,
+ LazyArray,
+ ReadContext,
+ plan_chunks,
+)
+from zarr_indexing.grid import dimension_grids_from_chunks
+from zarr_indexing.reader import basic_reader, numpy_reader, unit_step_reader
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+
+class BasicOnlySource:
+ def __init__(self, data: np.ndarray[Any, Any]) -> None:
+ self.data = data
+ self.keys: list[tuple[Any, ...]] = []
+
+ @property
+ def shape(self) -> tuple[int, ...]:
+ return self.data.shape
+
+ @property
+ def dtype(self) -> np.dtype[Any]:
+ return self.data.dtype
+
+ def __getitem__(self, key: tuple[Any, ...]) -> np.ndarray[Any, Any]:
+ assert all(isinstance(item, slice) and (item.step or 1) > 0 for item in key)
+ self.keys.append(key)
+ return self.data[key]
+
+
+class UnitStepOnlySource(BasicOnlySource):
+ """A source that rejects everything but ascending unit-step slices."""
+
+ def __getitem__(self, key: tuple[Any, ...]) -> np.ndarray[Any, Any]:
+ assert all(
+ isinstance(item, slice) and item.step == 1 and 0 <= item.start <= item.stop <= size
+ for item, size in zip(key, self.data.shape, strict=True)
+ )
+ self.keys.append(key)
+ return self.data[key]
+
+
+SOURCE = np.arange(6 * 7 * 8).reshape(6, 7, 8)
+BASE = IndexTransform.from_shape(SOURCE.shape)
+
+
+SUCCESSFUL_CONTRACT_CASES = (
+ pytest.param(
+ np.arange(8),
+ IndexTransform.from_shape((8,))[3],
+ (3,),
+ np.zeros((1, 0), dtype=np.intp),
+ np.array([[3]], dtype=np.intp),
+ np.array(3),
+ lambda array: array.lazy[3],
+ id="constant",
+ ),
+ pytest.param(
+ np.arange(8),
+ IndexTransform.from_shape((8,))[1:8:2],
+ (3,),
+ np.array([[0], [1], [2], [3]], dtype=np.intp),
+ np.array([[1], [3], [5], [7]], dtype=np.intp),
+ np.array([1, 3, 5, 7]),
+ lambda array: array.lazy[1:8:2],
+ id="positive-affine",
+ ),
+ pytest.param(
+ np.arange(8),
+ IndexTransform.from_shape((8,))[::-2],
+ (3,),
+ np.array([[-3], [-2], [-1], [0]], dtype=np.intp),
+ np.array([[7], [5], [3], [1]], dtype=np.intp),
+ np.array([7, 5, 3, 1]),
+ lambda array: array.lazy[::-2],
+ id="negative-affine",
+ ),
+ pytest.param(
+ np.arange(8),
+ IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(DimensionMap(input_dimension=0, offset=2, stride=0),),
+ ),
+ (3,),
+ np.array([[0], [1], [2]], dtype=np.intp),
+ np.array([[2], [2], [2]], dtype=np.intp),
+ np.array([2, 2, 2]),
+ lambda array: array.lazy.oindex[[2, 2, 2]],
+ id="zero-affine",
+ ),
+ pytest.param(
+ np.arange(20).reshape(4, 5),
+ IndexTransform.from_shape((4, 5)).oindex[[3, 1, 1], [4, 0]],
+ (2, 3),
+ np.array([[0, 0], [0, 1], [1, 0], [1, 1], [2, 0], [2, 1]], dtype=np.intp),
+ np.array([[3, 4], [3, 0], [1, 4], [1, 0], [1, 4], [1, 0]], dtype=np.intp),
+ np.array([[19, 15], [9, 5], [9, 5]]),
+ lambda array: array.lazy.oindex[[3, 1, 1], [4, 0]],
+ id="orthogonal-array",
+ ),
+ pytest.param(
+ np.arange(20).reshape(4, 5),
+ IndexTransform.from_shape((4, 5)).vindex[[3, 1, 1], [4, 0, 4]],
+ (2, 3),
+ np.array([[0], [1], [2]], dtype=np.intp),
+ np.array([[3, 4], [1, 0], [1, 4]], dtype=np.intp),
+ np.array([19, 5, 9]),
+ lambda array: array.lazy.vindex[[3, 1, 1], [4, 0, 4]],
+ id="correlated-array",
+ ),
+ pytest.param(
+ np.arange(8),
+ IndexTransform(
+ domain=IndexDomain((4,), (7,)),
+ output=(DimensionMap(input_dimension=0, offset=-3, stride=1),),
+ ),
+ (3,),
+ np.array([[4], [5], [6]], dtype=np.intp),
+ np.array([[1], [2], [3]], dtype=np.intp),
+ np.array([1, 2, 3]),
+ None,
+ id="non-zero-origin",
+ ),
+ pytest.param(
+ np.arange(8),
+ IndexTransform.from_shape((8,))[2:2],
+ (3,),
+ np.empty((0, 1), dtype=np.intp),
+ np.empty((0, 1), dtype=np.intp),
+ np.array([], dtype=np.intp),
+ lambda array: array.lazy[2:2],
+ id="empty",
+ ),
+)
+
+
+@pytest.mark.parametrize(
+ (
+ "source_data",
+ "transform",
+ "chunk_shape",
+ "request_coordinates",
+ "storage_coordinates",
+ "expected_values",
+ "lazy_selection",
+ ),
+ SUCCESSFUL_CONTRACT_CASES,
+)
+def test_successful_transform_contract_across_planning_readers_and_lazy_array(
+ source_data: np.ndarray[Any, Any],
+ transform: IndexTransform,
+ chunk_shape: tuple[int, ...],
+ request_coordinates: npt.NDArray[np.intp],
+ storage_coordinates: npt.NDArray[np.intp],
+ expected_values: np.ndarray[Any, Any],
+ lazy_selection: Callable[[LazyArray], LazyArray] | None,
+) -> None:
+ """One literal matrix keeps transform, planning, readers, and wrappers aligned."""
+ np.testing.assert_array_equal(transform.apply_many(request_coordinates), storage_coordinates)
+
+ grids = dimension_grids_from_chunks(chunk_shape, source_data.shape)
+ reconstructed_pairs = [
+ (
+ tuple(projection.cell_transform.apply(cell_coordinate)),
+ tuple(
+ local_coordinate + chunk_origin
+ for local_coordinate, chunk_origin in zip(
+ projection.chunk_transform.apply(cell_coordinate),
+ projection.chunk_domain.inclusive_min,
+ strict=True,
+ )
+ ),
+ )
+ for projection in plan_chunks(transform, grids)
+ for cell_coordinate in _domain_coordinates(projection.cell_transform.domain)
+ ]
+ expected_pairs = list(
+ zip(
+ map(tuple, request_coordinates.tolist()),
+ map(tuple, storage_coordinates.tolist()),
+ strict=True,
+ )
+ )
+ assert sorted(reconstructed_pairs) == sorted(expected_pairs)
+
+ for reader, source in (
+ (basic_reader, BasicOnlySource(source_data)),
+ (numpy_reader, source_data),
+ (unit_step_reader, UnitStepOnlySource(source_data)),
+ ):
+ out = np.empty(transform.domain.shape, dtype=source_data.dtype)
+ assert reader.read_into(source, ReadContext(transform), out) is None
+ np.testing.assert_array_equal(out, expected_values)
+
+ if lazy_selection is not None:
+ view = lazy_selection(LazyArray.from_numpy(source_data).with_parts(chunk_shape))
+ np.testing.assert_array_equal(view.result(), expected_values)
+
+
+def _domain_coordinates(domain: IndexDomain) -> list[tuple[int, ...]]:
+ return [
+ tuple(
+ position + origin for position, origin in zip(index, domain.inclusive_min, strict=True)
+ )
+ for index in np.ndindex(*domain.shape)
+ ]
+
+
+def test_read_context_public_annotations_resolve() -> None:
+ assert get_type_hints(ReadContext)["projection"] == ChunkProjection | None
+
+
+READER_CASES = (
+ pytest.param(
+ SOURCE,
+ BASE[1:6:2, 2, ::-2],
+ np.array(
+ [
+ [79, 77, 75, 73],
+ [191, 189, 187, 185],
+ [303, 301, 299, 297],
+ ]
+ ),
+ id="strided-reversed-nonzero-origin",
+ ),
+ pytest.param(
+ SOURCE,
+ BASE.oindex[[5, 1, 1], slice(1, 6), [7, 2]],
+ np.array(
+ [
+ [[295, 290], [303, 298], [311, 306], [319, 314], [327, 322]],
+ [[71, 66], [79, 74], [87, 82], [95, 90], [103, 98]],
+ [[71, 66], [79, 74], [87, 82], [95, 90], [103, 98]],
+ ]
+ ),
+ id="orthogonal-nonzero-origin",
+ ),
+ pytest.param(
+ SOURCE,
+ BASE.vindex[np.array([[5], [1]]), np.array([[2, 4, 0]])],
+ np.array(
+ [
+ [
+ [296, 297, 298, 299, 300, 301, 302, 303],
+ [312, 313, 314, 315, 316, 317, 318, 319],
+ [280, 281, 282, 283, 284, 285, 286, 287],
+ ],
+ [
+ [72, 73, 74, 75, 76, 77, 78, 79],
+ [88, 89, 90, 91, 92, 93, 94, 95],
+ [56, 57, 58, 59, 60, 61, 62, 63],
+ ],
+ ]
+ ),
+ id="correlated-broadcast",
+ ),
+ pytest.param(
+ SOURCE,
+ BASE.vindex[[5, 1], [2, 2]],
+ np.array(
+ [
+ [296, 297, 298, 299, 300, 301, 302, 303],
+ [72, 73, 74, 75, 76, 77, 78, 79],
+ ]
+ ),
+ id="correlated-vector",
+ ),
+ pytest.param(
+ SOURCE,
+ BASE[:, 0:0, :],
+ np.empty((6, 0, 8), dtype=SOURCE.dtype),
+ id="empty",
+ ),
+ pytest.param(SOURCE, BASE[2, 3, 4], np.array(140), id="scalar"),
+ pytest.param(
+ np.arange(8),
+ IndexTransform(
+ domain=IndexDomain((4,), (7,)),
+ output=(DimensionMap(input_dimension=0, offset=2, stride=0),),
+ ),
+ np.array([2, 2, 2]),
+ id="zero-stride-nonzero-origin",
+ ),
+)
+
+
+@pytest.mark.parametrize(("source_data", "transform", "expected"), READER_CASES)
+@pytest.mark.parametrize("reader_name", ["basic", "numpy", "unit-step"])
+def test_builtin_readers_match_the_transform(
+ source_data: np.ndarray[Any, Any],
+ transform: IndexTransform,
+ expected: np.ndarray[Any, Any],
+ reader_name: str,
+) -> None:
+ out = np.empty(transform.domain.shape, dtype=source_data.dtype)
+ if reader_name == "basic":
+ source = BasicOnlySource(source_data)
+ result = basic_reader.read_into(source, ReadContext(transform), out)
+ assert len(source.keys) == 1
+ elif reader_name == "unit-step":
+ source = UnitStepOnlySource(source_data)
+ result = unit_step_reader.read_into(source, ReadContext(transform), out)
+ assert len(source.keys) == 1
+ else:
+ result = numpy_reader.read_into(source_data, ReadContext(transform), out)
+ assert result is None
+ np.testing.assert_array_equal(out, expected)
+
+
+@pytest.mark.parametrize("reader_name", ["basic", "numpy", "unit-step"])
+def test_builtin_readers_share_transform_affine_overflow(reader_name: str) -> None:
+ transform = IndexTransform(
+ domain=IndexDomain.from_shape((1,)),
+ output=(ArrayMap(np.array([2**62], dtype=np.intp), stride=4),),
+ )
+
+ with pytest.raises(OverflowError, match="outside np.intp"):
+ transform.apply_many(np.array([[0]], dtype=np.intp))
+
+ out = np.empty(transform.domain.shape, dtype=np.intp)
+ if reader_name == "basic":
+ with pytest.raises(OverflowError, match="outside np.intp"):
+ basic_reader.read_into(BasicOnlySource(np.arange(1)), ReadContext(transform), out)
+ elif reader_name == "unit-step":
+ with pytest.raises(OverflowError, match="outside np.intp"):
+ unit_step_reader.read_into(
+ UnitStepOnlySource(np.arange(1)), ReadContext(transform), out
+ )
+ else:
+ with pytest.raises(OverflowError, match="outside np.intp"):
+ numpy_reader.read_into(np.arange(1), ReadContext(transform), out)
+
+
+def test_empty_domain_composed_fancy_transform_reads_as_empty() -> None:
+ """An ArrayMap composed over an empty domain resolves like any other map.
+
+ The composed map is legitimately empty along the vanished axis; the
+ resolvers used to fail reshaping it instead of noticing that an empty
+ domain selects nothing.
+ """
+ source_data = np.arange(6).reshape(2, 3)
+ view = LazyArray.from_numpy(source_data).lazy.oindex[slice(0, 0), np.array([2, 1, 2, 0])]
+ transform = view.lazy.oindex[slice(None), np.array([1, 3, 1])].transform
+ assert transform.domain.shape == (0, 3)
+
+ for reader, source in (
+ (basic_reader, BasicOnlySource(source_data)),
+ (numpy_reader, source_data),
+ (unit_step_reader, UnitStepOnlySource(source_data)),
+ ):
+ out = np.empty(transform.domain.shape, dtype=source_data.dtype)
+ assert reader.read_into(source, ReadContext(transform), out) is None
+
+
+def test_unit_step_reader_reads_through_lazy_array() -> None:
+ """The full dialect resolves through a source that only accepts unit-step slices.
+
+ `UnitStepOnlySource` asserts the shape of every key it receives, so each
+ selection here also proves no strided, descending, or non-slice key
+ reached the source — partitioned and unpartitioned alike.
+ """
+ selections: tuple[Callable[[LazyArray], LazyArray], ...] = (
+ lambda v: v.lazy[1:5, ::2, ::-1],
+ lambda v: v.lazy[5:1:-2, None, 3, ::3],
+ lambda v: v.lazy.oindex[[3, 0, 3], ::-2, [7, 7]],
+ lambda v: v.lazy.vindex[np.array([[0, 5]]), np.array([[6], [0]]), 2],
+ lambda v: v.lazy[2:2, :, ::-1],
+ lambda v: v.lazy[::5, 6, 1:8:4],
+ )
+ for select in selections:
+ for parts in (None, (2, 3, 8), (6, 7, 1)):
+ source = UnitStepOnlySource(SOURCE)
+ view = LazyArray(source).with_reader(unit_step_reader)
+ if parts is not None:
+ view = view.with_parts(parts)
+ expected = select(LazyArray.from_numpy(SOURCE)).result()
+ np.testing.assert_array_equal(select(view).result(), expected)
+ # An empty view allocates without reading; every other one must read.
+ assert (len(source.keys) > 0) == (expected.size > 0)
+
+
+def test_constant_outside_intp_has_transform_planner_reader_error_parity() -> None:
+ outside_intp = int(np.iinfo(np.intp).max) + 1
+ transform = IndexTransform(
+ domain=IndexDomain((), ()),
+ output=(ConstantMap(offset=outside_intp),),
+ )
+
+ with pytest.raises(OverflowError, match="outside np.intp range"):
+ transform.apply_many(np.zeros((1, 0), dtype=np.intp))
+
+ grids = dimension_grids_from_chunks((1,), (1,))
+ with pytest.raises(OverflowError, match="outside np.intp range"):
+ list(plan_chunks(transform, grids))
+
+ for reader, source in (
+ (basic_reader, BasicOnlySource(np.arange(1))),
+ (numpy_reader, np.arange(1)),
+ ):
+ with pytest.raises(OverflowError, match="outside np.intp range"):
+ reader.read_into(source, ReadContext(transform), np.empty((), dtype=np.intp))
+
+
+def test_numpy_reader_narrows_basic_slab_before_gather(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ source = np.arange(1_000_000).reshape(1000, 1000)
+ transform = IndexTransform.from_shape(source.shape).oindex[:10, [0, 999]]
+ seen: list[tuple[int, ...]] = []
+ real_take = reader_module._take # pyright: ignore[reportPrivateUsage]
+
+ def recording_take(array: Any, indices: npt.NDArray[np.intp], axis: int) -> Any:
+ seen.append(tuple(int(value) for value in array.shape))
+ return real_take(array, indices, axis)
+
+ monkeypatch.setattr(reader_module, "_take", recording_take)
+ out = np.empty(transform.domain.shape, dtype=source.dtype)
+ assert numpy_reader.read_into(source, ReadContext(transform), out) is None
+ assert out.shape == (10, 2)
+ np.testing.assert_array_equal(
+ out,
+ np.array(
+ [
+ [0, 999],
+ [1000, 1999],
+ [2000, 2999],
+ [3000, 3999],
+ [4000, 4999],
+ [5000, 5999],
+ [6000, 6999],
+ [7000, 7999],
+ [8000, 8999],
+ [9000, 9999],
+ ]
+ ),
+ )
+ assert seen
+ assert all(math.prod(shape) <= 20_000 for shape in seen), seen
+
+
+def test_numpy_reader_preserves_a_mask_in_the_supplied_buffer() -> None:
+ source = np.ma.masked_greater(np.arange(12).reshape(3, 4), 7)
+ transform = IndexTransform.from_shape(source.shape)[:, 1:]
+ out = np.ma.masked_all(transform.domain.shape, dtype=source.dtype)
+ assert numpy_reader.read_into(source, ReadContext(transform), out) is None
+ np.testing.assert_array_equal(np.ma.getmaskarray(out), np.ma.getmaskarray(source[:, 1:]))
+ np.testing.assert_array_equal(np.ma.filled(out, 0), np.ma.filled(source[:, 1:], 0))
+
+
+# ---------------------------------------------------------------------------
+# Diagonal gathers — output maps sharing an input axis
+# ---------------------------------------------------------------------------
+
+
+def test_reading_a_diagonal_gather_transform() -> None:
+ """Two index arrays bound to the same input axis resolve pointwise.
+
+ No selection dialect produces this transform — it is the hand-built
+ diagonal-extraction form — but the reader resolves it through the same
+ pointwise path as a correlated gather.
+ """
+ data = np.arange(30).reshape(5, 6)
+ rows = np.array([4, 0, 2])
+ cols = np.array([1, 5, 2])
+ transform = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(
+ ArrayMap(index_array=rows),
+ ArrayMap(index_array=cols),
+ ),
+ )
+
+ out = np.empty((3,), dtype=data.dtype)
+ basic_reader.read_into(data, ReadContext(transform), out)
+ np.testing.assert_array_equal(out, data[rows, cols])
+
+ out = np.empty((3,), dtype=data.dtype)
+ numpy_reader.read_into(data, ReadContext(transform), out)
+ np.testing.assert_array_equal(out, data[rows, cols])
+
+
+def test_reading_a_diagonal_gather_with_a_residual_slice_axis() -> None:
+ data = np.arange(60).reshape(5, 6, 2)
+ rows = np.array([[4], [0], [2]])
+ cols = np.array([[1], [5], [2]])
+ transform = IndexTransform(
+ domain=IndexDomain.from_shape((3, 2)),
+ output=(
+ ArrayMap(index_array=rows),
+ ArrayMap(index_array=cols),
+ DimensionMap(input_dimension=1),
+ ),
+ )
+
+ out = np.empty((3, 2), dtype=data.dtype)
+ basic_reader.read_into(data, ReadContext(transform), out)
+ np.testing.assert_array_equal(out, data[rows[:, 0], cols[:, 0], :])
diff --git a/packages/zarr-indexing/tests/test_tensorstore_parity.py b/packages/zarr-indexing/tests/test_tensorstore_parity.py
new file mode 100644
index 0000000000..9f4123fc02
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_tensorstore_parity.py
@@ -0,0 +1,435 @@
+"""TensorStore-parity oracle tests for IndexTransform semantics.
+
+Every case in this module was executed against tensorstore 0.1.84 (see the
+lazy-indexing design notes): the expected domains, values, and error conditions
+are TensorStore's observed behavior, which zarr's lazy indexing matches by
+design. Core rules pinned here:
+
+- **Domain preservation**: a step-1 slice keeps the literal coordinates of the
+ selected interval (`a[2:10]` has domain `[2, 10)`); nothing re-zeros
+ implicitly. Re-zeroing is explicit via `translate_to`.
+- **Strided-domain rule**: for step ``k``, ``origin = trunc(start/k)`` (rounded
+ toward zero), ``shape = ceil((stop - start)/k)``, and coordinate
+ ``origin + i`` maps to base cell ``start + i*k``.
+- **Strict containment**: non-empty slice intervals must lie within the domain
+ — no clamping, no negative-wrapping; empty intervals are valid anywhere;
+ reversed non-empty bounds are an error, not an empty result.
+- **Fancy-dim rule**: index-array dims get fresh explicit ``[0, n)`` domains;
+ index-array values are absolute domain coordinates.
+- **Translate rules**: ``translate_by``/``translate_to`` shift the input domain
+ while preserving which cells are addressed.
+"""
+
+from __future__ import annotations
+
+from typing import ClassVar
+
+import numpy as np
+import pytest
+
+from zarr_indexing.domain import IndexDomain
+from zarr_indexing.errors import BoundsCheckError
+from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap
+from zarr_indexing.transform import IndexTransform
+
+
+def _identity(lo: int, hi: int) -> IndexTransform:
+ """Identity transform over the 1-D domain [lo, hi)."""
+ return IndexTransform.identity(IndexDomain(inclusive_min=(lo,), exclusive_max=(hi,)))
+
+
+def _a() -> IndexTransform:
+ """The oracle's base fixture: identity over [0, 12)."""
+ return _identity(0, 12)
+
+
+def _w() -> IndexTransform:
+ """The oracle's translated fixture: identity over [-10, 2), cell c -> base c + 10."""
+ return _a().translate_domain_by((-10,))
+
+
+def _dim(t: IndexTransform) -> DimensionMap:
+ m = t.output[0]
+ assert isinstance(m, DimensionMap)
+ return m
+
+
+def _base_cells(t: IndexTransform) -> list[int]:
+ """The base cells a 1-D single-DimensionMap transform addresses, in order."""
+ m = _dim(t)
+ lo, hi = t.domain.inclusive_min[0], t.domain.exclusive_max[0]
+ return [m.offset + m.stride * c for c in range(lo, hi)]
+
+
+class TestDomainPreservation:
+ """Oracle section 1-2: step-1 slices keep literal coordinates."""
+
+ def test_slice_preserves_domain(self) -> None:
+ t = _a()[2:10]
+ assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((2,), (10,))
+ assert _base_cells(t) == list(range(2, 10))
+
+ def test_integer_on_preserved_domain_is_a_coordinate(self) -> None:
+ v = _a()[2:10]
+ assert isinstance(v[3].output[0], ConstantMap)
+ assert v[3].output[0].offset == 3 # coordinate 3 = base cell 3
+ assert v[2].output[0].offset == 2
+ assert v[9].output[0].offset == 9
+
+ @pytest.mark.parametrize("bad", [0, -1, 10])
+ def test_out_of_domain_integer_raises(self, bad: int) -> None:
+ with pytest.raises(BoundsCheckError, match=r"valid indices \[2, 10\)"):
+ _a()[2:10][bad]
+
+ def test_slice_of_slice_is_literal(self) -> None:
+ v = _a()[2:10]
+ t = v[3:7]
+ assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((3,), (7,))
+ assert _base_cells(t) == [3, 4, 5, 6]
+
+ def test_ellipsis_preserves_domain(self) -> None:
+ v = _a()[2:10]
+ t = v[...]
+ assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((2,), (10,))
+
+
+class TestNegativeOriginDomain:
+ """Oracle section 3: on domain [-10, 2), -1 is just another index."""
+
+ def test_translated_domain(self) -> None:
+ w = _w()
+ assert (w.domain.inclusive_min, w.domain.exclusive_max) == ((-10,), (2,))
+ assert _base_cells(w) == list(range(12))
+
+ @pytest.mark.parametrize(("coord", "base"), [(-5, 5), (-10, 0), (-1, 9), (1, 11)])
+ def test_negative_coordinates_address_cells(self, coord: int, base: int) -> None:
+ t = _w()[coord]
+ assert isinstance(t.output[0], ConstantMap)
+ assert t.output[0].offset == base
+
+ @pytest.mark.parametrize("bad", [-11, 2])
+ def test_out_of_domain_raises(self, bad: int) -> None:
+ with pytest.raises(BoundsCheckError, match=r"valid indices \[-10, 2\)"):
+ _w()[bad]
+
+ def test_negative_slice_bounds_are_coordinates(self) -> None:
+ t = _w()[-5:]
+ assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((-5,), (2,))
+ assert _base_cells(t) == [5, 6, 7, 8, 9, 10, 11]
+ t2 = _w()[-5:-2]
+ assert (t2.domain.inclusive_min, t2.domain.exclusive_max) == ((-5,), (-2,))
+ assert _base_cells(t2) == [5, 6, 7]
+
+
+class TestStridedDomains:
+ """Oracle section 5: origin = trunc(start/step), coord origin+i -> start + i*step."""
+
+ # (slice, expected (lo, hi), expected base cells) — verbatim oracle rows.
+ CASES: ClassVar[list[tuple[slice, tuple[int, int], list[int]]]] = [
+ (slice(1, 10, 3), (0, 3), [1, 4, 7]),
+ (slice(None, None, 2), (0, 6), [0, 2, 4, 6, 8, 10]),
+ (slice(2, 11, 3), (0, 3), [2, 5, 8]),
+ (slice(0, 12, 4), (0, 3), [0, 4, 8]),
+ (slice(5, 12, 2), (2, 6), [5, 7, 9, 11]),
+ (slice(6, 12, 2), (3, 6), [6, 8, 10]),
+ (slice(7, 12, 3), (2, 4), [7, 10]),
+ ]
+
+ @pytest.mark.parametrize(("sel", "dom", "cells"), CASES)
+ def test_strided_domain_and_cells(
+ self, sel: slice, dom: tuple[int, int], cells: list[int]
+ ) -> None:
+ t = _a()[sel]
+ assert (t.domain.inclusive_min[0], t.domain.exclusive_max[0]) == dom
+ assert _base_cells(t) == cells
+
+ def test_strided_on_negative_origin(self) -> None:
+ # w[-9:2:2] -> domain [-4, 2), base cells 1,3,5,7,9,11
+ t = _w()[-9:2:2]
+ assert (t.domain.inclusive_min[0], t.domain.exclusive_max[0]) == (-4, 2)
+ assert _base_cells(t) == [1, 3, 5, 7, 9, 11]
+ # w[::2] -> domain [-5, 1), base cells 0,2,4,6,8,10
+ t2 = _w()[::2]
+ assert (t2.domain.inclusive_min[0], t2.domain.exclusive_max[0]) == (-5, 1)
+ assert _base_cells(t2) == [0, 2, 4, 6, 8, 10]
+
+ def test_strided_composition(self) -> None:
+ s = _a()[1:10:3] # domain [0, 3), cells 1,4,7
+ assert [s[k].output[0].offset for k in range(3)] == [1, 4, 7]
+ t = s[1:3]
+ assert (t.domain.inclusive_min[0], t.domain.exclusive_max[0]) == (1, 3)
+ assert _base_cells(t) == [4, 7]
+ t2 = _a()[::2][1:4]
+ assert (t2.domain.inclusive_min[0], t2.domain.exclusive_max[0]) == (1, 4)
+ assert _base_cells(t2) == [2, 4, 6]
+ t3 = _a()[::2][::2]
+ assert (t3.domain.inclusive_min[0], t3.domain.exclusive_max[0]) == (0, 3)
+ assert _base_cells(t3) == [0, 4, 8]
+
+ @pytest.mark.parametrize("bad", [-2, -1, 3, 4])
+ def test_strided_bounds(self, bad: int) -> None:
+ with pytest.raises(BoundsCheckError, match=r"valid indices \[0, 3\)"):
+ _a()[1:10:3][bad]
+
+
+class TestStrictContainment:
+ """Oracle section 11: no clamping, no wrapping; empty intervals valid anywhere."""
+
+ @pytest.mark.parametrize(
+ "sel",
+ [
+ slice(5, 100),
+ slice(-3, None),
+ slice(-3, -1),
+ slice(0, 13),
+ slice(12, 14),
+ slice(100, 200),
+ ],
+ )
+ def test_uncontained_interval_raises(self, sel: slice) -> None:
+ with pytest.raises(BoundsCheckError, match="not contained"):
+ _a()[sel]
+
+ def test_uncontained_on_negative_origin(self) -> None:
+ with pytest.raises(BoundsCheckError, match="not contained"):
+ _w()[-20:]
+
+ @pytest.mark.parametrize(
+ ("sel", "pos"), [(slice(5, 5), 5), (slice(0, 0), 0), (slice(13, 13), 13)]
+ )
+ def test_empty_interval_valid_anywhere(self, sel: slice, pos: int) -> None:
+ t = _a()[sel]
+ assert t.domain.shape == (0,)
+ assert t.domain.inclusive_min[0] == pos
+
+ @pytest.mark.parametrize("sel", [slice(5, 2), slice(100, 50)])
+ def test_reversed_bounds_raise(self, sel: slice) -> None:
+ with pytest.raises(IndexError, match="valid.*interval|interval"):
+ _a()[sel]
+
+
+class TestNegativeStep:
+ """Section 1 of the negative-step study: one desugaring rule, both signs.
+
+ Every expectation below is TensorStore 0.1.84's recorded output (study
+ sections 1.3-1.5), which an exhaustive 32,980-case sweep found zero
+ disagreements with.
+ """
+
+ # (domain, slice, expected domain, expected offset, expected stride, cells)
+ RECORDED: ClassVar[list[tuple[tuple[int, int], slice, tuple[int, int], int, int]]] = [
+ ((0, 20), slice(15, 5, -1), (-15, -5), 0, -1),
+ ((0, 20), slice(15, 5, -2), (-7, -2), 1, -2),
+ ((0, 20), slice(15, 4, -2), (-7, -1), 1, -2),
+ ((0, 20), slice(None, None, -1), (-19, 1), 0, -1),
+ ((0, 20), slice(None, None, -2), (-9, 1), 1, -2),
+ ((0, 20), slice(5, None, -1), (-5, 1), 0, -1),
+ ((0, 20), slice(None, 5, -1), (-19, -5), 0, -1),
+ ((0, 20), slice(5, 5, -1), (-5, -5), 0, -1),
+ ((0, 20), slice(5, 4, -1), (-5, -4), 0, -1),
+ ((0, 20), slice(5, 4, -3), (-1, 0), 2, -3),
+ ((0, 20), slice(15, 5, -4), (-3, 0), 3, -4),
+ ((0, 20), slice(15, 5, -7), (-2, 0), 1, -7),
+ ((5, 25), slice(None, None, -2), (-12, -2), 0, -2),
+ ((-10, 10), slice(-1, -6, -2), (0, 3), -1, -2),
+ ((-10, 10), slice(-2, -9, -3), (0, 3), -2, -3),
+ ]
+
+ @pytest.mark.parametrize(
+ ("domain", "sel", "expected_domain", "offset", "stride"),
+ RECORDED,
+ ids=[f"{d}{s}" for d, s, _, _, _ in RECORDED],
+ )
+ def test_recorded_desugaring(
+ self,
+ domain: tuple[int, int],
+ sel: slice,
+ expected_domain: tuple[int, int],
+ offset: int,
+ stride: int,
+ ) -> None:
+ t = _identity(*domain)[sel]
+ assert (t.domain.inclusive_min[0], t.domain.exclusive_max[0]) == expected_domain
+ m = _dim(t)
+ assert (m.offset, m.stride) == (offset, stride)
+
+ @pytest.mark.parametrize(
+ ("domain", "sel", "cells"),
+ [
+ ((0, 20), slice(15, 5, -1), list(range(15, 5, -1))),
+ ((0, 20), slice(15, 5, -2), [15, 13, 11, 9, 7]),
+ ((0, 20), slice(None, None, -1), list(range(19, -1, -1))),
+ ((0, 20), slice(15, 5, -7), [15, 8]),
+ ((5, 25), slice(None, None, -2), list(range(24, 4, -2))),
+ ((-10, 10), slice(-1, -6, -2), [-1, -3, -5]),
+ ((-10, 10), slice(-2, -9, -3), [-2, -5, -8]),
+ ],
+ )
+ def test_recorded_cells(self, domain: tuple[int, int], sel: slice, cells: list[int]) -> None:
+ assert _base_cells(_identity(*domain)[sel]) == cells
+
+ def test_trunc_not_floor_or_ceil(self) -> None:
+ """The three rows of study section 1.2 that discriminate the rounding."""
+ # floor would give -8 here, trunc gives -7.
+ assert _identity(0, 20)[15:5:-2].domain.inclusive_min[0] == -7
+ # ceil would give 1 for both of these; trunc gives 0.
+ assert _identity(-10, 10)[-1:-6:-2].domain.inclusive_min[0] == 0
+ assert _identity(-10, 10)[-2:-9:-3].domain.inclusive_min[0] == 0
+
+ @pytest.mark.parametrize("sel", [slice(5, 15, -1), slice(5, 6, -1)])
+ def test_inverted_interval_raises(self, sel: slice) -> None:
+ with pytest.raises(IndexError, match="valid.*interval"):
+ _identity(0, 20)[sel]
+
+ @pytest.mark.parametrize("sel", [slice(20, 0, -1), slice(20, 19, -1), slice(15, -5, -1)])
+ def test_uncontained_interval_raises(self, sel: slice) -> None:
+ with pytest.raises(BoundsCheckError, match="not contained"):
+ _identity(0, 20)[sel]
+
+ def test_zero_step_raises(self) -> None:
+ with pytest.raises(IndexError, match="step must not be zero"):
+ _identity(0, 20)[15:5:0]
+
+ def test_empty_interval_legal_outside_the_domain(self) -> None:
+ t = _identity(0, 20)[25:25:-1]
+ assert t.domain.shape == (0,)
+ assert t.domain.inclusive_min[0] == -25
+
+ @pytest.mark.parametrize(
+ ("domain", "first", "second", "expected_domain", "offset", "stride", "cells"),
+ [
+ # Study section 1.5, recorded verbatim. Each row applies `second`
+ # to the view `first` produced — strides multiply, and a double
+ # reverse recovers the identity.
+ (
+ (0, 20),
+ slice(0, 20, 2),
+ slice(None, None, -1),
+ (-9, 1),
+ 0,
+ -2,
+ list(range(18, -2, -2)),
+ ),
+ ((0, 20), slice(0, 20, 2), slice(None, None, -2), (-4, 1), 2, -4, [18, 14, 10, 6, 2]),
+ (
+ (0, 20),
+ slice(None, None, -1),
+ slice(None, None, -1),
+ (0, 20),
+ 0,
+ 1,
+ list(range(20)),
+ ),
+ (
+ (0, 20),
+ slice(None, None, -1),
+ slice(None, None, 2),
+ (-9, 1),
+ 1,
+ -2,
+ list(range(19, -1, -2)),
+ ),
+ (
+ (-10, 10),
+ slice(None, None, -1),
+ slice(None, None, -3),
+ (-3, 4),
+ -1,
+ 3,
+ [-10, -7, -4, -1, 2, 5, 8],
+ ),
+ ],
+ ids=[
+ "strided-then-reverse",
+ "strided-then-reverse-by-two",
+ "double-reverse-is-identity",
+ "reverse-then-strided",
+ "reverse-then-strided-on-negative-origin",
+ ],
+ )
+ def test_recorded_composition(
+ self,
+ domain: tuple[int, int],
+ first: slice,
+ second: slice,
+ expected_domain: tuple[int, int],
+ offset: int,
+ stride: int,
+ cells: list[int],
+ ) -> None:
+ t = _identity(*domain)[first][second]
+ assert (t.domain.inclusive_min[0], t.domain.exclusive_max[0]) == expected_domain
+ m = _dim(t)
+ assert (m.offset, m.stride) == (offset, stride)
+ assert _base_cells(t) == cells
+
+ def test_negative_step_over_an_index_array_reverses_it(self) -> None:
+ """Study section 1.5: a reversing step materializes, it does not stride.
+
+ Recorded: `oindex[[3, 1, 2]]` then `[2:-1:-1]` gives index array
+ `[[2], [1], [3]]` over domain `[-2, 1)`.
+ """
+ base = IndexTransform.identity(IndexDomain(inclusive_min=(0, 0), exclusive_max=(4, 5)))
+ gathered = base.oindex[np.array([3, 1, 2]), slice(None)]
+ reversed_view = gathered[2:-1:-1, :]
+
+ assert reversed_view.domain.inclusive_min[0] == -2
+ assert reversed_view.domain.exclusive_max[0] == 1
+ m = reversed_view.output[0]
+ assert isinstance(m, ArrayMap)
+ np.testing.assert_array_equal(m.index_array.reshape(-1), np.array([2, 1, 3]))
+
+
+class TestTranslate:
+ """Oracle sections 4 and 12: translate_by / translate_to preserve the cell mapping."""
+
+ def test_translate_to_zero(self) -> None:
+ t = _a()[2:10].translate_domain_to((0,))
+ assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((0,), (8,))
+ assert _base_cells(t) == list(range(2, 10))
+
+ def test_translate_to_offset(self) -> None:
+ t = _a().translate_domain_to((5,))
+ assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((5,), (17,))
+ assert _base_cells(t) == list(range(12))
+
+ def test_translate_by_composes_with_stride(self) -> None:
+ # a[::2].translate_by[5] -> domain [5, 11), base = 2*(coord-5)
+ t = _a()[::2].translate_domain_by((5,))
+ assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((5,), (11,))
+ assert _base_cells(t) == [0, 2, 4, 6, 8, 10]
+ assert t[5].output[0].offset == 0
+ assert t[10].output[0].offset == 10
+ with pytest.raises(BoundsCheckError, match=r"valid indices \[5, 11\)"):
+ t[0]
+
+ def test_translate_strided_to(self) -> None:
+ t = _a()[1:10:3].translate_domain_to((100,))
+ assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((100,), (103,))
+ assert _base_cells(t) == [1, 4, 7]
+
+
+class TestFancyDims:
+ """Oracle section 7: fancy dims get fresh [0, n); values are absolute coordinates."""
+
+ def test_index_array_values_are_coordinates(self) -> None:
+ v = _a()[2:10]
+ t = v.oindex[(np.array([3, 5], dtype=np.intp),)]
+ assert (t.domain.inclusive_min, t.domain.exclusive_max) == ((0,), (2,))
+ m = t.output[0]
+ assert isinstance(m, ArrayMap)
+ storage = m.offset + m.stride * m.index_array
+ np.testing.assert_array_equal(np.asarray(storage).ravel(), [3, 5])
+
+ def test_index_array_on_negative_origin(self) -> None:
+ t = _w().oindex[(np.array([-10, -1], dtype=np.intp),)]
+ m = t.output[0]
+ assert isinstance(m, ArrayMap)
+ storage = m.offset + m.stride * m.index_array
+ np.testing.assert_array_equal(np.asarray(storage).ravel(), [0, 9])
+
+ def test_index_array_out_of_domain_raises(self) -> None:
+ v = _a()[2:10]
+ for bad in ([0, 3], [-1, 3], [3, 10]):
+ with pytest.raises(BoundsCheckError):
+ v.oindex[(np.array(bad, dtype=np.intp),)]
diff --git a/packages/zarr-indexing/tests/test_transform.py b/packages/zarr-indexing/tests/test_transform.py
new file mode 100644
index 0000000000..a13eaf6e28
--- /dev/null
+++ b/packages/zarr-indexing/tests/test_transform.py
@@ -0,0 +1,1250 @@
+from __future__ import annotations
+
+from typing import cast
+
+import numpy as np
+import pytest
+
+from zarr_indexing.domain import IndexDomain
+from zarr_indexing.errors import BoundsCheckError
+from zarr_indexing.lazy_array import LazyArray
+from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap
+from zarr_indexing.transform import (
+ IndexTransform,
+)
+
+
+class IndexLike:
+ """A scalar integer selector implemented only through `__index__`."""
+
+ def __init__(self, value: int) -> None:
+ self.value = value
+
+ def __index__(self) -> int:
+ return self.value
+
+
+class IntOnly:
+ def __int__(self) -> int:
+ return 2
+
+
+class BadIndex:
+ """An `__index__` that lies: the protocol requires an integer."""
+
+ def __index__(self) -> int:
+ return cast("int", 2.5)
+
+
+class TestIndexTransformConstruction:
+ def test_from_shape(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ assert t.input_rank == 2
+ assert t.output_rank == 2
+ assert t.domain.shape == (10, 20)
+ assert t.domain.origin == (0, 0)
+ for i, m in enumerate(t.output):
+ assert isinstance(m, DimensionMap)
+ assert m.input_dimension == i
+ assert m.offset == 0
+ assert m.stride == 1
+
+ def test_identity(self) -> None:
+ domain = IndexDomain(inclusive_min=(5,), exclusive_max=(15,))
+ t = IndexTransform.identity(domain)
+ assert t.input_rank == 1
+ assert t.output_rank == 1
+ assert t.domain == domain
+ assert isinstance(t.output[0], DimensionMap)
+ assert t.output[0].input_dimension == 0
+
+ def test_from_shape_0d(self) -> None:
+ t = IndexTransform.from_shape(())
+ assert t.input_rank == 0
+ assert t.output_rank == 0
+ assert t.domain.shape == ()
+
+ def test_custom_output_maps(self) -> None:
+ domain = IndexDomain.from_shape((10,))
+ maps = (ConstantMap(offset=42), DimensionMap(input_dimension=0, offset=5, stride=2))
+ t = IndexTransform(domain=domain, output=maps)
+ assert t.input_rank == 1
+ assert t.output_rank == 2
+
+ def test_validation_input_dimension_out_of_range(self) -> None:
+ domain = IndexDomain.from_shape((10,))
+ maps = (DimensionMap(input_dimension=5),)
+ with pytest.raises(ValueError, match="input_dimension"):
+ IndexTransform(domain=domain, output=maps)
+
+
+class TestIndexTransformApply:
+ @pytest.mark.parametrize(
+ ("transform", "points", "expected"),
+ [
+ pytest.param(
+ IndexTransform.identity(IndexDomain((-2, 5), (1, 8))),
+ np.array([-2, 7], dtype=np.int64),
+ np.array([-2, 7], dtype=np.intp),
+ id="identity-negative-and-nonzero-origins",
+ ),
+ pytest.param(
+ IndexTransform(
+ domain=IndexDomain((3,), (6,)),
+ output=(
+ ConstantMap(41),
+ DimensionMap(0, offset=10, stride=-2),
+ ),
+ ),
+ np.array([[3], [5]], dtype=np.int16),
+ np.array([[41, 4], [41, 0]], dtype=np.intp),
+ id="constant-and-negative-stride",
+ ),
+ pytest.param(
+ IndexTransform(
+ domain=IndexDomain((-2, 5), (1, 8)),
+ output=(
+ ArrayMap(
+ np.array([[7], [11], [13]], dtype=np.intp),
+ offset=-1,
+ stride=2,
+ ),
+ ),
+ ),
+ np.array(
+ [
+ [[-2, 5], [-1, 7]],
+ [[0, 6], [-2, 6]],
+ ],
+ dtype=np.intp,
+ ),
+ np.array([[[13], [21]], [[25], [13]]], dtype=np.intp),
+ id="array-map-singleton-broadcast-multidimensional-batch",
+ ),
+ pytest.param(
+ IndexTransform(IndexDomain((), ()), (ConstantMap(42),)),
+ np.empty((2, 0), dtype=np.intp),
+ np.array([[42], [42]], dtype=np.intp),
+ id="rank-zero-input",
+ ),
+ pytest.param(
+ IndexTransform(IndexDomain((-1,), (2,)), ()),
+ np.array([[-1], [1]], dtype=np.intp),
+ np.empty((2, 0), dtype=np.intp),
+ id="rank-zero-output",
+ ),
+ pytest.param(
+ IndexTransform.identity(IndexDomain.from_shape((2,))),
+ np.empty((0, 1), dtype=np.intp),
+ np.empty((0, 1), dtype=np.intp),
+ id="empty-batch",
+ ),
+ ],
+ )
+ def test_apply_many_maps_integer_point_batches(
+ self,
+ transform: IndexTransform,
+ points: np.ndarray,
+ expected: np.ndarray,
+ ) -> None:
+ result = transform.apply_many(points)
+
+ np.testing.assert_array_equal(result, expected)
+ assert result.dtype == np.dtype(np.intp)
+ assert result.flags.owndata
+
+ def test_apply_maps_one_point(self) -> None:
+ transform = IndexTransform(
+ IndexDomain((-2, 4), (1, 7)),
+ (
+ DimensionMap(1, offset=3, stride=-1),
+ DimensionMap(0, offset=2, stride=2),
+ ),
+ )
+
+ assert transform.apply((-1, 6)) == (-3, 0)
+
+ def test_apply_rejects_a_point_with_the_wrong_rank(self) -> None:
+ with pytest.raises(ValueError, match=r"point must have shape \(2,\), got \(1,\)"):
+ IndexTransform.from_shape((2, 3)).apply((1,))
+
+ def test_apply_rejects_an_explicitly_floating_rank_zero_point(self) -> None:
+ transform = IndexTransform(IndexDomain((), ()), ())
+ with pytest.raises(TypeError, match="integer dtype"):
+ transform.apply(np.array([], dtype=np.float64))
+
+ @pytest.mark.parametrize(
+ "points",
+ [
+ pytest.param(np.array(1, dtype=np.intp), id="no-coordinate-axis"),
+ pytest.param(np.zeros((4, 3), dtype=np.intp), id="wrong-trailing-size"),
+ ],
+ )
+ def test_apply_many_rejects_an_invalid_coordinate_axis(self, points: np.ndarray) -> None:
+ with pytest.raises(ValueError, match="trailing coordinate axis"):
+ IndexTransform.from_shape((2, 3)).apply_many(points)
+
+ @pytest.mark.parametrize(
+ "points",
+ [
+ pytest.param(np.array([[True]], dtype=np.bool_), id="bool"),
+ pytest.param(np.array([[1.0]], dtype=np.float64), id="float"),
+ pytest.param(np.array([["1"]], dtype=np.str_), id="string"),
+ pytest.param(np.array([[1]], dtype=object), id="object"),
+ ],
+ )
+ def test_apply_many_rejects_non_integer_coordinates(self, points: np.ndarray) -> None:
+ with pytest.raises(TypeError, match="integer dtype"):
+ IndexTransform.from_shape((2,)).apply_many(points)
+
+ def test_apply_many_reports_the_first_out_of_bounds_coordinate(self) -> None:
+ transform = IndexTransform.identity(IndexDomain((-2, 10), (2, 13)))
+ points = np.array(
+ [
+ [[-2, 10], [-1, 20]],
+ [[9, 11], [0, 12]],
+ ],
+ dtype=np.intp,
+ )
+
+ with pytest.raises(BoundsCheckError) as error:
+ transform.apply_many(points)
+
+ assert str(error.value) == (
+ "point at batch position (0, 1) has input dimension 1 coordinate 20 outside [10, 13)"
+ )
+
+ def test_apply_reports_a_single_point_error_without_batch_vocabulary(self) -> None:
+ transform = IndexTransform.identity(IndexDomain((-10,), (10,)))
+
+ with pytest.raises(BoundsCheckError) as error:
+ transform.apply((11,))
+
+ assert str(error.value) == (
+ "coordinate 11 on input dimension 0 is outside the domain [-10, 10)"
+ )
+ assert error.value.__cause__ is None
+ assert error.value.__suppress_context__
+
+ @pytest.mark.parametrize(
+ "beyond_intp",
+ [
+ pytest.param(int(np.iinfo(np.intp).max) + 1, id="first-uint64-coordinate"),
+ pytest.param(int(np.iinfo(np.uint64).max), id="maximum-uint64-coordinate"),
+ ],
+ )
+ def test_apply_many_maps_large_literal_coordinates_exactly(self, beyond_intp: int) -> None:
+ transform = IndexTransform(
+ IndexDomain((beyond_intp,), (beyond_intp + 1,)),
+ (DimensionMap(0, offset=-beyond_intp),),
+ )
+
+ result = transform.apply_many(np.array([[beyond_intp]], dtype=np.uint64))
+
+ np.testing.assert_array_equal(result, np.array([[0]], dtype=np.intp))
+ assert result.dtype == np.dtype(np.intp)
+ assert result.flags.owndata
+
+ @pytest.mark.parametrize(
+ ("transform", "points"),
+ [
+ pytest.param(
+ IndexTransform(
+ IndexDomain.from_shape((1,)),
+ (ConstantMap(np.iinfo(np.intp).max + 1),),
+ ),
+ [[0]],
+ id="constant",
+ ),
+ pytest.param(
+ IndexTransform(
+ IndexDomain.from_shape((2,)),
+ (DimensionMap(0, offset=np.iinfo(np.intp).max),),
+ ),
+ [[1]],
+ id="dimension",
+ ),
+ pytest.param(
+ IndexTransform(
+ IndexDomain.from_shape((1,)),
+ (
+ ArrayMap(
+ np.array([1], dtype=np.intp),
+ offset=np.iinfo(np.intp).max,
+ ),
+ ),
+ ),
+ [[0]],
+ id="array",
+ ),
+ pytest.param(
+ IndexTransform.identity(
+ IndexDomain(
+ (int(np.iinfo(np.intp).max) + 1,),
+ (int(np.iinfo(np.intp).max) + 2,),
+ )
+ ),
+ np.array([[int(np.iinfo(np.intp).max) + 1]], dtype=np.uint64),
+ id="large-input-identity",
+ ),
+ ],
+ )
+ def test_apply_many_rejects_mapped_coordinates_outside_intp(
+ self, transform: IndexTransform, points: list[list[int]] | np.ndarray
+ ) -> None:
+ with pytest.raises(OverflowError, match="output coordinate.*np.intp"):
+ transform.apply_many(points)
+
+ def test_apply_many_rejects_affine_coordinate_overflow(self) -> None:
+ transform = IndexTransform(
+ domain=IndexDomain.from_shape((1,)),
+ output=(ArrayMap(np.array([2**62], dtype=np.intp), stride=4),),
+ )
+ with pytest.raises(OverflowError, match="outside np.intp"):
+ transform.apply_many(np.array([[0]], dtype=np.intp))
+
+ def test_apply_rejects_affine_coordinate_overflow(self) -> None:
+ transform = IndexTransform(
+ domain=IndexDomain.from_shape((1,)),
+ output=(ArrayMap(np.array([2**62], dtype=np.intp), stride=4),),
+ )
+ with pytest.raises(OverflowError, match="outside np.intp"):
+ transform.apply((0,))
+
+
+class TestIndexTransformInverted:
+ @pytest.mark.parametrize(
+ ("transform", "points"),
+ [
+ pytest.param(
+ IndexTransform(
+ IndexDomain((-3, 4), (1, 7)),
+ (
+ DimensionMap(1, offset=10),
+ DimensionMap(0, offset=2, stride=-1),
+ ),
+ ),
+ np.array([[-3, 4], [0, 6]], dtype=np.intp),
+ id="permutation-translation-reversal-nonzero-origin",
+ ),
+ pytest.param(
+ IndexTransform(
+ IndexDomain((5, -2), (8, -1)),
+ (DimensionMap(0, offset=3), ConstantMap(99)),
+ ),
+ np.array([[5, -2], [7, -2]], dtype=np.intp),
+ id="constant-and-unreferenced-singleton",
+ ),
+ pytest.param(
+ IndexTransform(IndexDomain((), ()), ()),
+ np.empty((1, 0), dtype=np.intp),
+ id="rank-zero",
+ ),
+ ],
+ )
+ def test_inverted_round_trips_points(
+ self, transform: IndexTransform, points: np.ndarray
+ ) -> None:
+ inverse = transform.inverted()
+ mapped = transform.apply_many(points)
+
+ np.testing.assert_array_equal(inverse.apply_many(mapped), points)
+ assert inverse.apply(transform.apply(tuple(points[0]))) == tuple(points[0])
+ assert inverse.inverted() == transform
+
+ def test_inverted_rejects_unequal_ranks(self) -> None:
+ transform = IndexTransform(IndexDomain.from_shape((2,)), (ConstantMap(1), ConstantMap(2)))
+ with pytest.raises(ValueError, match="input rank must equal output rank"):
+ transform.inverted()
+
+ def test_inverted_rejects_an_array_map(self) -> None:
+ transform = IndexTransform(
+ IndexDomain.from_shape((2,)),
+ (ArrayMap(np.array([1, 0], dtype=np.intp)),),
+ )
+ with pytest.raises(ValueError, match="ArrayMap"):
+ transform.inverted()
+
+ def test_inverted_rejects_a_non_unit_stride(self) -> None:
+ transform = IndexTransform(
+ IndexDomain.from_shape((2,)),
+ (DimensionMap(0, stride=2),),
+ )
+ with pytest.raises(ValueError, match=r"stride must be \+1 or -1"):
+ transform.inverted()
+
+ def test_inverted_rejects_a_repeated_input_dimension(self) -> None:
+ transform = IndexTransform(
+ IndexDomain.from_shape((2, 1)),
+ (DimensionMap(0), DimensionMap(0, offset=5)),
+ )
+ with pytest.raises(ValueError, match="referenced more than once"):
+ transform.inverted()
+
+ def test_inverted_rejects_an_unreferenced_non_singleton_dimension(self) -> None:
+ transform = IndexTransform(
+ IndexDomain.from_shape((2, 2)),
+ (DimensionMap(0), ConstantMap(7)),
+ )
+ with pytest.raises(ValueError, match="unreferenced input dimension 1.*extent 2"):
+ transform.inverted()
+
+ def test_inverted_rejects_input_labels_that_cannot_be_preserved(self) -> None:
+ transform = IndexTransform.identity(IndexDomain((0,), (2,), labels=("row",)))
+ with pytest.raises(ValueError, match="input labels cannot be represented"):
+ transform.inverted()
+
+
+class TestIndexTransformBasicIndexing:
+ def test_slice_identity(self) -> None:
+ """slice(None) on identity transform is a no-op."""
+ t = IndexTransform.from_shape((10, 20))
+ result = t[slice(None), slice(None)]
+ assert result.domain.shape == (10, 20)
+ assert result.input_rank == 2
+ assert result.output_rank == 2
+
+ def test_slice_narrows(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ result = t[2:8, 5:15]
+ # Domains are preserved (TensorStore): the slice keeps its literal
+ # coordinates, so the map stays the identity (out = in).
+ assert result.domain.shape == (6, 10)
+ assert result.domain.origin == (2, 5)
+ assert isinstance(result.output[0], DimensionMap)
+ assert result.output[0].offset == 0
+ assert result.output[0].stride == 1
+ assert result.output[0].input_dimension == 0
+ assert isinstance(result.output[1], DimensionMap)
+ assert result.output[1].offset == 0
+ assert result.output[1].input_dimension == 1
+
+ def test_strided_slice(self) -> None:
+ t = IndexTransform.from_shape((10,))
+ result = t[::2]
+ assert result.domain.shape == (5,)
+ assert isinstance(result.output[0], DimensionMap)
+ assert result.output[0].offset == 0
+ assert result.output[0].stride == 2
+
+ def test_strided_slice_with_start(self) -> None:
+ t = IndexTransform.from_shape((10,))
+ result = t[1:9:3]
+ # indices: 1, 4, 7 -> 3 elements
+ assert result.domain.shape == (3,)
+ assert isinstance(result.output[0], DimensionMap)
+ assert result.output[0].offset == 1
+ assert result.output[0].stride == 3
+
+ def test_int_drops_dimension(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ result = t[3]
+ assert result.input_rank == 1
+ assert result.output_rank == 2
+ assert isinstance(result.output[0], ConstantMap)
+ assert result.output[0].offset == 3
+ assert isinstance(result.output[1], DimensionMap)
+ assert result.output[1].input_dimension == 0
+
+ def test_int_middle_dimension(self) -> None:
+ t = IndexTransform.from_shape((10, 20, 30))
+ result = t[:, 5, :]
+ assert result.input_rank == 2
+ assert result.output_rank == 3
+ assert isinstance(result.output[0], DimensionMap)
+ assert result.output[0].input_dimension == 0
+ assert isinstance(result.output[1], ConstantMap)
+ assert result.output[1].offset == 5
+ assert isinstance(result.output[2], DimensionMap)
+ assert result.output[2].input_dimension == 1
+
+ def test_ellipsis(self) -> None:
+ t = IndexTransform.from_shape((10, 20, 30))
+ result = t[2:8, ...]
+ assert result.input_rank == 3
+ assert result.domain.shape == (6, 20, 30)
+
+ def test_newaxis(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ result = t[np.newaxis, :, :]
+ assert result.input_rank == 3
+ assert result.domain.shape == (1, 10, 20)
+ assert result.output_rank == 2
+ assert isinstance(result.output[0], DimensionMap)
+ assert result.output[0].input_dimension == 1
+ assert isinstance(result.output[1], DimensionMap)
+ assert result.output[1].input_dimension == 2
+
+ def test_int_out_of_bounds(self) -> None:
+ t = IndexTransform.from_shape((10,))
+ with pytest.raises(IndexError):
+ t[10]
+
+ def test_negative_int_is_literal(self) -> None:
+ """Negative indices are literal coordinates (TensorStore convention),
+ not 'from the end' like NumPy."""
+ t = IndexTransform.from_shape((10,))
+ with pytest.raises(IndexError):
+ t[-1] # -1 is out of bounds for domain [0, 10)
+
+ def test_negative_int_valid_with_negative_origin(self) -> None:
+ """Negative index is valid if the domain includes negative coordinates."""
+ domain = IndexDomain(inclusive_min=(-5,), exclusive_max=(5,))
+ t = IndexTransform.identity(domain)
+ result = t[-3]
+ assert isinstance(result.output[0], ConstantMap)
+ assert result.output[0].offset == -3
+
+ def test_composition_of_slices(self) -> None:
+ """Slicing a sliced transform re-selects in literal domain coordinates."""
+ t = IndexTransform.from_shape((100,))
+ result = t[10:50][15:30]
+ assert result.domain.shape == (15,)
+ assert result.domain.origin == (15,)
+ assert isinstance(result.output[0], DimensionMap)
+ assert result.output[0].offset == 0
+ assert result.output[0].stride == 1
+
+ def test_composition_of_strides(self) -> None:
+ t = IndexTransform.from_shape((100,))
+ result = t[::2][::3]
+ # t[::2] -> shape (50,), offset=0, stride=2
+ # [::3] -> shape ceil(50/3)=17, offset=0, stride=2*3=6
+ assert result.domain.shape == (17,)
+ assert isinstance(result.output[0], DimensionMap)
+ assert result.output[0].stride == 6
+
+ def test_bare_int(self) -> None:
+ """Non-tuple selection."""
+ t = IndexTransform.from_shape((10, 20))
+ result = t[3]
+ assert result.input_rank == 1
+
+ def test_bare_slice(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ result = t[2:8]
+ assert result.domain.shape == (6, 20)
+
+ @pytest.mark.parametrize(
+ ("mode", "selection", "expected_selection"),
+ [
+ pytest.param("basic", IndexLike(2), 2, id="basic-scalar"),
+ pytest.param(
+ "basic",
+ slice(IndexLike(1), IndexLike(7), IndexLike(2)),
+ slice(1, 7, 2),
+ id="basic-slice-components",
+ ),
+ pytest.param("oindex", IndexLike(2), 2, id="orthogonal-scalar"),
+ pytest.param("vindex", IndexLike(2), 2, id="vectorized-scalar"),
+ ],
+ )
+ def test_literal_selectors_support_the_index_protocol(
+ self, mode: str, selection: object, expected_selection: object
+ ) -> None:
+ transform = IndexTransform.from_shape((8,))
+ if mode == "basic":
+ result = transform[selection]
+ expected = transform[expected_selection]
+ else:
+ result = getattr(transform, mode)[selection]
+ expected = getattr(transform, mode)[expected_selection]
+
+ assert result == expected
+
+ def test_literal_selector_rejects_int_only_objects(self) -> None:
+ with pytest.raises(IndexError, match="unsupported selection type"):
+ IndexTransform.from_shape((8,))[IntOnly()]
+
+ def test_literal_selector_propagates_malformed_index_protocol(self) -> None:
+ with pytest.raises(TypeError, match="__index__ returned non-int"):
+ IndexTransform.from_shape((8,))[BadIndex()]
+
+ def test_literal_slice_propagates_malformed_index_protocol(self) -> None:
+ with pytest.raises(TypeError, match="__index__ returned non-int"):
+ IndexTransform.from_shape((8,))[:: BadIndex()]
+
+
+class TestBasicIndexingOnArrayMaps:
+ """When a transform already has ArrayMap outputs, basic indexing must
+ apply the corresponding operation to the index_array's axes."""
+
+ def test_int_on_array_map_drops_axis(self) -> None:
+ """Integer index on a dimension referenced by an ArrayMap should
+ index into the array on that axis."""
+ arr = np.array([[10, 20], [30, 40], [50, 60]], dtype=np.intp)
+ # 2D input domain (3, 2), one ArrayMap output
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((3, 2)),
+ output=(ArrayMap(index_array=arr),),
+ )
+ # Index with int on dim 0 -> pick row 1 -> arr[1, :] = [30, 40]
+ result = t[1]
+ assert result.input_rank == 1
+ assert result.domain.shape == (2,)
+ assert isinstance(result.output[0], ArrayMap)
+ np.testing.assert_array_equal(result.output[0].index_array, np.array([30, 40]))
+
+ def test_slice_on_array_map(self) -> None:
+ """Slice on a dimension referenced by an ArrayMap should slice the array."""
+ arr = np.array([10, 20, 30, 40, 50], dtype=np.intp)
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((5,)),
+ output=(ArrayMap(index_array=arr),),
+ )
+ result = t[1:4]
+ assert result.domain.shape == (3,)
+ assert isinstance(result.output[0], ArrayMap)
+ np.testing.assert_array_equal(result.output[0].index_array, np.array([20, 30, 40]))
+
+ def test_strided_slice_on_array_map(self) -> None:
+ """Strided slice on ArrayMap should stride the array."""
+ arr = np.array([10, 20, 30, 40, 50], dtype=np.intp)
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((5,)),
+ output=(ArrayMap(index_array=arr),),
+ )
+ result = t[::2]
+ assert result.domain.shape == (3,)
+ assert isinstance(result.output[0], ArrayMap)
+ np.testing.assert_array_equal(result.output[0].index_array, np.array([10, 30, 50]))
+
+ def test_newaxis_on_array_map(self) -> None:
+ """Newaxis should insert an axis in the index_array."""
+ arr = np.array([10, 20, 30], dtype=np.intp)
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(ArrayMap(index_array=arr),),
+ )
+ result = t[np.newaxis, :]
+ assert result.input_rank == 2
+ assert result.domain.shape == (1, 3)
+ assert isinstance(result.output[0], ArrayMap)
+ assert result.output[0].index_array.shape == (1, 3)
+ np.testing.assert_array_equal(result.output[0].index_array, np.array([[10, 20, 30]]))
+
+ def test_int_drops_one_of_two_array_dims(self) -> None:
+ """2D array map, int on dim 0, slice on dim 1."""
+ arr = np.array([[10, 20, 30], [40, 50, 60]], dtype=np.intp)
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((2, 3)),
+ output=(ArrayMap(index_array=arr),),
+ )
+ result = t[0, 1:3]
+ assert result.input_rank == 1
+ assert result.domain.shape == (2,)
+ assert isinstance(result.output[0], ArrayMap)
+ # arr[0, 1:3] = [20, 30]
+ np.testing.assert_array_equal(result.output[0].index_array, np.array([20, 30]))
+
+
+class TestIndexTransformOindex:
+ def test_oindex_int_array(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ idx = np.array([1, 3, 5], dtype=np.intp)
+ result = t.oindex[idx, :]
+ assert result.input_rank == 2
+ assert result.domain.shape == (3, 20)
+ assert isinstance(result.output[0], ArrayMap)
+ # Full input rank: the array varies along its own axis (0), singleton on 1.
+ assert result.output[0].index_array.shape == (3, 1)
+ np.testing.assert_array_equal(result.output[0].index_array, idx.reshape(3, 1))
+ assert result.output[0].offset == 0
+ assert result.output[0].stride == 1
+ assert isinstance(result.output[1], DimensionMap)
+ assert result.output[1].input_dimension == 1
+
+ def test_oindex_bool_array(self) -> None:
+ t = IndexTransform.from_shape((5,))
+ mask = np.array([True, False, True, False, True])
+ result = t.oindex[mask]
+ assert result.domain.shape == (3,)
+ assert isinstance(result.output[0], ArrayMap)
+ np.testing.assert_array_equal(
+ result.output[0].index_array, np.array([0, 2, 4], dtype=np.intp)
+ )
+
+ def test_oindex_mixed(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ idx = np.array([2, 4], dtype=np.intp)
+ result = t.oindex[idx, 5:15]
+ assert result.input_rank == 2
+ assert result.domain.shape == (2, 10)
+ # fancy dim: fresh zero-origin; slice dim: preserved literal coords
+ assert result.domain.origin == (0, 5)
+ assert isinstance(result.output[0], ArrayMap)
+ assert isinstance(result.output[1], DimensionMap)
+ assert result.output[1].offset == 0
+
+ def test_oindex_multiple_arrays(self) -> None:
+ t = IndexTransform.from_shape((10, 20, 30))
+ idx0 = np.array([1, 3], dtype=np.intp)
+ idx1 = np.array([5, 10, 15], dtype=np.intp)
+ result = t.oindex[idx0, :, idx1]
+ assert result.input_rank == 3
+ assert result.domain.shape == (2, 20, 3)
+ assert isinstance(result.output[0], ArrayMap)
+ assert isinstance(result.output[1], DimensionMap)
+ assert isinstance(result.output[2], ArrayMap)
+
+ def test_oindex_multiple_arrays_preserves_independent_axes(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ result = t.oindex[np.array([1, 3]), np.array([2, 4, 6])]
+ assert result.domain.shape == (2, 3)
+ assert isinstance(result.output[0], ArrayMap)
+ assert isinstance(result.output[1], ArrayMap)
+ assert result.output[0].index_array.shape == (2, 1)
+ assert result.output[1].index_array.shape == (1, 3)
+
+
+class TestIndexTransformVindex:
+ def test_vindex_single_array(self) -> None:
+ t = IndexTransform.from_shape((10,))
+ idx = np.array([1, 3, 5], dtype=np.intp)
+ result = t.vindex[idx]
+ assert result.input_rank == 1
+ assert result.domain.shape == (3,)
+ assert isinstance(result.output[0], ArrayMap)
+ np.testing.assert_array_equal(result.output[0].index_array, idx)
+
+ def test_vindex_broadcast(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ idx0 = np.array([[1, 2], [3, 4]], dtype=np.intp)
+ idx1 = np.array([[10, 11], [12, 13]], dtype=np.intp)
+ result = t.vindex[idx0, idx1]
+ assert result.input_rank == 2
+ assert result.domain.shape == (2, 2)
+ assert isinstance(result.output[0], ArrayMap)
+ assert isinstance(result.output[1], ArrayMap)
+ np.testing.assert_array_equal(result.output[0].index_array, idx0)
+ np.testing.assert_array_equal(result.output[1].index_array, idx1)
+
+ def test_vindex_with_slice(self) -> None:
+ t = IndexTransform.from_shape((10, 20, 30))
+ idx = np.array([1, 3, 5], dtype=np.intp)
+ result = t.vindex[idx, :, :]
+ assert result.input_rank == 3
+ assert result.domain.shape == (3, 20, 30)
+ assert isinstance(result.output[0], ArrayMap)
+
+ def test_vindex_bool_mask(self) -> None:
+ t = IndexTransform.from_shape((5,))
+ mask = np.array([True, False, True, False, True])
+ result = t.vindex[mask]
+ assert result.domain.shape == (3,)
+ assert isinstance(result.output[0], ArrayMap)
+
+ def test_vindex_multidimensional_boolean_list_mask(self) -> None:
+ result = IndexTransform.from_shape((2, 3)).vindex[
+ [[True, False, True], [False, True, False]]
+ ]
+
+ assert result.domain.shape == (3,)
+ np.testing.assert_array_equal(
+ result.apply_many(np.array([[0], [1], [2]], dtype=np.intp)),
+ np.array([[0, 0], [0, 2], [1, 1]], dtype=np.intp),
+ )
+
+ def test_vindex_broadcast_different_shapes(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ idx0 = np.array([1, 2, 3], dtype=np.intp)
+ idx1 = np.array([[10], [11]], dtype=np.intp)
+ result = t.vindex[idx0, idx1]
+ assert result.input_rank == 2
+ assert result.domain.shape == (2, 3)
+
+ def test_vindex_multiple_arrays_preserves_shared_axes(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ result = t.vindex[np.array([1, 3]), np.array([2, 4])]
+ assert result.domain.shape == (2,)
+ assert isinstance(result.output[0], ArrayMap)
+ assert isinstance(result.output[1], ArrayMap)
+ assert result.output[0].index_array.shape == (2,)
+ assert result.output[1].index_array.shape == (2,)
+
+
+@pytest.mark.parametrize("mode", ["oindex", "vindex"])
+def test_direct_advanced_index_rejects_float_arrays(mode: str) -> None:
+ helper = getattr(IndexTransform.from_shape((5,)), mode)
+ with pytest.raises(IndexError, match="integer or boolean"):
+ helper[np.array([1.9, 3.2])]
+
+
+@pytest.mark.parametrize("mode", ["oindex", "vindex"])
+def test_direct_advanced_index_rejects_wrong_length_boolean_mask(mode: str) -> None:
+ helper = getattr(IndexTransform.from_shape((5,)), mode)
+ with pytest.raises(IndexError, match="boolean index.*dimension 5"):
+ helper[np.array([True, False])]
+
+
+class TestSelectionToTransform:
+ def test_basic_slice(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ result = t.select((slice(2, 8), slice(5, 15)), "basic")
+ assert result.domain.shape == (6, 10)
+ assert result.domain.origin == (2, 5) # preserved literal coordinates
+ assert isinstance(result.output[0], DimensionMap)
+ assert result.output[0].offset == 0
+
+ def test_basic_int(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ result = t.select((3, slice(None)), "basic")
+ assert result.input_rank == 1
+ assert isinstance(result.output[0], ConstantMap)
+ assert result.output[0].offset == 3
+
+ def test_basic_ellipsis(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ result = t.select(Ellipsis, "basic")
+ assert result.domain.shape == (10, 20)
+
+ def test_orthogonal(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ idx = np.array([1, 3, 5], dtype=np.intp)
+ result = t.select((idx, slice(None)), "orthogonal")
+ assert result.domain.shape == (3, 20)
+ assert isinstance(result.output[0], ArrayMap)
+
+ def test_vectorized(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ idx0 = np.array([1, 3], dtype=np.intp)
+ idx1 = np.array([5, 7], dtype=np.intp)
+ result = t.select((idx0, idx1), "vectorized")
+ assert result.domain.shape == (2,)
+ assert isinstance(result.output[0], ArrayMap)
+ assert isinstance(result.output[1], ArrayMap)
+
+ def test_composition_with_non_identity(self) -> None:
+ """Indexing a sliced transform uses literal domain coordinates.
+
+ The slice [10:50] preserves its domain, so a follow-up [15:30]
+ re-selects coordinates 15..29 of the base (TensorStore semantics), and
+ the composed map stays the identity (out = in).
+ """
+ t = IndexTransform.from_shape((100,))[10:50]
+ result = t.select(slice(15, 30), "basic")
+ assert (result.domain.inclusive_min, result.domain.exclusive_max) == ((15,), (30,))
+ assert isinstance(result.output[0], DimensionMap)
+ assert result.output[0].offset == 0
+ assert result.output[0].stride == 1
+
+
+class TestIndexTransformIntersect:
+ def test_constant_inside(self) -> None:
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((10,)),
+ output=(ConstantMap(offset=5),),
+ )
+ result = t.intersect(IndexDomain(inclusive_min=(0,), exclusive_max=(10,)))
+ assert result is not None
+ restricted, surviving = result
+ assert isinstance(restricted.output[0], ConstantMap)
+ assert restricted.output[0].offset == 5
+ assert surviving is None
+
+ def test_constant_outside(self) -> None:
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((10,)),
+ output=(ConstantMap(offset=5),),
+ )
+ result = t.intersect(IndexDomain(inclusive_min=(10,), exclusive_max=(20,)))
+ assert result is None
+
+ def test_dimension_partial(self) -> None:
+ """DimensionMap over [0,10) intersected with [5,15) narrows input to [5,10)."""
+ t = IndexTransform.from_shape((10,))
+ result = t.intersect(IndexDomain(inclusive_min=(5,), exclusive_max=(15,)))
+ assert result is not None
+ restricted, surviving = result
+ assert restricted.domain.inclusive_min == (5,)
+ assert restricted.domain.exclusive_max == (10,)
+ assert surviving is None
+
+ def test_dimension_no_overlap(self) -> None:
+ t = IndexTransform.from_shape((10,))
+ result = t.intersect(IndexDomain(inclusive_min=(20,), exclusive_max=(30,)))
+ assert result is None
+
+ def test_dimension_strided(self) -> None:
+ """stride=2, offset=1 over [0,5): storage 1,3,5,7,9. Chunk [4,8)."""
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((5,)),
+ output=(DimensionMap(input_dimension=0, offset=1, stride=2),),
+ )
+ result = t.intersect(IndexDomain(inclusive_min=(4,), exclusive_max=(8,)))
+ assert result is not None
+ restricted, _surviving = result
+ # input 2->5, input 3->7. Both in [4,8).
+ assert restricted.domain.inclusive_min == (2,)
+ assert restricted.domain.exclusive_max == (4,)
+
+ @pytest.mark.parametrize(
+ ("input_domain", "output_domain", "output_map", "expected"),
+ [
+ (
+ (2**53, 2**53 + 3),
+ (2**53 + 1, 2**53 + 2),
+ DimensionMap(input_dimension=0),
+ (2**53 + 1, 2**53 + 2),
+ ),
+ (
+ (-(2**53) - 2, -(2**53) + 1),
+ (-(2**53) - 1, -(2**53)),
+ DimensionMap(input_dimension=0),
+ (-(2**53) - 1, -(2**53)),
+ ),
+ (
+ (-(2**53) - 2, -(2**53) + 1),
+ (2**53 + 1, 2**53 + 2),
+ DimensionMap(input_dimension=0, stride=-1),
+ (-(2**53) - 1, -(2**53)),
+ ),
+ (
+ (2**53, 2**53 + 3),
+ (-(2**53) - 2, -(2**53) - 1),
+ DimensionMap(input_dimension=0, stride=-1),
+ (2**53 + 2, 2**53 + 3),
+ ),
+ ],
+ ids=[
+ "positive-coordinates-positive-stride",
+ "negative-coordinates-positive-stride",
+ "positive-coordinates-negative-stride",
+ "negative-coordinates-negative-stride",
+ ],
+ )
+ def test_dimension_intersection_is_exact_above_float_precision(
+ self,
+ input_domain: tuple[int, int],
+ output_domain: tuple[int, int],
+ output_map: DimensionMap,
+ expected: tuple[int, int],
+ ) -> None:
+ transform = IndexTransform(
+ domain=IndexDomain((input_domain[0],), (input_domain[1],)),
+ output=(output_map,),
+ )
+
+ result = transform.intersect(IndexDomain((output_domain[0],), (output_domain[1],)))
+
+ assert result is not None
+ restricted, _surviving = result
+ assert restricted.domain == IndexDomain((expected[0],), (expected[1],))
+
+ def test_dimension_intersection_accepts_unbounded_python_integer_precision(self) -> None:
+ huge = 10**400
+ transform = IndexTransform(
+ domain=IndexDomain((huge,), (huge + 2,)),
+ output=(DimensionMap(input_dimension=0),),
+ )
+
+ result = transform.intersect(IndexDomain((huge + 1,), (huge + 2,)))
+
+ assert result is not None
+ restricted, _surviving = result
+ assert restricted.domain == IndexDomain((huge + 1,), (huge + 2,))
+
+ def test_array_partial(self) -> None:
+ arr = np.array([3, 8, 15, 22], dtype=np.intp)
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((4,)),
+ output=(ArrayMap(index_array=arr),),
+ )
+ result = t.intersect(IndexDomain(inclusive_min=(5,), exclusive_max=(20,)))
+ assert result is not None
+ restricted, surviving = result
+ assert isinstance(restricted.output[0], ArrayMap)
+ np.testing.assert_array_equal(restricted.output[0].index_array, np.array([8, 15]))
+ assert surviving is not None
+ np.testing.assert_array_equal(surviving, np.array([1, 2]))
+
+ def test_array_none_inside(self) -> None:
+ arr = np.array([1, 2, 3], dtype=np.intp)
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(ArrayMap(index_array=arr),),
+ )
+ assert t.intersect(IndexDomain(inclusive_min=(10,), exclusive_max=(20,))) is None
+
+ def test_2d_mixed(self) -> None:
+ """2D: ConstantMap on dim 0, DimensionMap on dim 1."""
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((10,)),
+ output=(
+ ConstantMap(offset=5),
+ DimensionMap(input_dimension=0, offset=0, stride=1),
+ ),
+ )
+ chunk = IndexDomain(inclusive_min=(0, 5), exclusive_max=(10, 15))
+ result = t.intersect(chunk)
+ assert result is not None
+ restricted, _ = result
+ assert isinstance(restricted.output[0], ConstantMap)
+ assert restricted.output[0].offset == 5
+ assert isinstance(restricted.output[1], DimensionMap)
+ assert restricted.domain.inclusive_min == (5,)
+ assert restricted.domain.exclusive_max == (10,)
+
+
+class TestIndexTransformTranslate:
+ def test_translate_constant(self) -> None:
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((10,)),
+ output=(ConstantMap(offset=5),),
+ )
+ result = t.translate((-5,))
+ assert isinstance(result.output[0], ConstantMap)
+ assert result.output[0].offset == 0
+
+ def test_translate_dimension(self) -> None:
+ t = IndexTransform.from_shape((10,))
+ result = t.translate((-3,))
+ assert isinstance(result.output[0], DimensionMap)
+ assert result.output[0].offset == -3
+ assert result.output[0].stride == 1
+
+ def test_translate_array(self) -> None:
+ arr = np.array([5, 10], dtype=np.intp)
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((2,)),
+ output=(ArrayMap(index_array=arr, offset=3),),
+ )
+ result = t.translate((-3,))
+ assert isinstance(result.output[0], ArrayMap)
+ assert result.output[0].offset == 0
+ np.testing.assert_array_equal(result.output[0].index_array, arr)
+
+ def test_translate_2d(self) -> None:
+ t = IndexTransform.from_shape((10, 20))
+ result = t.translate((-5, -10))
+ assert isinstance(result.output[0], DimensionMap)
+ assert result.output[0].offset == -5
+ assert isinstance(result.output[1], DimensionMap)
+ assert result.output[1].offset == -10
+
+
+class TestArrayMapDependencyAxes:
+ """`ArrayMap.dependency_axes` derives the input axes an array varies on
+ from its (full-rank) shape: non-singleton axes vary, singleton axes do not."""
+
+ def test_orthogonal_single_axis(self) -> None:
+ t = IndexTransform.from_shape((10, 20)).oindex[np.array([1, 3]), np.array([2, 4, 6])]
+ m0, m1 = t.output[0], t.output[1]
+ assert isinstance(m0, ArrayMap)
+ assert isinstance(m1, ArrayMap)
+ assert m0.dependency_axes == (0,)
+ assert m1.dependency_axes == (1,)
+
+ def test_vectorized_shares_axes(self) -> None:
+ t = IndexTransform.from_shape((10, 20)).vindex[np.array([1, 3]), np.array([2, 4])]
+ m0, m1 = t.output[0], t.output[1]
+ assert isinstance(m0, ArrayMap)
+ assert isinstance(m1, ArrayMap)
+ assert m0.dependency_axes == (0,)
+ assert m1.dependency_axes == (0,)
+
+ def test_scalar_array_has_no_dependency(self) -> None:
+ assert ArrayMap(np.ones((1, 1), dtype=np.intp)).dependency_axes == ()
+
+ def test_zero_length_axis_has_no_dependency(self) -> None:
+ """An axis of size 0 carries no dependency either: it selects nothing, so
+ the array does not vary along it any more than along a singleton."""
+ assert ArrayMap(np.zeros((0, 4), dtype=np.intp)).dependency_axes == (1,)
+ assert ArrayMap(np.zeros((3, 0), dtype=np.intp)).dependency_axes == (0,)
+ assert ArrayMap(np.zeros((0, 1), dtype=np.intp)).dependency_axes == ()
+
+ def test_zero_length_axis_does_not_make_a_map_correlated(self) -> None:
+ """An empty orthogonal selection is legal, so it must classify as one."""
+ m = ArrayMap(index_array=np.zeros((0, 4), dtype=np.intp))
+ assert m.dependent_axis == 1
+
+
+class TestIntersectArrayMapClassification:
+ """`_intersect` must distinguish orthogonal (outer-product) ArrayMaps from
+ correlated (vectorized) ones by their dependency axes, keep surviving arrays
+ at full input rank, and preserve residual (slice) dimensions."""
+
+ def test_orthogonal_outer_product_keeps_full_rank(self) -> None:
+ """Two arrays on distinct axes narrow independently and stay full rank;
+ out_indices is a per-output-dim dict of surviving positions."""
+ t = IndexTransform.from_shape((10, 10)).oindex[np.array([1, 3, 8]), np.array([2, 6, 9])]
+ # Chunk covering storage [0,5) x [0,5): rows 1,3 survive (out pos 0,1),
+ # cols 2 survives (out pos 0).
+ chunk = IndexDomain(inclusive_min=(0, 0), exclusive_max=(5, 5))
+ result = t.intersect(chunk)
+ assert result is not None
+ restricted, out_indices = result
+ assert isinstance(restricted.output[0], ArrayMap)
+ assert isinstance(restricted.output[1], ArrayMap)
+ # Full input rank preserved (not raveled to 1-D).
+ assert restricted.output[0].index_array.ndim == 2
+ assert restricted.output[1].index_array.ndim == 2
+ assert restricted.domain.ndim == 2
+ assert isinstance(out_indices, dict)
+ np.testing.assert_array_equal(out_indices[0], np.array([0, 1]))
+ np.testing.assert_array_equal(out_indices[1], np.array([0]))
+
+ def test_correlated_with_residual_slice_preserves_slice_dim(self) -> None:
+ """A vindex transform with two correlated arrays plus a residual slice
+ dim intersects without a rank error and keeps the DimensionMap."""
+ t = IndexTransform.from_shape((4, 3, 5)).vindex[np.array([1, 3]), np.array([2, 0])]
+ # Chunk covering storage [0,2) x [2,3) x [0,5): only point (1,2,*) is in
+ # bounds on both array dims -> one surviving broadcast point.
+ chunk = IndexDomain(inclusive_min=(0, 2, 0), exclusive_max=(2, 3, 5))
+ result = t.intersect(chunk)
+ assert result is not None
+ restricted, out_indices = result
+ # A DimensionMap for the residual slice dim survives (no post-init error).
+ assert any(isinstance(m, DimensionMap) for m in restricted.output)
+ assert out_indices is not None
+
+ def test_length1_orthogonal_collapses_to_a_constant(self) -> None:
+ """A length-1 orthogonal array holds one coordinate: it is a ConstantMap.
+
+ The length-1 axis stays in the domain, and the remaining genuine array
+ intersects orthogonally — a single survivor vector, not a joint gather.
+ """
+ t = IndexTransform.from_shape((6, 6)).oindex[np.array([2]), np.array([1, 3, 5])]
+ assert isinstance(t.output[0], ConstantMap)
+ assert t.domain.shape == (1, 3)
+ chunk = IndexDomain(inclusive_min=(0, 0), exclusive_max=(6, 6))
+ result = t.intersect(chunk)
+ assert result is not None
+ _restricted, out_indices = result
+ assert isinstance(out_indices, np.ndarray)
+ np.testing.assert_array_equal(out_indices, [0, 1, 2])
+
+
+class TestDerivedMapDependency:
+ """A map's `input_dimension` must describe the array it is built with.
+
+ Three separate failures came from one stale value: a vectorized index applied
+ to an orthogonal map makes it correlated, but the old dependency was carried
+ onto the new array anyway. Readers fall back to that field when the shape
+ alone cannot say, so the wrong axis was believed much later — by a scatter
+ that filed positions under it, which is why the answer depended on how the
+ read was partitioned.
+ """
+
+ def test_a_vindex_over_a_fancy_view_is_marked_correlated(self) -> None:
+ base = np.arange(6)
+ view = (
+ LazyArray(base)
+ .lazy.oindex[np.array([0, 1])]
+ .lazy.vindex[np.array([[0, 1, 0], [1, 0, 1]])]
+ )
+ np.testing.assert_array_equal(
+ np.asarray(view.result()), base[[0, 1]][[[0, 1, 0], [1, 0, 1]]]
+ )
+
+ def test_the_same_view_resolves_alike_however_it_is_partitioned(self) -> None:
+ base = np.arange(36).reshape(6, 6)
+
+ def build(array: LazyArray) -> LazyArray:
+ return array.lazy.oindex[np.array([-3, -6, -4]), -4].lazy.vindex[np.array([[-2, -3]])]
+
+ unpartitioned = np.asarray(build(LazyArray(base)).result())
+ partitioned = np.asarray(build(LazyArray(base).with_parts((3, 3))).result())
+ np.testing.assert_array_equal(partitioned, unpartitioned)
+ np.testing.assert_array_equal(unpartitioned, np.array([[2, 20]]))
+
+ def test_dependency_axes_are_read_from_the_shape(self) -> None:
+ """What a map varies over is its non-singleton axes — nothing else.
+
+ The retired `input_dimension` field could contradict the array it rode
+ on; the shape cannot.
+ """
+ t = IndexTransform(
+ domain=IndexDomain.from_shape((2, 3)),
+ output=(
+ ArrayMap(index_array=np.array([[0, 1, 2]], dtype=np.intp)),
+ ArrayMap(index_array=np.array([[0], [1]], dtype=np.intp)),
+ ),
+ )
+ assert t.index_array_structure == "orthogonal"
+
+
+def test_an_orthogonal_step_over_a_correlated_view_is_an_outer_product() -> None:
+ """`oindex` after `vindex` means the outer product, not a joint gather.
+
+ The reindexing applied its index tuple positionally, which is NumPy's
+ *vectorized* rule, so two arrays collapsed into one axis and the result came
+ back a rank short of what was asked for.
+ """
+ base = np.arange(14).reshape(7, 2)
+ view = LazyArray(base).lazy.vindex[
+ np.array([[5, 5], [1, 2], [0, 4]]), np.array([[1, 1], [1, 0], [1, 0]])
+ ]
+ result = np.asarray(view.lazy.oindex[np.array([1, 1, 0]), np.array([1, 1, 0, 1])].result())
+ assert result.shape == (3, 4)
+ np.testing.assert_array_equal(result, np.array([[4, 4, 3, 4], [4, 4, 3, 4], [11, 11, 11, 11]]))
+
+
+@pytest.mark.parametrize(
+ ("value", "description"),
+ [(1, "one below the lower bound"), (10, "the exclusive upper bound itself")],
+)
+def test_an_index_array_value_just_outside_the_domain_is_refused(
+ value: int, description: str
+) -> None:
+ """The bound checks are probed at the boundary, not comfortably past it.
+
+ Both were only ever exercised from well outside the domain, so relaxing
+ either by one — `lo - 1` instead of `lo` — went unnoticed while letting a
+ view read a cell it does not address.
+ """
+ transform = IndexTransform.from_shape((12,))[2:10]
+ with pytest.raises(BoundsCheckError, match="out of bounds"):
+ transform.oindex[np.array([value, 3])]
+
+
+def test_an_index_array_value_at_each_end_of_the_domain_is_accepted() -> None:
+ """The other side of the same boundary: the extremes themselves are in range."""
+ transform = IndexTransform.from_shape((12,))[2:10]
+ array_map = transform.oindex[np.array([2, 9])].output[0]
+ assert isinstance(array_map, ArrayMap)
+ np.testing.assert_array_equal(array_map.index_array, np.array([2, 9]))
+
+
+# ---------------------------------------------------------------------------
+# Intersecting diagonal gathers
+# ---------------------------------------------------------------------------
+
+
+def test_intersecting_a_diagonal_gather_keeps_points_inside_the_domain() -> None:
+ """Index arrays sharing an input axis intersect pointwise, like vindex."""
+ rows = np.array([4, 0, 2])
+ cols = np.array([1, 5, 2])
+ transform = IndexTransform(
+ domain=IndexDomain.from_shape((3,)),
+ output=(
+ ArrayMap(index_array=rows),
+ ArrayMap(index_array=cols),
+ ),
+ )
+
+ result = transform.intersect(IndexDomain(inclusive_min=(0, 0), exclusive_max=(3, 3)))
+ assert result is not None
+ restricted, survivors = result
+ # Only the point (2, 2) has both coordinates inside [0, 3) x [0, 3).
+ assert restricted.domain.shape == (1,)
+ assert isinstance(survivors, np.ndarray)
+ np.testing.assert_array_equal(survivors, [2])
+ np.testing.assert_array_equal(restricted.apply((0,)), (2, 2))
+
+ assert transform.intersect(IndexDomain(inclusive_min=(0, 0), exclusive_max=(1, 1))) is None
+
+
+def test_index_array_structure_classifies_the_three_shapes() -> None:
+ base = IndexTransform.from_shape((4, 6))
+ assert (base[1:, ::2]).index_array_structure == "none"
+ assert (base.oindex[np.array([0, 2]), slice(None)]).index_array_structure == "orthogonal"
+ assert (base.vindex[np.array([0, 2]), np.array([1, 3])]).index_array_structure == "general"
+ diagonal = IndexTransform(
+ domain=IndexDomain.from_shape((2,)),
+ output=(
+ ArrayMap(index_array=np.array([0, 1])),
+ ArrayMap(index_array=np.array([2, 3])),
+ ),
+ )
+ assert diagonal.index_array_structure == "general"
diff --git a/packages/zarr-indexing/uv.lock b/packages/zarr-indexing/uv.lock
new file mode 100644
index 0000000000..2687ba71d8
--- /dev/null
+++ b/packages/zarr-indexing/uv.lock
@@ -0,0 +1,769 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+
+[[package]]
+name = "babel"
+version = "2.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
+]
+
+[[package]]
+name = "backrefs"
+version = "8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/56/4744bcd0c82184e80c52b0ac4076c261a8ffa1f1b343ff2f6e89ce0e1cef/backrefs-8.0.tar.gz", hash = "sha256:b556cd7d36c3a3a2f256b89590b176b8eddfb73bcfaee3a3ddd84ea66d21ce50", size = 7013081, upload-time = "2026-07-26T19:54:24.638Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/fd/9bf53b6a6f6f519ffaac765df2f2a25e5c2fc6d32cfd2b2747099e72c911/backrefs-8.0-py310-none-any.whl", hash = "sha256:4a627b817fd2dce43b79ab48da63613340509381cd8ce0897078a0bce79a2ab8", size = 380377, upload-time = "2026-07-26T19:54:17.457Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/29/4bd7ae72a2634da00379c2b3bcc5439e7c94620235c6afea8af15229a973/backrefs-8.0-py311-none-any.whl", hash = "sha256:f0c35cf0102ba6b6070c12a492be3c1c1d3f5839529784b9a9565d6d04569a01", size = 392169, upload-time = "2026-07-26T19:54:18.782Z" },
+ { url = "https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl", hash = "sha256:87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5", size = 398084, upload-time = "2026-07-26T19:54:19.954Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl", hash = "sha256:601ce68ca12385dbda06ce264406b4c4210cf5b79fd0fd627592365c92f29a88", size = 400040, upload-time = "2026-07-26T19:54:21.194Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl", hash = "sha256:9ec96efa080938be92323e8e730e57718c9c88eb15ad70bbef4e1766df591408", size = 411903, upload-time = "2026-07-26T19:54:23.221Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.7.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" },
+ { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" },
+ { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" },
+ { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" },
+ { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" },
+ { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" },
+ { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" },
+ { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" },
+ { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" },
+ { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
+ { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
+ { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
+ { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
+ { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
+ { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
+ { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
+ { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
+ { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
+ { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
+ { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "ghp-import"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" },
+]
+
+[[package]]
+name = "griffe-inherited-docstrings"
+version = "1.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "griffelib" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/da/fd002dc5f215cd896bfccaebe8b4aa1cdeed8ea1d9d60633685bd61ff933/griffe_inherited_docstrings-1.1.3.tar.gz", hash = "sha256:cd1f937ec9336a790e5425e7f9b92f5a5ab17f292ba86917f1c681c0704cb64e", size = 26738, upload-time = "2026-02-21T09:38:44.312Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/20/4bc15f242181daad1c104e0a7d33be49e712461ea89e548152be0365b9ea/griffe_inherited_docstrings-1.1.3-py3-none-any.whl", hash = "sha256:aa7f6e624515c50d9325a5cfdf4b2acac547f1889aca89092d5da7278f739695", size = 6710, upload-time = "2026-02-20T11:06:38.75Z" },
+]
+
+[[package]]
+name = "griffelib"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" },
+]
+
+[[package]]
+name = "hypothesis"
+version = "6.164.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7a/ac/7b76103bd74d8457e4de0c6a6c3a26ac6327016438bde125e0a3de83a5b8/hypothesis-6.164.0.tar.gz", hash = "sha256:5d63d263d8c71b571638c18d9591f6e34b836c60a12469e9d9105c1c785f00f1", size = 492022, upload-time = "2026-07-30T12:39:49.085Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/fe/d5b75a55892b33e72945f82efc71f645d29c0bfdb9f00727f7535a52edcc/hypothesis-6.164.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:14b861ac3353f8643b82a3ba76b8a0a54d2a06160c32b9a1f64a8ab41b179089", size = 771561, upload-time = "2026-07-30T12:39:00.404Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/b0/2f01e9efc7267446bad0e2a68f7472daa174a72553d213b16aefe44b2bda/hypothesis-6.164.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:3d8c8bb00a4b86ae90b9ad41f3e1c99d016ec3e64c0ff9d676a4bb7be4f56948", size = 767079, upload-time = "2026-07-30T12:39:23.123Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/26/d7bcd26b58e1df2bd39116b924b2a72676215d9650e68cbff9a629c3ce30/hypothesis-6.164.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e80e3ba8eaf37664eaa0f2625cef120b330b128a7df570210cf8be4f5ae65aaa", size = 1096364, upload-time = "2026-07-30T12:38:49.972Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/17/99fe7ea866935da83444c3ef7885a14fc7349d96ff61c6faebd37ef4edf2/hypothesis-6.164.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8cdf70f821e2d2f3a0bccaab29830aea8aefb63a77806e7e91246fb65a10c8d3", size = 1124963, upload-time = "2026-07-30T12:39:13.1Z" },
+ { url = "https://files.pythonhosted.org/packages/38/e8/df08be6296cbc1271d44e81f8ff9dcd6267a07552fb768e0fdc166e93d40/hypothesis-6.164.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcc3743e22b3cffa7267b4bc74d03628606e4a115495728e986a7be220987315", size = 1145886, upload-time = "2026-07-30T12:39:45.612Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/72/d5cf6fbfac40891d4281f630e16a6eb217ff56f97e350a06e0fd9322aa6a/hypothesis-6.164.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:730f09d4afcd8a918b3d589bfb6421e3b41c057aa57652a773ef4f512cc60836", size = 1101181, upload-time = "2026-07-30T12:39:05.194Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/ff/7ceb002329febffb678b65835ca6e9479a916325d088aadb0210d07f8252/hypothesis-6.164.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9651cb48cb5a995295b442138d15d381547b935dcb0066fca7148a7955347400", size = 1137970, upload-time = "2026-07-30T12:39:16.076Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/8f/c12c697b73ca9ca24d8a913879e3e0a9db86479754c7221554247c701565/hypothesis-6.164.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:51d161d2655dd86143b370c577267b5b7b4c2e8fcb8a3f22c1a787572aad707c", size = 1270184, upload-time = "2026-07-30T12:38:54.436Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/2f/93f1c850c794fc9c80f5e61b3b20652126b865e6f57b348ae530446aadc7/hypothesis-6.164.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e8a250552390128b57e3afe55035ce2c2cb1f6f0919817657854244f071bc5be", size = 1397987, upload-time = "2026-07-30T12:38:21.113Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/b8/bab2546325e15e87c8518dfbca263c81dbc35d566c516d66c9da98a38b77/hypothesis-6.164.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:570cd51944e1cc3443847d8afa3d17fcf8aac475a1f744c9e7318a5ad7ef5c9f", size = 1270755, upload-time = "2026-07-30T12:38:51.571Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/4e/ea97dd39678a42dc5a24e3e2a64d3b950fad9fb1dcce8d7be5afb52a0335/hypothesis-6.164.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3a423e543055b3de5af7a7624c4285422541658367211fa293a3a57dd0ad01ba", size = 1312888, upload-time = "2026-07-30T12:38:30.847Z" },
+ { url = "https://files.pythonhosted.org/packages/44/84/a6f2d5b12b23d65f16eb398750e430065f9d1f40f4418569e3b87ef58d23/hypothesis-6.164.0-cp310-abi3-win32.whl", hash = "sha256:f5e51490b2ce64c66138f24477d83c71b6224ab0ef65700da10187c464b54e94", size = 657401, upload-time = "2026-07-30T12:39:11.581Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/d3/c5ee410daa594cac2d3fe1fbe5473f2390e35f4369e168a817e43341ce2f/hypothesis-6.164.0-cp310-abi3-win_amd64.whl", hash = "sha256:c9059dfbb039342b6590bbce207f90e0f9a80fdf45a404c68c2d3e598be78ab3", size = 663566, upload-time = "2026-07-30T12:39:30.27Z" },
+ { url = "https://files.pythonhosted.org/packages/90/91/4942fe3f2f08b920368ed5a2937346259e843e382205513b4a0e70d2de9d/hypothesis-6.164.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6bc3373fe550cf4d7cadb94ceaeb91e431e1418a96b7baa330487366eaa67d3c", size = 773152, upload-time = "2026-07-30T12:38:33.328Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/df/e66d052386a2b6c3e2f3eab32a02d7de3c9c59cd21d5dd58c08ecfa715f0/hypothesis-6.164.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2780297ca68929b153eff7effb2ebe67e9487d2fd9f49fa961007f8f2d236c9e", size = 764713, upload-time = "2026-07-30T12:38:48.59Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/d5/5a50d14b8f04809e973c4dea884b367fef3663ff253c1205fa9e96229ef9/hypothesis-6.164.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b400bb4eb5a4a1e19cd5af3cc63817909e6b54b4603e04022bdba46860913d7", size = 1095160, upload-time = "2026-07-30T12:38:58.925Z" },
+ { url = "https://files.pythonhosted.org/packages/58/01/781b19ce4382ec239c4dc6ec3bd9f195e69e5570f2814bbf04b5781ecb18/hypothesis-6.164.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fca6632933fc506dd96926d9383483e4c0066c7ff62c748d059a3276da761e7", size = 1145199, upload-time = "2026-07-30T12:39:09.904Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/64/30e016863515ca01c1c738b05dd50491353d3ccae6432362e56e0c15d0da/hypothesis-6.164.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b9e1f6e89e5ec34735b727f3ce41d12e7f3b8efc162c91c8a225e10b54b504b4", size = 1267980, upload-time = "2026-07-30T12:38:18.733Z" },
+ { url = "https://files.pythonhosted.org/packages/84/23/17eb8d67d59ecd3a820c905fbdf514e371dd7d01631e62a304cdd5793abe/hypothesis-6.164.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:51b0f967f608707b24ed37a298174ae6eec7899bfe3f271d1c3062c39ad66c06", size = 1312181, upload-time = "2026-07-30T12:38:36.056Z" },
+ { url = "https://files.pythonhosted.org/packages/42/69/cff9f3cd9524252adda7c8e0e129dfc176e72f64fdf0bf1552d1ea43d78d/hypothesis-6.164.0-cp312-cp312-win_amd64.whl", hash = "sha256:5770df7d518bf867a9379e9081abd9e44db1d15473430e26a0946438c08c5926", size = 660690, upload-time = "2026-07-30T12:38:28.107Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/b4/729697380a22dc2ce8feae3c64b08bf3bd3c27e99c3706cb9bdac40c6fc8/hypothesis-6.164.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:29e7cb48974cb9fd87602e20625c890385793c6b56c18a957085a9c291f56ef8", size = 773046, upload-time = "2026-07-30T12:39:40.473Z" },
+ { url = "https://files.pythonhosted.org/packages/38/35/72374f02d90dfda198afd8aac6b1e7d1184506f97e62ebcf3d2c1e5bf761/hypothesis-6.164.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1ff8c3819345be8dd15ee6588ee9383869a54c9a3d2232cce5e26b456424135d", size = 764659, upload-time = "2026-07-30T12:38:55.896Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/75/fb26388915d71e5949b98ccd0c9d95edcbe6b45d0370f177d43633d81ae2/hypothesis-6.164.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33e88be13fac3ff7cb789a0b4cc43d99fb297db085f529fbb363188141c7d5bf", size = 1095078, upload-time = "2026-07-30T12:38:34.677Z" },
+ { url = "https://files.pythonhosted.org/packages/be/63/f6da6e39667d39a1e44c5df82fbe6cff070c29aaffa9beb62a5322e7d8ae/hypothesis-6.164.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2e296d03a77355ce2e1c32e85a636b555edf0ddaaef277f98f1b84fe38a4595", size = 1145015, upload-time = "2026-07-30T12:39:26.487Z" },
+ { url = "https://files.pythonhosted.org/packages/88/c7/55ba09727da3d9a60628c50e31e6083a36f403cb230f5e1a7bd1749a5c39/hypothesis-6.164.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:53698a1b246714539dd0ecc2d556cde613d74e9f7385ec4109e0651ab2d382d6", size = 1268027, upload-time = "2026-07-30T12:38:25.676Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/35/4789cade332f799b0e8f2f7ea0fe2aae6157a85e60f74497e316dd17a7e3/hypothesis-6.164.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:004c92c4b869f8e258f0641101b7743cae8420436f4465383f681c086ef95c9d", size = 1311895, upload-time = "2026-07-30T12:39:14.621Z" },
+ { url = "https://files.pythonhosted.org/packages/12/8a/18d85e624f8631aec42daa8a2f07c6edcedb7385b2c0f375ba8a30cbd065/hypothesis-6.164.0-cp313-cp313-win_amd64.whl", hash = "sha256:4878f81fa92a580d3e16b53e64e01a9d9fe1dca5973783558493a003138dbd36", size = 660656, upload-time = "2026-07-30T12:38:37.696Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/06/3c144d427799c7c72befb0bb3b199d419a89b96e1002fd8f0cc94c84ffb7/hypothesis-6.164.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9110010bdf6deb3ba9134f8ce8b683e8bb0fba108a351045c96d60c410eb6963", size = 773254, upload-time = "2026-07-30T12:38:38.919Z" },
+ { url = "https://files.pythonhosted.org/packages/74/2d/b61a10d9e70df04aa7e8f34efef8e4afe364e8995c59f894e1c35b428214/hypothesis-6.164.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df103e5d32b47d574c6e857d45361e2cba5a198d6dae4e4ee1bd248b3a2cbfa", size = 764786, upload-time = "2026-07-30T12:38:24.464Z" },
+ { url = "https://files.pythonhosted.org/packages/99/68/7f80ac7bdffe78686135311c919534be411d4565c2a5ba38fd389880c553/hypothesis-6.164.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4abec95020960c0ed08e5be318d2bcdde79f2c6fc7785e368a9389d31d3e802a", size = 1095578, upload-time = "2026-07-30T12:38:57.422Z" },
+ { url = "https://files.pythonhosted.org/packages/45/f9/97dcbac776bcf33cb4241b52111527821f707b60a84d03d0ea670b09a134/hypothesis-6.164.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b106756cc9abd50ab1632541ea7b7223792d877a084726aa0304237d758181e", size = 1145207, upload-time = "2026-07-30T12:38:23.387Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/df/68184b6f71540435c895cf35ad1d67a3634a887c597ab38d3372c0d20186/hypothesis-6.164.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:11c4aab2ae6757fc4bc3bbf009487e24fd3490365817bbf40b9ec85a7e02fabb", size = 1268357, upload-time = "2026-07-30T12:38:52.946Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/76/6a6851dc8af89a5c0418937d38456417b2a1fc9db15c992b9cb43d53a7a3/hypothesis-6.164.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4713edecbc0969557ca135769a36d1e524c8e3b7a2b271de48d98fa29f681bf6", size = 1312183, upload-time = "2026-07-30T12:39:28.604Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/19/83adeb1f8f045bd8a1ab9822d0c3db28b337d37fff01d809fcd6e3ea70f8/hypothesis-6.164.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e6882d316c390d33c55ec8f1675f35ab238d7c0473ccf8d235c69eaef6c621b9", size = 604771, upload-time = "2026-07-30T12:39:33.579Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/62/fcb48ebfbccdc5b695de175b9d1d344b3688782150f0603124bb70c0891b/hypothesis-6.164.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c3357633b38bca8c927fd90d02b39a0a3f35f24cdbcfb2fb1dcf69a3f63bd85", size = 660570, upload-time = "2026-07-30T12:39:43.898Z" },
+ { url = "https://files.pythonhosted.org/packages/42/61/5857da7db0435fa69df658a9eafba62eb8a1319454005ce2a0d97f6f9e4d/hypothesis-6.164.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:53152cb549f52d661c47768d0d12a192ef26a7a9758a7f13b8ec41e8e63d6325", size = 771839, upload-time = "2026-07-30T12:38:26.842Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/9c/22292a9dab1c544362d1759244132c7d71a9d9d5eda5d454ec735fba6bd3/hypothesis-6.164.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cee7898ad84b63da6506ae48483bb36f319a25ea4c2b1d2df47d021cc4080c24", size = 763363, upload-time = "2026-07-30T12:38:20.042Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/29/cc0c6e9a065a32f93fe52dde746232f007d2cabf619d4e7b1b37bd34c424/hypothesis-6.164.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0def33f0d236e54144a5218997e4492925144d4615f25fdbb4ac8e47b7b709e6", size = 1094171, upload-time = "2026-07-30T12:39:35.158Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/59/37040d0776a29d4bc6d0ca9a50ca2755200007e4a8ddc27b010115b69c85/hypothesis-6.164.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:471fd80d70f2df606b1320276168bc2c6007a586124a1d81628264ccb9266f68", size = 1144089, upload-time = "2026-07-30T12:38:43.024Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/10/5235ed3c090a2f12fa15cc1d08e5a36cfa31bc0607c45199b0806e930ab4/hypothesis-6.164.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2eb285756aee62890fd08d6e97cf77651dfe7c093ceac094df52120a7a8dbe68", size = 1266595, upload-time = "2026-07-30T12:39:36.979Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/97/ffc4cee4dfdffe658e839d5f4df72ae3fa7bfea9401550b475d9700e0ee2/hypothesis-6.164.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7c5215b5568968c35c6e124e5a4a8068f80419d6171414ddf735b49e1df1ab59", size = 1310998, upload-time = "2026-07-30T12:38:45.788Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/08/681d4a272cd2812151581c3328e41a80a34e420d676e419a25b4b9dc2291/hypothesis-6.164.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a845e59fae87bb47a6fb84e0d5adb5679b3b55042fc3f8791da91486103cfbf0", size = 660724, upload-time = "2026-07-30T12:38:40.341Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "markdown"
+version = "3.10.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "mergedeep"
+version = "1.3.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" },
+]
+
+[[package]]
+name = "mkdocs"
+version = "1.6.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "ghp-import" },
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "mergedeep" },
+ { name = "mkdocs-get-deps" },
+ { name = "packaging" },
+ { name = "pathspec" },
+ { name = "pyyaml" },
+ { name = "pyyaml-env-tag" },
+ { name = "watchdog" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" },
+]
+
+[[package]]
+name = "mkdocs-autorefs"
+version = "1.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "mkdocs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" },
+]
+
+[[package]]
+name = "mkdocs-get-deps"
+version = "0.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mergedeep" },
+ { name = "platformdirs" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" },
+]
+
+[[package]]
+name = "mkdocs-material"
+version = "9.7.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "babel" },
+ { name = "backrefs" },
+ { name = "colorama" },
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "mkdocs" },
+ { name = "mkdocs-material-extensions" },
+ { name = "paginate" },
+ { name = "pygments" },
+ { name = "pymdown-extensions" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" },
+]
+
+[[package]]
+name = "mkdocs-material-extensions"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" },
+]
+
+[[package]]
+name = "mkdocstrings"
+version = "1.0.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jinja2" },
+ { name = "markdown" },
+ { name = "markupsafe" },
+ { name = "mkdocs" },
+ { name = "mkdocs-autorefs" },
+ { name = "pymdown-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" },
+]
+
+[[package]]
+name = "mkdocstrings-python"
+version = "2.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "griffelib" },
+ { name = "mkdocs-autorefs" },
+ { name = "mkdocstrings" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" },
+ { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" },
+ { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" },
+ { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" },
+ { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" },
+ { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" },
+ { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" },
+ { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" },
+ { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" },
+ { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" },
+ { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" },
+ { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" },
+ { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" },
+ { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" },
+ { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" },
+ { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" },
+ { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" },
+ { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" },
+ { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" },
+ { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "paginate"
+version = "0.5.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.11.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pymdown-extensions"
+version = "11.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "pyyaml-env-tag"
+version = "1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" },
+ { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" },
+ { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]
+
+[[package]]
+name = "watchdog"
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
+ { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
+ { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
+ { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
+ { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
+ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
+ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
+]
+
+[[package]]
+name = "zarr-indexing"
+source = { editable = "." }
+dependencies = [
+ { name = "numpy" },
+]
+
+[package.optional-dependencies]
+testing = [
+ { name = "hypothesis" },
+]
+
+[package.dev-dependencies]
+docs = [
+ { name = "griffe-inherited-docstrings" },
+ { name = "mkdocs" },
+ { name = "mkdocs-material" },
+ { name = "mkdocstrings" },
+ { name = "mkdocstrings-python" },
+ { name = "ruff" },
+]
+test = [
+ { name = "hypothesis" },
+ { name = "pytest" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "hypothesis", marker = "extra == 'testing'", specifier = ">=6.160.0" },
+ { name = "numpy", specifier = ">=2" },
+]
+provides-extras = ["testing"]
+
+[package.metadata.requires-dev]
+docs = [
+ { name = "griffe-inherited-docstrings", specifier = "==1.1.3" },
+ { name = "mkdocs", specifier = "==1.6.1" },
+ { name = "mkdocs-material", specifier = "==9.7.7" },
+ { name = "mkdocstrings", specifier = "==1.0.6" },
+ { name = "mkdocstrings-python", specifier = "==2.0.5" },
+ { name = "ruff", specifier = "==0.15.22" },
+]
+test = [
+ { name = "hypothesis", specifier = ">=6.160.0" },
+ { name = "pytest" },
+]
diff --git a/packages/zarr-metadata/.readthedocs.yaml b/packages/zarr-metadata/.readthedocs.yaml
new file mode 100644
index 0000000000..828773818c
--- /dev/null
+++ b/packages/zarr-metadata/.readthedocs.yaml
@@ -0,0 +1,43 @@
+# Read the Docs configuration for the zarr-metadata docs site, separate from
+# the zarr-python site configured by the repo-root .readthedocs.yaml. The RTD
+# project for zarr-metadata must set its configuration-file path to
+# packages/zarr-metadata/.readthedocs.yaml.
+version: 2
+
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.12"
+ jobs:
+ post_checkout:
+ # Cancel pull request builds that do not touch this package. Exit code
+ # 183 cancels the build and reports success to the Git provider. Scoped
+ # to PR builds ("external" versions) because origin/main is only a
+ # meaningful diff base there.
+ - |
+ if [ "$READTHEDOCS_VERSION_TYPE" = "external" ] && git diff --quiet origin/main -- packages/zarr-metadata;
+ then
+ exit 183;
+ fi
+ install:
+ - pip install --upgrade pip
+ - pip install ./packages/zarr-metadata --group packages/zarr-metadata/pyproject.toml:docs
+ build:
+ html:
+ # Build from inside the package rather than pointing `-f` at its config
+ # from the repo root. mkdocs resolves some settings relative to the
+ # current working directory rather than to the config file, so building
+ # from elsewhere looks for them in the wrong place -- and silently, since
+ # the paths are valid, just wrong. zarr-indexing hit this: with
+ # `pymdownx.snippets` and a relative `base_path`, its snippets were
+ # searched for under the repo-root docs/ and the build failed with
+ # SnippetMissingError, while `just docs-check` passed because it runs
+ # from here. Building from the package directory makes this identical to
+ # the local and CI invocations, so a green build there means a green
+ # build here.
+ #
+ # $READTHEDOCS_OUTPUT is absolute, so the cd does not affect it.
+ - cd packages/zarr-metadata && mkdocs build --strict --site-dir $READTHEDOCS_OUTPUT/html
+
+mkdocs:
+ configuration: packages/zarr-metadata/mkdocs.yml
diff --git a/packages/zarr-metadata/CHANGELOG.md b/packages/zarr-metadata/CHANGELOG.md
new file mode 100644
index 0000000000..c1e9f81a61
--- /dev/null
+++ b/packages/zarr-metadata/CHANGELOG.md
@@ -0,0 +1,346 @@
+# Release notes
+
+
+
+## 0.5.0 (2026-08-14)
+
+### Bugfixes
+
+- `JSONValue`'s array arm is now the covariant `Sequence["JSONValue"]` rather
+ than the invariant `list["JSONValue"] | tuple["JSONValue", ...]`. Values typed
+ with a narrower element type — a `list[str]` field on a TypedDict, a
+ `Sequence[float]` — now count as JSON values, and TypedDicts whose fields
+ carry precise types are now assignable to `Mapping[str, JSONValue]`.
+ Type-level cost, accepted deliberately: `Sequence` says nothing about the
+ concrete container and admits `str`/`bytes`, so runtime code narrowing a JSON
+ array must exclude `str`/`bytes`/`bytearray` — as it already had to, since
+ `str` was always a union arm. ([#4264](https://github.com/zarr-developers/zarr-python/pull/4264))
+
+### Deprecations and Removals
+
+- Unified the naming grammar for SCREAMING_SNAKE constants with the one used for
+ type names. A constant's name is now a purely syntactic transformation of the
+ name of the `Literal` type it manifests, so the format version is spelled
+ `ZARR_V2`/`ZARR_V3` and comes first, matching the `ZarrV2`/`ZarrV3` prefix on
+ the corresponding type:
+
+ - `ARRAY_METADATA_STORE_KEY_V2` → `ZARR_V2_ARRAY_METADATA_STORE_KEY`
+ - `ARRAY_METADATA_STORE_KEY_V3` → `ZARR_V3_ARRAY_METADATA_STORE_KEY`
+ - `ATTRIBUTES_STORE_KEY_V2` → `ZARR_V2_ATTRIBUTES_STORE_KEY`
+ - `GROUP_METADATA_STORE_KEY_V2` → `ZARR_V2_GROUP_METADATA_STORE_KEY`
+ - `GROUP_METADATA_STORE_KEY_V3` → `ZARR_V3_GROUP_METADATA_STORE_KEY`
+ - `CONSOLIDATED_METADATA_STORE_KEY_V2` → `ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY`
+ - `ARRAY_ORDER_V2` → `ZARR_V2_ARRAY_ORDER`
+ - `ARRAY_DIMENSION_SEPARATOR_V2` → `ZARR_V2_ARRAY_DIMENSION_SEPARATOR`
+ - `CONSOLIDATED_METADATA_KEY_V3` → `ZARR_V3_CONSOLIDATED_METADATA_KEY`
+
+ The old names are removed, not aliased. This supersedes the 0.4.0 convention
+ under which type names put the format version first while constants put it
+ last: every constant that manifests a `Literal` type now follows the same rule
+ as that type.
+
+ The last of those is the one rename the syntactic rule does not force:
+ `ZARR_V3_CONSOLIDATED_METADATA_KEY` manifests no `Literal` type, so it is
+ outside the rule and was renamed for consistency with its siblings.
+
+ Digit runs stay glued to the token they follow, so spec vocabulary is
+ preserved: `Uint8DataTypeName` pairs with `UINT8_DATA_TYPE_NAME` (not
+ `UINT_8_...`) and `Crc32cCodecName` with `CRC32C_CODEC_NAME`. No dtype, codec,
+ chunk-grid, or chunk-key-encoding constant changed name.
+
+ Constants that do not manifest a `Literal` type are outside the rule and are
+ unchanged: the `*_METADATA_*_KEYS_V2`/`_V3` key sets, the
+ `CANONICAL_*_HEX_FLOAT*` bit patterns, and `UNSET`. The key sets keep the
+ version-last spelling, so `zarr_metadata.model` exports both
+ `ARRAY_METADATA_REQUIRED_KEYS_V2` and `ZARR_V2_ARRAY_METADATA_STORE_KEY`. They
+ name validation policy rather than a spec document, have no paired type to
+ derive from, and renaming them would be a second breaking change buying only
+ cosmetic consistency — so it is deliberately deferred.
+
+ `tests/test_public_api.py::test_constant_names_derive_from_their_type_names`
+ derives every constant name from the type it manifests and asserts they match,
+ so the two grammars cannot diverge again.
+
+ Store keys also moved to the modules that describe the documents they name,
+ matching the package's layering (the `v2`/`v3` modules describe the specs; the
+ `model` layer is built on top of them). `ZARR_V2_ATTRIBUTES_STORE_KEY` now
+ lives in `zarr_metadata.v2.attributes` beside the `.zattrs` type it names,
+ rather than in the array model; the other five moved likewise, and
+ `ZarrV2AttributesStoreKey` is no longer an array-specific concept.
+ `zarr_metadata.model` re-exports all six, so
+ `from zarr_metadata.model import ZARR_V2_ARRAY_METADATA_STORE_KEY` is
+ unaffected.
+
+ `CONSOLIDATED_METADATA_KEY_V3` moved to `zarr_metadata.v3.consolidated` and was
+ renamed to `ZARR_V3_CONSOLIDATED_METADATA_KEY` for consistency. It is not a
+ store key: unlike v2's `.zmetadata` file, v3 consolidated metadata is embedded
+ as an extension field inside the group's own `zarr.json`.
+
+ All seven keys and the six store-key `Literal` aliases are now also exported
+ from the top-level `zarr_metadata` namespace, alongside the document types and
+ the rest of the spec vocabulary, so `from zarr_metadata import
+ ZARR_V2_ARRAY_METADATA_STORE_KEY` works. The model layer's validators, parsers,
+ type guards, and metadata key sets remain `zarr_metadata.model` imports.
+
+ ([#4232](https://github.com/zarr-developers/zarr-python/pull/4232))
+
+### Misc
+
+- The source distribution now ships an explicit allowlist (`/src`, `/tests`,
+ `/docs`, `/mkdocs.yml`, `/justfile`, `/CHANGELOG.md`) rather than whatever
+ happens to sit in the package directory, so an sdist both tests and documents
+ itself and cannot pick up scratch files from the tree it was built in. ([#4248](https://github.com/zarr-developers/zarr-python/pull/4248))
+
+
+## 0.4.0 (2026-07-29)
+
+### Features
+
+- Added `zarr_metadata.model`: frozen-dataclass models (`ZarrV2ArrayMetadata`,
+ `ZarrV3ArrayMetadata`, `ZarrV2GroupMetadata`, `ZarrV3GroupMetadata`,
+ `ZarrV2ConsolidatedMetadata`, `ZarrV3ConsolidatedMetadata`, `ZarrV3NamedConfig`)
+ that are canonical, semantically lossless representations of Zarr metadata
+ documents, plus structural validators (`validate_*` / `is_*` / `parse_*`).
+ Every v3 extension point (data type, chunk grid, chunk key encoding, codecs,
+ storage transformers) is held as `ZarrV3NamedConfig`: a name, configuration,
+ and `must_understand` obligation; nothing is interpreted. On the wire, an
+ empty configuration with the default obligation uses the spec's plain-string
+ shorthand. Model fields are annotated with the role alias
+ `ZarrV3MetadataField` (today exactly `ZarrV3NamedConfig`), so annotations
+ convey the logical meaning and stay put if the spec adds another field form.
+
+ Validation is strict about what the types declare: v2 `dtype` / `order` /
+ `compressor` / `filters` / `dimension_separator` shapes and the fixed
+ `zarr_format` / `node_type` literals are all enforced. Every
+ `ValidationProblem` carries a machine-readable `kind`
+ (`missing_key` / `invalid_type` / `invalid_value` / `invalid_json`) so
+ consumers can dispatch on the failure mode without matching message strings,
+ and every ingestion failure — including missing store keys and undecodable
+ bytes in `from_key_value` — surfaces as `MetadataValidationError`. An
+ adversarial review added further structural checks: JSON booleans are not
+ accepted as dimension lengths, dimensions are non-negative,
+ `dimension_names` must have one entry per dimension of `shape`, `attributes`
+ and `configuration` values are JSON-checked recursively (like `fill_value`),
+ non-finite floats and non-standard JSON constants are rejected, abstract
+ mappings and sequences normalize to encoder-safe canonical containers,
+ v2 `shape` and `chunks` must have the same rank, non-null v2 filter pipelines
+ contain at least one filter, document `TypeIs` guards only narrow values that
+ already use the declared canonical containers,
+ and the inline consolidated-metadata envelope and entries are deep-validated
+ so the group validator's verdict always agrees with the model constructor.
+
+ The v3 models expose `must_understand_fields`: the subset of `extra_fields`
+ not explicitly waived with `must_understand: false` (fields are implicitly
+ must-understand per the spec). Readers discharge the spec's fail-to-open
+ duty by subtracting the extension names they recognize; the model only
+ partitions by obligation, since recognition is reader-specific.
+
+ Optional pydantic integration ships as `zarr_metadata.pydantic` (importing it
+ requires pydantic 2.13 or newer; the core package does not depend on it): one
+ `Annotated`
+ field type per model, validating raw documents through `from_json`, passing
+ core-model instances through unchanged, serializing via `to_json`, and
+ publishing JSON Schemas derived from private constrained document types that
+ mirror the independently expressible runtime rules. Cross-field cardinality
+ relations still require runtime validation. The instances are the core model
+ classes, so values interoperate freely with non-pydantic code.
+
+ `create_default` keeps its output self-consistent: overriding `shape` without
+ a chunk grid derives one regular chunk covering the array (v3
+ `chunk_shape == shape`; v2 `chunks == shape`) instead of silently keeping the
+ scalar default's 0-d grid.
+
+ A v2 `.zarray` that omits `dimension_separator` is interpreted with the v2
+ convention's default `"."` (the model previously normalized absence to `"/"`,
+ which would misaddress the chunks of real-world default-separator arrays).
+ The value is never null: absent, `"."`, or `"/"` are the only spellings.
+
+ Optional document keys use `UNSET` — a PEP 661 sentinel
+ (`typing_extensions.Sentinel`), usable directly in type expressions — never
+ `None`: in a model, `None` always corresponds to a JSON `null` in the
+ document (a v2 `compressor`, an unnamed dimension inside `dimension_names`),
+ and `UNSET` always means the key is absent. Checker note: ty types the
+ sentinel exactly; pyright needs `<= 1.1.404` until microsoft/pyright#11115
+ is fixed (this package's CI pins it); mypy users need a `cast` or
+ `type: ignore` at narrowing sites until python/mypy#21647 merges. This keeps semantically distinct spellings
+ distinct — an absent `dimension_names` ("there are no dimension names") and
+ an explicit `[null, null]` ("every dimension has a name, which is null") are
+ different documents and round-trip as such. The `consolidated_metadata: null`
+ written by a historical zarr-python bug is the one deliberate exception to
+ faithful round-tripping: those stores remain readable, but the bug spelling
+ is repaired to absence on read and never written back.
+
+ The v2 models treat the `.zattrs` file's presence as part of the store:
+ `attributes` is `UNSET` when no `.zattrs` file exists (and `to_key_value`
+ emits none), while an explicit empty `.zattrs` is `{}` and round-trips as a
+ file. Previously `to_key_value` always emitted `.zattrs`, silently adding a
+ file to stores that never had one.
+
+ The store-key `Literal` aliases (`ZarrV2ArrayMetadataStoreKey`,
+ `ZarrV2AttributesStoreKey`, ...) are exported from `zarr_metadata.model`
+ alongside their constants, and each `to_key_value` return type is keyed by
+ them, so the set of store keys a model can emit is visible in its signature.
+ `from_key_value` deliberately keeps `Mapping[str, bytes]` input: it accepts
+ any string-keyed store mapping and ignores unrelated keys.
+
+ `to_json` returns a document that shares no mutable state with the model:
+ every value that can hold a mutable container (attributes, configurations,
+ extra fields, v2 codec configurations, fill values, consolidated entries) is
+ deep-copied on the way out, so editing a serialized document can never
+ silently mutate the frozen model that produced it. ([#4119](https://github.com/zarr-developers/zarr-python/issues/4119))
+
+### Improved Documentation
+
+- `zarr-metadata` now has a standalone documentation site at
+ , with a comprehensive API reference
+ covering every public module, versioned by this package's release tags. The
+ package also gained a `justfile` collecting its development commands
+ (`test`, `lint`, `typecheck`, `docs-check`, `docs-serve`, `changelog-draft`),
+ which the package CI workflow now delegates to. ([#4208](https://github.com/zarr-developers/zarr-python/issues/4208))
+
+### Deprecations and Removals
+
+- The document (TypedDict) types are renamed to put the format version at the
+ front of the name and to mark the JSON-document form with a `JSON` suffix,
+ so a format version can never be misread as a class revision and the bare
+ entity names are reserved for the `zarr_metadata.model` dataclasses:
+
+ - `ArrayMetadataV2` → `ZarrV2ArrayMetadataJSON` (and `...Partial` accordingly)
+ - `ArrayMetadataV3` → `ZarrV3ArrayMetadataJSON` (and `...Partial` accordingly)
+ - `GroupMetadataV2` → `ZarrV2GroupMetadataJSON` (and `...Partial` accordingly)
+ - `GroupMetadataV3` → `ZarrV3GroupMetadataJSON` (and `...Partial` accordingly)
+ - `ConsolidatedMetadataV2` → `ZarrV2ConsolidatedMetadataJSON`
+ - `ConsolidatedMetadataV3` → `ZarrV3ConsolidatedMetadataJSON`
+ - `NamedConfigV3` → `ZarrV3NamedConfigJSON`
+ - `MetadataV3` → `ZarrV3MetadataFieldJSON` (the union of the bare-name and
+ named-configuration spellings of one metadata field)
+ - `ExtensionFieldV3` → `ZarrV3ExtensionField`
+ - `CodecMetadataV2` → `ZarrV2CodecMetadata`
+ - `DataTypeMetadataV2` → `ZarrV2DataTypeMetadata`
+ - `ArrayOrderV2` → `ZarrV2ArrayOrder`
+ - `ArrayDimensionSeparatorV2` → `ZarrV2ArrayDimensionSeparator`
+ - `ZArrayMetadata` → `ZarrV2ZArrayJSON` (the strict on-disk `.zarray` document)
+ - `ZGroupMetadata` → `ZarrV2ZGroupJSON` (the strict on-disk `.zgroup` document)
+ - `ZAttrsMetadata` → `ZarrV2ZAttrsJSON` (the `.zattrs` document)
+
+ The old names are removed, not aliased. The `zarr_metadata.pydantic` field
+ types take the bare entity names (`ZarrV3ArrayMetadata`, ...), matching the
+ model classes they validate into.
+
+ The conventions, stated once for future additions: CamelCase type names put
+ the format version first (`ZarrV2ArrayMetadataJSON`,
+ `ZarrV3ArrayMetadataStoreKey`), while SCREAMING_SNAKE constants and
+ snake_case functions put it last (`ARRAY_METADATA_STORE_KEY_V2`,
+ `validate_array_metadata_v3`). The `JSON` suffix marks a raw-document type
+ whose bare name is taken by (or reserved for) a `zarr_metadata.model`
+ dataclass; raw field-level types the models hold verbatim
+ (`ZarrV2CodecMetadata`, `ZarrV3ExtensionField`) keep their bare names.
+ Extension-entity types put the registered entity name first and end in
+ exactly one role suffix (`BloscCodecMetadata`, `Uint8DataTypeName`) — the
+ `V2` in `V2ChunkKeyEncodingMetadata` is that encoding's entity name, not a
+ format version, which is always spelled `ZarrV2`/`ZarrV3`. Every public
+ type name is checked against this grammar by
+ `tests/test_public_api.py::test_public_type_names_comply_with_naming_grammar`.
+
+ ([#4119](https://github.com/zarr-developers/zarr-python/issues/4119))
+
+
+## 0.3.0 (2026-06-19)
+
+### Deprecations and Removals
+
+- Introduces a new `JSONValue` type that models python objects that serialize directly to JSON. This type is used to annotate the contents of `attributes` and `fill_value` fields, replacing the use of the overly wide `object` type. This is technically a breaking change. ([#4037](https://github.com/zarr-developers/zarr-python/pull/4037))
+- Promoted a curated "front door" of names to the top-level `zarr_metadata`
+ namespace, so consumers can write e.g. `from zarr_metadata import
+ ArrayMetadataV3, ShardingIndexLocation, BLOSC_CNAME` instead of importing from
+ deep submodule paths. The front door covers every metadata-document TypedDict,
+ each codec/chunk-grid/chunk-key-encoding canonical type, the full data-type
+ trio for every dtype, and every constant + `Literal` pair. Deep submodule paths
+ continue to work unchanged.
+
+ Several promoted names were given clearer, less ambiguous spellings than their
+ deep-module names, since they now appear bare at the top level:
+ `Endian`/`ENDIAN` → `Endianness`/`ENDIANNESS`,
+ `IndexLocation`/`INDEX_LOCATION` → `ShardingIndexLocation`/`SHARDING_INDEX_LOCATION`,
+ `RoundingMode`/`ROUNDING_MODE` → `CastRoundingMode`/`CAST_ROUNDING_MODE`,
+ `OutOfRangeMode`/`OUT_OF_RANGE_MODE` → `CastOutOfRangeMode`/`CAST_OUT_OF_RANGE_MODE`,
+ `DateTimeUnit` → `NumpyTimeUnit`,
+ `NamedConfig` → `NamedConfigV3`, and
+ `MetadataFieldV3` → `MetadataV3` (matching the name `zarrs` uses for this
+ `name`-or-`{name, configuration}` shape).
+
+ Also added the `NUMPY_TIME_UNIT` runtime constant (a `Final` tuple paired with
+ the `NumpyTimeUnit` Literal) in `zarr_metadata.v3.data_type.numpy_timedelta64`. ([#4083](https://github.com/zarr-developers/zarr-python/pull/4083))
+
+
+## 0.2.0 (2026-05-19)
+
+### Bugfixes
+
+- `GzipCodecConfiguration.level` is now required, and `GzipCodecMetadata`
+ no longer accepts the bare-string `"gzip"` form. The codec's compressed
+ output depends on `level`, so metadata that omits it cannot reproducibly
+ identify the chunk bytes produced by a writer. **Breaking** for consumers
+ that previously typed gzip codec metadata as the bare string or
+ constructed a `GzipCodecConfiguration` without `level`.
+ ([#3978](https://github.com/zarr-developers/zarr-python/pull/3978))
+- `BytesCodecObject.configuration` is now `NotRequired`. The configuration
+ has no required keys (`endian` is conditionally required at runtime
+ based on data type), so the object form may omit it entirely — matching
+ the bare-string short-hand. **Soft-breaking** for consumers that
+ previously relied on `configuration` always being present.
+ ([#3978](https://github.com/zarr-developers/zarr-python/pull/3978))
+- Better modelling of Zarr v2 stored metadata. Zarr v2 splits a node's
+ metadata across two JSON documents (`.zarray`/`.zgroup` and `.zattrs`),
+ but `GroupMetadataV2` had no `attributes` field while `ArrayMetadataV2`
+ did — an inconsistency. `GroupMetadataV2` now also has an optional
+ `attributes` field, and `ArrayMetadataV2.attributes` is now
+ `NotRequired` for symmetry. **Soft-breaking** for consumers that
+ relied on `ArrayMetadataV2.attributes` always being present.
+ ([#3962](https://github.com/zarr-developers/zarr-python/pull/3962))
+
+### Features
+
+- Added `ArrayMetadataV3Partial`, `GroupMetadataV3Partial`,
+ `ArrayMetadataV2Partial`, and `GroupMetadataV2Partial` — sibling
+ TypedDicts to the existing full metadata types, declared with
+ `total=False` so every field is `NotRequired`. Use these when typing
+ dicts that intentionally hold a subset of a complete metadata document
+ (test fixtures, fragment templates, in-progress builders). An
+ equivalence test pins each `Partial` to the keys and value types of
+ its full sibling so the two cannot drift.
+ ([#3982](https://github.com/zarr-developers/zarr-python/pull/3982))
+- Added three new top-level types modelling the **strict on-disk** shape
+ of Zarr v2 metadata documents: `ZArrayMetadata` (the `.zarray` file),
+ `ZGroupMetadata` (the `.zgroup` file), and `ZAttrsMetadata` (the
+ `.zattrs` file). Use these when you want a type that faithfully matches
+ what's stored on disk; use the merged `ArrayMetadataV2`/`GroupMetadataV2`
+ when you want the in-memory representation a Python program typically
+ works with.
+ ([#3962](https://github.com/zarr-developers/zarr-python/pull/3962))
+- Added typed constants exposing the spec-permitted values of constrained
+ Literal fields, importable at the per-codec module level. For example,
+ `from zarr_metadata.v3.codec.bytes import ENDIAN` provides
+ `("little", "big")` as a tuple, enabling runtime iteration or validator
+ generation without re-stating the Literal values by hand.
+ ([#3978](https://github.com/zarr-developers/zarr-python/pull/3978))
+
+## 0.1.1 (2026-05-06)
+
+### Misc
+
+- First usable release on PyPI. Version 0.1.0 was uploaded then deleted to
+ reserve the project name; this version is the first one PyPI will install.
+ No source changes from 0.1.0.
+ ([#3949](https://github.com/zarr-developers/zarr-python/pull/3949))
+
+## 0.1.0 (2026-05-01)
+
+### Features
+
+- Initial release. Provides `TypedDict` definitions and `Literal` aliases
+ for the JSON shapes specified by Zarr v2 and v3 metadata, plus a subset
+ of `zarr-extensions` types and the un-specified-but-widely-used
+ consolidated metadata documents. Pair with a runtime validator like
+ `pydantic` to check JSON loaded from disk.
+ ([#3919](https://github.com/zarr-developers/zarr-python/pull/3919))
diff --git a/packages/zarr-metadata/LICENSE.txt b/packages/zarr-metadata/LICENSE.txt
new file mode 100644
index 0000000000..1e8da4d242
--- /dev/null
+++ b/packages/zarr-metadata/LICENSE.txt
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015-2025 Zarr Developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/zarr-metadata/README.md b/packages/zarr-metadata/README.md
new file mode 100644
index 0000000000..6b6b172aec
--- /dev/null
+++ b/packages/zarr-metadata/README.md
@@ -0,0 +1,134 @@
+# zarr-metadata
+
+Python types, models, and validators for Zarr v2 and v3 metadata.
+
+Documentation:
+
+## What this is
+
+Two layers and an optional integration:
+
+- **Typed JSON shapes**: `TypedDict` definitions and `Literal` aliases for the
+ JSON documents specified by the [Zarr v2](https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html)
+ and [Zarr v3](https://zarr-specs.readthedocs.io/en/latest/v3/core/index.html)
+ specifications, plus types for [`zarr-extensions`](https://github.com/zarr-developers/zarr-extensions/)
+ and a few widely-used-but-unspecified entities (e.g. consolidated metadata).
+- **Document models** (`zarr_metadata.model`): canonical frozen-dataclass
+ models of whole metadata documents, with structural validators, loc-aware
+ parsers, and store-key (de)serialization. A document produced by `to_json`
+ shares no mutable state with the model that produced it.
+- **Optional Pydantic integration** (`zarr_metadata.pydantic`, requires
+ Pydantic 2.13 or newer): each model as a Pydantic field type that validates
+ raw documents through the same strict parser.
+
+## What this is for
+
+The public `TypedDict` definitions describe the static JSON shape of Zarr
+metadata. For strict, loc-aware validation of JSON loaded from disk, use the
+model parser:
+
+```python
+import json
+from zarr_metadata.model import ZarrV3ArrayMetadata
+
+with open("zarr.json", "rb") as f:
+ raw = json.load(f)
+
+metadata = ZarrV3ArrayMetadata.from_json(raw)
+```
+
+The optional Pydantic integration delegates raw input to the same strict
+parser and returns the same normalized model class:
+
+```python
+from pydantic import TypeAdapter
+import zarr_metadata.pydantic as zmp
+
+metadata = TypeAdapter(zmp.ZarrV3ArrayMetadata).validate_python(raw)
+encoded = metadata.to_key_value()["zarr.json"]
+```
+
+A bare `TypeAdapter` over a public document `TypedDict` is a coercive shape
+adapter, not a Zarr conformance validator; it may coerce values or discard
+members that the strict model parser rejects.
+
+## Validation boundary
+
+The model validators enforce the declared document structure and a small set
+of context-free consistency rules, including fixed format literals, finite
+JSON numbers, non-negative dimensions, non-empty v3 codec pipelines, and one
+`dimension_names` entry per array dimension. They do not interpret extension
+names or configurations, resolve codec pipelines, or decide whether a data
+type, chunk grid, codec, or storage transformer is supported. Those decisions
+belong to consumer implementations.
+
+The Pydantic integration's generated JSON Schemas express independently
+checkable document structure and field constraints, but they are not a
+replacement for runtime model validation. Standard JSON Schema treats a
+mathematically integral number such as `1.0` as an integer, while the runtime
+boundary requires Python `int` values, and it cannot express arbitrary
+same-length relations such as `dimension_names` versus `shape` or v2 `chunks`
+versus `shape`. Consumers should run the model parser after schema validation.
+
+## Scope
+
+At minimum, this library supports what Zarr-Python needs: the complete
+Zarr v2 and v3 specs, consolidated metadata, and a subset of the metadata
+defined in `zarr-extensions`. We are generally open to contributions that
+add types, models, or structural validation for Zarr metadata with a
+published spec.
+
+Runtime array behavior is out of scope: nothing here encodes or decodes
+chunks, resolves codec or data type names to implementations, or performs
+store I/O. The models begin and end at the metadata documents themselves —
+`from_key_value` / `to_key_value` map documents to store keys and bytes,
+and everything past that belongs to consumer libraries.
+
+## Developing
+
+Package-scoped development commands live in the [`justfile`](./justfile)
+(requires [just](https://github.com/casey/just)):
+
+```
+just test # run the test suite (extra args go to pytest)
+just lint # ruff, same invocation as CI
+just typecheck # pyright, pinned to the version CI uses
+just docs-check # strict build of the docs site
+just check # all of the above
+just docs-serve # serve the docs site locally
+```
+
+Run them from this directory, or from anywhere in the repository as
+`just packages/zarr-metadata/`.
+
+## Releasing
+
+The package version is derived from git tags by `hatch-vcs`. Tags must
+match the pattern `zarr_metadata-v` (e.g. `zarr_metadata-v0.2.0`)
+so they do not collide with the main `zarr-python` release tags.
+
+To cut a release:
+
+1. Create and push a tag of the form `zarr_metadata-v` on the
+ commit you want to publish, e.g.:
+ ```
+ git tag zarr_metadata-v0.2.0
+ git push origin zarr_metadata-v0.2.0
+ ```
+2. Pushing the tag fires the `zarr-metadata release` workflow, which
+ builds the wheel/sdist (version resolved from the tag), runs an
+ install smoke test, and publishes to PyPI via OIDC trusted publishing.
+
+We intentionally do *not* create a GitHub Release for `zarr-metadata`
+versions — GitHub Releases live at the repo level, and a zarr-metadata
+release would surface in the zarr-python repo's Releases UI as if it
+were a zarr-python release.
+
+To dry-run a build against TestPyPI, dispatch the workflow manually
+(`Actions` → `zarr-metadata release` → `Run workflow`). Manual dispatches
+build from the current commit; with no recent tag the version will look
+like `0.1.devN`, which is fine for TestPyPI.
+
+## License
+
+[MIT](./LICENSE.txt)
diff --git a/packages/zarr-metadata/changes/README.md b/packages/zarr-metadata/changes/README.md
new file mode 100644
index 0000000000..bf0fc85425
--- /dev/null
+++ b/packages/zarr-metadata/changes/README.md
@@ -0,0 +1,25 @@
+Writing a changelog entry for `zarr-metadata`
+---------------------------------------------
+
+Fragments in **this** directory are released notes for the `zarr-metadata`
+package only — kept separate from the parent zarr-python `changes/`
+directory so a PR touching only `packages/zarr-metadata/` produces a
+release note for this package only.
+
+Please put a new file in this directory named `xxxx..md`, where
+
+- `xxxx` is the pull request number associated with this entry
+- `` is one of:
+ - feature
+ - bugfix
+ - doc
+ - removal
+ - misc
+
+Inside the file, please write a short description of what you have
+changed, and how it impacts users of `zarr-metadata`.
+
+A `zarr-metadata` release runs `towncrier build` in `packages/zarr-metadata/`,
+which consumes the fragments here and updates `CHANGELOG.md`. Fragments
+that describe parent zarr-python changes (not the metadata package)
+belong in the top-level `changes/` directory, not here.
diff --git a/packages/zarr-metadata/docs/_static/favicon-96x96.png b/packages/zarr-metadata/docs/_static/favicon-96x96.png
new file mode 100644
index 0000000000..e77977ccf4
Binary files /dev/null and b/packages/zarr-metadata/docs/_static/favicon-96x96.png differ
diff --git a/packages/zarr-metadata/docs/_static/logo_bw.png b/packages/zarr-metadata/docs/_static/logo_bw.png
new file mode 100644
index 0000000000..df1979d3cc
Binary files /dev/null and b/packages/zarr-metadata/docs/_static/logo_bw.png differ
diff --git a/packages/zarr-metadata/docs/api/index.md b/packages/zarr-metadata/docs/api/index.md
new file mode 100644
index 0000000000..5e230c7aa2
--- /dev/null
+++ b/packages/zarr-metadata/docs/api/index.md
@@ -0,0 +1,34 @@
+---
+title: API reference
+---
+
+# API reference
+
+The package is organized to mirror the structure of the Zarr specifications:
+
+- [`zarr_metadata.model`](model.md) — frozen-dataclass document models,
+ structural validators, loc-aware parsers, and the `UNSET` sentinel
+- [`zarr_metadata.pydantic`](pydantic.md) — optional Pydantic field types
+ over the models
+- [`zarr_metadata.v2`](v2.md) — `TypedDict` shapes for Zarr v2 documents
+ (`.zarray`, `.zgroup`, `.zattrs`, `.zmetadata`)
+- [`zarr_metadata.v3`](v3/index.md) — `TypedDict` shapes for Zarr v3
+ documents, with subpackages for [chunk grids](v3/chunk_grid.md),
+ [chunk key encodings](v3/chunk_key_encoding.md), [codecs](v3/codec.md),
+ and [data types](v3/data_type.md)
+
+The document types, models, and spec vocabulary — including the store keys —
+are re-exported at the top level, so
+`from zarr_metadata import ZarrV3ArrayMetadataJSON` and
+`from zarr_metadata.v3.array import ZarrV3ArrayMetadataJSON` are equivalent.
+The model layer's validators, parsers, type guards, and metadata key sets are
+imported from [`zarr_metadata.model`](model.md) directly.
+
+## Common types
+
+A few cross-cutting aliases are exported only from the top-level
+`zarr_metadata` namespace:
+
+::: zarr_metadata.JSONValue
+
+::: zarr_metadata.ZarrV3NamedConfigJSON
diff --git a/packages/zarr-metadata/docs/api/model.md b/packages/zarr-metadata/docs/api/model.md
new file mode 100644
index 0000000000..c82ba98f2d
--- /dev/null
+++ b/packages/zarr-metadata/docs/api/model.md
@@ -0,0 +1,5 @@
+---
+title: model
+---
+
+::: zarr_metadata.model
diff --git a/packages/zarr-metadata/docs/api/pydantic.md b/packages/zarr-metadata/docs/api/pydantic.md
new file mode 100644
index 0000000000..edecb416a7
--- /dev/null
+++ b/packages/zarr-metadata/docs/api/pydantic.md
@@ -0,0 +1,5 @@
+---
+title: pydantic
+---
+
+::: zarr_metadata.pydantic
diff --git a/packages/zarr-metadata/docs/api/v2.md b/packages/zarr-metadata/docs/api/v2.md
new file mode 100644
index 0000000000..2fe5b6ec56
--- /dev/null
+++ b/packages/zarr-metadata/docs/api/v2.md
@@ -0,0 +1,17 @@
+---
+title: v2
+---
+
+::: zarr_metadata.v2
+ options:
+ members: false
+
+::: zarr_metadata.v2.array
+
+::: zarr_metadata.v2.group
+
+::: zarr_metadata.v2.attributes
+
+::: zarr_metadata.v2.codec
+
+::: zarr_metadata.v2.consolidated
diff --git a/packages/zarr-metadata/docs/api/v3/chunk_grid.md b/packages/zarr-metadata/docs/api/v3/chunk_grid.md
new file mode 100644
index 0000000000..724b1c9d8d
--- /dev/null
+++ b/packages/zarr-metadata/docs/api/v3/chunk_grid.md
@@ -0,0 +1,11 @@
+---
+title: chunk_grid
+---
+
+::: zarr_metadata.v3.chunk_grid
+ options:
+ members: false
+
+::: zarr_metadata.v3.chunk_grid.regular
+
+::: zarr_metadata.v3.chunk_grid.rectilinear
diff --git a/packages/zarr-metadata/docs/api/v3/chunk_key_encoding.md b/packages/zarr-metadata/docs/api/v3/chunk_key_encoding.md
new file mode 100644
index 0000000000..bb063deb25
--- /dev/null
+++ b/packages/zarr-metadata/docs/api/v3/chunk_key_encoding.md
@@ -0,0 +1,11 @@
+---
+title: chunk_key_encoding
+---
+
+::: zarr_metadata.v3.chunk_key_encoding
+ options:
+ members: false
+
+::: zarr_metadata.v3.chunk_key_encoding.default
+
+::: zarr_metadata.v3.chunk_key_encoding.v2
diff --git a/packages/zarr-metadata/docs/api/v3/codec.md b/packages/zarr-metadata/docs/api/v3/codec.md
new file mode 100644
index 0000000000..cb96d2c7d5
--- /dev/null
+++ b/packages/zarr-metadata/docs/api/v3/codec.md
@@ -0,0 +1,25 @@
+---
+title: codec
+---
+
+::: zarr_metadata.v3.codec
+ options:
+ members: false
+
+::: zarr_metadata.v3.codec.blosc
+
+::: zarr_metadata.v3.codec.bytes
+
+::: zarr_metadata.v3.codec.cast_value
+
+::: zarr_metadata.v3.codec.crc32c
+
+::: zarr_metadata.v3.codec.gzip
+
+::: zarr_metadata.v3.codec.scale_offset
+
+::: zarr_metadata.v3.codec.sharding_indexed
+
+::: zarr_metadata.v3.codec.transpose
+
+::: zarr_metadata.v3.codec.zstd
diff --git a/packages/zarr-metadata/docs/api/v3/data_type.md b/packages/zarr-metadata/docs/api/v3/data_type.md
new file mode 100644
index 0000000000..f482c33201
--- /dev/null
+++ b/packages/zarr-metadata/docs/api/v3/data_type.md
@@ -0,0 +1,45 @@
+---
+title: data_type
+---
+
+::: zarr_metadata.v3.data_type
+ options:
+ members: false
+
+::: zarr_metadata.v3.data_type.bool
+
+::: zarr_metadata.v3.data_type.int8
+
+::: zarr_metadata.v3.data_type.int16
+
+::: zarr_metadata.v3.data_type.int32
+
+::: zarr_metadata.v3.data_type.int64
+
+::: zarr_metadata.v3.data_type.uint8
+
+::: zarr_metadata.v3.data_type.uint16
+
+::: zarr_metadata.v3.data_type.uint32
+
+::: zarr_metadata.v3.data_type.uint64
+
+::: zarr_metadata.v3.data_type.float16
+
+::: zarr_metadata.v3.data_type.float32
+
+::: zarr_metadata.v3.data_type.float64
+
+::: zarr_metadata.v3.data_type.complex64
+
+::: zarr_metadata.v3.data_type.complex128
+
+::: zarr_metadata.v3.data_type.raw
+
+::: zarr_metadata.v3.data_type.bytes
+
+::: zarr_metadata.v3.data_type.string
+
+::: zarr_metadata.v3.data_type.numpy_datetime64
+
+::: zarr_metadata.v3.data_type.numpy_timedelta64
diff --git a/packages/zarr-metadata/docs/api/v3/index.md b/packages/zarr-metadata/docs/api/v3/index.md
new file mode 100644
index 0000000000..f20267d372
--- /dev/null
+++ b/packages/zarr-metadata/docs/api/v3/index.md
@@ -0,0 +1,15 @@
+---
+title: v3
+---
+
+::: zarr_metadata.v3
+ options:
+ members: false
+
+::: zarr_metadata.v3.ZarrV3MetadataFieldJSON
+
+::: zarr_metadata.v3.array
+
+::: zarr_metadata.v3.group
+
+::: zarr_metadata.v3.consolidated
diff --git a/packages/zarr-metadata/docs/index.md b/packages/zarr-metadata/docs/index.md
new file mode 100644
index 0000000000..58e4f290c6
--- /dev/null
+++ b/packages/zarr-metadata/docs/index.md
@@ -0,0 +1,98 @@
+# zarr-metadata
+
+Basic tools for modelling Zarr metadata, with minimal dependencies.
+
+`zarr-metadata` is developed in the
+[zarr-python repository](https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-metadata)
+and released independently of `zarr` itself. Install it with:
+
+```
+pip install zarr-metadata
+```
+
+## Who needs this
+
+This library might be useful to you if your software interacts with Zarr metadata documents.
+
+## What this is
+
+This library is *not* a full Zarr implementation. Instead, it's a collection of data structures and routines that
+closely model the content of the Zarr specifications, such as:
+
+- **Typed JSON shapes** ([`zarr_metadata.v2`](api/v2.md) and
+ [`zarr_metadata.v3`](api/v3/index.md)): `TypedDict` definitions and
+ `Literal` aliases for the JSON documents specified by the
+ [Zarr v2](https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html) and
+ [Zarr v3](https://zarr-specs.readthedocs.io/en/latest/v3/core/index.html)
+ specifications, plus types for
+ [zarr-extensions](https://github.com/zarr-developers/zarr-extensions/) and a
+ few widely-used-but-unspecified entities (e.g. consolidated metadata).
+- **Document models** ([`zarr_metadata.model`](api/model.md)): canonical
+ frozen-dataclass models of whole metadata documents, with structural
+ validators, loc-aware parsers, and store-key (de)serialization. A document
+ produced by `to_json` shares no mutable state with the model that produced
+ it.
+- **Optional Pydantic integration** ([`zarr_metadata.pydantic`](api/pydantic.md),
+ requires Pydantic 2.13 or newer): each model as a Pydantic field type that
+ validates raw documents through the same strict parser.
+
+## What this is for
+
+The public `TypedDict` definitions describe the static JSON shape of Zarr
+metadata. For strict, loc-aware validation of JSON loaded from disk, use the
+model parser:
+
+```python
+import json
+from zarr_metadata.model import ZarrV3ArrayMetadata
+
+with open("zarr.json", "rb") as f:
+ raw = json.load(f)
+
+metadata = ZarrV3ArrayMetadata.from_json(raw)
+```
+
+The optional Pydantic integration delegates raw input to the same strict
+parser and returns the same normalized model class:
+
+```python
+from pydantic import TypeAdapter
+import zarr_metadata.pydantic as zmp
+
+metadata = TypeAdapter(zmp.ZarrV3ArrayMetadata).validate_python(raw)
+encoded = metadata.to_key_value()["zarr.json"]
+```
+
+A bare `TypeAdapter` over a public document `TypedDict` is a coercive shape
+adapter, not a Zarr conformance validator; it may coerce values or discard
+members that the strict model parser rejects.
+
+## Validation boundary
+
+The model validators enforce the declared document structure and a small set
+of context-free consistency rules, including fixed format literals, finite
+JSON numbers, non-negative dimensions, non-empty v3 codec pipelines, and one
+`dimension_names` entry per array dimension. They do not interpret extension
+names or configurations, resolve codec pipelines, or decide whether a data
+type, chunk grid, codec, or storage transformer is supported. Those decisions
+belong to consumer implementations.
+
+## Scope
+
+At minimum, this library supports what Zarr-Python needs: the complete
+Zarr v2 and v3 specs, consolidated metadata, and a subset of the metadata
+defined in `zarr-extensions`. We are generally open to contributions that
+add types, models, or structural validation for Zarr metadata with a
+published spec.
+
+Runtime array behavior is out of scope: nothing here encodes or decodes
+chunks, resolves codec or data type names to implementations, or performs
+store I/O. The models begin and end at the metadata documents themselves —
+`from_key_value` / `to_key_value` map documents to store keys and bytes,
+and everything past that belongs to consumer libraries.
+
+## Reference
+
+- [API reference](api/index.md)
+- [Changelog](https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-metadata/CHANGELOG.md)
+- [License (MIT)](https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-metadata/LICENSE.txt)
diff --git a/packages/zarr-metadata/justfile b/packages/zarr-metadata/justfile
new file mode 100644
index 0000000000..0f1861ed7d
--- /dev/null
+++ b/packages/zarr-metadata/justfile
@@ -0,0 +1,58 @@
+# Development verbs for the zarr-metadata package. Recipes run with this
+# directory as the working directory regardless of where `just` is invoked.
+
+# List available recipes
+default:
+ @just --list
+
+# Run the test suite; extra args are passed to pytest
+test *args:
+ uv run --group test pytest tests {{ args }}
+
+# Lint with the same invocation CI uses
+lint:
+ uvx ruff check .
+
+# Pinned to the last pyright that types PEP 661 sentinels in class attributes
+# correctly; 1.1.405+ regressed (microsoft/pyright#11115). Unpin when fixed.
+pyright_version := "1.1.404"
+
+# CI runs pyright on python 3.11; the pinned pyright predates 3.14, whose
+# stdlib it cannot parse, so pin the interpreter to match CI.
+# Type-check the package sources
+typecheck:
+ uv run --python 3.11 --group test --with 'pyright=={{ pyright_version }}' pyright src
+
+# Run everything CI runs for this package
+check: lint typecheck test docs-check
+
+# Preview the changelog that the next release would generate
+changelog-draft:
+ uvx towncrier build --draft --version Unreleased
+
+# Build this package's documentation site, warnings as errors
+docs-check:
+ env DISABLE_MKDOCS_2_WARNING=true uv run --group docs mkdocs build --strict
+
+# With no argument, uses port 8000 if free, otherwise an ephemeral free port;
+# an explicitly requested port is used as-is so a conflict fails loudly.
+# Serve this package's documentation site
+docs-serve port="":
+ #!/usr/bin/env bash
+ set -euo pipefail
+ port="{{ port }}"
+ if [ -z "$port" ]; then
+ port=$(uv run --group docs python -c '
+ import socket
+ s = socket.socket()
+ try:
+ s.bind(("127.0.0.1", 8000))
+ except OSError:
+ s.close()
+ s = socket.socket()
+ s.bind(("127.0.0.1", 0))
+ print(s.getsockname()[1])
+ s.close()
+ ')
+ fi
+ exec env DISABLE_MKDOCS_2_WARNING=true uv run --group docs mkdocs serve -a "localhost:$port"
diff --git a/packages/zarr-metadata/mkdocs.yml b/packages/zarr-metadata/mkdocs.yml
new file mode 100644
index 0000000000..18e1fc8c35
--- /dev/null
+++ b/packages/zarr-metadata/mkdocs.yml
@@ -0,0 +1,111 @@
+site_name: zarr-metadata
+# The package lives in the zarr-python monorepo; point the header source
+# widget at the package directory rather than the repository root.
+repo_name: zarr-python/packages/zarr-metadata
+repo_url: https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-metadata
+# Absolute because mkdocs would otherwise append this to repo_url's subpath.
+edit_uri: https://github.com/zarr-developers/zarr-python/edit/main/packages/zarr-metadata/docs/
+site_description: Spec-defined metadata types, models, and validators for Zarr v2 and v3.
+site_author: Davis Bennett
+site_url: !ENV [READTHEDOCS_CANONICAL_URL, 'https://zarr-metadata.readthedocs.io/']
+docs_dir: docs
+use_directory_urls: true
+
+nav:
+ - index.md
+ - API Reference:
+ - api/index.md
+ - 'zarr_metadata.model': api/model.md
+ - 'zarr_metadata.pydantic': api/pydantic.md
+ - 'zarr_metadata.v2': api/v2.md
+ - 'zarr_metadata.v3':
+ - api/v3/index.md
+ - 'zarr_metadata.v3.chunk_grid': api/v3/chunk_grid.md
+ - 'zarr_metadata.v3.chunk_key_encoding': api/v3/chunk_key_encoding.md
+ - 'zarr_metadata.v3.codec': api/v3/codec.md
+ - 'zarr_metadata.v3.data_type': api/v3/data_type.md
+ - Changelog: https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-metadata/CHANGELOG.md
+ # This site is a Read the Docs subproject of zarr-python; give readers a way
+ # back to the parent docs, which list every companion package.
+ - 'zarr-python ↪': https://zarr.readthedocs.io/
+
+watch:
+ - src
+
+theme:
+ language: en
+ name: material
+ logo: _static/logo_bw.png
+ favicon: _static/favicon-96x96.png
+
+ palette:
+ # Light mode
+ - media: "(prefers-color-scheme: light)"
+ scheme: default
+ toggle:
+ icon: material/brightness-7
+ name: Switch to dark mode
+
+ # Dark mode
+ - media: "(prefers-color-scheme: dark)"
+ scheme: slate
+ toggle:
+ icon: material/brightness-4
+ name: Switch to light mode
+
+ font:
+ text: Roboto
+ code: Roboto Mono
+
+ features:
+ - content.code.annotate
+ - content.code.copy
+ - navigation.indexes
+ - navigation.instant
+ - navigation.tracking
+ - search.suggest
+ - search.share
+
+plugins:
+ - autorefs
+ - search
+ - mkdocstrings:
+ enable_inventory: true
+ handlers:
+ python:
+ paths: [src]
+ options:
+ allow_inspection: true
+ docstring_section_style: list
+ docstring_style: numpy
+ inherited_members: true
+ line_length: 60
+ separate_signature: true
+ show_root_heading: true
+ show_signature_annotations: true
+ show_source: true
+ show_symbol_type_toc: true
+ signature_crossrefs: true
+ show_if_no_docstring: true
+ extensions:
+ - griffe_inherited_docstrings
+
+ inventories:
+ - https://docs.python.org/3/objects.inv
+ - https://zarr.readthedocs.io/en/stable/objects.inv
+
+markdown_extensions:
+ - admonition
+ - attr_list
+ - def_list
+ - footnotes
+ - md_in_html
+ - pymdownx.details
+ - pymdownx.superfences
+ - toc:
+ permalink: true
+ - pymdownx.highlight:
+ anchor_linenums: true
+ line_spans: __span
+ pygments_lang_class: true
+ - pymdownx.inlinehilite
diff --git a/packages/zarr-metadata/pyproject.toml b/packages/zarr-metadata/pyproject.toml
new file mode 100644
index 0000000000..a58d3579a1
--- /dev/null
+++ b/packages/zarr-metadata/pyproject.toml
@@ -0,0 +1,169 @@
+[build-system]
+requires = ["hatchling>=1.29.0", "hatch-vcs"]
+build-backend = "hatchling.build"
+
+[project]
+name = "zarr-metadata"
+dynamic = ["version"]
+description = "Spec-defined metadata types, models, and validators for Zarr v2 and v3."
+readme = "README.md"
+requires-python = ">=3.11"
+license = "MIT"
+license-files = ["LICENSE.txt"]
+authors = [
+ { name = "Davis Bennett", email = "davis.v.bennett@gmail.com" },
+]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: Developers",
+ "Intended Audience :: Information Technology",
+ "Intended Audience :: Science/Research",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Topic :: Scientific/Engineering",
+ "Topic :: Software Development :: Libraries :: Python Modules",
+ "Typing :: Typed",
+]
+keywords = ["zarr"]
+dependencies = [
+ # >=4.16: first release where `Sentinel` pickles by reference
+ # (`__reduce__` returns the sentinel's name), so `UNSET` — and any model
+ # holding it — can cross process boundaries and be deep-copied with its
+ # singleton identity intact. 4.15 and earlier refuse to pickle sentinels.
+ "typing_extensions>=4.16",
+]
+
+[project.urls]
+Homepage = "https://github.com/zarr-developers/zarr-python"
+Source = "https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-metadata"
+Issues = "https://github.com/zarr-developers/zarr-python/issues"
+Changelog = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-metadata/CHANGELOG.md"
+Documentation = "https://zarr-metadata.readthedocs.io/"
+
+[dependency-groups]
+test = ["pytest", "pydantic>=2.13", "jsonschema"]
+docs = [
+ # Pins match the zarr-python docs environment in the repo-root
+ # pyproject.toml so the two sites render with the same toolchain.
+ "mkdocs-material==9.7.6",
+ "mkdocs==1.6.1",
+ "mkdocstrings==1.0.4",
+ "mkdocstrings-python==2.0.5",
+ "griffe-inherited-docstrings==1.1.3",
+ # mkdocstrings uses ruff to format rendered signatures
+ "ruff==0.15.20",
+]
+
+[tool.hatch.version]
+source = "vcs"
+tag-pattern = '^zarr_metadata-v(?P.+)$'
+# `git_describe_command` ensures we get the zarr_metadata tags instead of latest.
+# `local_scheme` strips the git commit info so the appending info is just a counter from latest tag.
+# test-pypi doesn't accept git commit info in tags, and the count should be enough to distinguish unique runs.
+raw-options = { root = "../..", git_describe_command = "git describe --dirty --tags --long --match zarr_metadata-v*", local_scheme = "no-local-version" }
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/zarr_metadata"]
+
+# An allowlist, so nothing that merely happens to sit in the package directory
+# — a scratch script, a stray notebook — can ride along in a release. The list
+# keeps an sdist self-testing and self-documenting: every fixture this suite
+# reads is a JSON file sitting next to the test module that loads it, so
+# `/tests` is the whole test dependency, and `/docs` plus `/mkdocs.yml` are a
+# self-contained site (mkdocstrings reads `src`, nothing reaches outside the
+# package) so `just docs-check` runs from an unpacked sdist too. `changes/`
+# and `.readthedocs.yaml` are deliberately absent: towncrier fragments are
+# repo bookkeeping, and the RTD config addresses paths from the repo root.
+# `pyproject.toml`, `README.md` and `LICENSE.txt` are added by hatchling itself.
+[tool.hatch.build.targets.sdist]
+include = [
+ "/src",
+ "/tests",
+ "/docs",
+ "/mkdocs.yml",
+ "/justfile",
+ "/CHANGELOG.md",
+]
+
+[tool.ruff]
+extend = "../../pyproject.toml"
+target-version = "py311"
+
+[tool.pytest.ini_options]
+minversion = "7"
+testpaths = ["tests"]
+xfail_strict = true
+addopts = ["-ra", "--strict-config", "--strict-markers"]
+filterwarnings = [
+ "error",
+ # Pydantic validates these public immutable-shape TypedDicts correctly but
+ # cannot enforce the type checker's ReadOnly mutation restriction.
+ "ignore:Items? .* using the `ReadOnly` qualifier.*:UserWarning:pydantic._internal._generate_schema",
+]
+
+[tool.numpydoc_validation]
+checks = [
+ "GL10",
+ "SS04",
+ "PR02",
+ "PR03",
+ "PR05",
+ "PR06",
+]
+
+# CI pins pyright==1.1.404: later versions regress PEP 661 sentinel typing in
+# class attributes (microsoft/pyright#11115), which zarr_metadata.model._sentinel
+# relies on. Use the same pin locally; unpin when the fix lands.
+[tool.pyright]
+include = ["src"]
+enableExperimentalFeatures = true
+typeCheckingMode = "strict"
+pythonVersion = "3.11"
+
+[tool.towncrier]
+# Fragments for this package live alongside the package source, separate
+# from the parent zarr-python `changes/` directory, so a PR touching only
+# `packages/zarr-metadata/` produces a release note for this package only.
+directory = "changes"
+filename = "CHANGELOG.md"
+package = "zarr_metadata"
+underlines = ["", "", ""]
+title_format = "## {version} ({project_date})"
+issue_format = "[#{issue}](https://github.com/zarr-developers/zarr-python/pull/{issue})"
+start_string = "\n"
+
+# Declaring any type replaces towncrier's built-in set, so all five the
+# `changes/README.md` menu offers are restated here. They are the defaults
+# verbatim except for `misc`, whose `showcontent` towncrier defaults to false:
+# a `misc` entry would render as a bare PR link, which tells a reader nothing.
+# A change worth a release note is worth a sentence, whatever its category.
+[[tool.towncrier.type]]
+directory = "feature"
+name = "Features"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "bugfix"
+name = "Bugfixes"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "doc"
+name = "Improved Documentation"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "removal"
+name = "Deprecations and Removals"
+showcontent = true
+
+[[tool.towncrier.type]]
+directory = "misc"
+name = "Misc"
+showcontent = true
diff --git a/packages/zarr-metadata/src/zarr_metadata/__init__.py b/packages/zarr-metadata/src/zarr_metadata/__init__.py
new file mode 100644
index 0000000000..1a6b39f04d
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/__init__.py
@@ -0,0 +1,402 @@
+from importlib.metadata import version
+
+from zarr_metadata._common import JSONValue, ZarrV3NamedConfigJSON
+from zarr_metadata.model import (
+ UNSET,
+ ZARR_V2_ARRAY_METADATA_STORE_KEY,
+ ZARR_V2_ATTRIBUTES_STORE_KEY,
+ ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY,
+ ZARR_V2_GROUP_METADATA_STORE_KEY,
+ ZARR_V3_ARRAY_METADATA_STORE_KEY,
+ ZARR_V3_CONSOLIDATED_METADATA_KEY,
+ ZARR_V3_GROUP_METADATA_STORE_KEY,
+ MetadataValidationError,
+ ProblemKind,
+ ValidationProblem,
+ ZarrV2ArrayMetadata,
+ ZarrV2ArrayMetadataPartial,
+ ZarrV2ArrayMetadataStoreKey,
+ ZarrV2AttributesStoreKey,
+ ZarrV2ConsolidatedMetadata,
+ ZarrV2ConsolidatedMetadataStoreKey,
+ ZarrV2GroupMetadata,
+ ZarrV2GroupMetadataPartial,
+ ZarrV2GroupMetadataStoreKey,
+ ZarrV3ArrayMetadata,
+ ZarrV3ArrayMetadataPartial,
+ ZarrV3ArrayMetadataStoreKey,
+ ZarrV3ConsolidatedMetadata,
+ ZarrV3GroupMetadata,
+ ZarrV3GroupMetadataPartial,
+ ZarrV3GroupMetadataStoreKey,
+ ZarrV3MetadataField,
+ ZarrV3NamedConfig,
+)
+from zarr_metadata.v2.array import (
+ ZARR_V2_ARRAY_DIMENSION_SEPARATOR,
+ ZARR_V2_ARRAY_ORDER,
+ ZarrV2ArrayDimensionSeparator,
+ ZarrV2ArrayMetadataJSON,
+ ZarrV2ArrayMetadataJSONPartial,
+ ZarrV2ArrayOrder,
+ ZarrV2DataTypeMetadata,
+ ZarrV2ZArrayJSON,
+)
+from zarr_metadata.v2.attributes import ZarrV2ZAttrsJSON
+from zarr_metadata.v2.codec import ZarrV2CodecMetadata
+from zarr_metadata.v2.consolidated import ZarrV2ConsolidatedMetadataJSON
+from zarr_metadata.v2.group import (
+ ZarrV2GroupMetadataJSON,
+ ZarrV2GroupMetadataJSONPartial,
+ ZarrV2ZGroupJSON,
+)
+from zarr_metadata.v3._common import ZarrV3MetadataFieldJSON
+from zarr_metadata.v3.array import (
+ ZarrV3ArrayMetadataJSON,
+ ZarrV3ArrayMetadataJSONPartial,
+ ZarrV3ExtensionField,
+)
+from zarr_metadata.v3.chunk_grid.rectilinear import (
+ RECTILINEAR_CHUNK_GRID_NAME,
+ RectilinearChunkGridMetadata,
+ RectilinearChunkGridName,
+)
+from zarr_metadata.v3.chunk_grid.regular import (
+ REGULAR_CHUNK_GRID_NAME,
+ RegularChunkGridMetadata,
+ RegularChunkGridName,
+)
+from zarr_metadata.v3.chunk_key_encoding.default import (
+ DEFAULT_CHUNK_KEY_ENCODING_NAME,
+ DEFAULT_CHUNK_KEY_ENCODING_SEPARATOR,
+ DefaultChunkKeyEncodingMetadata,
+ DefaultChunkKeyEncodingName,
+ DefaultChunkKeyEncodingSeparator,
+)
+from zarr_metadata.v3.chunk_key_encoding.v2 import (
+ V2_CHUNK_KEY_ENCODING_NAME,
+ V2_CHUNK_KEY_ENCODING_SEPARATOR,
+ V2ChunkKeyEncodingMetadata,
+ V2ChunkKeyEncodingName,
+ V2ChunkKeyEncodingSeparator,
+)
+from zarr_metadata.v3.codec.blosc import (
+ BLOSC_CNAME,
+ BLOSC_CODEC_NAME,
+ BLOSC_SHUFFLE,
+ BloscCName,
+ BloscCodecMetadata,
+ BloscCodecName,
+ BloscShuffle,
+)
+from zarr_metadata.v3.codec.bytes import (
+ BYTES_CODEC_NAME,
+ ENDIANNESS,
+ BytesCodecMetadata,
+ BytesCodecName,
+ Endianness,
+)
+from zarr_metadata.v3.codec.cast_value import (
+ CAST_OUT_OF_RANGE_MODE,
+ CAST_ROUNDING_MODE,
+ CAST_VALUE_CODEC_NAME,
+ CastOutOfRangeMode,
+ CastRoundingMode,
+ CastValueCodecMetadata,
+ CastValueCodecName,
+)
+from zarr_metadata.v3.codec.crc32c import CRC32C_CODEC_NAME, Crc32cCodecMetadata, Crc32cCodecName
+from zarr_metadata.v3.codec.gzip import GZIP_CODEC_NAME, GzipCodecMetadata, GzipCodecName
+from zarr_metadata.v3.codec.scale_offset import (
+ SCALE_OFFSET_CODEC_NAME,
+ ScaleOffsetCodecMetadata,
+ ScaleOffsetCodecName,
+)
+from zarr_metadata.v3.codec.sharding_indexed import (
+ SHARDING_INDEX_LOCATION,
+ SHARDING_INDEXED_CODEC_NAME,
+ ShardingIndexedCodecMetadata,
+ ShardingIndexedCodecName,
+ ShardingIndexLocation,
+)
+from zarr_metadata.v3.codec.transpose import (
+ TRANSPOSE_CODEC_NAME,
+ TransposeCodecMetadata,
+ TransposeCodecName,
+)
+from zarr_metadata.v3.codec.zstd import ZSTD_CODEC_NAME, ZstdCodecMetadata, ZstdCodecName
+from zarr_metadata.v3.consolidated import ZarrV3ConsolidatedMetadataJSON
+from zarr_metadata.v3.data_type.bool import (
+ BOOL_DATA_TYPE_NAME,
+ BoolDataTypeName,
+ BoolFillValue,
+)
+from zarr_metadata.v3.data_type.bytes import (
+ BYTES_DATA_TYPE_NAME,
+ BytesDataTypeName,
+ BytesFillValue,
+)
+from zarr_metadata.v3.data_type.complex64 import (
+ COMPLEX64_DATA_TYPE_NAME,
+ Complex64DataTypeName,
+ Complex64FillValue,
+)
+from zarr_metadata.v3.data_type.complex128 import (
+ COMPLEX128_DATA_TYPE_NAME,
+ Complex128DataTypeName,
+ Complex128FillValue,
+)
+from zarr_metadata.v3.data_type.float16 import (
+ FLOAT16_DATA_TYPE_NAME,
+ Float16DataTypeName,
+ Float16FillValue,
+)
+from zarr_metadata.v3.data_type.float32 import (
+ FLOAT32_DATA_TYPE_NAME,
+ Float32DataTypeName,
+ Float32FillValue,
+)
+from zarr_metadata.v3.data_type.float64 import (
+ FLOAT64_DATA_TYPE_NAME,
+ Float64DataTypeName,
+ Float64FillValue,
+)
+from zarr_metadata.v3.data_type.int8 import (
+ INT8_DATA_TYPE_NAME,
+ Int8DataTypeName,
+ Int8FillValue,
+)
+from zarr_metadata.v3.data_type.int16 import (
+ INT16_DATA_TYPE_NAME,
+ Int16DataTypeName,
+ Int16FillValue,
+)
+from zarr_metadata.v3.data_type.int32 import (
+ INT32_DATA_TYPE_NAME,
+ Int32DataTypeName,
+ Int32FillValue,
+)
+from zarr_metadata.v3.data_type.int64 import (
+ INT64_DATA_TYPE_NAME,
+ Int64DataTypeName,
+ Int64FillValue,
+)
+from zarr_metadata.v3.data_type.numpy_datetime64 import (
+ NUMPY_DATETIME64_DATA_TYPE_NAME,
+ NumpyDatetime64DataTypeName,
+ NumpyDatetime64FillValue,
+)
+from zarr_metadata.v3.data_type.numpy_timedelta64 import (
+ NUMPY_TIME_UNIT,
+ NUMPY_TIMEDELTA64_DATA_TYPE_NAME,
+ NumpyTimedelta64DataTypeName,
+ NumpyTimedelta64FillValue,
+ NumpyTimeUnit,
+)
+from zarr_metadata.v3.data_type.raw import RawBytesDataTypeName, RawBytesFillValue
+from zarr_metadata.v3.data_type.string import (
+ STRING_DATA_TYPE_NAME,
+ StringDataTypeName,
+ StringFillValue,
+)
+from zarr_metadata.v3.data_type.struct import (
+ STRUCT_DATA_TYPE_NAME,
+ StructDataTypeName,
+ StructFillValue,
+)
+from zarr_metadata.v3.data_type.uint8 import (
+ UINT8_DATA_TYPE_NAME,
+ Uint8DataTypeName,
+ Uint8FillValue,
+)
+from zarr_metadata.v3.data_type.uint16 import (
+ UINT16_DATA_TYPE_NAME,
+ Uint16DataTypeName,
+ Uint16FillValue,
+)
+from zarr_metadata.v3.data_type.uint32 import (
+ UINT32_DATA_TYPE_NAME,
+ Uint32DataTypeName,
+ Uint32FillValue,
+)
+from zarr_metadata.v3.data_type.uint64 import (
+ UINT64_DATA_TYPE_NAME,
+ Uint64DataTypeName,
+ Uint64FillValue,
+)
+from zarr_metadata.v3.group import ZarrV3GroupMetadataJSON, ZarrV3GroupMetadataJSONPartial
+
+__version__ = version("zarr-metadata")
+
+
+__all__ = [
+ "BLOSC_CNAME",
+ "BLOSC_CODEC_NAME",
+ "BLOSC_SHUFFLE",
+ "BOOL_DATA_TYPE_NAME",
+ "BYTES_CODEC_NAME",
+ "BYTES_DATA_TYPE_NAME",
+ "CAST_OUT_OF_RANGE_MODE",
+ "CAST_ROUNDING_MODE",
+ "CAST_VALUE_CODEC_NAME",
+ "COMPLEX64_DATA_TYPE_NAME",
+ "COMPLEX128_DATA_TYPE_NAME",
+ "CRC32C_CODEC_NAME",
+ "DEFAULT_CHUNK_KEY_ENCODING_NAME",
+ "DEFAULT_CHUNK_KEY_ENCODING_SEPARATOR",
+ "ENDIANNESS",
+ "FLOAT16_DATA_TYPE_NAME",
+ "FLOAT32_DATA_TYPE_NAME",
+ "FLOAT64_DATA_TYPE_NAME",
+ "GZIP_CODEC_NAME",
+ "INT8_DATA_TYPE_NAME",
+ "INT16_DATA_TYPE_NAME",
+ "INT32_DATA_TYPE_NAME",
+ "INT64_DATA_TYPE_NAME",
+ "NUMPY_DATETIME64_DATA_TYPE_NAME",
+ "NUMPY_TIMEDELTA64_DATA_TYPE_NAME",
+ "NUMPY_TIME_UNIT",
+ "RECTILINEAR_CHUNK_GRID_NAME",
+ "REGULAR_CHUNK_GRID_NAME",
+ "SCALE_OFFSET_CODEC_NAME",
+ "SHARDING_INDEXED_CODEC_NAME",
+ "SHARDING_INDEX_LOCATION",
+ "STRING_DATA_TYPE_NAME",
+ "STRUCT_DATA_TYPE_NAME",
+ "TRANSPOSE_CODEC_NAME",
+ "UINT8_DATA_TYPE_NAME",
+ "UINT16_DATA_TYPE_NAME",
+ "UINT32_DATA_TYPE_NAME",
+ "UINT64_DATA_TYPE_NAME",
+ "UNSET",
+ "V2_CHUNK_KEY_ENCODING_NAME",
+ "V2_CHUNK_KEY_ENCODING_SEPARATOR",
+ "ZARR_V2_ARRAY_DIMENSION_SEPARATOR",
+ "ZARR_V2_ARRAY_METADATA_STORE_KEY",
+ "ZARR_V2_ARRAY_ORDER",
+ "ZARR_V2_ATTRIBUTES_STORE_KEY",
+ "ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY",
+ "ZARR_V2_GROUP_METADATA_STORE_KEY",
+ "ZARR_V3_ARRAY_METADATA_STORE_KEY",
+ "ZARR_V3_CONSOLIDATED_METADATA_KEY",
+ "ZARR_V3_GROUP_METADATA_STORE_KEY",
+ "ZSTD_CODEC_NAME",
+ "BloscCName",
+ "BloscCodecMetadata",
+ "BloscCodecName",
+ "BloscShuffle",
+ "BoolDataTypeName",
+ "BoolFillValue",
+ "BytesCodecMetadata",
+ "BytesCodecName",
+ "BytesDataTypeName",
+ "BytesFillValue",
+ "CastOutOfRangeMode",
+ "CastRoundingMode",
+ "CastValueCodecMetadata",
+ "CastValueCodecName",
+ "Complex64DataTypeName",
+ "Complex64FillValue",
+ "Complex128DataTypeName",
+ "Complex128FillValue",
+ "Crc32cCodecMetadata",
+ "Crc32cCodecName",
+ "DefaultChunkKeyEncodingMetadata",
+ "DefaultChunkKeyEncodingName",
+ "DefaultChunkKeyEncodingSeparator",
+ "Endianness",
+ "Float16DataTypeName",
+ "Float16FillValue",
+ "Float32DataTypeName",
+ "Float32FillValue",
+ "Float64DataTypeName",
+ "Float64FillValue",
+ "GzipCodecMetadata",
+ "GzipCodecName",
+ "Int8DataTypeName",
+ "Int8FillValue",
+ "Int16DataTypeName",
+ "Int16FillValue",
+ "Int32DataTypeName",
+ "Int32FillValue",
+ "Int64DataTypeName",
+ "Int64FillValue",
+ "JSONValue",
+ "MetadataValidationError",
+ "NumpyDatetime64DataTypeName",
+ "NumpyDatetime64FillValue",
+ "NumpyTimeUnit",
+ "NumpyTimedelta64DataTypeName",
+ "NumpyTimedelta64FillValue",
+ "ProblemKind",
+ "RawBytesDataTypeName",
+ "RawBytesFillValue",
+ "RectilinearChunkGridMetadata",
+ "RectilinearChunkGridName",
+ "RegularChunkGridMetadata",
+ "RegularChunkGridName",
+ "ScaleOffsetCodecMetadata",
+ "ScaleOffsetCodecName",
+ "ShardingIndexLocation",
+ "ShardingIndexedCodecMetadata",
+ "ShardingIndexedCodecName",
+ "StringDataTypeName",
+ "StringFillValue",
+ "StructDataTypeName",
+ "StructFillValue",
+ "TransposeCodecMetadata",
+ "TransposeCodecName",
+ "Uint8DataTypeName",
+ "Uint8FillValue",
+ "Uint16DataTypeName",
+ "Uint16FillValue",
+ "Uint32DataTypeName",
+ "Uint32FillValue",
+ "Uint64DataTypeName",
+ "Uint64FillValue",
+ "V2ChunkKeyEncodingMetadata",
+ "V2ChunkKeyEncodingName",
+ "V2ChunkKeyEncodingSeparator",
+ "ValidationProblem",
+ "ZarrV2ArrayDimensionSeparator",
+ "ZarrV2ArrayMetadata",
+ "ZarrV2ArrayMetadataJSON",
+ "ZarrV2ArrayMetadataJSONPartial",
+ "ZarrV2ArrayMetadataPartial",
+ "ZarrV2ArrayMetadataStoreKey",
+ "ZarrV2ArrayOrder",
+ "ZarrV2AttributesStoreKey",
+ "ZarrV2CodecMetadata",
+ "ZarrV2ConsolidatedMetadata",
+ "ZarrV2ConsolidatedMetadataJSON",
+ "ZarrV2ConsolidatedMetadataStoreKey",
+ "ZarrV2DataTypeMetadata",
+ "ZarrV2GroupMetadata",
+ "ZarrV2GroupMetadataJSON",
+ "ZarrV2GroupMetadataJSONPartial",
+ "ZarrV2GroupMetadataPartial",
+ "ZarrV2GroupMetadataStoreKey",
+ "ZarrV2ZArrayJSON",
+ "ZarrV2ZAttrsJSON",
+ "ZarrV2ZGroupJSON",
+ "ZarrV3ArrayMetadata",
+ "ZarrV3ArrayMetadataJSON",
+ "ZarrV3ArrayMetadataJSONPartial",
+ "ZarrV3ArrayMetadataPartial",
+ "ZarrV3ArrayMetadataStoreKey",
+ "ZarrV3ConsolidatedMetadata",
+ "ZarrV3ConsolidatedMetadataJSON",
+ "ZarrV3ExtensionField",
+ "ZarrV3GroupMetadata",
+ "ZarrV3GroupMetadataJSON",
+ "ZarrV3GroupMetadataJSONPartial",
+ "ZarrV3GroupMetadataPartial",
+ "ZarrV3GroupMetadataStoreKey",
+ "ZarrV3MetadataField",
+ "ZarrV3MetadataFieldJSON",
+ "ZarrV3NamedConfig",
+ "ZarrV3NamedConfigJSON",
+ "ZstdCodecMetadata",
+ "ZstdCodecName",
+ "__version__",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/_common.py b/packages/zarr-metadata/src/zarr_metadata/_common.py
new file mode 100644
index 0000000000..08c143107f
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/_common.py
@@ -0,0 +1,50 @@
+"""
+Top-level cross-version primitives for Zarr metadata.
+
+Version-specific types live under `zarr_metadata.v2` and `zarr_metadata.v3`.
+Codec and dtype spec types live under `zarr_metadata.v3.codec` and
+`zarr_metadata.v3.data_type`.
+"""
+
+from collections.abc import Mapping, Sequence
+from typing import NotRequired
+
+from typing_extensions import TypeAliasType, TypedDict
+
+JSONValue = TypeAliasType(
+ "JSONValue",
+ int | float | bool | str | Sequence["JSONValue"] | Mapping[str, "JSONValue"] | None,
+)
+"""A recursive type alias for JSON-encodable values.
+
+Defined via `TypeAliasType` (rather than a plain `TypeAlias`) so the
+self-reference is a named recursion point that pydantic can resolve when
+building a `TypeAdapter`; a bare recursive `TypeAlias` raises
+`PydanticUserError`/`RecursionError` at validation time.
+
+The array arm is the covariant `Sequence` rather than the invariant
+`list["JSONValue"] | tuple["JSONValue", ...]`, so values typed with a
+*narrower* element type still count as JSON values: a `list[str]` field on a
+TypedDict is assignable to `JSONValue` under `Sequence` but not under
+`list[JSONValue]` (`list` is invariant in its element type, and pyright's
+diagnostic for that failure suggests exactly this change). This is what lets
+downstream TypedDicts give their fields precise types (`Sequence[str]`,
+`list[int]`, ...) while remaining assignable to `Mapping[str, JSONValue]`.
+The type-level cost, accepted deliberately: `Sequence` says nothing about the
+concrete container, and it admits `str`/`bytes` (`str` was already a union
+arm); runtime code narrowing a JSON array must exclude `str`/`bytes`/
+`bytearray` regardless of how this alias is spelled.
+"""
+
+
+class ZarrV3NamedConfigJSON(TypedDict):
+ """
+ Externally-tagged union member for a metadata field.
+
+ The optional `configuration` mapping holds arbitrary JSON-encodable
+ values. `must_understand` is implicitly true when absent.
+ """
+
+ name: str
+ configuration: NotRequired[Mapping[str, JSONValue]]
+ must_understand: NotRequired[bool]
diff --git a/packages/zarr-metadata/src/zarr_metadata/_pydantic_schema.py b/packages/zarr-metadata/src/zarr_metadata/_pydantic_schema.py
new file mode 100644
index 0000000000..e9792d6931
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/_pydantic_schema.py
@@ -0,0 +1,114 @@
+"""Private input types used only to generate accurate Pydantic JSON schemas."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping # noqa: TC003 # resolved by Pydantic at runtime
+from typing import Annotated, Literal, NotRequired
+
+from pydantic import Field
+from typing_extensions import TypedDict
+
+from zarr_metadata._common import JSONValue
+from zarr_metadata.v2.array import ( # noqa: TC001 # resolved by Pydantic at runtime
+ ZarrV2DataTypeMetadata,
+)
+from zarr_metadata.v2.codec import ( # resolved by Pydantic at runtime
+ ZarrV2CodecMetadata,
+)
+
+NonNegativeInt = Annotated[int, Field(ge=0)]
+
+
+class ZarrV3NamedConfigJSON(TypedDict, closed=True):
+ """Closed v3 named configuration accepted at optional extension points."""
+
+ name: str
+ configuration: NotRequired[Mapping[str, JSONValue]]
+ must_understand: NotRequired[bool]
+
+
+class ZarrV3MandatoryNamedConfigJSON(TypedDict, closed=True):
+ """Closed named configuration accepted where understanding is mandatory."""
+
+ name: str
+ configuration: NotRequired[Mapping[str, JSONValue]]
+ must_understand: NotRequired[Literal[True]]
+
+
+ZarrV3MetadataFieldJSON = str | ZarrV3NamedConfigJSON
+ZarrV3MandatoryMetadataFieldJSON = str | ZarrV3MandatoryNamedConfigJSON
+ZarrV3CodecPipelineJSON = Annotated[tuple[ZarrV3MetadataFieldJSON, ...], Field(min_length=1)]
+ZarrV2FilterPipelineJSON = Annotated[tuple[ZarrV2CodecMetadata, ...], Field(min_length=1)]
+
+
+class ZarrV3ArrayMetadataJSON(TypedDict, extra_items=JSONValue):
+ """Schema input for a v3 array document, including arbitrary extensions."""
+
+ zarr_format: Literal[3]
+ node_type: Literal["array"]
+ data_type: ZarrV3MandatoryMetadataFieldJSON
+ shape: tuple[NonNegativeInt, ...]
+ chunk_grid: ZarrV3MandatoryMetadataFieldJSON
+ chunk_key_encoding: ZarrV3MandatoryMetadataFieldJSON
+ fill_value: JSONValue
+ codecs: ZarrV3CodecPipelineJSON
+ attributes: NotRequired[Mapping[str, JSONValue]]
+ storage_transformers: NotRequired[tuple[ZarrV3MetadataFieldJSON, ...]]
+ dimension_names: NotRequired[tuple[str | None, ...]]
+
+
+class ZarrV3ConsolidatedMetadataJSON(TypedDict, closed=True):
+ """Schema input for the closed inline consolidated-metadata envelope."""
+
+ kind: Literal["inline"]
+ must_understand: Literal[False]
+ metadata: Mapping[str, ZarrV3ArrayMetadataJSON | ZarrV3GroupMetadataJSON]
+
+
+class ZarrV3GroupMetadataJSON(TypedDict, extra_items=JSONValue):
+ """Schema input for a v3 group document, including arbitrary extensions."""
+
+ zarr_format: Literal[3]
+ node_type: Literal["group"]
+ attributes: NotRequired[Mapping[str, JSONValue]]
+ consolidated_metadata: NotRequired[ZarrV3ConsolidatedMetadataJSON | None]
+
+
+class ZarrV2ArrayMetadataJSON(TypedDict, closed=True):
+ """Schema input for the closed, merged v2 array representation."""
+
+ zarr_format: Literal[2]
+ shape: tuple[NonNegativeInt, ...]
+ chunks: tuple[NonNegativeInt, ...]
+ dtype: ZarrV2DataTypeMetadata
+ compressor: ZarrV2CodecMetadata | None
+ fill_value: JSONValue
+ order: Literal["C", "F"]
+ filters: ZarrV2FilterPipelineJSON | None
+ dimension_separator: NotRequired[Literal[".", "/"]]
+ attributes: NotRequired[Mapping[str, JSONValue]]
+
+
+class ZarrV2GroupMetadataJSON(TypedDict, closed=True):
+ """Schema input for the closed, merged v2 group representation."""
+
+ zarr_format: Literal[2]
+ attributes: NotRequired[Mapping[str, JSONValue]]
+
+
+class ZarrV2ConsolidatedMetadataJSON(TypedDict, closed=True):
+ """Schema input matching the v2 consolidated model's structural parser."""
+
+ zarr_consolidated_format: Literal[1]
+ metadata: Mapping[str, JSONValue]
+
+
+__all__ = [
+ "ZarrV2ArrayMetadataJSON",
+ "ZarrV2ConsolidatedMetadataJSON",
+ "ZarrV2GroupMetadataJSON",
+ "ZarrV3ArrayMetadataJSON",
+ "ZarrV3ConsolidatedMetadataJSON",
+ "ZarrV3GroupMetadataJSON",
+ "ZarrV3MetadataFieldJSON",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/model/__init__.py b/packages/zarr-metadata/src/zarr_metadata/model/__init__.py
new file mode 100644
index 0000000000..edf3561d1d
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/model/__init__.py
@@ -0,0 +1,148 @@
+"""In-memory models for Zarr metadata documents.
+
+Models are frozen dataclasses that hold a canonical, semantically lossless
+representation of the JSON documents; they never interpret extension points
+(codecs, chunk grids, data types). Validators check JSON structure, not domain validity.
+Each document concept gets a `validate_*` function returning every problem
+found (a `list[ValidationProblem]`, each with a machine-readable `kind`), an
+`is_*` type guard, and a `parse_*` function that narrows or raises
+`MetadataValidationError`. Model `from_json` / `from_key_value` constructors
+raise `MetadataValidationError` for every ingestion failure, including
+missing store keys and undecodable bytes.
+"""
+
+from zarr_metadata.model._array import (
+ ZarrV2ArrayMetadata,
+ ZarrV2ArrayMetadataPartial,
+ ZarrV3ArrayMetadata,
+ ZarrV3ArrayMetadataPartial,
+ ZarrV3MetadataField,
+ ZarrV3NamedConfig,
+)
+from zarr_metadata.model._group import (
+ ZarrV2ConsolidatedMetadata,
+ ZarrV2GroupMetadata,
+ ZarrV2GroupMetadataPartial,
+ ZarrV3ConsolidatedMetadata,
+ ZarrV3GroupMetadata,
+ ZarrV3GroupMetadataPartial,
+)
+from zarr_metadata.model._sentinel import UNSET
+from zarr_metadata.model._validation import (
+ ARRAY_METADATA_OPTIONAL_KEYS_V3,
+ ARRAY_METADATA_REQUIRED_KEYS_V2,
+ ARRAY_METADATA_REQUIRED_KEYS_V3,
+ ARRAY_METADATA_STANDARD_KEYS_V3,
+ GROUP_METADATA_OPTIONAL_KEYS_V3,
+ GROUP_METADATA_REQUIRED_KEYS_V2,
+ GROUP_METADATA_REQUIRED_KEYS_V3,
+ GROUP_METADATA_STANDARD_KEYS_V3,
+ MetadataValidationError,
+ ProblemKind,
+ ValidationProblem,
+ is_array_metadata_v2,
+ is_array_metadata_v3,
+ is_group_metadata_v2,
+ is_group_metadata_v3,
+ is_json,
+ is_metadata_field_v3,
+ parse_array_metadata_v2,
+ parse_array_metadata_v3,
+ parse_group_metadata_v2,
+ parse_group_metadata_v3,
+ parse_json,
+ parse_metadata_field_v3,
+ validate_array_metadata_v2,
+ validate_array_metadata_v3,
+ validate_group_metadata_v2,
+ validate_group_metadata_v3,
+ validate_json,
+ validate_metadata_field_v3,
+)
+
+# Store keys are facts about the on-disk specs, so they are defined in the
+# `v2`/`v3` modules that describe those documents. They are re-exported here
+# because the model layer is where consumers reach for them.
+from zarr_metadata.v2.array import (
+ ZARR_V2_ARRAY_METADATA_STORE_KEY,
+ ZarrV2ArrayMetadataStoreKey,
+)
+from zarr_metadata.v2.attributes import (
+ ZARR_V2_ATTRIBUTES_STORE_KEY,
+ ZarrV2AttributesStoreKey,
+)
+from zarr_metadata.v2.consolidated import (
+ ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY,
+ ZarrV2ConsolidatedMetadataStoreKey,
+)
+from zarr_metadata.v2.group import (
+ ZARR_V2_GROUP_METADATA_STORE_KEY,
+ ZarrV2GroupMetadataStoreKey,
+)
+from zarr_metadata.v3.array import (
+ ZARR_V3_ARRAY_METADATA_STORE_KEY,
+ ZarrV3ArrayMetadataStoreKey,
+)
+from zarr_metadata.v3.consolidated import ZARR_V3_CONSOLIDATED_METADATA_KEY
+from zarr_metadata.v3.group import (
+ ZARR_V3_GROUP_METADATA_STORE_KEY,
+ ZarrV3GroupMetadataStoreKey,
+)
+
+__all__ = [
+ "ARRAY_METADATA_OPTIONAL_KEYS_V3",
+ "ARRAY_METADATA_REQUIRED_KEYS_V2",
+ "ARRAY_METADATA_REQUIRED_KEYS_V3",
+ "ARRAY_METADATA_STANDARD_KEYS_V3",
+ "GROUP_METADATA_OPTIONAL_KEYS_V3",
+ "GROUP_METADATA_REQUIRED_KEYS_V2",
+ "GROUP_METADATA_REQUIRED_KEYS_V3",
+ "GROUP_METADATA_STANDARD_KEYS_V3",
+ "UNSET",
+ "ZARR_V2_ARRAY_METADATA_STORE_KEY",
+ "ZARR_V2_ATTRIBUTES_STORE_KEY",
+ "ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY",
+ "ZARR_V2_GROUP_METADATA_STORE_KEY",
+ "ZARR_V3_ARRAY_METADATA_STORE_KEY",
+ "ZARR_V3_CONSOLIDATED_METADATA_KEY",
+ "ZARR_V3_GROUP_METADATA_STORE_KEY",
+ "MetadataValidationError",
+ "ProblemKind",
+ "ValidationProblem",
+ "ZarrV2ArrayMetadata",
+ "ZarrV2ArrayMetadataPartial",
+ "ZarrV2ArrayMetadataStoreKey",
+ "ZarrV2AttributesStoreKey",
+ "ZarrV2ConsolidatedMetadata",
+ "ZarrV2ConsolidatedMetadataStoreKey",
+ "ZarrV2GroupMetadata",
+ "ZarrV2GroupMetadataPartial",
+ "ZarrV2GroupMetadataStoreKey",
+ "ZarrV3ArrayMetadata",
+ "ZarrV3ArrayMetadataPartial",
+ "ZarrV3ArrayMetadataStoreKey",
+ "ZarrV3ConsolidatedMetadata",
+ "ZarrV3GroupMetadata",
+ "ZarrV3GroupMetadataPartial",
+ "ZarrV3GroupMetadataStoreKey",
+ "ZarrV3MetadataField",
+ "ZarrV3NamedConfig",
+ "is_array_metadata_v2",
+ "is_array_metadata_v3",
+ "is_group_metadata_v2",
+ "is_group_metadata_v3",
+ "is_json",
+ "is_metadata_field_v3",
+ "parse_array_metadata_v2",
+ "parse_array_metadata_v3",
+ "parse_group_metadata_v2",
+ "parse_group_metadata_v3",
+ "parse_json",
+ "parse_metadata_field_v3",
+ "validate_array_metadata_v2",
+ "validate_array_metadata_v3",
+ "validate_group_metadata_v2",
+ "validate_group_metadata_v3",
+ "validate_json",
+ "validate_metadata_field_v3",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/model/_array.py b/packages/zarr-metadata/src/zarr_metadata/model/_array.py
new file mode 100644
index 0000000000..0b562bc188
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/model/_array.py
@@ -0,0 +1,498 @@
+"""In-memory models for Zarr array metadata documents."""
+
+from __future__ import annotations
+
+import copy
+import dataclasses
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Literal, TypeAlias, cast
+
+from typing_extensions import TypedDict, Unpack
+
+from zarr_metadata.model._sentinel import UNSET
+from zarr_metadata.model._validation import (
+ ARRAY_METADATA_STANDARD_KEYS_V3,
+ MetadataValidationError,
+ ValidationProblem,
+ arrays_to_tuples,
+ dump_store_json,
+ load_store_json,
+ parse_array_metadata_v2,
+ parse_array_metadata_v3,
+ parse_metadata_field_v3,
+)
+from zarr_metadata.v2.array import ZARR_V2_ARRAY_METADATA_STORE_KEY
+from zarr_metadata.v2.attributes import ZARR_V2_ATTRIBUTES_STORE_KEY
+from zarr_metadata.v3.array import ZARR_V3_ARRAY_METADATA_STORE_KEY
+
+if TYPE_CHECKING:
+ from zarr_metadata._common import JSONValue, ZarrV3NamedConfigJSON
+ from zarr_metadata.v2.array import (
+ ZarrV2ArrayDimensionSeparator,
+ ZarrV2ArrayMetadataJSON,
+ ZarrV2ArrayMetadataStoreKey,
+ ZarrV2ArrayOrder,
+ ZarrV2DataTypeMetadata,
+ )
+ from zarr_metadata.v2.attributes import ZarrV2AttributesStoreKey
+ from zarr_metadata.v2.codec import ZarrV2CodecMetadata
+ from zarr_metadata.v3._common import ZarrV3MetadataFieldJSON
+ from zarr_metadata.v3.array import (
+ ZarrV3ArrayMetadataJSON,
+ ZarrV3ArrayMetadataStoreKey,
+ ZarrV3ExtensionField,
+ )
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class ZarrV3NamedConfig:
+ """A normalized v3 metadata field with its reader obligation.
+
+ Bare names and missing configurations normalize to an empty configuration.
+ Bare names and missing `must_understand` members normalize to the spec's
+ implicit `True` value.
+ """
+
+ name: str
+ configuration: dict[str, JSONValue]
+ must_understand: bool = True
+
+ def to_json(self) -> ZarrV3MetadataFieldJSON:
+ if not self.configuration and self.must_understand:
+ return self.name
+ out: ZarrV3NamedConfigJSON = {"name": self.name}
+ if self.configuration:
+ # to_json output shares no mutable state with the model.
+ out["configuration"] = copy.deepcopy(self.configuration)
+ if not self.must_understand:
+ out["must_understand"] = False
+ return out
+
+ @classmethod
+ def from_json(cls, data: object) -> ZarrV3NamedConfig:
+ field = parse_metadata_field_v3(data)
+ if isinstance(field, str):
+ return cls(name=field, configuration={}, must_understand=True)
+ # Sound cast: parse_metadata_field_v3 checked the configuration is a
+ # string-keyed mapping of JSON values; arrays_to_tuples only converts
+ # lists to tuples within that shape.
+ configuration = cast(
+ "dict[str, JSONValue]", arrays_to_tuples(dict(field.get("configuration", {})))
+ )
+ return cls(
+ name=field["name"],
+ configuration=configuration,
+ must_understand=field.get("must_understand", True),
+ )
+
+
+ZarrV3MetadataField: TypeAlias = ZarrV3NamedConfig
+"""The in-memory model of one field of a v3 metadata document.
+
+This is the role-named alias for annotation positions: model fields and
+consumer signatures should say `ZarrV3MetadataField` (the logical meaning)
+rather than `ZarrV3NamedConfig` (the serialized form the field currently
+takes). Today every metadata field normalizes to a named configuration plus
+its reader obligation, so the alias is exactly `ZarrV3NamedConfig`; if a future
+spec revision adds a field form that cannot be normalized to those values,
+this alias widens to a union and annotation sites do not change. Mirrors the
+raw-layer split between `ZarrV3NamedConfigJSON` (shape) and
+`ZarrV3MetadataFieldJSON` (field union).
+"""
+
+
+def must_understand_subset(
+ extra_fields: Mapping[str, ZarrV3ExtensionField],
+) -> dict[str, ZarrV3ExtensionField]:
+ """The subset of `extra_fields` the reader is obligated to understand.
+
+ Per the v3 spec, an extension field is implicitly `must_understand: True`
+ unless it explicitly says otherwise, and an implementation MUST fail to
+ open a group or array carrying fields it does not recognize that are not
+ explicitly `must_understand: false`. A non-mapping field value cannot
+ carry the explicit waiver, so it always requires understanding (the
+ runtime isinstance check defends against values looser than the declared
+ `ZarrV3ExtensionField`).
+ """
+ fields = cast("Mapping[str, object]", extra_fields)
+ return cast(
+ "dict[str, ZarrV3ExtensionField]",
+ {
+ name: value
+ for name, value in fields.items()
+ if not (
+ isinstance(value, Mapping)
+ and cast("Mapping[str, object]", value).get("must_understand") is False
+ )
+ },
+ )
+
+
+class ZarrV3ArrayMetadataPartial(TypedDict, total=False):
+ """
+ Partial form of the constructor-settable fields of `ZarrV3ArrayMetadata`.
+
+ Every key is optional and typed with the model's own (not serialized)
+ value types, so it describes valid keyword arguments to
+ `ZarrV3ArrayMetadata.update`. The `init=False` fields `zarr_format` and
+ `node_type` are intentionally excluded, since they cannot be passed to
+ `dataclasses.replace`.
+
+ Drift between this type and the model's settable fields is prevented by
+ `tests/model/test_array.py::test_partial_keys_match_settable_model_fields`.
+ """
+
+ shape: tuple[int, ...]
+ fill_value: JSONValue
+ data_type: ZarrV3MetadataField
+ chunk_grid: ZarrV3MetadataField
+ codecs: tuple[ZarrV3MetadataField, ...]
+ chunk_key_encoding: ZarrV3MetadataField
+ dimension_names: tuple[str | None, ...] | UNSET
+ attributes: dict[str, JSONValue]
+ storage_transformers: tuple[ZarrV3MetadataField, ...]
+ extra_fields: dict[str, ZarrV3ExtensionField]
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class ZarrV3ArrayMetadata:
+ """In-memory model of a v3 array metadata document.
+
+ A canonical, semantically lossless representation of the `zarr.json`
+ content for an array. Extension points (`data_type`, `chunk_grid`,
+ `chunk_key_encoding`, `codecs`, `storage_transformers`) are held as
+ `ZarrV3MetadataField` values (currently `ZarrV3NamedConfig` name,
+ configuration, and obligation records) and are never interpreted;
+ `fill_value` is held verbatim in its JSON form. Equivalent extension
+ spellings normalize to shorthand strings when configuration is empty and
+ understanding is required.
+ """
+
+ zarr_format: Literal[3] = field(default=3, init=False)
+ node_type: Literal["array"] = field(default="array", init=False)
+ shape: tuple[int, ...]
+ fill_value: JSONValue
+ data_type: ZarrV3MetadataField
+ chunk_grid: ZarrV3MetadataField
+ codecs: tuple[ZarrV3MetadataField, ...]
+ chunk_key_encoding: ZarrV3MetadataField
+ dimension_names: tuple[str | None, ...] | UNSET
+ attributes: dict[str, JSONValue]
+ storage_transformers: tuple[ZarrV3MetadataField, ...]
+ extra_fields: dict[str, ZarrV3ExtensionField]
+
+ @classmethod
+ def create_default(cls, **overrides: Unpack[ZarrV3ArrayMetadataPartial]) -> ZarrV3ArrayMetadata:
+ """
+ Create a default (empty) v3 array metadata model, with optional overrides.
+
+ The default is a structurally-valid scalar `uint8` array — the array
+ analog of `list()` returning `[]`. Any field can be overridden by keyword
+ (the same fields accepted by `update`). Overriding `shape` without
+ `chunk_grid` derives a consistent default grid: one regular chunk
+ covering the array (`chunk_shape` equal to `shape`).
+
+ The derivation is deliberately one-way. A user-supplied `chunk_grid`
+ is an extension point and is taken verbatim — deriving `shape` from
+ it would require interpreting the grid's configuration, which this
+ layer never does (and cannot do for unrecognized grid names). So
+ overriding `chunk_grid` without `shape` keeps the scalar default
+ `shape=()`, and consistency between the two is the caller's
+ responsibility.
+ """
+ if "shape" in overrides and "chunk_grid" not in overrides:
+ overrides["chunk_grid"] = ZarrV3NamedConfig(
+ name="regular", configuration={"chunk_shape": tuple(overrides["shape"])}
+ )
+ default = cls(
+ shape=(),
+ fill_value=0,
+ data_type=ZarrV3NamedConfig(name="uint8", configuration={}),
+ chunk_grid=ZarrV3NamedConfig(name="regular", configuration={"chunk_shape": ()}),
+ codecs=(ZarrV3NamedConfig(name="bytes", configuration={}),),
+ chunk_key_encoding=ZarrV3NamedConfig(name="default", configuration={}),
+ dimension_names=UNSET,
+ attributes={},
+ storage_transformers=(),
+ extra_fields={},
+ )
+ return default.update(**overrides)
+
+ def update(self, **kwargs: Unpack[ZarrV3ArrayMetadataPartial]) -> ZarrV3ArrayMetadata:
+ """
+ Return a new `ZarrV3ArrayMetadata` with the given fields updated.
+
+ Only the constructor-settable fields listed in
+ `ZarrV3ArrayMetadataPartial` can be updated; any attempt to update
+ other fields (including the fixed `zarr_format` / `node_type`) is
+ rejected at the type level. Each given field fully replaces its
+ previous value, including `extra_fields`.
+
+ This is useful for test fixtures that want to override a few fields of a
+ base template without having to re-specify the entire document.
+
+ No re-validation is performed (`update` is `dataclasses.replace`), so
+ a repair or edit can produce an invalid document; validity is checked
+ on `from_json`, not on field replacement.
+ """
+ return dataclasses.replace(self, **kwargs)
+
+ def __post_init__(self) -> None:
+ overlap = set(self.extra_fields.keys()).intersection(ARRAY_METADATA_STANDARD_KEYS_V3)
+ if overlap:
+ raise MetadataValidationError(
+ [
+ ValidationProblem(
+ ("extra_fields",),
+ "Extra fields cannot overlap with standard Zarr V3 array metadata fields",
+ "invalid_value",
+ )
+ ]
+ )
+
+ def to_json(self) -> ZarrV3ArrayMetadataJSON:
+ # to_json output shares no mutable state with the model: every value
+ # that can hold a mutable container is deep-copied.
+ out: ZarrV3ArrayMetadataJSON = {
+ "zarr_format": self.zarr_format,
+ "node_type": self.node_type,
+ "shape": self.shape,
+ "fill_value": copy.deepcopy(self.fill_value),
+ "data_type": self.data_type.to_json(),
+ "chunk_grid": self.chunk_grid.to_json(),
+ "codecs": tuple(codec.to_json() for codec in self.codecs),
+ "chunk_key_encoding": self.chunk_key_encoding.to_json(),
+ }
+ if self.dimension_names is not UNSET:
+ out["dimension_names"] = self.dimension_names
+ if len(self.attributes) > 0:
+ out["attributes"] = copy.deepcopy(self.attributes)
+ if len(self.storage_transformers) > 0:
+ out["storage_transformers"] = tuple(
+ transformer.to_json() for transformer in self.storage_transformers
+ )
+ # Extra fields are the TypedDict's `extra_items` (PEP 728). Assign them
+ # by key rather than `out.update(**...)`: type checkers understand the
+ # indexed-write path against `extra_items`, but not the `update(**...)`
+ # overload.
+ for key, value in self.extra_fields.items():
+ out[key] = copy.deepcopy(value)
+ return out
+
+ @classmethod
+ def from_json(cls, data: object) -> ZarrV3ArrayMetadata:
+ parsed = parse_array_metadata_v3(arrays_to_tuples(data))
+ # Sound cast: the TypedDict types all non-standard keys as its
+ # `extra_items` (`ZarrV3ExtensionField`); the comprehension's inferred value
+ # type is the union over ALL keys because the key filter cannot narrow it.
+ extra_fields = cast(
+ "dict[str, ZarrV3ExtensionField]",
+ {k: v for k, v in parsed.items() if k not in ARRAY_METADATA_STANDARD_KEYS_V3},
+ )
+ return cls(
+ shape=parsed["shape"],
+ fill_value=parsed["fill_value"],
+ data_type=ZarrV3NamedConfig.from_json(parsed["data_type"]),
+ chunk_grid=ZarrV3NamedConfig.from_json(parsed["chunk_grid"]),
+ codecs=tuple(ZarrV3NamedConfig.from_json(c) for c in parsed["codecs"]),
+ chunk_key_encoding=ZarrV3NamedConfig.from_json(parsed["chunk_key_encoding"]),
+ dimension_names=parsed.get("dimension_names", UNSET),
+ attributes=dict(parsed.get("attributes", {})),
+ storage_transformers=tuple(
+ ZarrV3NamedConfig.from_json(t) for t in parsed.get("storage_transformers", ())
+ ),
+ extra_fields=extra_fields,
+ )
+
+ @property
+ def must_understand_fields(self) -> dict[str, ZarrV3ExtensionField]:
+ """Extra fields the reader is obligated to understand.
+
+ Everything in `extra_fields` not explicitly waived with
+ `must_understand: false` (the spec's implicit-true rule). A compliant
+ reader MUST fail to open the array if this contains any field it does
+ not recognize; the model layer only partitions by obligation, since
+ recognition is reader-specific.
+ """
+ return must_understand_subset(self.extra_fields)
+
+ @classmethod
+ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV3ArrayMetadata:
+ return cls.from_json(load_store_json(mapping, ZARR_V3_ARRAY_METADATA_STORE_KEY))
+
+ def to_key_value(
+ self, *, indent: int | str | None = None
+ ) -> Mapping[ZarrV3ArrayMetadataStoreKey, bytes]:
+ return {ZARR_V3_ARRAY_METADATA_STORE_KEY: dump_store_json(self.to_json(), indent=indent)}
+
+
+class ZarrV2ArrayMetadataPartial(TypedDict, total=False):
+ """
+ Partial form of the constructor-settable fields of `ZarrV2ArrayMetadata`.
+
+ Every key is optional and typed with the model's own value types, so it
+ describes valid keyword arguments to `ZarrV2ArrayMetadata.update` and
+ `create_default`. The `init=False` field `zarr_format` is intentionally
+ excluded, since it cannot be passed to `dataclasses.replace`.
+
+ Drift between this type and the model's settable fields is prevented by
+ `tests/model/test_array.py::test_v2_partial_keys_match_settable_model_fields`.
+ """
+
+ shape: tuple[int, ...]
+ dtype: ZarrV2DataTypeMetadata
+ chunks: tuple[int, ...]
+ fill_value: JSONValue
+ order: ZarrV2ArrayOrder
+ compressor: ZarrV2CodecMetadata | None
+ filters: tuple[ZarrV2CodecMetadata, ...] | None
+ dimension_separator: ZarrV2ArrayDimensionSeparator
+ attributes: dict[str, JSONValue] | UNSET
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class ZarrV2ArrayMetadata:
+ """In-memory model of a v2 array metadata document.
+
+ A canonical, lossless representation of the `.zarray` content plus the
+ sibling `.zattrs` attributes. `dtype`, `compressor`, and `filters` are
+ held in their raw JSON forms and are never interpreted; `fill_value` is
+ held verbatim in its JSON form. `attributes` is `UNSET` when no
+ `.zattrs` file (or merged `attributes` key) exists — distinct from an
+ explicit empty `.zattrs`, which is `{}` and round-trips as a file. One
+ spelling normalization: a `.zarray` that omits `dimension_separator`
+ means `"."` by the v2 convention, and the model holds and re-emits that
+ value explicitly.
+ """
+
+ zarr_format: Literal[2] = field(default=2, init=False)
+ shape: tuple[int, ...]
+ dtype: ZarrV2DataTypeMetadata
+ chunks: tuple[int, ...]
+ fill_value: JSONValue
+ order: ZarrV2ArrayOrder
+ compressor: ZarrV2CodecMetadata | None
+ filters: tuple[ZarrV2CodecMetadata, ...] | None
+ # "." is the v2 convention's default for an ABSENT dimension_separator key;
+ # from_json normalizes absence to it (a semantics-preserving spelling
+ # normalization, like the v3 bare-string metadata-field form). The value
+ # is never None: the document grammar has no null spelling for this field.
+ dimension_separator: ZarrV2ArrayDimensionSeparator = field(default=".")
+ attributes: dict[str, JSONValue] | UNSET
+
+ def update(self, **kwargs: Unpack[ZarrV2ArrayMetadataPartial]) -> ZarrV2ArrayMetadata:
+ """
+ Return a new `ZarrV2ArrayMetadata` with the given fields updated.
+
+ Only the constructor-settable fields listed in
+ `ZarrV2ArrayMetadataPartial` can be updated; the fixed `zarr_format` is
+ rejected at the type level. Each given field fully replaces its previous
+ value.
+ """
+ return dataclasses.replace(self, **kwargs)
+
+ @classmethod
+ def create_default(cls, **overrides: Unpack[ZarrV2ArrayMetadataPartial]) -> ZarrV2ArrayMetadata:
+ """
+ Create a default (empty) v2 array metadata model, with optional overrides.
+
+ The default is a structurally-valid scalar `uint8` (`"|u1"`) array — the
+ array analog of `list()` returning `[]`. Any field can be overridden by
+ keyword (the same fields accepted by `update`). Overriding `shape`
+ without `chunks` derives `chunks` equal to `shape` (one chunk covering
+ the array).
+
+ The derivation is deliberately one-way, matching the v3 model:
+ overriding `chunks` without `shape` keeps the scalar default
+ `shape=()`, and consistency between the two is the caller's
+ responsibility.
+ """
+ if "shape" in overrides and "chunks" not in overrides:
+ overrides["chunks"] = tuple(overrides["shape"])
+ default = cls(
+ shape=(),
+ dtype="|u1",
+ chunks=(),
+ fill_value=0,
+ order="C",
+ compressor=None,
+ filters=None,
+ attributes=UNSET,
+ )
+ return default.update(**overrides)
+
+ def to_json(self) -> ZarrV2ArrayMetadataJSON:
+ """Return the merged in-memory document form.
+
+ `attributes` is included when set (even empty). This is not the
+ on-disk `.zarray` content: a conforming `.zarray` must exclude
+ `attributes` (they live in the sibling `.zattrs` file). Use
+ `to_key_value` to produce the spec-conforming split for storage.
+ """
+ # to_json output shares no mutable state with the model: every value
+ # that can hold a mutable container is deep-copied.
+ out: ZarrV2ArrayMetadataJSON = {
+ "zarr_format": self.zarr_format,
+ "shape": self.shape,
+ "dtype": self.dtype,
+ "order": self.order,
+ "chunks": self.chunks,
+ "fill_value": copy.deepcopy(self.fill_value),
+ "dimension_separator": self.dimension_separator,
+ "compressor": copy.deepcopy(self.compressor),
+ "filters": copy.deepcopy(self.filters),
+ }
+ if self.attributes is not UNSET:
+ out["attributes"] = copy.deepcopy(self.attributes)
+ return out
+
+ @classmethod
+ def from_json(cls, data: object) -> ZarrV2ArrayMetadata:
+ parsed = parse_array_metadata_v2(arrays_to_tuples(data))
+ return cls(
+ shape=parsed["shape"],
+ dtype=parsed["dtype"],
+ chunks=parsed["chunks"],
+ fill_value=parsed["fill_value"],
+ order=parsed["order"],
+ compressor=parsed["compressor"],
+ filters=parsed["filters"],
+ dimension_separator=parsed.get("dimension_separator", "."),
+ attributes=(dict(parsed["attributes"]) if "attributes" in parsed else UNSET),
+ )
+
+ @classmethod
+ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2ArrayMetadata:
+ zarray_raw = cast("object", load_store_json(mapping, ZARR_V2_ARRAY_METADATA_STORE_KEY))
+ if not isinstance(zarray_raw, Mapping):
+ return cls.from_json(zarray_raw)
+ zarray = cast("Mapping[str, object]", zarray_raw)
+ if "attributes" in zarray:
+ raise MetadataValidationError(
+ [
+ ValidationProblem(
+ ("attributes",),
+ "unexpected document member",
+ "invalid_value",
+ )
+ ]
+ )
+ if ZARR_V2_ATTRIBUTES_STORE_KEY in mapping:
+ zattrs = cast("object", load_store_json(mapping, ZARR_V2_ATTRIBUTES_STORE_KEY))
+ return cls.from_json({**zarray, "attributes": zattrs})
+ return cls.from_json(zarray)
+
+ def to_key_value(
+ self, *, indent: int | str | None = None
+ ) -> Mapping[ZarrV2ArrayMetadataStoreKey | ZarrV2AttributesStoreKey, bytes]:
+ # Attributes live only in the sibling `.zattrs` file; the `.zarray`
+ # document must exclude them. The `.zattrs` key is present exactly
+ # when attributes are set (even empty) — UNSET emits no file.
+ zarray = {k: v for k, v in self.to_json().items() if k != "attributes"}
+ out: dict[ZarrV2ArrayMetadataStoreKey | ZarrV2AttributesStoreKey, bytes] = {
+ ZARR_V2_ARRAY_METADATA_STORE_KEY: dump_store_json(zarray, indent=indent)
+ }
+ if self.attributes is not UNSET:
+ out[ZARR_V2_ATTRIBUTES_STORE_KEY] = dump_store_json(self.attributes, indent=indent)
+ return out
diff --git a/packages/zarr-metadata/src/zarr_metadata/model/_group.py b/packages/zarr-metadata/src/zarr_metadata/model/_group.py
new file mode 100644
index 0000000000..63dfe5611f
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/model/_group.py
@@ -0,0 +1,436 @@
+"""In-memory models for Zarr group and consolidated metadata documents."""
+
+from __future__ import annotations
+
+import copy
+import dataclasses
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Literal, cast
+
+from typing_extensions import TypedDict, Unpack
+
+from zarr_metadata.model._array import (
+ ZarrV3ArrayMetadata,
+ must_understand_subset,
+)
+from zarr_metadata.model._sentinel import UNSET
+from zarr_metadata.model._validation import (
+ GROUP_METADATA_STANDARD_KEYS_V3,
+ MetadataValidationError,
+ ValidationProblem,
+ arrays_to_tuples,
+ dump_store_json,
+ load_store_json,
+ parse_group_metadata_v2,
+ parse_group_metadata_v3,
+ validate_consolidated_metadata_v3,
+ validate_json,
+)
+from zarr_metadata.v2.attributes import ZARR_V2_ATTRIBUTES_STORE_KEY
+from zarr_metadata.v2.consolidated import ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY
+from zarr_metadata.v2.group import ZARR_V2_GROUP_METADATA_STORE_KEY
+from zarr_metadata.v3.consolidated import ZARR_V3_CONSOLIDATED_METADATA_KEY
+from zarr_metadata.v3.group import ZARR_V3_GROUP_METADATA_STORE_KEY
+
+if TYPE_CHECKING:
+ from zarr_metadata._common import JSONValue
+ from zarr_metadata.v2.attributes import ZarrV2AttributesStoreKey
+ from zarr_metadata.v2.consolidated import ZarrV2ConsolidatedMetadataStoreKey
+ from zarr_metadata.v2.group import ZarrV2GroupMetadataJSON, ZarrV2GroupMetadataStoreKey
+ from zarr_metadata.v3.array import ZarrV3ExtensionField
+ from zarr_metadata.v3.consolidated import ZarrV3ConsolidatedMetadataJSON
+ from zarr_metadata.v3.group import ZarrV3GroupMetadataJSON, ZarrV3GroupMetadataStoreKey
+
+
+class ZarrV3GroupMetadataPartial(TypedDict, total=False):
+ """
+ Partial form of the constructor-settable fields of `ZarrV3GroupMetadata`.
+
+ Every key is optional and typed with the model's own value types, so it
+ describes valid keyword arguments to `ZarrV3GroupMetadata.update` and
+ `create_default`. The `init=False` fields `zarr_format` and `node_type`
+ are intentionally excluded, since they cannot be passed to
+ `dataclasses.replace`.
+
+ Drift between this type and the model's settable fields is prevented by
+ `tests/model/test_group.py::test_group_partial_keys_match_settable_model_fields`.
+ """
+
+ attributes: dict[str, JSONValue]
+ consolidated_metadata: ZarrV3ConsolidatedMetadata | UNSET
+ extra_fields: dict[str, ZarrV3ExtensionField]
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class ZarrV3GroupMetadata:
+ """In-memory model of a v3 group metadata document.
+
+ A canonical, semantically lossless representation of the `zarr.json`
+ content for a group. The `consolidated_metadata` reference-implementation
+ convention is modeled as a typed field holding thin child models; every
+ other unknown top-level key lands in `extra_fields` verbatim.
+ """
+
+ zarr_format: Literal[3] = field(default=3, init=False)
+ node_type: Literal["group"] = field(default="group", init=False)
+ attributes: dict[str, JSONValue]
+ consolidated_metadata: ZarrV3ConsolidatedMetadata | UNSET
+ extra_fields: dict[str, ZarrV3ExtensionField]
+
+ def __post_init__(self) -> None:
+ reserved = GROUP_METADATA_STANDARD_KEYS_V3 | {ZARR_V3_CONSOLIDATED_METADATA_KEY}
+ if set(self.extra_fields.keys()).intersection(reserved):
+ raise MetadataValidationError(
+ [
+ ValidationProblem(
+ ("extra_fields",),
+ "Extra fields cannot overlap with standard Zarr V3 group metadata fields",
+ "invalid_value",
+ )
+ ]
+ )
+
+ @classmethod
+ def create_default(cls, **overrides: Unpack[ZarrV3GroupMetadataPartial]) -> ZarrV3GroupMetadata:
+ """
+ Create a default (empty) v3 group metadata model, with optional overrides.
+
+ The default is a structurally-valid group with no attributes — the group
+ analog of `list()` returning `[]`. Any field can be overridden by keyword
+ (the same fields accepted by `update`).
+ """
+ default = cls(attributes={}, consolidated_metadata=UNSET, extra_fields={})
+ return default.update(**overrides)
+
+ def update(self, **kwargs: Unpack[ZarrV3GroupMetadataPartial]) -> ZarrV3GroupMetadata:
+ """
+ Return a new `ZarrV3GroupMetadata` with the given fields updated.
+
+ Only the constructor-settable fields listed in
+ `ZarrV3GroupMetadataPartial` can be updated; the fixed `zarr_format` /
+ `node_type` are rejected at the type level. Each given field fully
+ replaces its previous value, including `extra_fields`.
+ """
+ return dataclasses.replace(self, **kwargs)
+
+ def to_json(self) -> ZarrV3GroupMetadataJSON:
+ # to_json output shares no mutable state with the model: every value
+ # that can hold a mutable container is deep-copied.
+ out: ZarrV3GroupMetadataJSON = {
+ "zarr_format": self.zarr_format,
+ "node_type": self.node_type,
+ }
+ if len(self.attributes) > 0:
+ out["attributes"] = copy.deepcopy(self.attributes)
+ if self.consolidated_metadata is not UNSET:
+ # Consolidated metadata is a known non-core top-level JSON field.
+ out[ZARR_V3_CONSOLIDATED_METADATA_KEY] = cast(
+ "ZarrV3ExtensionField", self.consolidated_metadata.to_json()
+ )
+ for key, value in self.extra_fields.items():
+ out[key] = copy.deepcopy(value)
+ return out
+
+ @classmethod
+ def from_json(cls, data: object) -> ZarrV3GroupMetadata:
+ parsed = parse_group_metadata_v3(arrays_to_tuples(data))
+ # Cast for narrowing across standard and arbitrary extra TypedDict items.
+ consolidated_raw = cast("object", parsed.get(ZARR_V3_CONSOLIDATED_METADATA_KEY, UNSET))
+ consolidated: ZarrV3ConsolidatedMetadata | UNSET
+ if consolidated_raw is UNSET or consolidated_raw is None:
+ # consolidated_metadata: null was written by a historical
+ # zarr-python bug; it gets no model representation. It is read as
+ # absence and never written back — repaired, not preserved.
+ consolidated = UNSET
+ else:
+ consolidated = ZarrV3ConsolidatedMetadata.from_json(consolidated_raw)
+ # Sound cast: the TypedDict types all non-standard keys as its
+ # `extra_items` (`ZarrV3ExtensionField`); the comprehension's inferred value
+ # type is the union over ALL keys because the key filter cannot narrow it.
+ extra_fields = cast(
+ "dict[str, ZarrV3ExtensionField]",
+ {
+ k: v
+ for k, v in parsed.items()
+ if k not in GROUP_METADATA_STANDARD_KEYS_V3
+ and k != ZARR_V3_CONSOLIDATED_METADATA_KEY
+ },
+ )
+ return cls(
+ attributes=dict(parsed.get("attributes", {})),
+ consolidated_metadata=consolidated,
+ extra_fields=extra_fields,
+ )
+
+ @property
+ def must_understand_fields(self) -> dict[str, ZarrV3ExtensionField]:
+ """Extra fields the reader is obligated to understand.
+
+ Everything in `extra_fields` not explicitly waived with
+ `must_understand: false` (the spec's implicit-true rule). A compliant
+ reader MUST fail to open the group if this contains any field it does
+ not recognize; the model layer only partitions by obligation, since
+ recognition is reader-specific.
+ """
+ return must_understand_subset(self.extra_fields)
+
+ @classmethod
+ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV3GroupMetadata:
+ return cls.from_json(load_store_json(mapping, ZARR_V3_GROUP_METADATA_STORE_KEY))
+
+ def to_key_value(
+ self, *, indent: int | str | None = None
+ ) -> Mapping[ZarrV3GroupMetadataStoreKey, bytes]:
+ return {ZARR_V3_GROUP_METADATA_STORE_KEY: dump_store_json(self.to_json(), indent=indent)}
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class ZarrV3ConsolidatedMetadata:
+ """In-memory model of v3 inline consolidated metadata.
+
+ Models the reference-implementation convention where consolidated metadata
+ is embedded as an extension field on a group's `zarr.json`. Each entry in
+ `metadata` is a complete child document, held as a thin array or group
+ model. `must_understand` is typed permissively as `bool` to mirror the
+ document shape, but only `False` is valid; this is enforced at runtime.
+ """
+
+ kind: Literal["inline"] = field(default="inline", init=False)
+ must_understand: bool = False
+ metadata: dict[str, ZarrV3ArrayMetadata | ZarrV3GroupMetadata]
+
+ def __post_init__(self) -> None:
+ if self.must_understand is not False:
+ raise MetadataValidationError(
+ [
+ ValidationProblem(
+ ("must_understand",),
+ f"Invalid value for 'must_understand'. Expected False. "
+ f"Got {self.must_understand!r}.",
+ "invalid_value",
+ )
+ ]
+ )
+
+ def to_json(self) -> ZarrV3ConsolidatedMetadataJSON:
+ # `must_understand` is emitted as the literal False: the field is typed
+ # permissively as `bool`, but `__post_init__` guarantees the value.
+ return {
+ "kind": self.kind,
+ "must_understand": False,
+ "metadata": {key: node.to_json() for key, node in self.metadata.items()},
+ }
+
+ @classmethod
+ def from_json(cls, data: object) -> ZarrV3ConsolidatedMetadata:
+ normalized = arrays_to_tuples(data)
+ problems = validate_consolidated_metadata_v3(normalized)
+ if problems:
+ raise MetadataValidationError(problems)
+ env = cast("Mapping[str, object]", normalized)
+ entries: dict[str, ZarrV3ArrayMetadata | ZarrV3GroupMetadata] = {}
+ for key, entry in cast("Mapping[str, object]", env["metadata"]).items():
+ node_type = cast("Mapping[str, object]", entry).get("node_type")
+ if node_type == "array":
+ entries[key] = ZarrV3ArrayMetadata.from_json(entry)
+ else:
+ entries[key] = ZarrV3GroupMetadata.from_json(entry)
+ return cls(metadata=entries)
+
+
+class ZarrV2GroupMetadataPartial(TypedDict, total=False):
+ """
+ Partial form of the constructor-settable fields of `ZarrV2GroupMetadata`.
+
+ Every key is optional and typed with the model's own value types, so it
+ describes valid keyword arguments to `ZarrV2GroupMetadata.update` and
+ `create_default`. The `init=False` field `zarr_format` is intentionally
+ excluded, since it cannot be passed to `dataclasses.replace`.
+
+ Drift between this type and the model's settable fields is prevented by
+ `tests/model/test_group.py::test_group_partial_keys_match_settable_model_fields`.
+ """
+
+ attributes: dict[str, JSONValue] | UNSET
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class ZarrV2GroupMetadata:
+ """In-memory model of a v2 group metadata document.
+
+ A canonical, lossless representation of the `.zgroup` content plus the
+ sibling `.zattrs` attributes, folded into a single in-memory value
+ (mirroring the merged `ZarrV2GroupMetadataJSON` document form). `attributes` is
+ `UNSET` when no `.zattrs` file (or merged `attributes` key) exists —
+ distinct from an explicit empty `.zattrs`, which is `{}` and round-trips
+ as a file.
+ """
+
+ zarr_format: Literal[2] = field(default=2, init=False)
+ attributes: dict[str, JSONValue] | UNSET
+
+ @classmethod
+ def create_default(cls, **overrides: Unpack[ZarrV2GroupMetadataPartial]) -> ZarrV2GroupMetadata:
+ """
+ Create a default (empty) v2 group metadata model, with optional overrides.
+
+ The default is a structurally-valid group with no attributes — the group
+ analog of `list()` returning `[]`. Any field can be overridden by keyword
+ (the same fields accepted by `update`).
+ """
+ default = cls(attributes=UNSET)
+ return default.update(**overrides)
+
+ def update(self, **kwargs: Unpack[ZarrV2GroupMetadataPartial]) -> ZarrV2GroupMetadata:
+ """
+ Return a new `ZarrV2GroupMetadata` with the given fields updated.
+
+ Only the constructor-settable fields listed in
+ `ZarrV2GroupMetadataPartial` can be updated; the fixed `zarr_format`
+ is rejected at the type level. Each given field fully replaces its
+ previous value.
+ """
+ return dataclasses.replace(self, **kwargs)
+
+ def to_json(self) -> ZarrV2GroupMetadataJSON:
+ """Return the merged in-memory document form.
+
+ `attributes` is included when set (even empty). This is not the
+ on-disk `.zgroup` content: a conforming `.zgroup` must exclude
+ `attributes` (they live in the sibling `.zattrs` file). Use
+ `to_key_value` to produce the spec-conforming split for storage.
+ """
+ # to_json output shares no mutable state with the model.
+ out: ZarrV2GroupMetadataJSON = {"zarr_format": self.zarr_format}
+ if self.attributes is not UNSET:
+ out["attributes"] = copy.deepcopy(self.attributes)
+ return out
+
+ @classmethod
+ def from_json(cls, data: object) -> ZarrV2GroupMetadata:
+ parsed = parse_group_metadata_v2(arrays_to_tuples(data))
+ return cls(attributes=(dict(parsed["attributes"]) if "attributes" in parsed else UNSET))
+
+ @classmethod
+ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2GroupMetadata:
+ zgroup_raw = cast("object", load_store_json(mapping, ZARR_V2_GROUP_METADATA_STORE_KEY))
+ if not isinstance(zgroup_raw, Mapping):
+ return cls.from_json(zgroup_raw)
+ zgroup = cast("Mapping[str, object]", zgroup_raw)
+ if "attributes" in zgroup:
+ raise MetadataValidationError(
+ [
+ ValidationProblem(
+ ("attributes",),
+ "unexpected document member",
+ "invalid_value",
+ )
+ ]
+ )
+ if ZARR_V2_ATTRIBUTES_STORE_KEY in mapping:
+ zattrs = cast("object", load_store_json(mapping, ZARR_V2_ATTRIBUTES_STORE_KEY))
+ return cls.from_json({**zgroup, "attributes": zattrs})
+ return cls.from_json(zgroup)
+
+ def to_key_value(
+ self, *, indent: int | str | None = None
+ ) -> Mapping[ZarrV2GroupMetadataStoreKey | ZarrV2AttributesStoreKey, bytes]:
+ # Attributes live only in the sibling `.zattrs` file; the `.zgroup`
+ # document must exclude them. The `.zattrs` key is present exactly
+ # when attributes are set (even empty) — UNSET emits no file.
+ zgroup = {k: v for k, v in self.to_json().items() if k != "attributes"}
+ out: dict[ZarrV2GroupMetadataStoreKey | ZarrV2AttributesStoreKey, bytes] = {
+ ZARR_V2_GROUP_METADATA_STORE_KEY: dump_store_json(zgroup, indent=indent)
+ }
+ if self.attributes is not UNSET:
+ out[ZARR_V2_ATTRIBUTES_STORE_KEY] = dump_store_json(self.attributes, indent=indent)
+ return out
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class ZarrV2ConsolidatedMetadata:
+ """In-memory model of a v2 `.zmetadata` document.
+
+ The `metadata` map holds the flat file-keyed entries (`"path/.zarray"`,
+ `"path/.zattrs"`, ...) verbatim, preserving the normalized JSON tree.
+ Entries are deliberately NOT merged into per-node models: which nodes had
+ a `.zattrs` file at all is information the canonical representation must
+ keep. Interpreting entries into node models is consumer work.
+ """
+
+ zarr_consolidated_format: Literal[1] = field(default=1, init=False)
+ metadata: dict[str, JSONValue]
+
+ def to_json(self) -> dict[str, JSONValue]:
+ # to_json output shares no mutable state with the model.
+ return {
+ "zarr_consolidated_format": self.zarr_consolidated_format,
+ "metadata": copy.deepcopy(self.metadata),
+ }
+
+ @classmethod
+ def from_json(cls, data: object) -> ZarrV2ConsolidatedMetadata:
+ normalized = arrays_to_tuples(data)
+ if not isinstance(normalized, Mapping):
+ raise MetadataValidationError(
+ [ValidationProblem((), "expected a mapping", "invalid_type")]
+ )
+ doc = cast("Mapping[str, object]", normalized)
+ problems: list[ValidationProblem] = [
+ ValidationProblem((key,), "missing required key", "missing_key")
+ for key in ("zarr_consolidated_format", "metadata")
+ if key not in doc
+ ]
+ problems.extend(
+ ValidationProblem((key,), "unexpected document member", "invalid_value")
+ for key in doc.keys() - {"zarr_consolidated_format", "metadata"}
+ )
+ if "zarr_consolidated_format" in doc and (
+ not isinstance(doc["zarr_consolidated_format"], int)
+ or isinstance(doc["zarr_consolidated_format"], bool)
+ or doc["zarr_consolidated_format"] != 1
+ ):
+ problems.append(
+ ValidationProblem(
+ ("zarr_consolidated_format",),
+ f"expected 1, got {doc['zarr_consolidated_format']!r}",
+ "invalid_value",
+ )
+ )
+ if "metadata" in doc:
+ entries = doc["metadata"]
+ if not isinstance(entries, Mapping) or not all(
+ isinstance(k, str) for k in cast("Mapping[object, object]", entries)
+ ):
+ problems.append(
+ ValidationProblem(
+ ("metadata",), "expected a mapping with string keys", "invalid_type"
+ )
+ )
+ else:
+ for key, value in cast("Mapping[str, object]", entries).items():
+ problems.extend(
+ ValidationProblem(
+ ("metadata", key, *problem.loc), problem.message, problem.kind
+ )
+ for problem in validate_json(value)
+ )
+ if problems:
+ raise MetadataValidationError(problems)
+ entries_tupled = cast(
+ "dict[str, JSONValue]",
+ arrays_to_tuples(dict(cast("Mapping[str, object]", doc["metadata"]))),
+ )
+ return cls(metadata=entries_tupled)
+
+ @classmethod
+ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2ConsolidatedMetadata:
+ return cls.from_json(load_store_json(mapping, ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY))
+
+ def to_key_value(
+ self, *, indent: int | str | None = None
+ ) -> Mapping[ZarrV2ConsolidatedMetadataStoreKey, bytes]:
+ return {
+ ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY: dump_store_json(self.to_json(), indent=indent)
+ }
diff --git a/packages/zarr-metadata/src/zarr_metadata/model/_sentinel.py b/packages/zarr-metadata/src/zarr_metadata/model/_sentinel.py
new file mode 100644
index 0000000000..ad71e216fa
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/model/_sentinel.py
@@ -0,0 +1,37 @@
+"""The absence sentinel for optional metadata-document keys.
+
+The models observe one invariant: `None` in a model always corresponds to a
+JSON `null` in the document (a v2 `compressor`/`filters` value, an unnamed
+dimension inside `dimension_names`), and `UNSET` always means the document
+key is absent. The two are never interchangeable, so a model value can never
+leak into a document as a spelling the writer did not intend.
+
+Check with identity: `if model.dimension_names is UNSET: ...`.
+
+Because the contract is identity, the sentinel must never be reconstructed
+from state: pickling and copying work by *reference* (typing_extensions >=
+4.16 implements `Sentinel.__reduce__` as a lookup of the sentinel's name on
+its defining module), so `pickle.loads(pickle.dumps(UNSET)) is UNSET` holds
+across process boundaries, and models holding `UNSET` pickle and deep-copy
+freely. Earlier typing_extensions releases refused to pickle sentinels
+outright — hence the `>=4.16` floor in this package's dependencies.
+
+Checker support (PEP 661 is Final; stdlib `sentinel` arrives in Python
+3.15): ty types this spelling exactly, including `is`/`is not` narrowing.
+Pyright supports it but a regression (1.1.405+, tracked as
+https://github.com/microsoft/pyright/issues/11115) degrades class-attribute
+reads to `Unknown`, so this package pins pyright to the last good version
+until the fix lands. Mypy support is in review
+(https://github.com/python/mypy/pull/21647); until it merges, mypy-checked
+consumers of these fields need a `cast` or `type: ignore` at narrowing
+sites. This is a deliberate short-term cost: the sentinel is the standard,
+and the checkers are converging on it.
+"""
+
+from __future__ import annotations
+
+from typing_extensions import Sentinel
+
+UNSET = Sentinel("UNSET")
+"""Marks a metadata-document key as absent (PEP 661 sentinel; usable directly
+in type expressions, e.g. `tuple[str, ...] | UNSET`). Test with `is UNSET`."""
diff --git a/packages/zarr-metadata/src/zarr_metadata/model/_validation.py b/packages/zarr-metadata/src/zarr_metadata/model/_validation.py
new file mode 100644
index 0000000000..a12e1911b1
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/model/_validation.py
@@ -0,0 +1,875 @@
+"""Structural validation for Zarr metadata documents.
+
+Validators check JSON structure (key presence, value shapes, and fixed
+literals like `zarr_format`), not domain validity. Each concept gets a
+`validate_*` function returning every problem found, an `is_*` type guard,
+and a `parse_*` function that narrows or raises `MetadataValidationError`.
+
+Every `ValidationProblem` carries a machine-readable `kind` alongside its
+human-readable `message`, so consumers can dispatch on the failure mode
+(`missing_key`, `invalid_type`, `invalid_value`, `invalid_json`) without
+string-matching messages.
+"""
+
+from __future__ import annotations
+
+import json
+import math
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
+from typing import Any, Final, Literal, NoReturn, cast
+
+from typing_extensions import TypeIs
+
+from zarr_metadata._common import JSONValue
+from zarr_metadata.v2.array import ZarrV2ArrayMetadataJSON
+from zarr_metadata.v2.group import ZarrV2GroupMetadataJSON
+from zarr_metadata.v3._common import ZarrV3MetadataFieldJSON
+from zarr_metadata.v3.array import ZarrV3ArrayMetadataJSON
+from zarr_metadata.v3.group import ZarrV3GroupMetadataJSON
+
+ProblemKind = Literal["missing_key", "invalid_type", "invalid_value", "invalid_json"]
+"""Machine-readable classification of a `ValidationProblem`.
+
+- `missing_key`: a required key (document key or store key) is absent.
+- `invalid_type`: a value has the wrong structural type (e.g. a string where
+ a mapping is required, a non-JSON-serializable object).
+- `invalid_value`: a value has an acceptable type but an invalid content
+ (e.g. `zarr_format: 2` in a v3 document, `order: "Q"`).
+- `invalid_json`: bytes that do not decode as JSON.
+"""
+
+
+@dataclass(frozen=True, slots=True)
+class ValidationProblem:
+ """A single structural problem found while validating a metadata document.
+
+ `loc` is the path from the document root to the offending value, e.g.
+ `("codecs", 0, "name")`. An empty `loc` refers to the document as a whole.
+ `kind` classifies the failure mode for programmatic dispatch; `message`
+ is the human-readable description.
+ """
+
+ loc: tuple[str | int, ...]
+ message: str
+ kind: ProblemKind
+
+ def __str__(self) -> str:
+ location = ".".join(str(part) for part in self.loc) if self.loc else ""
+ return f"{location}: {self.message}"
+
+
+class MetadataValidationError(ValueError):
+ """Raised when a value fails structural metadata validation.
+
+ Carries every problem found (not just the first) in `.problems`.
+ """
+
+ def __init__(self, problems: list[ValidationProblem]) -> None:
+ self.problems = problems
+ super().__init__("\n".join(str(problem) for problem in problems))
+
+
+def _prefix(loc_head: str | int, problems: list[ValidationProblem]) -> list[ValidationProblem]:
+ """Prepend `loc_head` to the `loc` of every problem (for nested validators)."""
+ return [ValidationProblem((loc_head, *p.loc), p.message, p.kind) for p in problems]
+
+
+def validate_json(value: object) -> list[ValidationProblem]:
+ """Return every reason `value` is not JSON-serializable (recursively)."""
+ if isinstance(value, float):
+ if math.isfinite(value):
+ return []
+ return [ValidationProblem((), f"non-finite float {value!r} is not JSON", "invalid_value")]
+ if isinstance(value, (str, int, bool)) or value is None:
+ return []
+ problems: list[ValidationProblem] = []
+ if isinstance(value, Mapping):
+ for key, item in cast("Mapping[object, object]", value).items():
+ if not isinstance(key, str):
+ problems.append(
+ ValidationProblem((), f"non-string key {key!r} in JSON object", "invalid_type")
+ )
+ continue
+ problems.extend(_prefix(key, validate_json(item)))
+ return problems
+ if isinstance(value, Sequence) and not isinstance(value, (bytes, bytearray)):
+ for index, item in enumerate(cast("Sequence[object]", value)):
+ problems.extend(_prefix(index, validate_json(item)))
+ return problems
+ return [ValidationProblem((), f"not a JSON-serializable value: {value!r}", "invalid_type")]
+
+
+def _is_canonical_json(value: object) -> TypeIs[JSONValue]:
+ """Whether `value` already uses the concrete containers in `JSONValue`."""
+ if isinstance(value, float):
+ return math.isfinite(value)
+ if isinstance(value, (str, int, bool)) or value is None:
+ return True
+ if isinstance(value, (list, tuple)):
+ sequence = cast("list[object] | tuple[object, ...]", value)
+ return all(_is_canonical_json(item) for item in sequence)
+ if isinstance(value, dict):
+ mapping = cast("dict[object, object]", value)
+ return all(
+ isinstance(key, str) and _is_canonical_json(item) for key, item in mapping.items()
+ )
+ return False
+
+
+def is_json(value: object) -> TypeIs[JSONValue]:
+ """Whether `value` is a canonical JSON structure (recursively)."""
+ return _is_canonical_json(value)
+
+
+def parse_json(value: object) -> JSONValue:
+ """Return a canonical `JSONValue`, or raise `MetadataValidationError`."""
+ normalized = arrays_to_tuples(value)
+ problems = validate_json(normalized)
+ if problems:
+ raise MetadataValidationError(problems)
+ return cast(JSONValue, normalized)
+
+
+# The standard top-level keys of a v3 array metadata document. Anything outside
+# this set is an extension field. Built from the TypedDict's required/optional
+# key sets (which resolve inherited keys, unlike `__annotations__`).
+ARRAY_METADATA_REQUIRED_KEYS_V3: Final[frozenset[str]] = frozenset(
+ ZarrV3ArrayMetadataJSON.__required_keys__
+)
+ARRAY_METADATA_OPTIONAL_KEYS_V3: Final[frozenset[str]] = frozenset(
+ ZarrV3ArrayMetadataJSON.__optional_keys__
+)
+ARRAY_METADATA_STANDARD_KEYS_V3: Final[frozenset[str]] = (
+ ARRAY_METADATA_REQUIRED_KEYS_V3 | ARRAY_METADATA_OPTIONAL_KEYS_V3
+)
+
+ARRAY_METADATA_REQUIRED_KEYS_V2: Final[frozenset[str]] = frozenset(
+ ZarrV2ArrayMetadataJSON.__required_keys__
+)
+ARRAY_METADATA_OPTIONAL_KEYS_V2: Final[frozenset[str]] = frozenset(
+ ZarrV2ArrayMetadataJSON.__optional_keys__
+)
+ARRAY_METADATA_STANDARD_KEYS_V2: Final[frozenset[str]] = (
+ ARRAY_METADATA_REQUIRED_KEYS_V2 | ARRAY_METADATA_OPTIONAL_KEYS_V2
+)
+
+# The standard top-level keys of a v3 group metadata document. Anything outside
+# this set is an extension field.
+GROUP_METADATA_REQUIRED_KEYS_V3: Final[frozenset[str]] = frozenset(
+ ZarrV3GroupMetadataJSON.__required_keys__
+)
+GROUP_METADATA_OPTIONAL_KEYS_V3: Final[frozenset[str]] = frozenset(
+ ZarrV3GroupMetadataJSON.__optional_keys__
+)
+GROUP_METADATA_STANDARD_KEYS_V3: Final[frozenset[str]] = (
+ GROUP_METADATA_REQUIRED_KEYS_V3 | GROUP_METADATA_OPTIONAL_KEYS_V3
+)
+
+GROUP_METADATA_REQUIRED_KEYS_V2: Final[frozenset[str]] = frozenset(
+ ZarrV2GroupMetadataJSON.__required_keys__
+)
+GROUP_METADATA_OPTIONAL_KEYS_V2: Final[frozenset[str]] = frozenset(
+ ZarrV2GroupMetadataJSON.__optional_keys__
+)
+GROUP_METADATA_STANDARD_KEYS_V2: Final[frozenset[str]] = (
+ GROUP_METADATA_REQUIRED_KEYS_V2 | GROUP_METADATA_OPTIONAL_KEYS_V2
+)
+
+
+def _missing_keys(required: frozenset[str], doc: Mapping[str, object]) -> list[ValidationProblem]:
+ """One `missing_key` problem per required key absent from `doc`."""
+ return [
+ ValidationProblem((key,), "missing required key", "missing_key")
+ for key in sorted(required - doc.keys())
+ ]
+
+
+def _unexpected_keys(
+ allowed: frozenset[str], doc: Mapping[object, object]
+) -> list[ValidationProblem]:
+ """One problem per member outside a closed document's declared shape."""
+ problems: list[ValidationProblem] = []
+ for key in doc:
+ if not isinstance(key, str):
+ problems.append(
+ ValidationProblem((), f"non-string document key {key!r}", "invalid_type")
+ )
+ elif key not in allowed:
+ problems.append(
+ ValidationProblem((key,), "unexpected document member", "invalid_value")
+ )
+ return problems
+
+
+def _check_literal(
+ doc: Mapping[str, object], key: str, expected: object
+) -> list[ValidationProblem]:
+ """One `invalid_value` problem if `doc[key]` is present but not `expected`."""
+ if key in doc and (type(doc[key]) is not type(expected) or doc[key] != expected):
+ return [
+ ValidationProblem((key,), f"expected {expected!r}, got {doc[key]!r}", "invalid_value")
+ ]
+ return []
+
+
+def _validate_extension_fields_v3(
+ doc: Mapping[object, object],
+ standard_keys: frozenset[str],
+ *,
+ additional_reserved_keys: frozenset[str] = frozenset(),
+) -> list[ValidationProblem]:
+ """Validate v3 top-level key types and unknown-field JSON payloads."""
+ problems: list[ValidationProblem] = []
+ reserved_keys = standard_keys | additional_reserved_keys
+ for key, value in doc.items():
+ if not isinstance(key, str):
+ problems.append(
+ ValidationProblem((), f"non-string top-level key {key!r}", "invalid_type")
+ )
+ continue
+ if key in reserved_keys:
+ continue
+ problems.extend(_prefix(key, validate_json(value)))
+ return problems
+
+
+def validate_metadata_field_v3(
+ value: object, *, allow_must_understand_false: bool = True
+) -> list[ValidationProblem]:
+ """Return every reason `value` is not a v3 metadata field.
+
+ A metadata field is a bare name string or a mapping containing `name` and
+ optional `configuration` and `must_understand` members.
+ """
+ if isinstance(value, str):
+ return []
+ if not isinstance(value, Mapping):
+ return [
+ ValidationProblem(
+ (),
+ "expected a metadata field (string or extension object)",
+ "invalid_type",
+ )
+ ]
+ field = cast("Mapping[object, object]", value)
+ problems: list[ValidationProblem] = []
+ allowed_keys = frozenset({"name", "configuration", "must_understand"})
+ for key in field:
+ if not isinstance(key, str):
+ problems.append(
+ ValidationProblem((), f"non-string metadata field key {key!r}", "invalid_type")
+ )
+ elif key not in allowed_keys:
+ problems.append(
+ ValidationProblem((key,), "unexpected metadata field member", "invalid_value")
+ )
+ if not isinstance(field.get("name"), str):
+ problems.append(ValidationProblem(("name",), "expected a string name", "invalid_type"))
+ if "configuration" in field:
+ configuration = field["configuration"]
+ if not isinstance(configuration, Mapping):
+ problems.append(
+ ValidationProblem(("configuration",), "expected a mapping", "invalid_type")
+ )
+ elif not all(isinstance(k, str) for k in cast("Mapping[object, object]", configuration)):
+ problems.append(
+ ValidationProblem(("configuration",), "expected string keys", "invalid_type")
+ )
+ else:
+ for key, item in cast("Mapping[str, object]", configuration).items():
+ problems.extend(_prefix("configuration", _prefix(key, validate_json(item))))
+ if "must_understand" in field:
+ must_understand = field["must_understand"]
+ if not isinstance(must_understand, bool):
+ problems.append(
+ ValidationProblem(("must_understand",), "expected a boolean", "invalid_type")
+ )
+ elif not allow_must_understand_false and not must_understand:
+ problems.append(
+ ValidationProblem(
+ ("must_understand",),
+ "false is not supported at this extension point",
+ "invalid_value",
+ )
+ )
+ return problems
+
+
+def is_metadata_field_v3(value: object) -> TypeIs[ZarrV3MetadataFieldJSON]:
+ """Whether `value` is a v3 metadata field: a bare name or a named config."""
+ if isinstance(value, str):
+ return True
+ if not isinstance(value, dict):
+ return False
+ field = cast("dict[object, object]", value)
+ return _is_canonical_json(field) and not validate_metadata_field_v3(field)
+
+
+def parse_metadata_field_v3(value: object) -> ZarrV3MetadataFieldJSON:
+ """Return `value` narrowed to `ZarrV3MetadataFieldJSON`, or raise `MetadataValidationError`."""
+ normalized = arrays_to_tuples(value)
+ problems = validate_metadata_field_v3(normalized)
+ if problems:
+ raise MetadataValidationError(problems)
+ return cast(ZarrV3MetadataFieldJSON, normalized)
+
+
+def _is_int_sequence(value: object) -> bool:
+ """Whether `value` is a non-string sequence of integers.
+
+ JSON booleans decode to `bool`, which is an `int` subclass in Python but
+ is not an integer in a metadata document, so booleans are excluded.
+ """
+ return (
+ not isinstance(value, (str, bytes, bytearray))
+ and isinstance(value, Sequence)
+ and all(
+ isinstance(item, int) and not isinstance(item, bool)
+ for item in cast("Sequence[object]", value)
+ )
+ )
+
+
+def _validate_dim_sequence(doc: Mapping[str, object], key: str) -> list[ValidationProblem]:
+ """Validate a dimension sequence (`shape` / `chunks`) if present in `doc`.
+
+ Dimension lengths are non-negative integers.
+ """
+ if key not in doc:
+ return []
+ value = doc[key]
+ if not _is_int_sequence(value):
+ return [ValidationProblem((key,), "expected a sequence of int", "invalid_type")]
+ if any(item < 0 for item in cast("Sequence[int]", value)):
+ return [ValidationProblem((key,), "expected non-negative integers", "invalid_value")]
+ return []
+
+
+def _is_dtype_v2(value: object) -> bool:
+ """Whether `value` is shaped like a v2 dtype: a string or field records.
+
+ A field record is a `(name, dtype)` or `(name, dtype, shape)` sequence,
+ where `dtype` is itself a string or nested field records and `shape` is a
+ sequence of int. The string content is NOT interpreted — whether the
+ string names a real dtype is domain validity, not structure.
+ """
+ if isinstance(value, str):
+ return True
+ if not isinstance(value, Sequence):
+ return False
+ for record in cast("Sequence[object]", value):
+ if isinstance(record, str) or not isinstance(record, Sequence):
+ return False
+ fields = cast("Sequence[object]", record)
+ if len(fields) not in (2, 3):
+ return False
+ if not isinstance(fields[0], str):
+ return False
+ if not _is_dtype_v2(fields[1]):
+ return False
+ if len(fields) == 3 and not _is_int_sequence(fields[2]):
+ return False
+ return True
+
+
+def _is_canonical_dtype_v2(value: object) -> bool:
+ """Whether a validated v2 dtype uses the tuple-backed public representation."""
+ if isinstance(value, str):
+ return True
+ if not isinstance(value, tuple):
+ return False
+ for record in cast("tuple[object, ...]", value):
+ if not isinstance(record, tuple):
+ return False
+ fields = cast("tuple[object, ...]", record)
+ if not _is_canonical_dtype_v2(fields[1]):
+ return False
+ if len(fields) == 3 and not isinstance(fields[2], tuple):
+ return False
+ return True
+
+
+def _is_canonical_metadata_field_v3(value: object) -> bool:
+ """Whether a validated v3 metadata field has its declared runtime container type."""
+ return isinstance(value, (str, dict))
+
+
+def _is_canonical_array_metadata_v3(value: object) -> bool:
+ """Whether a validated v3 array document matches `ZarrV3ArrayMetadataJSON` at runtime."""
+ if not isinstance(value, dict):
+ return False
+ doc = cast("dict[str, object]", value)
+ if not isinstance(doc["shape"], tuple) or not isinstance(doc["codecs"], tuple):
+ return False
+ if "storage_transformers" in doc and not isinstance(doc["storage_transformers"], tuple):
+ return False
+ if "dimension_names" in doc and not isinstance(doc["dimension_names"], tuple):
+ return False
+ if not all(
+ _is_canonical_metadata_field_v3(doc[key])
+ for key in ("data_type", "chunk_grid", "chunk_key_encoding")
+ ):
+ return False
+ if not all(
+ _is_canonical_metadata_field_v3(item) for item in cast("tuple[object, ...]", doc["codecs"])
+ ):
+ return False
+ return "storage_transformers" not in doc or all(
+ _is_canonical_metadata_field_v3(item)
+ for item in cast("tuple[object, ...]", doc["storage_transformers"])
+ )
+
+
+def _is_canonical_array_metadata_v2(value: object) -> bool:
+ """Whether a validated v2 array document matches `ZarrV2ArrayMetadataJSON` at runtime."""
+ if not isinstance(value, dict):
+ return False
+ doc = cast("dict[str, object]", value)
+ if not isinstance(doc["shape"], tuple) or not isinstance(doc["chunks"], tuple):
+ return False
+ if not _is_canonical_dtype_v2(doc["dtype"]):
+ return False
+ compressor = doc["compressor"]
+ if compressor is not None and not isinstance(compressor, dict):
+ return False
+ filters = doc["filters"]
+ return filters is None or (
+ isinstance(filters, tuple)
+ and all(isinstance(item, dict) for item in cast("tuple[object, ...]", filters))
+ )
+
+
+def _is_codec_v2(value: object) -> bool:
+ """Whether `value` is shaped like a v2 codec config: a mapping with a string `id`."""
+ return isinstance(value, Mapping) and isinstance(
+ cast("Mapping[object, object]", value).get("id"), str
+ )
+
+
+def _validate_codec_v2(value: object) -> list[ValidationProblem]:
+ """Validate a v2 codec's required shape and JSON-valued configuration."""
+ if not _is_codec_v2(value):
+ return [
+ ValidationProblem(
+ (), "expected a codec configuration with a string 'id'", "invalid_type"
+ )
+ ]
+ return validate_json(value)
+
+
+def _validate_attributes(value: object) -> list[ValidationProblem]:
+ """Validate an `attributes` value: a mapping with string keys.
+
+ Returns a problem at `("attributes",)` if it is not, else `[]`. Shared by the
+ v2 and v3 validators. Unlike the other `validate_*` functions (which
+ return value-relative locs for the caller to `_prefix`), this emits the
+ already-parent-relative `("attributes",)` loc, since it is only ever called
+ with a document's `attributes` value.
+ """
+ if not isinstance(value, Mapping) or not all(
+ isinstance(k, str) for k in cast("Mapping[object, object]", value)
+ ):
+ return [
+ ValidationProblem(
+ ("attributes",), "expected a mapping with string keys", "invalid_type"
+ )
+ ]
+ problems: list[ValidationProblem] = []
+ for key, item in cast("Mapping[str, object]", value).items():
+ problems.extend(_prefix("attributes", _prefix(key, validate_json(item))))
+ return problems
+
+
+def validate_array_metadata_v3(value: object) -> list[ValidationProblem]:
+ """Return every reason `value` is not a structurally-valid v3 array doc.
+
+ Checks structure, not domain validity. Unknown top-level keys are allowed
+ (they map to `extra_fields`).
+ """
+ if not isinstance(value, Mapping):
+ return [ValidationProblem((), "expected a mapping", "invalid_type")]
+ doc = cast("Mapping[str, object]", value)
+ problems: list[ValidationProblem] = _missing_keys(ARRAY_METADATA_REQUIRED_KEYS_V3, doc)
+ problems.extend(
+ _validate_extension_fields_v3(
+ cast("Mapping[object, object]", value), ARRAY_METADATA_STANDARD_KEYS_V3
+ )
+ )
+ problems.extend(_check_literal(doc, "zarr_format", 3))
+ problems.extend(_check_literal(doc, "node_type", "array"))
+ problems.extend(_validate_dim_sequence(doc, "shape"))
+ if "fill_value" in doc:
+ problems.extend(_prefix("fill_value", validate_json(doc["fill_value"])))
+ for key in ("data_type", "chunk_grid", "chunk_key_encoding"):
+ if key in doc:
+ problems.extend(
+ _prefix(
+ key,
+ validate_metadata_field_v3(doc[key], allow_must_understand_false=False),
+ )
+ )
+ for key in ("codecs", "storage_transformers"):
+ if key in doc:
+ entries = doc[key]
+ if isinstance(entries, str) or not isinstance(entries, Sequence):
+ problems.append(ValidationProblem((key,), "expected a sequence", "invalid_type"))
+ else:
+ if key == "codecs" and len(cast("Sequence[object]", entries)) == 0:
+ problems.append(
+ ValidationProblem(
+ ("codecs",), "expected at least one codec", "invalid_value"
+ )
+ )
+ for index, entry in enumerate(cast("Sequence[object]", entries)):
+ problems.extend(_prefix(key, _prefix(index, validate_metadata_field_v3(entry))))
+ if "attributes" in doc:
+ problems.extend(_validate_attributes(doc["attributes"]))
+ if "dimension_names" in doc:
+ # Simple typed sequences (dimension_names, shape, chunks) report a single
+ # field-level loc, not per-bad-item locs; per-index locs are reserved for
+ # the metadata-field lists (codecs, storage_transformers).
+ names = doc["dimension_names"]
+ if isinstance(names, str) or not isinstance(names, Sequence):
+ problems.append(
+ ValidationProblem(("dimension_names",), "expected a sequence", "invalid_type")
+ )
+ elif not all(
+ item is None or isinstance(item, str) for item in cast("Sequence[object]", names)
+ ):
+ problems.append(
+ ValidationProblem(
+ ("dimension_names",), "expected items of str or None", "invalid_type"
+ )
+ )
+ elif _is_int_sequence(doc.get("shape")) and len(cast("Sequence[object]", names)) != len(
+ cast("Sequence[int]", doc["shape"])
+ ):
+ problems.append(
+ ValidationProblem(
+ ("dimension_names",),
+ "expected one name per dimension of shape",
+ "invalid_value",
+ )
+ )
+ return problems
+
+
+def is_array_metadata_v3(value: object) -> TypeIs[ZarrV3ArrayMetadataJSON]:
+ """Whether `value` is a structurally-valid v3 array metadata document."""
+ return (
+ _is_canonical_json(value)
+ and not validate_array_metadata_v3(value)
+ and _is_canonical_array_metadata_v3(value)
+ )
+
+
+def parse_array_metadata_v3(value: object) -> ZarrV3ArrayMetadataJSON:
+ """Return `value` as `ZarrV3ArrayMetadataJSON`, or raise `MetadataValidationError`."""
+ normalized = arrays_to_tuples(value)
+ problems = validate_array_metadata_v3(normalized)
+ if problems:
+ raise MetadataValidationError(problems)
+ return cast("ZarrV3ArrayMetadataJSON", normalized)
+
+
+def validate_array_metadata_v2(value: object) -> list[ValidationProblem]:
+ """Return every reason `value` is not a structurally-valid v2 array doc.
+
+ Checks structure, not domain validity: `dtype` must be a string or field
+ records, but the string content is not interpreted; `compressor` and
+ `filters` are required keys that may be `None`, and otherwise must be
+ codec configurations (mappings with a string `id`).
+ """
+ if not isinstance(value, Mapping):
+ return [ValidationProblem((), "expected a mapping", "invalid_type")]
+ doc = cast("Mapping[str, object]", value)
+ problems: list[ValidationProblem] = _missing_keys(ARRAY_METADATA_REQUIRED_KEYS_V2, doc)
+ problems.extend(
+ _unexpected_keys(ARRAY_METADATA_STANDARD_KEYS_V2, cast("Mapping[object, object]", value))
+ )
+ problems.extend(_check_literal(doc, "zarr_format", 2))
+ shape_problems = _validate_dim_sequence(doc, "shape")
+ chunks_problems = _validate_dim_sequence(doc, "chunks")
+ problems.extend(shape_problems)
+ problems.extend(chunks_problems)
+ if (
+ not shape_problems
+ and not chunks_problems
+ and _is_int_sequence(doc.get("shape"))
+ and _is_int_sequence(doc.get("chunks"))
+ ):
+ shape = cast("Sequence[int]", doc["shape"])
+ chunks = cast("Sequence[int]", doc["chunks"])
+ if len(shape) != len(chunks):
+ problems.append(
+ ValidationProblem(
+ ("chunks",),
+ "expected the same number of dimensions as shape",
+ "invalid_value",
+ )
+ )
+ if "dtype" in doc and not _is_dtype_v2(doc["dtype"]):
+ problems.append(
+ ValidationProblem(
+ ("dtype",),
+ "expected a v2 dtype string or a sequence of field records",
+ "invalid_type",
+ )
+ )
+ if "order" in doc and doc["order"] not in ("C", "F"):
+ problems.append(
+ ValidationProblem(
+ ("order",), f"expected 'C' or 'F', got {doc['order']!r}", "invalid_value"
+ )
+ )
+ if "compressor" in doc:
+ compressor = doc["compressor"]
+ if compressor is not None:
+ problems.extend(_prefix("compressor", _validate_codec_v2(compressor)))
+ if "filters" in doc:
+ filters = doc["filters"]
+ if filters is not None and (
+ isinstance(filters, str)
+ or not isinstance(filters, Sequence)
+ or not all(_is_codec_v2(item) for item in cast("Sequence[object]", filters))
+ ):
+ problems.append(
+ ValidationProblem(
+ ("filters",),
+ "expected null or a sequence of codec configurations with string 'id's",
+ "invalid_type",
+ )
+ )
+ elif filters is not None:
+ if len(cast("Sequence[object]", filters)) == 0:
+ problems.append(
+ ValidationProblem(("filters",), "expected at least one filter", "invalid_value")
+ )
+ for index, item in enumerate(cast("Sequence[object]", filters)):
+ problems.extend(_prefix("filters", _prefix(index, validate_json(item))))
+ if "dimension_separator" in doc and doc["dimension_separator"] not in (".", "/"):
+ problems.append(
+ ValidationProblem(
+ ("dimension_separator",),
+ f"expected '.' or '/', got {doc['dimension_separator']!r}",
+ "invalid_value",
+ )
+ )
+ if "fill_value" in doc:
+ problems.extend(_prefix("fill_value", validate_json(doc["fill_value"])))
+ if "attributes" in doc:
+ problems.extend(_validate_attributes(doc["attributes"]))
+ return problems
+
+
+def is_array_metadata_v2(value: object) -> TypeIs[ZarrV2ArrayMetadataJSON]:
+ """Whether `value` is a structurally-valid v2 array metadata document."""
+ return (
+ _is_canonical_json(value)
+ and not validate_array_metadata_v2(value)
+ and _is_canonical_array_metadata_v2(value)
+ )
+
+
+def parse_array_metadata_v2(value: object) -> ZarrV2ArrayMetadataJSON:
+ """Return `value` as `ZarrV2ArrayMetadataJSON`, or raise `MetadataValidationError`."""
+ normalized = arrays_to_tuples(value)
+ problems = validate_array_metadata_v2(normalized)
+ if problems:
+ raise MetadataValidationError(problems)
+ return cast("ZarrV2ArrayMetadataJSON", normalized)
+
+
+def validate_consolidated_metadata_v3(value: object) -> list[ValidationProblem]:
+ """Return every reason `value` is not a valid inline consolidated envelope.
+
+ Locs are value-relative (the caller prefixes with `consolidated_metadata`
+ where appropriate). Entries recurse into the array and group document
+ validators, so a validator verdict always agrees with what
+ `ZarrV3ConsolidatedMetadata.from_json` accepts.
+ """
+ if not isinstance(value, Mapping):
+ return [ValidationProblem((), "expected a mapping", "invalid_type")]
+ env = cast("Mapping[str, object]", value)
+ problems: list[ValidationProblem] = [
+ ValidationProblem((key,), "missing required key", "missing_key")
+ for key in ("kind", "must_understand", "metadata")
+ if key not in env
+ ]
+ problems.extend(
+ _unexpected_keys(
+ frozenset({"kind", "must_understand", "metadata"}),
+ cast("Mapping[object, object]", value),
+ )
+ )
+ problems.extend(_check_literal(env, "kind", "inline"))
+ if "must_understand" in env and env["must_understand"] is not False:
+ problems.append(ValidationProblem(("must_understand",), "expected False", "invalid_value"))
+ if "metadata" in env:
+ entries = env["metadata"]
+ if not isinstance(entries, Mapping):
+ problems.append(ValidationProblem(("metadata",), "expected a mapping", "invalid_type"))
+ else:
+ for key, entry in cast("Mapping[object, object]", entries).items():
+ if not isinstance(key, str):
+ problems.append(
+ ValidationProblem(("metadata",), f"non-string key {key!r}", "invalid_type")
+ )
+ continue
+ entry_obj: object = entry
+ node_type: object = None
+ if isinstance(entry, Mapping):
+ node_type = cast("Mapping[str, object]", entry).get("node_type")
+ if node_type == "array":
+ problems.extend(
+ _prefix("metadata", _prefix(key, validate_array_metadata_v3(entry_obj)))
+ )
+ elif node_type == "group":
+ problems.extend(
+ _prefix("metadata", _prefix(key, validate_group_metadata_v3(entry_obj)))
+ )
+ else:
+ problems.append(
+ ValidationProblem(
+ ("metadata", key, "node_type"),
+ "expected 'array' or 'group'",
+ "invalid_value",
+ )
+ )
+ return problems
+
+
+def validate_group_metadata_v3(value: object) -> list[ValidationProblem]:
+ """Return every reason `value` is not a structurally-valid v3 group doc.
+
+ Checks structure, not domain validity. Unknown top-level keys are allowed
+ (they map to `extra_fields`); a `consolidated_metadata` key, if present,
+ is deep-validated (envelope and entries) via
+ `validate_consolidated_metadata_v3`.
+ """
+ if not isinstance(value, Mapping):
+ return [ValidationProblem((), "expected a mapping", "invalid_type")]
+ doc = cast("Mapping[str, object]", value)
+ problems: list[ValidationProblem] = _missing_keys(GROUP_METADATA_REQUIRED_KEYS_V3, doc)
+ problems.extend(
+ _validate_extension_fields_v3(
+ cast("Mapping[object, object]", value),
+ GROUP_METADATA_STANDARD_KEYS_V3,
+ additional_reserved_keys=frozenset({"consolidated_metadata"}),
+ )
+ )
+ problems.extend(_check_literal(doc, "zarr_format", 3))
+ problems.extend(_check_literal(doc, "node_type", "group"))
+ if "attributes" in doc:
+ problems.extend(_validate_attributes(doc["attributes"]))
+ if "consolidated_metadata" in doc and doc["consolidated_metadata"] is not None:
+ # consolidated_metadata: null (a historical zarr-python bug) is
+ # structurally accepted so those stores remain readable, but the model
+ # repairs it to absence on read and never writes it back.
+ problems.extend(
+ _prefix(
+ "consolidated_metadata",
+ validate_consolidated_metadata_v3(doc["consolidated_metadata"]),
+ )
+ )
+ return problems
+
+
+def is_group_metadata_v3(value: object) -> TypeIs[ZarrV3GroupMetadataJSON]:
+ """Whether `value` is a structurally-valid v3 group metadata document."""
+ return _is_canonical_json(value) and not validate_group_metadata_v3(value)
+
+
+def parse_group_metadata_v3(value: object) -> ZarrV3GroupMetadataJSON:
+ """Return `value` narrowed to `ZarrV3GroupMetadataJSON`, or raise `MetadataValidationError`."""
+ normalized = arrays_to_tuples(value)
+ problems = validate_group_metadata_v3(normalized)
+ if problems:
+ raise MetadataValidationError(problems)
+ return cast(ZarrV3GroupMetadataJSON, normalized)
+
+
+def validate_group_metadata_v2(value: object) -> list[ValidationProblem]:
+ """Return every reason `value` is not a structurally-valid v2 group doc.
+
+ Validates the in-memory merged form: the `.zgroup` fields plus an
+ optional `attributes` mapping folded in from `.zattrs`.
+ """
+ if not isinstance(value, Mapping):
+ return [ValidationProblem((), "expected a mapping", "invalid_type")]
+ doc = cast("Mapping[str, object]", value)
+ problems: list[ValidationProblem] = _missing_keys(GROUP_METADATA_REQUIRED_KEYS_V2, doc)
+ problems.extend(
+ _unexpected_keys(GROUP_METADATA_STANDARD_KEYS_V2, cast("Mapping[object, object]", value))
+ )
+ problems.extend(_check_literal(doc, "zarr_format", 2))
+ if "attributes" in doc:
+ problems.extend(_validate_attributes(doc["attributes"]))
+ return problems
+
+
+def is_group_metadata_v2(value: object) -> TypeIs[ZarrV2GroupMetadataJSON]:
+ """Whether `value` is a structurally-valid v2 group metadata document."""
+ return _is_canonical_json(value) and not validate_group_metadata_v2(value)
+
+
+def parse_group_metadata_v2(value: object) -> ZarrV2GroupMetadataJSON:
+ """Return `value` narrowed to `ZarrV2GroupMetadataJSON`, or raise `MetadataValidationError`."""
+ normalized = arrays_to_tuples(value)
+ problems = validate_group_metadata_v2(normalized)
+ if problems:
+ raise MetadataValidationError(problems)
+ return cast(ZarrV2GroupMetadataJSON, normalized)
+
+
+def _reject_json_constant(constant: str) -> NoReturn:
+ """Reject the JavaScript constants accepted by Python's JSON decoder."""
+ raise ValueError(f"non-standard JSON constant {constant!r}")
+
+
+def load_store_json(mapping: Mapping[str, bytes], key: str) -> Any:
+ """Decode the JSON document stored at `key` in `mapping`.
+
+ Every ingestion failure surfaces as `MetadataValidationError`: a missing
+ store key is a `missing_key` problem and undecodable bytes are an
+ `invalid_json` problem, rather than leaking `KeyError` /
+ `json.JSONDecodeError` to callers.
+ """
+ if key not in mapping:
+ raise MetadataValidationError(
+ [ValidationProblem((key,), "missing store key", "missing_key")]
+ )
+ try:
+ return json.loads(mapping[key], parse_constant=_reject_json_constant)
+ except (UnicodeDecodeError, ValueError) as exc:
+ raise MetadataValidationError(
+ [ValidationProblem((key,), f"invalid JSON: {exc}", "invalid_json")]
+ ) from exc
+
+
+def dump_store_json(value: object, *, indent: int | str | None = None) -> bytes:
+ """Encode a metadata document as strict RFC 8259 JSON bytes."""
+ return json.dumps(value, indent=indent, allow_nan=False).encode("utf-8")
+
+
+def arrays_to_tuples(obj: object) -> object:
+ """Recursively materialize mappings and convert array-like values to tuples."""
+ if isinstance(obj, Sequence) and not isinstance(obj, (str, bytes, bytearray)):
+ sequence = cast("Sequence[object]", obj)
+ converted_sequence = tuple(arrays_to_tuples(item) for item in sequence)
+ if isinstance(obj, tuple) and all(
+ converted is original
+ for converted, original in zip(converted_sequence, sequence, strict=True)
+ ):
+ return cast("tuple[object, ...]", obj)
+ return converted_sequence
+ if isinstance(obj, Mapping):
+ mapping = cast("Mapping[object, object]", obj)
+ converted: dict[object, object] = {
+ key: arrays_to_tuples(value) for key, value in mapping.items()
+ }
+ if isinstance(obj, dict) and all(converted[key] is value for key, value in mapping.items()):
+ return cast("object", obj)
+ return converted
+ return obj
diff --git a/packages/zarr-metadata/src/zarr_metadata/py.typed b/packages/zarr-metadata/src/zarr_metadata/py.typed
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/packages/zarr-metadata/src/zarr_metadata/pydantic.py b/packages/zarr-metadata/src/zarr_metadata/pydantic.py
new file mode 100644
index 0000000000..8584efa570
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/pydantic.py
@@ -0,0 +1,176 @@
+"""Optional pydantic (v2) integration: field types over the core models.
+
+Importing this module requires pydantic; the core package deliberately does
+not depend on it, so this module is never imported by `zarr_metadata` itself.
+
+Each exported name is an `Annotated` field type over the corresponding core
+model class — the instances ARE the core classes, so values interoperate
+freely with non-pydantic code (equality, isinstance, nesting). Validation
+delegates to the library: a raw document routes through `from_json` (the
+single source of truth for structural validation and normalization, so
+pydantic's field-level coercion can never bypass it), an existing model
+instance passes through unchanged, and serialization emits the canonical
+document via `to_json`. `MetadataValidationError` subclasses `ValueError`,
+so a failed parse surfaces as a pydantic `ValidationError` carrying the
+loc-annotated problem messages.
+
+Usage:
+
+ import zarr_metadata.pydantic as zmp
+
+ class ArrayManifest(BaseModel):
+ path: str
+ metadata: zmp.ZarrV3ArrayMetadata
+
+Static type checkers see each field type as its core model class, so
+`manifest.metadata` is a `zarr_metadata.model.ZarrV3ArrayMetadata`.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Annotated, TypeVar
+
+from pydantic import BeforeValidator, InstanceOf, PlainSerializer
+
+from zarr_metadata import model as _model
+from zarr_metadata._pydantic_schema import (
+ ZarrV2ArrayMetadataJSON as _ZarrV2ArrayMetadataSchema,
+)
+from zarr_metadata._pydantic_schema import (
+ ZarrV2ConsolidatedMetadataJSON as _ZarrV2ConsolidatedMetadataSchema,
+)
+from zarr_metadata._pydantic_schema import (
+ ZarrV2GroupMetadataJSON as _ZarrV2GroupMetadataSchema,
+)
+from zarr_metadata._pydantic_schema import (
+ ZarrV3ArrayMetadataJSON as _ZarrV3ArrayMetadataSchema,
+)
+from zarr_metadata._pydantic_schema import (
+ ZarrV3ConsolidatedMetadataJSON as _ZarrV3ConsolidatedMetadataSchema,
+)
+from zarr_metadata._pydantic_schema import (
+ ZarrV3GroupMetadataJSON as _ZarrV3GroupMetadataSchema,
+)
+from zarr_metadata._pydantic_schema import (
+ ZarrV3MetadataFieldJSON as _ZarrV3MetadataFieldSchema,
+)
+from zarr_metadata.v2.array import ZarrV2ArrayMetadataJSON as _ZarrV2ArrayMetadataJSON
+from zarr_metadata.v2.consolidated import (
+ ZarrV2ConsolidatedMetadataJSON as _ZarrV2ConsolidatedMetadataJSON,
+)
+from zarr_metadata.v2.group import ZarrV2GroupMetadataJSON as _ZarrV2GroupMetadataJSON
+from zarr_metadata.v3._common import ZarrV3MetadataFieldJSON as _ZarrV3MetadataFieldJSON
+from zarr_metadata.v3.array import ZarrV3ArrayMetadataJSON as _ZarrV3ArrayMetadataJSON
+from zarr_metadata.v3.consolidated import (
+ ZarrV3ConsolidatedMetadataJSON as _ZarrV3ConsolidatedMetadataJSON,
+)
+from zarr_metadata.v3.group import ZarrV3GroupMetadataJSON as _ZarrV3GroupMetadataJSON
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+_M = TypeVar("_M")
+
+
+def _coerce_to(cls: type[_M], parse: Callable[[object], _M]) -> Callable[[object], _M]:
+ """A validator that passes instances of `cls` through and parses anything else."""
+
+ def coerce(value: object) -> _M:
+ if isinstance(value, cls):
+ return value
+ return parse(value)
+
+ return coerce
+
+
+ZarrV3ArrayMetadata = Annotated[
+ InstanceOf[_model.ZarrV3ArrayMetadata],
+ BeforeValidator(
+ _coerce_to(_model.ZarrV3ArrayMetadata, _model.ZarrV3ArrayMetadata.from_json),
+ json_schema_input_type=_ZarrV3ArrayMetadataSchema,
+ ),
+ PlainSerializer(_model.ZarrV3ArrayMetadata.to_json, return_type=_ZarrV3ArrayMetadataJSON),
+]
+"""Field type for a v3 array metadata document (`zarr.json` content)."""
+
+ZarrV2ArrayMetadata = Annotated[
+ InstanceOf[_model.ZarrV2ArrayMetadata],
+ BeforeValidator(
+ _coerce_to(_model.ZarrV2ArrayMetadata, _model.ZarrV2ArrayMetadata.from_json),
+ json_schema_input_type=_ZarrV2ArrayMetadataSchema,
+ ),
+ PlainSerializer(_model.ZarrV2ArrayMetadata.to_json, return_type=_ZarrV2ArrayMetadataJSON),
+]
+"""Field type for a v2 array metadata document (merged `.zarray` + `.zattrs` form)."""
+
+ZarrV3GroupMetadata = Annotated[
+ InstanceOf[_model.ZarrV3GroupMetadata],
+ BeforeValidator(
+ _coerce_to(_model.ZarrV3GroupMetadata, _model.ZarrV3GroupMetadata.from_json),
+ json_schema_input_type=_ZarrV3GroupMetadataSchema,
+ ),
+ PlainSerializer(_model.ZarrV3GroupMetadata.to_json, return_type=_ZarrV3GroupMetadataJSON),
+]
+"""Field type for a v3 group metadata document (`zarr.json` content)."""
+
+ZarrV2GroupMetadata = Annotated[
+ InstanceOf[_model.ZarrV2GroupMetadata],
+ BeforeValidator(
+ _coerce_to(_model.ZarrV2GroupMetadata, _model.ZarrV2GroupMetadata.from_json),
+ json_schema_input_type=_ZarrV2GroupMetadataSchema,
+ ),
+ PlainSerializer(_model.ZarrV2GroupMetadata.to_json, return_type=_ZarrV2GroupMetadataJSON),
+]
+"""Field type for a v2 group metadata document (merged `.zgroup` + `.zattrs` form)."""
+
+ZarrV3ConsolidatedMetadata = Annotated[
+ InstanceOf[_model.ZarrV3ConsolidatedMetadata],
+ BeforeValidator(
+ _coerce_to(
+ _model.ZarrV3ConsolidatedMetadata,
+ _model.ZarrV3ConsolidatedMetadata.from_json,
+ ),
+ json_schema_input_type=_ZarrV3ConsolidatedMetadataSchema,
+ ),
+ PlainSerializer(
+ _model.ZarrV3ConsolidatedMetadata.to_json,
+ return_type=_ZarrV3ConsolidatedMetadataJSON,
+ ),
+]
+"""Field type for v3 inline consolidated metadata."""
+
+ZarrV2ConsolidatedMetadata = Annotated[
+ InstanceOf[_model.ZarrV2ConsolidatedMetadata],
+ BeforeValidator(
+ _coerce_to(
+ _model.ZarrV2ConsolidatedMetadata,
+ _model.ZarrV2ConsolidatedMetadata.from_json,
+ ),
+ json_schema_input_type=_ZarrV2ConsolidatedMetadataSchema,
+ ),
+ PlainSerializer(
+ _model.ZarrV2ConsolidatedMetadata.to_json,
+ return_type=_ZarrV2ConsolidatedMetadataJSON,
+ ),
+]
+"""Field type for a v2 `.zmetadata` document."""
+
+ZarrV3MetadataField = Annotated[
+ InstanceOf[_model.ZarrV3NamedConfig],
+ BeforeValidator(
+ _coerce_to(_model.ZarrV3NamedConfig, _model.ZarrV3NamedConfig.from_json),
+ json_schema_input_type=_ZarrV3MetadataFieldSchema,
+ ),
+ PlainSerializer(_model.ZarrV3NamedConfig.to_json, return_type=_ZarrV3MetadataFieldJSON),
+]
+"""Field type for one normalized v3 metadata extension envelope."""
+
+__all__ = [
+ "ZarrV2ArrayMetadata",
+ "ZarrV2ConsolidatedMetadata",
+ "ZarrV2GroupMetadata",
+ "ZarrV3ArrayMetadata",
+ "ZarrV3ConsolidatedMetadata",
+ "ZarrV3GroupMetadata",
+ "ZarrV3MetadataField",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v2/__init__.py b/packages/zarr-metadata/src/zarr_metadata/v2/__init__.py
new file mode 100644
index 0000000000..b9001d168e
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v2/__init__.py
@@ -0,0 +1,26 @@
+"""Zarr v2 metadata types."""
+
+from zarr_metadata.v2.array import (
+ ZarrV2ArrayDimensionSeparator,
+ ZarrV2ArrayMetadataJSON,
+ ZarrV2ArrayOrder,
+ ZarrV2DataTypeMetadata,
+ ZarrV2ZArrayJSON,
+)
+from zarr_metadata.v2.attributes import ZarrV2ZAttrsJSON
+from zarr_metadata.v2.codec import ZarrV2CodecMetadata
+from zarr_metadata.v2.consolidated import ZarrV2ConsolidatedMetadataJSON
+from zarr_metadata.v2.group import ZarrV2GroupMetadataJSON, ZarrV2ZGroupJSON
+
+__all__ = [
+ "ZarrV2ArrayDimensionSeparator",
+ "ZarrV2ArrayMetadataJSON",
+ "ZarrV2ArrayOrder",
+ "ZarrV2CodecMetadata",
+ "ZarrV2ConsolidatedMetadataJSON",
+ "ZarrV2DataTypeMetadata",
+ "ZarrV2GroupMetadataJSON",
+ "ZarrV2ZArrayJSON",
+ "ZarrV2ZAttrsJSON",
+ "ZarrV2ZGroupJSON",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v2/array.py b/packages/zarr-metadata/src/zarr_metadata/v2/array.py
new file mode 100644
index 0000000000..e026e5c655
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v2/array.py
@@ -0,0 +1,170 @@
+"""Zarr v2 array metadata types."""
+
+from collections.abc import Mapping
+from typing import Final, Literal, NotRequired
+
+from typing_extensions import TypeAliasType, TypedDict
+
+from zarr_metadata._common import JSONValue
+from zarr_metadata.v2.codec import ZarrV2CodecMetadata
+
+ZarrV2DataTypeMetadata = TypeAliasType(
+ "ZarrV2DataTypeMetadata",
+ str
+ | tuple[
+ tuple[str, "ZarrV2DataTypeMetadata"]
+ | tuple[str, "ZarrV2DataTypeMetadata", tuple[int, ...]],
+ ...,
+ ],
+)
+"""The v2 dtype representation.
+
+Either a numpy-style dtype string (e.g. `"/.zarray` for
+ a v2 array. User attributes live in a sibling `.zattrs` file and are
+ NOT part of this type; see `ZarrV2ZAttrsJSON`.
+
+ See https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html
+ """
+
+ zarr_format: Literal[2]
+ shape: tuple[int, ...]
+ chunks: tuple[int, ...]
+ dtype: ZarrV2DataTypeMetadata
+ compressor: ZarrV2CodecMetadata | None
+ fill_value: JSONValue
+ order: ZarrV2ArrayOrder
+ filters: tuple[ZarrV2CodecMetadata, ...] | None
+ dimension_separator: NotRequired[ZarrV2ArrayDimensionSeparator]
+
+
+class ZarrV2ArrayMetadataJSON(TypedDict):
+ """
+ Zarr v2 array metadata document, in-memory merged form.
+
+ Models the union of `.zarray` (the spec-defined fields) and `.zattrs`
+ (user attributes). On disk, attributes live in a sibling `.zattrs` file
+ and are not part of `.zarray`; this type folds them in as the
+ `attributes` field so a single TypedDict represents the complete
+ in-memory state of a v2 array node. Consumers that read or write a
+ real `.zarray` file should split / merge `attributes` accordingly,
+ or use `ZarrV2ZArrayJSON` (strict on-disk) plus `ZarrV2ZAttrsJSON` directly.
+
+ See https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html
+ """
+
+ zarr_format: Literal[2]
+ shape: tuple[int, ...]
+ chunks: tuple[int, ...]
+ dtype: ZarrV2DataTypeMetadata
+ compressor: ZarrV2CodecMetadata | None
+ fill_value: JSONValue
+ order: ZarrV2ArrayOrder
+ filters: tuple[ZarrV2CodecMetadata, ...] | None
+ dimension_separator: NotRequired[ZarrV2ArrayDimensionSeparator]
+ attributes: NotRequired[Mapping[str, JSONValue]]
+ """User attributes from the sibling `.zattrs` file (not part of `.zarray`).
+
+ See the class docstring for the rationale behind the merged representation.
+ """
+
+
+class ZarrV2ArrayMetadataJSONPartial(TypedDict, total=False):
+ """
+ Partial form of `ZarrV2ArrayMetadataJSON`: every field is `NotRequired`.
+
+ Field annotations mirror `ZarrV2ArrayMetadataJSON` exactly. The only difference is
+ `total=False`, which makes every key optional at the type level.
+
+ Use this when typing dicts that intentionally hold a subset of a complete
+ v2 array metadata document — e.g. test fixtures that override only a few
+ fields of a base template, or callers that build a fragment to be merged
+ into a complete document elsewhere.
+
+ The `NotRequired[...]` wrappers on `dimension_separator` and `attributes`
+ are intentional: keeping them preserves byte-identical `__annotations__`
+ with `ZarrV2ArrayMetadataJSON` so the `==` check in
+ `tests/test_partial_equivalence.py` passes without special-casing those
+ fields (PEP 655 explicitly permits `NotRequired` inside `total=False`).
+
+ Note: v2 array metadata has no `extra_items` setting (the v2 spec has no
+ extension-field concept), so this partial inherits the same closed shape.
+
+ Drift between this type and `ZarrV2ArrayMetadataJSON` is prevented by
+ `tests/test_partial_equivalence.py`.
+ """
+
+ zarr_format: Literal[2]
+ shape: tuple[int, ...]
+ chunks: tuple[int, ...]
+ dtype: ZarrV2DataTypeMetadata
+ compressor: ZarrV2CodecMetadata | None
+ fill_value: JSONValue
+ order: ZarrV2ArrayOrder
+ filters: tuple[ZarrV2CodecMetadata, ...] | None
+ dimension_separator: NotRequired[ZarrV2ArrayDimensionSeparator]
+ attributes: NotRequired[Mapping[str, JSONValue]]
+ """User attributes from the sibling `.zattrs` file (not part of `.zarray`).
+
+ See the class docstring for the rationale behind the merged representation.
+ """
+
+
+ZarrV2ArrayMetadataStoreKey = Literal[".zarray"]
+"""Literal type of the store key holding a v2 array's metadata document."""
+
+ZARR_V2_ARRAY_METADATA_STORE_KEY: Final[ZarrV2ArrayMetadataStoreKey] = ".zarray"
+"""The store key a v2 array's metadata document is persisted under."""
+
+
+__all__ = [
+ "ZARR_V2_ARRAY_DIMENSION_SEPARATOR",
+ "ZARR_V2_ARRAY_METADATA_STORE_KEY",
+ "ZARR_V2_ARRAY_ORDER",
+ "ZarrV2ArrayDimensionSeparator",
+ "ZarrV2ArrayMetadataJSON",
+ "ZarrV2ArrayMetadataJSONPartial",
+ "ZarrV2ArrayMetadataStoreKey",
+ "ZarrV2ArrayOrder",
+ "ZarrV2DataTypeMetadata",
+ "ZarrV2ZArrayJSON",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v2/attributes.py b/packages/zarr-metadata/src/zarr_metadata/v2/attributes.py
new file mode 100644
index 0000000000..68785d1660
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v2/attributes.py
@@ -0,0 +1,36 @@
+"""Zarr v2 user-attributes file content.
+
+See https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html
+"""
+
+from collections.abc import Mapping
+from typing import Final, Literal
+
+from zarr_metadata._common import JSONValue
+
+ZarrV2ZAttrsJSON = Mapping[str, JSONValue]
+"""On-disk `.zattrs` file content.
+
+A JSON object holding user-defined attributes for a v2 array or group.
+Spec-defined keys for arrays / groups live in sibling `.zarray` / `.zgroup`
+files (modeled by `ZarrV2ZArrayJSON` / `ZarrV2ZGroupJSON`). This type does not
+constrain the keys or values of the attributes mapping.
+"""
+
+
+ZarrV2AttributesStoreKey = Literal[".zattrs"]
+"""Literal type of the store key holding a v2 node's user attributes."""
+
+ZARR_V2_ATTRIBUTES_STORE_KEY: Final[ZarrV2AttributesStoreKey] = ".zattrs"
+"""The store key a v2 node's user attributes are persisted under.
+
+Shared by arrays and groups: both node types keep their attributes in a
+sibling `.zattrs` file.
+"""
+
+
+__all__ = [
+ "ZARR_V2_ATTRIBUTES_STORE_KEY",
+ "ZarrV2AttributesStoreKey",
+ "ZarrV2ZAttrsJSON",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v2/codec.py b/packages/zarr-metadata/src/zarr_metadata/v2/codec.py
new file mode 100644
index 0000000000..69125544e6
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v2/codec.py
@@ -0,0 +1,29 @@
+"""
+Zarr v2 codec configuration shape.
+
+In v2, compressors and filters are numcodecs configuration dicts: a required
+`id` field naming the codec, plus arbitrary codec-specific extra fields.
+"""
+
+from typing_extensions import TypedDict
+
+from zarr_metadata._common import JSONValue
+
+
+class ZarrV2CodecMetadata(TypedDict, extra_items=JSONValue):
+ """
+ A numcodecs configuration dict, used as a v2 compressor or filter.
+
+ The required `id` field names the codec; codec-specific parameters
+ (e.g. `cname`, `clevel` for blosc) appear as extra fields.
+
+ See the "compressor" and "filters" sections of
+ https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html
+ """
+
+ id: str
+
+
+__all__ = [
+ "ZarrV2CodecMetadata",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py b/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py
new file mode 100644
index 0000000000..999c9131da
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py
@@ -0,0 +1,56 @@
+"""Zarr v2 consolidated metadata (`.zmetadata` file).
+
+This module models the de-facto `.zmetadata` file used by the reference
+Python implementation of Zarr v2. **This is NOT a spec artifact.** There
+is no Zarr v2 specification that defines `.zmetadata`; it is a
+canonical-implementation convention.
+"""
+
+from collections.abc import Mapping
+from typing import Final, Literal
+
+from typing_extensions import TypedDict
+
+from zarr_metadata.v2.array import ZarrV2ZArrayJSON
+from zarr_metadata.v2.attributes import ZarrV2ZAttrsJSON
+from zarr_metadata.v2.group import ZarrV2ZGroupJSON
+
+
+class ZarrV2ConsolidatedMetadataJSON(TypedDict):
+ """
+ `.zmetadata` file contents.
+
+ The `metadata` map uses flat path keys (`"foo/bar/.zarray"`,
+ `"foo/.zattrs"`, etc.) pointing to the JSON contents of the file at
+ that path. The keys include the filename suffix, not just the node
+ path; the value's shape is determined by which file the key points at:
+
+ - `/.zarray` -> `ZarrV2ZArrayJSON`
+ - `/.zgroup` -> `ZarrV2ZGroupJSON`
+ - `/.zattrs` -> `ZarrV2ZAttrsJSON`
+
+ The TypedDict cannot discriminate the value shape on the key suffix
+ at the type level; consumers should narrow at runtime by inspecting
+ `key.endswith(".zarray")` etc.
+ """
+
+ zarr_consolidated_format: int
+ metadata: Mapping[str, ZarrV2ZArrayJSON | ZarrV2ZGroupJSON | ZarrV2ZAttrsJSON]
+
+
+ZarrV2ConsolidatedMetadataStoreKey = Literal[".zmetadata"]
+"""Literal type of the store key holding a v2 hierarchy's consolidated metadata."""
+
+ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY: Final[ZarrV2ConsolidatedMetadataStoreKey] = ".zmetadata"
+"""The store key a v2 hierarchy's consolidated metadata is persisted under.
+
+Like the document it names, this is a reference-implementation convention
+rather than a spec artifact; see the module docstring.
+"""
+
+
+__all__ = [
+ "ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY",
+ "ZarrV2ConsolidatedMetadataJSON",
+ "ZarrV2ConsolidatedMetadataStoreKey",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v2/group.py b/packages/zarr-metadata/src/zarr_metadata/v2/group.py
new file mode 100644
index 0000000000..34d72742c2
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v2/group.py
@@ -0,0 +1,90 @@
+"""Zarr v2 group metadata types.
+
+See https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html
+"""
+
+from collections.abc import Mapping
+from typing import Final, Literal, NotRequired
+
+from typing_extensions import TypedDict
+
+from zarr_metadata._common import JSONValue
+
+
+class ZarrV2ZGroupJSON(TypedDict):
+ """
+ On-disk `.zgroup` file content.
+
+ Strict shape of the JSON document persisted at `/.zgroup` for
+ a v2 group. The spec defines exactly one field. User attributes live
+ in a sibling `.zattrs` file and are NOT part of this type; see
+ `ZarrV2ZAttrsJSON`.
+
+ See https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html
+ """
+
+ zarr_format: Literal[2]
+
+
+class ZarrV2GroupMetadataJSON(TypedDict):
+ """
+ Zarr v2 group metadata document, in-memory merged form.
+
+ Models the union of `.zgroup` (the spec-defined `zarr_format` field)
+ and `.zattrs` (user attributes). On disk these are persisted as two
+ separate files; this type folds them so a single TypedDict represents
+ the complete in-memory state of a v2 group node. Consumers that read
+ or write the real on-disk files should use `ZarrV2ZGroupJSON` (strict
+ `.zgroup`) plus `ZarrV2ZAttrsJSON` directly.
+
+ See https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html
+ """
+
+ zarr_format: Literal[2]
+ attributes: NotRequired[Mapping[str, JSONValue]]
+
+
+class ZarrV2GroupMetadataJSONPartial(TypedDict, total=False):
+ """
+ Partial form of `ZarrV2GroupMetadataJSON`: every field is `NotRequired`.
+
+ Field annotations mirror `ZarrV2GroupMetadataJSON` exactly. The only difference is
+ `total=False`, which makes every key optional at the type level.
+
+ Use this when typing dicts that intentionally hold a subset of a complete
+ v2 group metadata document — e.g. test fixtures that override only a few
+ fields of a base template, or callers that build a fragment to be merged
+ into a complete document elsewhere. Provided for symmetry with the other
+ `*Partial` types; the practical effect is that `zarr_format` becomes optional.
+
+ The `NotRequired[...]` wrapper on `attributes` is intentional: keeping it
+ preserves byte-identical `__annotations__` with `ZarrV2GroupMetadataJSON` so the
+ `==` check in `tests/test_partial_equivalence.py` passes without
+ special-casing that field (PEP 655 explicitly permits `NotRequired` inside
+ `total=False`).
+
+ Note: v2 group metadata has no `extra_items` setting (the v2 spec has no
+ extension-field concept), so this partial inherits the same closed shape.
+
+ Drift between this type and `ZarrV2GroupMetadataJSON` is prevented by
+ `tests/test_partial_equivalence.py`.
+ """
+
+ zarr_format: Literal[2]
+ attributes: NotRequired[Mapping[str, JSONValue]]
+
+
+ZarrV2GroupMetadataStoreKey = Literal[".zgroup"]
+"""Literal type of the store key holding a v2 group's metadata document."""
+
+ZARR_V2_GROUP_METADATA_STORE_KEY: Final[ZarrV2GroupMetadataStoreKey] = ".zgroup"
+"""The store key a v2 group's metadata document is persisted under."""
+
+
+__all__ = [
+ "ZARR_V2_GROUP_METADATA_STORE_KEY",
+ "ZarrV2GroupMetadataJSON",
+ "ZarrV2GroupMetadataJSONPartial",
+ "ZarrV2GroupMetadataStoreKey",
+ "ZarrV2ZGroupJSON",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/__init__.py b/packages/zarr-metadata/src/zarr_metadata/v3/__init__.py
new file mode 100644
index 0000000000..4e335f9573
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/__init__.py
@@ -0,0 +1,14 @@
+"""Zarr v3 metadata types."""
+
+from zarr_metadata.v3._common import ZarrV3MetadataFieldJSON
+from zarr_metadata.v3.array import ZarrV3ArrayMetadataJSON, ZarrV3ExtensionField
+from zarr_metadata.v3.consolidated import ZarrV3ConsolidatedMetadataJSON
+from zarr_metadata.v3.group import ZarrV3GroupMetadataJSON
+
+__all__ = [
+ "ZarrV3ArrayMetadataJSON",
+ "ZarrV3ConsolidatedMetadataJSON",
+ "ZarrV3ExtensionField",
+ "ZarrV3GroupMetadataJSON",
+ "ZarrV3MetadataFieldJSON",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/_common.py b/packages/zarr-metadata/src/zarr_metadata/v3/_common.py
new file mode 100644
index 0000000000..406b76b723
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/_common.py
@@ -0,0 +1,23 @@
+"""Internal cross-cutting aliases for Zarr v3 metadata.
+
+This module is private (underscore-prefixed) and exists to avoid circular
+imports between leaf modules and sub-package `__init__.py` re-exports.
+Public consumers should import `ZarrV3MetadataFieldJSON` from `zarr_metadata.v3`.
+"""
+
+from zarr_metadata._common import ZarrV3NamedConfigJSON
+
+ZarrV3MetadataFieldJSON = str | ZarrV3NamedConfigJSON
+"""The JSON shape of any v3 metadata extension-point entry: either a bare
+short-hand name string or a `{name, configuration, must_understand}` envelope.
+
+Used for `data_type`, `chunk_grid`, `chunk_key_encoding`, individual
+codec entries, and `storage_transformers` in v3 array metadata, and for
+the inner `codecs` / `index_codecs` lists of the `sharding_indexed`
+codec.
+"""
+
+
+__all__ = [
+ "ZarrV3MetadataFieldJSON",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/array.py b/packages/zarr-metadata/src/zarr_metadata/v3/array.py
new file mode 100644
index 0000000000..31a5f6b755
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/array.py
@@ -0,0 +1,95 @@
+"""Zarr v3 array metadata types."""
+
+from collections.abc import Mapping
+from typing import Final, Literal, NotRequired, TypeAlias
+
+from typing_extensions import TypedDict
+
+from zarr_metadata._common import JSONValue
+from zarr_metadata.v3._common import ZarrV3MetadataFieldJSON
+
+ZarrV3ExtensionField: TypeAlias = JSONValue
+"""The JSON value of an unknown top-level v3 metadata field.
+
+An object carrying the literal member `must_understand: false` may be ignored.
+Every other JSON shape implicitly requires understanding; recognition itself
+belongs to the reader rather than this structural type.
+"""
+
+
+class ZarrV3ArrayMetadataJSON(TypedDict, extra_items=ZarrV3ExtensionField):
+ """
+ Zarr v3 array metadata document (the `zarr.json` content for an array).
+
+ Extra keys may contain arbitrary JSON values.
+
+ See https://zarr-specs.readthedocs.io/en/latest/v3/core/index.html#array-metadata
+ """
+
+ zarr_format: Literal[3]
+ node_type: Literal["array"]
+ data_type: ZarrV3MetadataFieldJSON
+ shape: tuple[int, ...]
+ chunk_grid: ZarrV3MetadataFieldJSON
+ chunk_key_encoding: ZarrV3MetadataFieldJSON
+ fill_value: JSONValue
+ codecs: tuple[ZarrV3MetadataFieldJSON, ...]
+ attributes: NotRequired[Mapping[str, JSONValue]]
+ storage_transformers: NotRequired[tuple[ZarrV3MetadataFieldJSON, ...]]
+ dimension_names: NotRequired[tuple[str | None, ...]]
+
+
+class ZarrV3ArrayMetadataJSONPartial(TypedDict, total=False, extra_items=ZarrV3ExtensionField):
+ """
+ Partial form of `ZarrV3ArrayMetadataJSON`: every field is `NotRequired`.
+
+ Field annotations and `extra_items=` mirror `ZarrV3ArrayMetadataJSON` exactly.
+ The only difference is `total=False`, which makes every key optional
+ at the type level.
+
+ Use this when typing dicts that intentionally hold a subset of a complete
+ v3 array metadata document — e.g. test fixtures that override only a few
+ fields of a base template, or callers that build a fragment to be merged
+ into a complete document elsewhere.
+
+ The `NotRequired[...]` wrappers on `attributes`, `storage_transformers`,
+ and `dimension_names` are intentional: keeping them preserves byte-identical
+ `__annotations__` with `ZarrV3ArrayMetadataJSON` so the `==` check in
+ `tests/test_partial_equivalence.py` passes without special-casing those
+ fields (PEP 655 explicitly permits `NotRequired` inside `total=False`).
+
+ Drift between this type and `ZarrV3ArrayMetadataJSON` is prevented by
+ `tests/test_partial_equivalence.py`.
+ """
+
+ zarr_format: Literal[3]
+ node_type: Literal["array"]
+ data_type: ZarrV3MetadataFieldJSON
+ shape: tuple[int, ...]
+ chunk_grid: ZarrV3MetadataFieldJSON
+ chunk_key_encoding: ZarrV3MetadataFieldJSON
+ fill_value: JSONValue
+ codecs: tuple[ZarrV3MetadataFieldJSON, ...]
+ attributes: NotRequired[Mapping[str, JSONValue]]
+ storage_transformers: NotRequired[tuple[ZarrV3MetadataFieldJSON, ...]]
+ dimension_names: NotRequired[tuple[str | None, ...]]
+
+
+ZarrV3ArrayMetadataStoreKey = Literal["zarr.json"]
+"""Literal type of the store key holding a v3 array's metadata document."""
+
+ZARR_V3_ARRAY_METADATA_STORE_KEY: Final[ZarrV3ArrayMetadataStoreKey] = "zarr.json"
+"""The store key a v3 array's metadata document is persisted under.
+
+v3 uses one key for both node types; the document's `node_type` field
+distinguishes an array from a group.
+"""
+
+
+__all__ = [
+ "ZARR_V3_ARRAY_METADATA_STORE_KEY",
+ "ZarrV3ArrayMetadataJSON",
+ "ZarrV3ArrayMetadataJSONPartial",
+ "ZarrV3ArrayMetadataStoreKey",
+ "ZarrV3ExtensionField",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/__init__.py b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/__init__.py
new file mode 100644
index 0000000000..22b3ab52be
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/__init__.py
@@ -0,0 +1,23 @@
+"""
+Zarr v3 chunk grid metadata types.
+
+Each chunk grid lives in its own submodule:
+
+- `regular` -- core v3 spec
+- `rectilinear` -- zarr-extensions
+
+The `ChunkGridMetadata` aliases re-exported here are the canonical type
+for each grid's permitted JSON shapes. For the underlying
+`ChunkGridObject`, `ChunkGridConfiguration`, etc., import directly
+from the leaf submodule.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/core/index.html#chunk-grids
+"""
+
+from zarr_metadata.v3.chunk_grid.rectilinear import RectilinearChunkGridMetadata
+from zarr_metadata.v3.chunk_grid.regular import RegularChunkGridMetadata
+
+__all__ = [
+ "RectilinearChunkGridMetadata",
+ "RegularChunkGridMetadata",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/rectilinear.py b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/rectilinear.py
new file mode 100644
index 0000000000..e3551e3c72
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/rectilinear.py
@@ -0,0 +1,54 @@
+"""
+Rectilinear chunk grid (zarr-extensions).
+
+See https://github.com/zarr-developers/zarr-extensions/tree/main/chunk-grids/rectilinear
+"""
+
+from typing import Final, Literal
+
+from typing_extensions import TypedDict
+
+RECTILINEAR_CHUNK_GRID_NAME: Final = "rectilinear"
+"""The `name` field value of the rectilinear chunk grid."""
+
+RectilinearChunkGridName = Literal["rectilinear"]
+"""Literal type of the `name` field of the rectilinear chunk grid."""
+
+RectilinearDimSpec = int | tuple[int | tuple[int, int], ...]
+"""JSON shape for one dimension's rectilinear spec.
+
+Either a bare integer (uniform shorthand for a regular dimension within
+a rectilinear grid), or a tuple of integers and/or `[value, count]` RLE
+pairs.
+"""
+
+
+class RectilinearChunkGridConfiguration(TypedDict):
+ """Configuration for the rectilinear chunk grid."""
+
+ kind: Literal["inline"]
+ chunk_shapes: tuple[RectilinearDimSpec, ...]
+
+
+class RectilinearChunkGridObject(TypedDict):
+ """Rectilinear chunk grid metadata in object form."""
+
+ name: RectilinearChunkGridName
+ configuration: RectilinearChunkGridConfiguration
+
+
+RectilinearChunkGridMetadata = RectilinearChunkGridObject
+"""Permitted JSON shape for rectilinear chunk grid metadata.
+
+`kind` and `chunk_shapes` are required, so only the object form is valid;
+the short-hand-name form is not permitted by the spec for this grid.
+"""
+
+__all__ = [
+ "RECTILINEAR_CHUNK_GRID_NAME",
+ "RectilinearChunkGridConfiguration",
+ "RectilinearChunkGridMetadata",
+ "RectilinearChunkGridName",
+ "RectilinearChunkGridObject",
+ "RectilinearDimSpec",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/regular.py b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/regular.py
new file mode 100644
index 0000000000..2f7a089934
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_grid/regular.py
@@ -0,0 +1,44 @@
+"""
+Regular chunk grid (Zarr v3 core spec).
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/core/index.html#regular-grids
+"""
+
+from typing import Final, Literal
+
+from typing_extensions import TypedDict
+
+REGULAR_CHUNK_GRID_NAME: Final = "regular"
+"""The `name` field value of the regular chunk grid."""
+
+RegularChunkGridName = Literal["regular"]
+"""Literal type of the `name` field of the regular chunk grid."""
+
+
+class RegularChunkGridConfiguration(TypedDict):
+ """Configuration for the regular chunk grid."""
+
+ chunk_shape: tuple[int, ...]
+
+
+class RegularChunkGridObject(TypedDict):
+ """Regular chunk grid metadata in object form."""
+
+ name: RegularChunkGridName
+ configuration: RegularChunkGridConfiguration
+
+
+RegularChunkGridMetadata = RegularChunkGridObject
+"""Permitted JSON shape for regular chunk grid metadata.
+
+`chunk_shape` is required and has no default, so only the object form is
+valid; the short-hand-name form is not permitted by the spec for this grid.
+"""
+
+__all__ = [
+ "REGULAR_CHUNK_GRID_NAME",
+ "RegularChunkGridConfiguration",
+ "RegularChunkGridMetadata",
+ "RegularChunkGridName",
+ "RegularChunkGridObject",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/__init__.py b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/__init__.py
new file mode 100644
index 0000000000..b6774efbe3
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/__init__.py
@@ -0,0 +1,25 @@
+"""
+Zarr v3 chunk key encoding metadata types.
+
+Each chunk key encoding lives in its own submodule:
+
+- `default` -- v3 default encoding (`/`-separated)
+- `v2` -- v2-compatibility encoding (`.`-separated by default)
+
+Both are defined by the v3 core spec.
+
+The `ChunkKeyEncodingMetadata` aliases re-exported here are the canonical
+type for each encoding's permitted JSON shapes. For the underlying
+`ChunkKeyEncodingObject`, `ChunkKeyEncodingConfiguration`, etc., import
+directly from the leaf submodule.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/core/index.html#chunk-key-encoding
+"""
+
+from zarr_metadata.v3.chunk_key_encoding.default import DefaultChunkKeyEncodingMetadata
+from zarr_metadata.v3.chunk_key_encoding.v2 import V2ChunkKeyEncodingMetadata
+
+__all__ = [
+ "DefaultChunkKeyEncodingMetadata",
+ "V2ChunkKeyEncodingMetadata",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/default.py b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/default.py
new file mode 100644
index 0000000000..c783861b34
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/default.py
@@ -0,0 +1,61 @@
+"""
+Default chunk key encoding (Zarr v3 core spec).
+
+The chunk key for a chunk with grid index `(k, j, i, ...)` is formed
+by appending `ckji...` (where `` is `separator`).
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/core/index.html#chunk-key-encoding
+"""
+
+from typing import Final, Literal, NotRequired
+
+from typing_extensions import TypedDict
+
+DEFAULT_CHUNK_KEY_ENCODING_NAME: Final = "default"
+"""The `name` field value of the default chunk key encoding."""
+
+DefaultChunkKeyEncodingName = Literal["default"]
+"""Literal type of the `name` field of the default chunk key encoding."""
+
+DefaultChunkKeyEncodingSeparator = Literal["/", "."]
+"""Literal type of permitted `separator` values for the default chunk key encoding.
+
+Defaults to `"/"` if absent.
+"""
+
+DEFAULT_CHUNK_KEY_ENCODING_SEPARATOR: Final = ("/", ".")
+"""Tuple of permitted values for the `separator` field of the default chunk key encoding."""
+
+
+class DefaultChunkKeyEncodingConfiguration(TypedDict):
+ """Configuration for the default chunk key encoding.
+
+ `separator` is optional and defaults to `"/"` per spec.
+ """
+
+ separator: NotRequired[DefaultChunkKeyEncodingSeparator]
+
+
+class DefaultChunkKeyEncodingObject(TypedDict):
+ """Default chunk key encoding metadata in object form."""
+
+ name: DefaultChunkKeyEncodingName
+ configuration: NotRequired[DefaultChunkKeyEncodingConfiguration]
+
+
+DefaultChunkKeyEncodingMetadata = DefaultChunkKeyEncodingObject | DefaultChunkKeyEncodingName
+"""Permitted JSON shapes for the default chunk-key encoding metadata.
+
+The configuration has no required keys (`separator` defaults to `"/"`),
+so the short-hand-name form is permitted in addition to the object form.
+"""
+
+__all__ = [
+ "DEFAULT_CHUNK_KEY_ENCODING_NAME",
+ "DEFAULT_CHUNK_KEY_ENCODING_SEPARATOR",
+ "DefaultChunkKeyEncodingConfiguration",
+ "DefaultChunkKeyEncodingMetadata",
+ "DefaultChunkKeyEncodingName",
+ "DefaultChunkKeyEncodingObject",
+ "DefaultChunkKeyEncodingSeparator",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/v2.py b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/v2.py
new file mode 100644
index 0000000000..e2783d296d
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/chunk_key_encoding/v2.py
@@ -0,0 +1,67 @@
+"""
+v2-compatibility chunk key encoding (Zarr v3 core spec).
+
+Intended only to allow existing v2 arrays to be converted to v3 without
+having to rename chunks. Not recommended for new arrays.
+
+Naming note: these are Zarr **v3** types. The leading `V2` in
+`V2ChunkKeyEncodingMetadata` (and friends) is the encoding's registered
+*entity name* (`"v2"`), not the format-version marker that `ZarrV2...`
+names carry — this package's version-prefixed names always spell it
+`ZarrV2` / `ZarrV3`.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/core/index.html#chunk-key-encoding
+"""
+
+from typing import Final, Literal, NotRequired
+
+from typing_extensions import TypedDict
+
+V2_CHUNK_KEY_ENCODING_NAME: Final = "v2"
+"""The `name` field value of the v2 chunk key encoding."""
+
+V2ChunkKeyEncodingName = Literal["v2"]
+"""Literal type of the `name` field of the v2 chunk key encoding."""
+
+V2ChunkKeyEncodingSeparator = Literal["/", "."]
+"""Literal type of permitted `separator` values for the v2 chunk key encoding.
+
+Defaults to `"."` if absent.
+"""
+
+V2_CHUNK_KEY_ENCODING_SEPARATOR: Final = ("/", ".")
+"""Tuple of permitted values for the `separator` field of the v2 chunk key encoding."""
+
+
+class V2ChunkKeyEncodingConfiguration(TypedDict):
+ """Configuration for the v2 chunk key encoding.
+
+ `separator` is optional and defaults to `"."` per spec.
+ """
+
+ separator: NotRequired[V2ChunkKeyEncodingSeparator]
+
+
+class V2ChunkKeyEncodingObject(TypedDict):
+ """v2-compatibility chunk key encoding metadata in object form."""
+
+ name: V2ChunkKeyEncodingName
+ configuration: NotRequired[V2ChunkKeyEncodingConfiguration]
+
+
+V2ChunkKeyEncodingMetadata = V2ChunkKeyEncodingObject | V2ChunkKeyEncodingName
+"""Permitted JSON shapes for the v2-compatibility chunk-key encoding metadata.
+
+The configuration has no required keys (`separator` defaults to `"."`),
+so the short-hand-name form is permitted in addition to the object form.
+"""
+
+__all__ = [
+ "V2_CHUNK_KEY_ENCODING_NAME",
+ "V2_CHUNK_KEY_ENCODING_SEPARATOR",
+ "V2ChunkKeyEncodingConfiguration",
+ "V2ChunkKeyEncodingMetadata",
+ "V2ChunkKeyEncodingName",
+ "V2ChunkKeyEncodingObject",
+ "V2ChunkKeyEncodingSeparator",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/__init__.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/__init__.py
new file mode 100644
index 0000000000..c8a9a150fc
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/__init__.py
@@ -0,0 +1,40 @@
+"""
+Zarr v3 codec spec types.
+
+Each codec defined by the spec or by zarr-extensions has its own submodule
+(`blosc`, `bytes`, `cast_value`, `crc32c`, `gzip`, `scale_offset`,
+`sharding_indexed`, `transpose`, `zstd`).
+
+The `CodecMetadata` aliases re-exported here are the canonical type for
+each codec's permitted JSON shapes (object form plus, where the spec allows,
+a bare-string short-hand form). For the underlying `CodecObject`,
+`CodecConfiguration`, etc., import directly from the leaf submodule.
+
+For the field-level "any codec entry" alias (used in array metadata's
+`codecs` list and in sharding's inner pipelines), import `ZarrV3MetadataFieldJSON`
+from `zarr_metadata.v3`.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/codecs/index.html
+"""
+
+from zarr_metadata.v3.codec.blosc import BloscCodecMetadata
+from zarr_metadata.v3.codec.bytes import BytesCodecMetadata
+from zarr_metadata.v3.codec.cast_value import CastValueCodecMetadata
+from zarr_metadata.v3.codec.crc32c import Crc32cCodecMetadata
+from zarr_metadata.v3.codec.gzip import GzipCodecMetadata
+from zarr_metadata.v3.codec.scale_offset import ScaleOffsetCodecMetadata
+from zarr_metadata.v3.codec.sharding_indexed import ShardingIndexedCodecMetadata
+from zarr_metadata.v3.codec.transpose import TransposeCodecMetadata
+from zarr_metadata.v3.codec.zstd import ZstdCodecMetadata
+
+__all__ = [
+ "BloscCodecMetadata",
+ "BytesCodecMetadata",
+ "CastValueCodecMetadata",
+ "Crc32cCodecMetadata",
+ "GzipCodecMetadata",
+ "ScaleOffsetCodecMetadata",
+ "ShardingIndexedCodecMetadata",
+ "TransposeCodecMetadata",
+ "ZstdCodecMetadata",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/blosc.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/blosc.py
new file mode 100644
index 0000000000..5a986c8260
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/blosc.py
@@ -0,0 +1,65 @@
+"""
+Blosc codec types.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/codecs/blosc/index.html
+"""
+
+from typing import Final, Literal, NotRequired
+
+from typing_extensions import TypedDict
+
+BLOSC_CODEC_NAME: Final = "blosc"
+"""The `name` field value of the `blosc` codec."""
+
+BloscCodecName = Literal["blosc"]
+"""Literal type of the `name` field of the `blosc` codec."""
+
+BloscShuffle = Literal["noshuffle", "shuffle", "bitshuffle"]
+"""Literal type of blosc shuffle mode names."""
+
+BLOSC_SHUFFLE: Final = ("noshuffle", "shuffle", "bitshuffle")
+"""Tuple of permitted values for the `shuffle` field of the `blosc` codec."""
+
+BloscCName = Literal["lz4", "lz4hc", "blosclz", "snappy", "zlib", "zstd"]
+"""Literal type of blosc compressor identifiers."""
+
+BLOSC_CNAME: Final = ("lz4", "lz4hc", "blosclz", "snappy", "zlib", "zstd")
+"""Tuple of permitted values for the `cname` field of the `blosc` codec."""
+
+
+class BloscCodecConfiguration(TypedDict):
+ """Configuration for the Zarr v3 `blosc` codec."""
+
+ cname: BloscCName
+ clevel: int
+ shuffle: BloscShuffle
+ blocksize: int
+ typesize: NotRequired[int]
+
+
+class BloscCodecObject(TypedDict):
+ """`blosc` codec metadata in object form."""
+
+ name: BloscCodecName
+ configuration: BloscCodecConfiguration
+
+
+BloscCodecMetadata = BloscCodecObject
+"""Permitted JSON shape for `blosc` codec metadata.
+
+The configuration has multiple required keys (`cname`, `clevel`, `shuffle`,
+`blocksize`), so only the object form is valid; the short-hand-name form
+is not permitted by the spec for this codec.
+"""
+
+__all__ = [
+ "BLOSC_CNAME",
+ "BLOSC_CODEC_NAME",
+ "BLOSC_SHUFFLE",
+ "BloscCName",
+ "BloscCodecConfiguration",
+ "BloscCodecMetadata",
+ "BloscCodecName",
+ "BloscCodecObject",
+ "BloscShuffle",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/bytes.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/bytes.py
new file mode 100644
index 0000000000..04e746f898
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/bytes.py
@@ -0,0 +1,64 @@
+"""
+Bytes codec types.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/codecs/bytes/index.html
+"""
+
+from typing import Final, Literal, NotRequired
+
+from typing_extensions import TypedDict
+
+BYTES_CODEC_NAME: Final = "bytes"
+"""The `name` field value of the `bytes` codec."""
+
+BytesCodecName = Literal["bytes"]
+"""Literal type of the `name` field of the `bytes` codec."""
+
+Endianness = Literal["little", "big"]
+"""Literal type of byte order of multi-byte numeric data."""
+
+ENDIANNESS: Final = ("little", "big")
+"""Tuple of permitted values for the `endian` field of the `bytes` codec."""
+
+
+class BytesCodecConfiguration(TypedDict):
+ """
+ Configuration for the Zarr v3 `bytes` codec.
+
+ The `endian` field is required for multi-byte data types.
+ """
+
+ endian: NotRequired[Endianness]
+
+
+class BytesCodecObject(TypedDict):
+ """`bytes` codec metadata in object form.
+
+ `configuration` is itself optional — when no configuration fields are
+ set, the entire `configuration` key may be omitted. This matches the
+ bare-string short-hand form (`BytesCodecName`) at the canonical data
+ level; both encodings describe a `bytes` codec with default settings.
+ """
+
+ name: BytesCodecName
+ configuration: NotRequired[BytesCodecConfiguration]
+
+
+BytesCodecMetadata = BytesCodecObject | BytesCodecName
+"""Permitted JSON shapes for `bytes` codec metadata.
+
+The configuration has no required keys (`endian` is conditionally required
+at runtime based on data type), so the spec's short-hand-name form is
+permitted in addition to the object form, and the object form may itself
+omit `configuration` entirely.
+"""
+
+__all__ = [
+ "BYTES_CODEC_NAME",
+ "ENDIANNESS",
+ "BytesCodecConfiguration",
+ "BytesCodecMetadata",
+ "BytesCodecName",
+ "BytesCodecObject",
+ "Endianness",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/cast_value.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/cast_value.py
new file mode 100644
index 0000000000..96c39e5916
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/cast_value.py
@@ -0,0 +1,107 @@
+"""
+Cast-value codec types.
+
+See https://github.com/zarr-developers/zarr-extensions/tree/main/codecs/cast_value
+"""
+
+from typing import Final, Literal, NotRequired
+
+from typing_extensions import TypedDict
+
+from zarr_metadata._common import JSONValue
+from zarr_metadata.v3._common import ZarrV3MetadataFieldJSON
+
+CAST_VALUE_CODEC_NAME: Final = "cast_value"
+"""The `name` field value of the `cast_value` codec."""
+
+CastValueCodecName = Literal["cast_value"]
+"""Literal type of the `name` field of the `cast_value` codec."""
+
+CastRoundingMode = Literal[
+ "nearest-even",
+ "towards-zero",
+ "towards-positive",
+ "towards-negative",
+ "nearest-away",
+]
+"""Literal type of permitted values for the `rounding` configuration field.
+
+Defaults to `"nearest-even"` if absent.
+"""
+
+CAST_ROUNDING_MODE: Final = (
+ "nearest-even",
+ "towards-zero",
+ "towards-positive",
+ "towards-negative",
+ "nearest-away",
+)
+"""Tuple of permitted values for the `rounding` field of the `cast_value` codec."""
+
+CastOutOfRangeMode = Literal["clamp", "wrap"]
+"""Literal type of permitted values for the `out_of_range` configuration field.
+
+If absent, out-of-range values are an encoding/decoding error.
+"""
+
+CAST_OUT_OF_RANGE_MODE: Final = ("clamp", "wrap")
+"""Tuple of permitted values for the `out_of_range` field of the `cast_value` codec."""
+
+ScalarMapEntry = tuple[JSONValue, JSONValue]
+"""A single `[input, output]` mapping in a `scalar_map` direction.
+
+Each scalar is JSON-encoded per its data type's fill-value rules (so
+e.g. `"NaN"` and `"+Infinity"` are permitted).
+"""
+
+
+class ScalarMap(TypedDict):
+ """Optional encode/decode scalar overrides for the cast_value codec."""
+
+ encode: NotRequired[tuple[ScalarMapEntry, ...]]
+ decode: NotRequired[tuple[ScalarMapEntry, ...]]
+
+
+class CastValueCodecConfiguration(TypedDict):
+ """
+ Configuration for the Zarr v3 `cast_value` codec.
+
+ `data_type` is the target data type that input values are cast to. It
+ is the same shape as the top-level array `data_type` field: either a
+ bare-string primitive name or a `{name, configuration}` envelope.
+ """
+
+ data_type: ZarrV3MetadataFieldJSON
+ rounding: NotRequired[CastRoundingMode]
+ out_of_range: NotRequired[CastOutOfRangeMode]
+ scalar_map: NotRequired[ScalarMap]
+
+
+class CastValueCodecObject(TypedDict):
+ """`cast_value` codec metadata in object form."""
+
+ name: CastValueCodecName
+ configuration: CastValueCodecConfiguration
+
+
+CastValueCodecMetadata = CastValueCodecObject
+"""Permitted JSON shape for `cast_value` codec metadata.
+
+`configuration.data_type` is required, so only the object form is valid;
+the short-hand-name form is not permitted by the spec for this codec.
+"""
+
+
+__all__ = [
+ "CAST_OUT_OF_RANGE_MODE",
+ "CAST_ROUNDING_MODE",
+ "CAST_VALUE_CODEC_NAME",
+ "CastOutOfRangeMode",
+ "CastRoundingMode",
+ "CastValueCodecConfiguration",
+ "CastValueCodecMetadata",
+ "CastValueCodecName",
+ "CastValueCodecObject",
+ "ScalarMap",
+ "ScalarMapEntry",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/crc32c.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/crc32c.py
new file mode 100644
index 0000000000..6b9b46c43d
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/crc32c.py
@@ -0,0 +1,50 @@
+"""
+CRC32C codec types.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/codecs/crc32c/index.html
+
+The CRC32C codec has no configuration fields, so the `configuration`
+key is absent from the metadata.
+"""
+
+from typing import Final, Literal, NotRequired
+
+from typing_extensions import TypedDict
+
+CRC32C_CODEC_NAME: Final = "crc32c"
+"""The `name` field value of the `crc32c` codec."""
+
+Crc32cCodecName = Literal["crc32c"]
+"""Literal type of the `name` field of the `crc32c` codec."""
+
+
+class Empty(TypedDict, closed=True):
+ """An empty mapping"""
+
+
+class Crc32cCodecObject(TypedDict):
+ """`crc32c` codec metadata in object form.
+
+ Per spec the codec has no configuration fields. `configuration` is
+ optional and, if present, should be an empty mapping.
+ """
+
+ name: Crc32cCodecName
+ configuration: NotRequired[Empty]
+
+
+Crc32cCodecMetadata = Crc32cCodecObject | Crc32cCodecName
+"""Permitted JSON shapes for `crc32c` codec metadata.
+
+The spec's Extension definition allows extensions with no required
+configuration to be encoded as a bare short-hand name. CRC32C has no
+configuration, so both forms are valid.
+"""
+
+
+__all__ = [
+ "CRC32C_CODEC_NAME",
+ "Crc32cCodecMetadata",
+ "Crc32cCodecName",
+ "Crc32cCodecObject",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/gzip.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/gzip.py
new file mode 100644
index 0000000000..3b9936f8cd
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/gzip.py
@@ -0,0 +1,55 @@
+"""
+Gzip codec types.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/codecs/gzip/index.html
+"""
+
+from typing import Final, Literal
+
+from typing_extensions import TypedDict
+
+GZIP_CODEC_NAME: Final = "gzip"
+"""The `name` field value of the `gzip` codec."""
+
+GzipCodecName = Literal["gzip"]
+"""Literal type of the `name` field of the `gzip` codec."""
+
+
+class GzipCodecConfiguration(TypedDict):
+ """
+ Configuration for the Zarr v3 `gzip` codec.
+
+ `level` is an integer in the range 0-9; 0 disables compression and 9
+ is slowest with the best compression ratio. The codec's compressed
+ output depends on `level`, so metadata that omits it cannot
+ reproducibly identify the chunk bytes produced by a writer — `level`
+ is required for the metadata to fulfill its reproducibility role,
+ even though the spec text does not mark it required with RFC 2119
+ keywords.
+ """
+
+ level: int
+
+
+class GzipCodecObject(TypedDict):
+ """`gzip` codec metadata in object form."""
+
+ name: GzipCodecName
+ configuration: GzipCodecConfiguration
+
+
+GzipCodecMetadata = GzipCodecObject
+"""Permitted JSON shape for `gzip` codec metadata.
+
+`configuration.level` is required (it determines the codec's output bytes
+and is therefore part of the metadata's reproducibility contract), so
+only the object form is valid; the short-hand-name form is not permitted.
+"""
+
+__all__ = [
+ "GZIP_CODEC_NAME",
+ "GzipCodecConfiguration",
+ "GzipCodecMetadata",
+ "GzipCodecName",
+ "GzipCodecObject",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/scale_offset.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/scale_offset.py
new file mode 100644
index 0000000000..9701db8497
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/scale_offset.py
@@ -0,0 +1,61 @@
+"""
+Scale-offset codec types.
+
+See https://github.com/zarr-developers/zarr-extensions/tree/main/codecs/scale_offset
+"""
+
+from typing import Final, Literal, NotRequired
+
+from typing_extensions import TypedDict
+
+from zarr_metadata._common import JSONValue
+
+SCALE_OFFSET_CODEC_NAME: Final = "scale_offset"
+"""The `name` field value of the `scale_offset` codec."""
+
+ScaleOffsetCodecName = Literal["scale_offset"]
+"""Literal type of the `name` field of the `scale_offset` codec."""
+
+
+class ScaleOffsetCodecConfiguration(TypedDict):
+ """
+ Configuration for the Zarr v3 `scale_offset` codec.
+
+ Both fields are optional. A missing `offset` is the additive identity
+ (e.g. 0 for numeric types); a missing `scale` is the multiplicative
+ identity (e.g. 1). Each scalar is JSON-encoded per the input array's
+ fill-value rules, so `"NaN"` and `"+Infinity"` style strings are
+ permitted in addition to numbers.
+ """
+
+ offset: NotRequired[JSONValue]
+ scale: NotRequired[JSONValue]
+
+
+class ScaleOffsetCodecObject(TypedDict):
+ """`scale_offset` codec metadata in object form.
+
+ `configuration` is itself optional per spec — when both `offset` and
+ `scale` are at their identity defaults, the codec is a no-op and the
+ entire `configuration` field may be omitted.
+ """
+
+ name: ScaleOffsetCodecName
+ configuration: NotRequired[ScaleOffsetCodecConfiguration]
+
+
+ScaleOffsetCodecMetadata = ScaleOffsetCodecObject | ScaleOffsetCodecName
+"""Permitted JSON shapes for `scale_offset` codec metadata.
+
+The configuration has no required keys (both `offset` and `scale` are
+optional, and the configuration itself is optional), so the short-hand-name
+form is permitted in addition to the object form.
+"""
+
+__all__ = [
+ "SCALE_OFFSET_CODEC_NAME",
+ "ScaleOffsetCodecConfiguration",
+ "ScaleOffsetCodecMetadata",
+ "ScaleOffsetCodecName",
+ "ScaleOffsetCodecObject",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/sharding_indexed.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/sharding_indexed.py
new file mode 100644
index 0000000000..a8c9247ec4
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/sharding_indexed.py
@@ -0,0 +1,71 @@
+"""
+Sharding-indexed codec types.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/codecs/sharding-indexed/index.html
+"""
+
+from typing import Final, Literal, NotRequired
+
+from typing_extensions import TypedDict
+
+from zarr_metadata.v3._common import ZarrV3MetadataFieldJSON
+
+SHARDING_INDEXED_CODEC_NAME: Final = "sharding_indexed"
+"""The `name` field value of the `sharding_indexed` codec."""
+
+ShardingIndexedCodecName = Literal["sharding_indexed"]
+"""Literal type of the `name` field of the `sharding_indexed` codec."""
+
+ShardingIndexLocation = Literal["start", "end"]
+"""Literal type of the position of the shard index within the encoded shard."""
+
+SHARDING_INDEX_LOCATION: Final = ("start", "end")
+"""Tuple of permitted values for the `index_location` field of the `sharding_indexed` codec."""
+
+
+class ShardingIndexedCodecConfiguration(TypedDict):
+ """
+ Configuration for the Zarr v3 `sharding_indexed` codec.
+
+ `chunk_shape` is the shape of inner chunks along each dimension;
+ it must evenly divide the shard shape.
+
+ `codecs` is the codec pipeline applied to each inner chunk; exactly
+ one array-to-bytes codec is required.
+
+ `index_codecs` is the codec pipeline applied to the shard index;
+ it must be deterministic (no variable-size compression).
+
+ `index_location` defaults to `"end"` per the spec.
+ """
+
+ chunk_shape: tuple[int, ...]
+ codecs: tuple[ZarrV3MetadataFieldJSON, ...]
+ index_codecs: tuple[ZarrV3MetadataFieldJSON, ...]
+ index_location: NotRequired[ShardingIndexLocation]
+
+
+class ShardingIndexedCodecObject(TypedDict):
+ """`sharding_indexed` codec metadata in object form."""
+
+ name: ShardingIndexedCodecName
+ configuration: ShardingIndexedCodecConfiguration
+
+
+ShardingIndexedCodecMetadata = ShardingIndexedCodecObject
+"""Permitted JSON shape for `sharding_indexed` codec metadata.
+
+The configuration has multiple required keys (`chunk_shape`, `codecs`,
+`index_codecs`), so only the object form is valid; the short-hand-name
+form is not permitted by the spec for this codec.
+"""
+
+__all__ = [
+ "SHARDING_INDEXED_CODEC_NAME",
+ "SHARDING_INDEX_LOCATION",
+ "ShardingIndexLocation",
+ "ShardingIndexedCodecConfiguration",
+ "ShardingIndexedCodecMetadata",
+ "ShardingIndexedCodecName",
+ "ShardingIndexedCodecObject",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/transpose.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/transpose.py
new file mode 100644
index 0000000000..ac469b356a
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/transpose.py
@@ -0,0 +1,49 @@
+"""
+Transpose codec types.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/codecs/transpose/index.html
+"""
+
+from typing import Final, Literal
+
+from typing_extensions import TypedDict
+
+TRANSPOSE_CODEC_NAME: Final = "transpose"
+"""The `name` field value of the `transpose` codec."""
+
+TransposeCodecName = Literal["transpose"]
+"""Literal type of the `name` field of the `transpose` codec."""
+
+
+class TransposeCodecConfiguration(TypedDict):
+ """
+ Configuration for the Zarr v3 `transpose` codec.
+
+ `order` is a permutation of the dimension indices 0..n-1 that
+ specifies the dimension reordering applied during encoding.
+ """
+
+ order: tuple[int, ...]
+
+
+class TransposeCodecObject(TypedDict):
+ """`transpose` codec metadata in object form."""
+
+ name: TransposeCodecName
+ configuration: TransposeCodecConfiguration
+
+
+TransposeCodecMetadata = TransposeCodecObject
+"""Permitted JSON shape for `transpose` codec metadata.
+
+`order` is required, so only the object form is valid; the short-hand-name
+form is not permitted by the spec for this codec.
+"""
+
+__all__ = [
+ "TRANSPOSE_CODEC_NAME",
+ "TransposeCodecConfiguration",
+ "TransposeCodecMetadata",
+ "TransposeCodecName",
+ "TransposeCodecObject",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/codec/zstd.py b/packages/zarr-metadata/src/zarr_metadata/v3/codec/zstd.py
new file mode 100644
index 0000000000..c0faa64bed
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/codec/zstd.py
@@ -0,0 +1,51 @@
+"""
+Zstandard codec types.
+
+See https://github.com/zarr-developers/zarr-specs/pull/256 (unmerged at
+time of writing; the configuration shape below reflects the proposed
+specification).
+"""
+
+from typing import Final, Literal
+
+from typing_extensions import TypedDict
+
+ZSTD_CODEC_NAME: Final = "zstd"
+"""The `name` field value of the `zstd` codec."""
+
+ZstdCodecName = Literal["zstd"]
+"""Literal type of the `name` field of the `zstd` codec."""
+
+
+class ZstdCodecConfiguration(TypedDict):
+ """
+ Configuration for the Zarr v3 `zstd` codec.
+
+ Both fields are required per the proposed specification.
+ """
+
+ level: int
+ checksum: bool
+
+
+class ZstdCodecObject(TypedDict):
+ """`zstd` codec metadata in object form."""
+
+ name: ZstdCodecName
+ configuration: ZstdCodecConfiguration
+
+
+ZstdCodecMetadata = ZstdCodecObject
+"""Permitted JSON shape for `zstd` codec metadata.
+
+Both `level` and `checksum` are required, so only the object form is
+valid; the short-hand-name form is not permitted by the spec for this codec.
+"""
+
+__all__ = [
+ "ZSTD_CODEC_NAME",
+ "ZstdCodecConfiguration",
+ "ZstdCodecMetadata",
+ "ZstdCodecName",
+ "ZstdCodecObject",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py b/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py
new file mode 100644
index 0000000000..a9fe0c1f8f
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py
@@ -0,0 +1,49 @@
+"""Zarr v3 consolidated metadata types.
+
+There is no Zarr v3 specification for consolidated metadata. This module
+models the inline-on-group convention used by the reference Python
+implementation (and zarrs), where consolidated metadata is embedded as
+an extension field on a group's `zarr.json`.
+
+This is a known non-core interoperability extension. Its
+`{kind, must_understand, metadata}` payload is an unknown top-level JSON value
+to the core document model; implementations that recognize the convention may
+interpret it through this dedicated type.
+"""
+
+from collections.abc import Mapping
+from typing import Final, Literal
+
+from typing_extensions import TypedDict
+
+from zarr_metadata.v3.array import ZarrV3ArrayMetadataJSON
+from zarr_metadata.v3.group import ZarrV3GroupMetadataJSON
+
+
+class ZarrV3ConsolidatedMetadataJSON(TypedDict):
+ """
+ Inline consolidated metadata embedded in a v3 group.
+
+ The `metadata` map contains only v3 array and group entries. V2 entries
+ are excluded from this interoperability convention by design; the v3 core
+ specification does not define consolidated metadata.
+ """
+
+ kind: Literal["inline"]
+ must_understand: Literal[False]
+ metadata: Mapping[str, ZarrV3ArrayMetadataJSON | ZarrV3GroupMetadataJSON]
+
+
+ZARR_V3_CONSOLIDATED_METADATA_KEY: Final = "consolidated_metadata"
+"""The key under which consolidated metadata is embedded in a v3 group document.
+
+Unlike the v2 `.zmetadata` file, this is not a store key: consolidated metadata
+is carried as an extension field inside the group's own `zarr.json`. Like its v2
+counterpart it is a reference-implementation convention, not a spec artifact.
+"""
+
+
+__all__ = [
+ "ZARR_V3_CONSOLIDATED_METADATA_KEY",
+ "ZarrV3ConsolidatedMetadataJSON",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/__init__.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/__init__.py
new file mode 100644
index 0000000000..180f9c500d
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/__init__.py
@@ -0,0 +1,105 @@
+"""
+Zarr v3 data type spec types.
+
+Each v3 data type has its own submodule:
+
+- Core primitives: `bool`, `int8`/`16`/`32`/`64`, `uint8`/`16`/`32`/`64`,
+ `float16`/`32`/`64`, `complex64`/`128`, `raw` (for `r`)
+- zarr-extensions: `bytes`, `string`, `numpy_datetime64`, `numpy_timedelta64`,
+ `struct`
+
+The two canonical types per dtype are re-exported here:
+
+- `DataTypeName` -- the literal type of the dtype's `data_type` string
+ (or, for named-config dtypes, the literal value of their `name` field)
+- `FillValue` -- the permitted JSON shape of the `fill_value` field
+
+Named-config dtypes (`numpy_datetime64`, `numpy_timedelta64`, `struct`) also
+expose their envelope TypedDict here. For configuration TypedDicts, branded
+`HexFloat` / `Base64Bytes` types, and the corresponding validator
+functions, import directly from the leaf submodule.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/data-types/index.html
+"""
+
+from zarr_metadata.v3.data_type.bool import BoolDataTypeName, BoolFillValue
+from zarr_metadata.v3.data_type.bytes import BytesDataTypeName, BytesFillValue
+from zarr_metadata.v3.data_type.complex64 import Complex64DataTypeName, Complex64FillValue
+from zarr_metadata.v3.data_type.complex128 import (
+ Complex128DataTypeName,
+ Complex128FillValue,
+)
+from zarr_metadata.v3.data_type.float16 import Float16DataTypeName, Float16FillValue
+from zarr_metadata.v3.data_type.float32 import Float32DataTypeName, Float32FillValue
+from zarr_metadata.v3.data_type.float64 import Float64DataTypeName, Float64FillValue
+from zarr_metadata.v3.data_type.int8 import Int8DataTypeName, Int8FillValue
+from zarr_metadata.v3.data_type.int16 import Int16DataTypeName, Int16FillValue
+from zarr_metadata.v3.data_type.int32 import Int32DataTypeName, Int32FillValue
+from zarr_metadata.v3.data_type.int64 import Int64DataTypeName, Int64FillValue
+from zarr_metadata.v3.data_type.numpy_datetime64 import (
+ NumpyDatetime64,
+ NumpyDatetime64DataTypeName,
+ NumpyDatetime64FillValue,
+)
+from zarr_metadata.v3.data_type.numpy_timedelta64 import (
+ NumpyTimedelta64,
+ NumpyTimedelta64DataTypeName,
+ NumpyTimedelta64FillValue,
+)
+from zarr_metadata.v3.data_type.raw import RawBytesDataTypeName, RawBytesFillValue
+from zarr_metadata.v3.data_type.string import StringDataTypeName, StringFillValue
+from zarr_metadata.v3.data_type.struct import (
+ Struct,
+ StructDataTypeName,
+ StructFillValue,
+)
+from zarr_metadata.v3.data_type.uint8 import Uint8DataTypeName, Uint8FillValue
+from zarr_metadata.v3.data_type.uint16 import Uint16DataTypeName, Uint16FillValue
+from zarr_metadata.v3.data_type.uint32 import Uint32DataTypeName, Uint32FillValue
+from zarr_metadata.v3.data_type.uint64 import Uint64DataTypeName, Uint64FillValue
+
+__all__ = [
+ "BoolDataTypeName",
+ "BoolFillValue",
+ "BytesDataTypeName",
+ "BytesFillValue",
+ "Complex64DataTypeName",
+ "Complex64FillValue",
+ "Complex128DataTypeName",
+ "Complex128FillValue",
+ "Float16DataTypeName",
+ "Float16FillValue",
+ "Float32DataTypeName",
+ "Float32FillValue",
+ "Float64DataTypeName",
+ "Float64FillValue",
+ "Int8DataTypeName",
+ "Int8FillValue",
+ "Int16DataTypeName",
+ "Int16FillValue",
+ "Int32DataTypeName",
+ "Int32FillValue",
+ "Int64DataTypeName",
+ "Int64FillValue",
+ "NumpyDatetime64",
+ "NumpyDatetime64DataTypeName",
+ "NumpyDatetime64FillValue",
+ "NumpyTimedelta64",
+ "NumpyTimedelta64DataTypeName",
+ "NumpyTimedelta64FillValue",
+ "RawBytesDataTypeName",
+ "RawBytesFillValue",
+ "StringDataTypeName",
+ "StringFillValue",
+ "Struct",
+ "StructDataTypeName",
+ "StructFillValue",
+ "Uint8DataTypeName",
+ "Uint8FillValue",
+ "Uint16DataTypeName",
+ "Uint16FillValue",
+ "Uint32DataTypeName",
+ "Uint32FillValue",
+ "Uint64DataTypeName",
+ "Uint64FillValue",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/bool.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/bool.py
new file mode 100644
index 0000000000..e36613a154
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/bool.py
@@ -0,0 +1,23 @@
+"""
+Zarr v3 `bool` data type.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/data-types/index.html
+"""
+
+from typing import Final, Literal
+
+BOOL_DATA_TYPE_NAME: Final = "bool"
+"""The `data_type` value for the `bool` type."""
+
+BoolDataTypeName = Literal["bool"]
+"""Literal type of the `data_type` field for `bool`."""
+
+BoolFillValue = bool
+"""Permitted JSON shape of the `fill_value` field for `bool`: a JSON boolean."""
+
+
+__all__ = [
+ "BOOL_DATA_TYPE_NAME",
+ "BoolDataTypeName",
+ "BoolFillValue",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/bytes.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/bytes.py
new file mode 100644
index 0000000000..c7eed64f0f
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/bytes.py
@@ -0,0 +1,48 @@
+"""
+Zarr `bytes` data type (variable-length raw bytes, zarr-extensions).
+
+See https://github.com/zarr-developers/zarr-extensions/tree/main/data-types/bytes
+"""
+
+import re
+from typing import Final, Literal, NewType
+
+BYTES_DATA_TYPE_NAME: Final = "bytes"
+"""The `data_type` value for the variable-length `bytes` type."""
+
+BytesDataTypeName = Literal["bytes"]
+"""Literal type of the `data_type` field for `bytes`."""
+
+Base64Bytes = NewType("Base64Bytes", str)
+"""A standard-alphabet base64-encoded byte sequence."""
+
+_BASE64_RE: Final = re.compile(r"^[A-Za-z0-9+/]*={0,2}$")
+
+
+def base64_bytes(value: str) -> Base64Bytes:
+ """Validate `value` as a Base64Bytes and brand it.
+
+ Raises ValueError if `value` is not standard-alphabet base64
+ (length must be a multiple of 4 once padded; only `A-Z`, `a-z`,
+ `0-9`, `+`, `/`, and trailing `=` padding are permitted).
+ """
+ if len(value) % 4 != 0 or not _BASE64_RE.fullmatch(value):
+ raise ValueError(f"Expected standard-alphabet base64, got {value!r}")
+ return Base64Bytes(value)
+
+
+BytesFillValue = tuple[int, ...] | Base64Bytes
+"""Permitted JSON shape of the `fill_value` field for `bytes`.
+
+Either a JSON array of integers in `[0, 255]` (one per byte), or a
+`Base64Bytes` string encoding the byte sequence.
+"""
+
+
+__all__ = [
+ "BYTES_DATA_TYPE_NAME",
+ "Base64Bytes",
+ "BytesDataTypeName",
+ "BytesFillValue",
+ "base64_bytes",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/complex128.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/complex128.py
new file mode 100644
index 0000000000..780bbbb02f
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/complex128.py
@@ -0,0 +1,37 @@
+"""
+Zarr v3 `complex128` data type.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/data-types/index.html
+"""
+
+from typing import Final, Literal
+
+from zarr_metadata.v3.data_type.float64 import Float64FillValue
+
+COMPLEX128_DATA_TYPE_NAME: Final = "complex128"
+"""The `data_type` value for the `complex128` type."""
+
+Complex128DataTypeName = Literal["complex128"]
+"""Literal type of the `data_type` field for `complex128`."""
+
+Complex128Component = Float64FillValue
+"""One real or imaginary component of a `complex128` fill value.
+
+Same shape as a `float64` fill value: a JSON number, a named sentinel,
+or a `HexFloat64` string.
+"""
+
+Complex128FillValue = tuple[Complex128Component, Complex128Component]
+"""Permitted JSON shape of the `fill_value` field for `complex128`.
+
+A two-element JSON array `[real, imag]` where each component is a
+`Complex128Component`.
+"""
+
+
+__all__ = [
+ "COMPLEX128_DATA_TYPE_NAME",
+ "Complex128Component",
+ "Complex128DataTypeName",
+ "Complex128FillValue",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/complex64.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/complex64.py
new file mode 100644
index 0000000000..4aca608899
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/complex64.py
@@ -0,0 +1,37 @@
+"""
+Zarr v3 `complex64` data type.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/data-types/index.html
+"""
+
+from typing import Final, Literal
+
+from zarr_metadata.v3.data_type.float32 import Float32FillValue
+
+COMPLEX64_DATA_TYPE_NAME: Final = "complex64"
+"""The `data_type` value for the `complex64` type."""
+
+Complex64DataTypeName = Literal["complex64"]
+"""Literal type of the `data_type` field for `complex64`."""
+
+Complex64Component = Float32FillValue
+"""One real or imaginary component of a `complex64` fill value.
+
+Same shape as a `float32` fill value: a JSON number, a named sentinel,
+or a `HexFloat32` string.
+"""
+
+Complex64FillValue = tuple[Complex64Component, Complex64Component]
+"""Permitted JSON shape of the `fill_value` field for `complex64`.
+
+A two-element JSON array `[real, imag]` where each component is a
+`Complex64Component`.
+"""
+
+
+__all__ = [
+ "COMPLEX64_DATA_TYPE_NAME",
+ "Complex64Component",
+ "Complex64DataTypeName",
+ "Complex64FillValue",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/float16.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/float16.py
new file mode 100644
index 0000000000..41eec441df
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/float16.py
@@ -0,0 +1,71 @@
+"""
+Zarr v3 `float16` data type.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/data-types/index.html
+"""
+
+import re
+from typing import Final, Literal, NewType
+
+FLOAT16_DATA_TYPE_NAME: Final = "float16"
+"""The `data_type` value for the `float16` type."""
+
+Float16DataTypeName = Literal["float16"]
+"""Literal type of the `data_type` field for `float16`."""
+
+Float16SpecialFillValue = Literal["NaN", "Infinity", "-Infinity"]
+"""Named non-finite fill values permitted by the spec for IEEE 754 floats."""
+
+HexFloat16 = NewType("HexFloat16", str)
+"""A 6-character hex string (`0x` + 4 hex digits) encoding the
+unsigned-integer representation of a float16."""
+
+_HEX_FLOAT16_RE: Final = re.compile(r"^0x[0-9a-fA-F]{4}$")
+
+
+def hex_float16(value: str) -> HexFloat16:
+ """Validate `value` as a HexFloat16 and brand it.
+
+ Raises ValueError if `value` is not exactly `0x` followed by 4 hex
+ digits.
+ """
+ if not _HEX_FLOAT16_RE.fullmatch(value):
+ raise ValueError(f"Expected '0x' followed by 4 hex digits, got {value!r}")
+ return HexFloat16(value)
+
+
+Float16FillValue = float | int | Float16SpecialFillValue | HexFloat16
+"""Permitted JSON shape of the `fill_value` field for `float16`.
+
+Either a JSON number, one of the named non-finite sentinels (`"NaN"`,
+`"Infinity"`, `"-Infinity"`), or a `HexFloat16` (`0xYYYY` string encoding
+the unsigned-integer representation of the IEEE 754 value).
+"""
+
+CANONICAL_NAN_HEX_FLOAT16: Final = "0x7e00"
+"""Canonical hex form of the float16 NaN sentinel `"NaN"`.
+
+Per spec the named `"NaN"` sentinel denotes the float with sign=0, the
+most significant mantissa bit set, and all other mantissa bits zero
+(the IEEE 754 default quiet NaN). Other NaN bit patterns must be
+encoded with the explicit hex-string form.
+"""
+
+CANONICAL_POSITIVE_INFINITY_HEX_FLOAT16: Final = "0x7c00"
+"""Canonical hex form of the float16 `"Infinity"` sentinel."""
+
+CANONICAL_NEGATIVE_INFINITY_HEX_FLOAT16: Final = "0xfc00"
+"""Canonical hex form of the float16 `"-Infinity"` sentinel."""
+
+
+__all__ = [
+ "CANONICAL_NAN_HEX_FLOAT16",
+ "CANONICAL_NEGATIVE_INFINITY_HEX_FLOAT16",
+ "CANONICAL_POSITIVE_INFINITY_HEX_FLOAT16",
+ "FLOAT16_DATA_TYPE_NAME",
+ "Float16DataTypeName",
+ "Float16FillValue",
+ "Float16SpecialFillValue",
+ "HexFloat16",
+ "hex_float16",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/float32.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/float32.py
new file mode 100644
index 0000000000..37b7d4f6e8
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/float32.py
@@ -0,0 +1,71 @@
+"""
+Zarr v3 `float32` data type.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/data-types/index.html
+"""
+
+import re
+from typing import Final, Literal, NewType
+
+FLOAT32_DATA_TYPE_NAME: Final = "float32"
+"""The `data_type` value for the `float32` type."""
+
+Float32DataTypeName = Literal["float32"]
+"""Literal type of the `data_type` field for `float32`."""
+
+Float32SpecialFillValue = Literal["NaN", "Infinity", "-Infinity"]
+"""Named non-finite fill values permitted by the spec for IEEE 754 floats."""
+
+HexFloat32 = NewType("HexFloat32", str)
+"""A 10-character hex string (`0x` + 8 hex digits) encoding the
+unsigned-integer representation of a float32."""
+
+_HEX_FLOAT32_RE: Final = re.compile(r"^0x[0-9a-fA-F]{8}$")
+
+
+def hex_float32(value: str) -> HexFloat32:
+ """Validate `value` as a HexFloat32 and brand it.
+
+ Raises ValueError if `value` is not exactly `0x` followed by 8 hex
+ digits.
+ """
+ if not _HEX_FLOAT32_RE.fullmatch(value):
+ raise ValueError(f"Expected '0x' followed by 8 hex digits, got {value!r}")
+ return HexFloat32(value)
+
+
+Float32FillValue = float | int | Float32SpecialFillValue | HexFloat32
+"""Permitted JSON shape of the `fill_value` field for `float32`.
+
+Either a JSON number, one of the named non-finite sentinels (`"NaN"`,
+`"Infinity"`, `"-Infinity"`), or a `HexFloat32` (`0xYYYYYYYY` string
+encoding the unsigned-integer representation of the IEEE 754 value).
+"""
+
+CANONICAL_NAN_HEX_FLOAT32: Final = "0x7fc00000"
+"""Canonical hex form of the float32 NaN sentinel `"NaN"`.
+
+Per spec the named `"NaN"` sentinel denotes the float with sign=0, the
+most significant mantissa bit set, and all other mantissa bits zero
+(the IEEE 754 default quiet NaN). Other NaN bit patterns must be
+encoded with the explicit hex-string form.
+"""
+
+CANONICAL_POSITIVE_INFINITY_HEX_FLOAT32: Final = "0x7f800000"
+"""Canonical hex form of the float32 `"Infinity"` sentinel."""
+
+CANONICAL_NEGATIVE_INFINITY_HEX_FLOAT32: Final = "0xff800000"
+"""Canonical hex form of the float32 `"-Infinity"` sentinel."""
+
+
+__all__ = [
+ "CANONICAL_NAN_HEX_FLOAT32",
+ "CANONICAL_NEGATIVE_INFINITY_HEX_FLOAT32",
+ "CANONICAL_POSITIVE_INFINITY_HEX_FLOAT32",
+ "FLOAT32_DATA_TYPE_NAME",
+ "Float32DataTypeName",
+ "Float32FillValue",
+ "Float32SpecialFillValue",
+ "HexFloat32",
+ "hex_float32",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/float64.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/float64.py
new file mode 100644
index 0000000000..9a5cf98288
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/float64.py
@@ -0,0 +1,72 @@
+"""
+Zarr v3 `float64` data type.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/data-types/index.html
+"""
+
+import re
+from typing import Final, Literal, NewType
+
+FLOAT64_DATA_TYPE_NAME: Final = "float64"
+"""The `data_type` value for the `float64` type."""
+
+Float64DataTypeName = Literal["float64"]
+"""Literal type of the `data_type` field for `float64`."""
+
+Float64SpecialFillValue = Literal["NaN", "Infinity", "-Infinity"]
+"""Named non-finite fill values permitted by the spec for IEEE 754 floats."""
+
+HexFloat64 = NewType("HexFloat64", str)
+"""An 18-character hex string (`0x` + 16 hex digits) encoding the
+unsigned-integer representation of a float64."""
+
+_HEX_FLOAT64_RE: Final = re.compile(r"^0x[0-9a-fA-F]{16}$")
+
+
+def hex_float64(value: str) -> HexFloat64:
+ """Validate `value` as a HexFloat64 and brand it.
+
+ Raises ValueError if `value` is not exactly `0x` followed by 16 hex
+ digits.
+ """
+ if not _HEX_FLOAT64_RE.fullmatch(value):
+ raise ValueError(f"Expected '0x' followed by 16 hex digits, got {value!r}")
+ return HexFloat64(value)
+
+
+Float64FillValue = float | int | Float64SpecialFillValue | HexFloat64
+"""Permitted JSON shape of the `fill_value` field for `float64`.
+
+Either a JSON number, one of the named non-finite sentinels (`"NaN"`,
+`"Infinity"`, `"-Infinity"`), or a `HexFloat64` (`0xYYYYYYYYYYYYYYYY`
+string encoding the unsigned-integer representation of the IEEE 754
+value).
+"""
+
+CANONICAL_NAN_HEX_FLOAT64: Final = "0x7ff8000000000000"
+"""Canonical hex form of the float64 NaN sentinel `"NaN"`.
+
+Per spec the named `"NaN"` sentinel denotes the float with sign=0, the
+most significant mantissa bit set, and all other mantissa bits zero
+(the IEEE 754 default quiet NaN). Other NaN bit patterns must be
+encoded with the explicit hex-string form.
+"""
+
+CANONICAL_POSITIVE_INFINITY_HEX_FLOAT64: Final = "0x7ff0000000000000"
+"""Canonical hex form of the float64 `"Infinity"` sentinel."""
+
+CANONICAL_NEGATIVE_INFINITY_HEX_FLOAT64: Final = "0xfff0000000000000"
+"""Canonical hex form of the float64 `"-Infinity"` sentinel."""
+
+
+__all__ = [
+ "CANONICAL_NAN_HEX_FLOAT64",
+ "CANONICAL_NEGATIVE_INFINITY_HEX_FLOAT64",
+ "CANONICAL_POSITIVE_INFINITY_HEX_FLOAT64",
+ "FLOAT64_DATA_TYPE_NAME",
+ "Float64DataTypeName",
+ "Float64FillValue",
+ "Float64SpecialFillValue",
+ "HexFloat64",
+ "hex_float64",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/int16.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/int16.py
new file mode 100644
index 0000000000..b76f06761a
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/int16.py
@@ -0,0 +1,23 @@
+"""
+Zarr v3 `int16` data type.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/data-types/index.html
+"""
+
+from typing import Final, Literal
+
+INT16_DATA_TYPE_NAME: Final = "int16"
+"""The `data_type` value for the `int16` type."""
+
+Int16DataTypeName = Literal["int16"]
+"""Literal type of the `data_type` field for `int16`."""
+
+Int16FillValue = int
+"""Permitted JSON shape of the `fill_value` field for `int16`: a JSON integer in [-32768, 32767]."""
+
+
+__all__ = [
+ "INT16_DATA_TYPE_NAME",
+ "Int16DataTypeName",
+ "Int16FillValue",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/int32.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/int32.py
new file mode 100644
index 0000000000..7b41ec6c54
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/int32.py
@@ -0,0 +1,23 @@
+"""
+Zarr v3 `int32` data type.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/data-types/index.html
+"""
+
+from typing import Final, Literal
+
+INT32_DATA_TYPE_NAME: Final = "int32"
+"""The `data_type` value for the `int32` type."""
+
+Int32DataTypeName = Literal["int32"]
+"""Literal type of the `data_type` field for `int32`."""
+
+Int32FillValue = int
+"""Permitted JSON shape of the `fill_value` field for `int32`: a JSON integer in [-2**31, 2**31 - 1]."""
+
+
+__all__ = [
+ "INT32_DATA_TYPE_NAME",
+ "Int32DataTypeName",
+ "Int32FillValue",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/int64.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/int64.py
new file mode 100644
index 0000000000..0005675c66
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/int64.py
@@ -0,0 +1,23 @@
+"""
+Zarr v3 `int64` data type.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/data-types/index.html
+"""
+
+from typing import Final, Literal
+
+INT64_DATA_TYPE_NAME: Final = "int64"
+"""The `data_type` value for the `int64` type."""
+
+Int64DataTypeName = Literal["int64"]
+"""Literal type of the `data_type` field for `int64`."""
+
+Int64FillValue = int
+"""Permitted JSON shape of the `fill_value` field for `int64`: a JSON integer in [-2**63, 2**63 - 1]."""
+
+
+__all__ = [
+ "INT64_DATA_TYPE_NAME",
+ "Int64DataTypeName",
+ "Int64FillValue",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/int8.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/int8.py
new file mode 100644
index 0000000000..a5a16de761
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/int8.py
@@ -0,0 +1,23 @@
+"""
+Zarr v3 `int8` data type.
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/data-types/index.html
+"""
+
+from typing import Final, Literal
+
+INT8_DATA_TYPE_NAME: Final = "int8"
+"""The `data_type` value for the `int8` type."""
+
+Int8DataTypeName = Literal["int8"]
+"""Literal type of the `data_type` field for `int8`."""
+
+Int8FillValue = int
+"""Permitted JSON shape of the `fill_value` field for `int8`: a JSON integer in [-128, 127]."""
+
+
+__all__ = [
+ "INT8_DATA_TYPE_NAME",
+ "Int8DataTypeName",
+ "Int8FillValue",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_datetime64.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_datetime64.py
new file mode 100644
index 0000000000..8784160f71
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_datetime64.py
@@ -0,0 +1,60 @@
+"""
+Zarr `numpy.datetime64` data type (zarr-extensions).
+
+See https://github.com/zarr-developers/zarr-extensions/tree/main/data-types/numpy.datetime64
+"""
+
+from typing import Final, Literal
+
+from typing_extensions import ReadOnly, TypedDict
+
+NUMPY_DATETIME64_DATA_TYPE_NAME: Final = "numpy.datetime64"
+"""The `name` field value of the `numpy.datetime64` data type."""
+
+NumpyDatetime64DataTypeName = Literal["numpy.datetime64"]
+"""Literal type of the `name` field of the `numpy.datetime64` data type."""
+
+NumpyTimeUnit = Literal[
+ "Y", "M", "W", "D", "h", "m", "s", "ms", "us", "μs", "ns", "ps", "fs", "as", "generic"
+]
+"""Time unit codes used by numpy.datetime64."""
+
+
+class NumpyDatetime64Configuration(TypedDict):
+ """
+ Configuration for the `numpy.datetime64` data type.
+
+ Attributes
+ ----------
+ unit
+ A string encoding a unit of time.
+ scale_factor
+ The multiplier relative to the unit.
+ """
+
+ unit: ReadOnly[NumpyTimeUnit]
+ scale_factor: ReadOnly[int]
+
+
+class NumpyDatetime64(TypedDict):
+ """`numpy.datetime64` data type metadata."""
+
+ name: NumpyDatetime64DataTypeName
+ configuration: NumpyDatetime64Configuration
+
+
+NumpyDatetime64FillValue = int | Literal["NaT"]
+"""Permitted JSON shape of the `fill_value` field for `numpy.datetime64`.
+
+Either a JSON integer (count of `unit * scale_factor` since the epoch),
+or the string `"NaT"` (equivalent to the integer `-2**63`).
+"""
+
+__all__ = [
+ "NUMPY_DATETIME64_DATA_TYPE_NAME",
+ "NumpyDatetime64",
+ "NumpyDatetime64Configuration",
+ "NumpyDatetime64DataTypeName",
+ "NumpyDatetime64FillValue",
+ "NumpyTimeUnit",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_timedelta64.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_timedelta64.py
new file mode 100644
index 0000000000..f5c8c77bf8
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/numpy_timedelta64.py
@@ -0,0 +1,80 @@
+"""
+Zarr `numpy.timedelta64` data type (zarr-extensions).
+
+See https://github.com/zarr-developers/zarr-extensions/tree/main/data-types/numpy.timedelta64
+"""
+
+from typing import Final, Literal
+
+from typing_extensions import ReadOnly, TypedDict
+
+NUMPY_TIMEDELTA64_DATA_TYPE_NAME: Final = "numpy.timedelta64"
+"""The `name` field value of the `numpy.timedelta64` data type."""
+
+NumpyTimedelta64DataTypeName = Literal["numpy.timedelta64"]
+"""Literal type of the `name` field of the `numpy.timedelta64` data type."""
+
+NumpyTimeUnit = Literal[
+ "Y", "M", "W", "D", "h", "m", "s", "ms", "us", "μs", "ns", "ps", "fs", "as", "generic"
+]
+"""Time unit codes used by numpy.timedelta64."""
+
+NUMPY_TIME_UNIT: Final = (
+ "Y",
+ "M",
+ "W",
+ "D",
+ "h",
+ "m",
+ "s",
+ "ms",
+ "us",
+ "μs",
+ "ns",
+ "ps",
+ "fs",
+ "as",
+ "generic",
+)
+"""Runtime tuple of the permitted `numpy.timedelta64`/`numpy.datetime64` unit strings."""
+
+
+class NumpyTimedelta64Configuration(TypedDict):
+ """
+ Configuration for the `numpy.timedelta64` data type.
+
+ Attributes
+ ----------
+ unit
+ A string encoding a unit of time.
+ scale_factor
+ The multiplier relative to the unit.
+ """
+
+ unit: ReadOnly[NumpyTimeUnit]
+ scale_factor: ReadOnly[int]
+
+
+class NumpyTimedelta64(TypedDict):
+ """`numpy.timedelta64` data type metadata."""
+
+ name: NumpyTimedelta64DataTypeName
+ configuration: NumpyTimedelta64Configuration
+
+
+NumpyTimedelta64FillValue = int | Literal["NaT"]
+"""Permitted JSON shape of the `fill_value` field for `numpy.timedelta64`.
+
+Either a JSON integer (a count of `unit * scale_factor`), or the string
+`"NaT"` (equivalent to the integer `-2**63`).
+"""
+
+__all__ = [
+ "NUMPY_TIMEDELTA64_DATA_TYPE_NAME",
+ "NUMPY_TIME_UNIT",
+ "NumpyTimeUnit",
+ "NumpyTimedelta64",
+ "NumpyTimedelta64Configuration",
+ "NumpyTimedelta64DataTypeName",
+ "NumpyTimedelta64FillValue",
+]
diff --git a/packages/zarr-metadata/src/zarr_metadata/v3/data_type/raw.py b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/raw.py
new file mode 100644
index 0000000000..c9c688c9fa
--- /dev/null
+++ b/packages/zarr-metadata/src/zarr_metadata/v3/data_type/raw.py
@@ -0,0 +1,45 @@
+"""
+Zarr v3 `r` raw-bytes data type (parameterised by bit count).
+
+The `data_type` value is a string of the form `r` where `N` is a
+positive multiple of 8 (e.g. `r8`, `r16`, `r24`).
+
+See https://zarr-specs.readthedocs.io/en/latest/v3/core/index.html
+"""
+
+import re
+from typing import Final, NewType
+
+RawBytesDataTypeName = NewType("RawBytesDataTypeName", str)
+"""A spec-conformant `r