From 3c43e3e980fa10ae73b51a662cffba820c35467d Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 15 Jul 2026 09:56:50 +0200 Subject: [PATCH 01/32] docs: add roadmap page outlining future plans Adds a Roadmap page to the documentation describing the goals for the next major cycle of work ("v4"), the intended changes per theme, and the three-stream release model (additive minors, deprecations, one minimal removals-only major). Assisted-by: ClaudeCode:claude-fable-5 --- changes/+roadmap.doc.md | 1 + docs/roadmap.md | 324 ++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 326 insertions(+) create mode 100644 changes/+roadmap.doc.md create mode 100644 docs/roadmap.md diff --git a/changes/+roadmap.doc.md b/changes/+roadmap.doc.md new file mode 100644 index 0000000000..8a473acac9 --- /dev/null +++ b/changes/+roadmap.doc.md @@ -0,0 +1 @@ +Added a Roadmap page to the documentation outlining future plans and intended changes to the library. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000000..b68c86ad5e --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,324 @@ +# 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/d-v-b/zarr-python-planning) +repository.* + +!!! note + + This roadmap reflects the current thinking of the core developers. It is a + statement of direction, not a schedule: the work ships when it is ready, + and individual items may change shape as the proposals are discussed and + refined. + +## 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 ergonomic for high-latency +(cloud) storage, and backwards compatibility with Zarr-Python 2.x where +possible. Those goals were largely achieved, and more than a year on, the +2.x → 3.x transition is effectively resolved. + +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. + +## 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: +**"support a 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: + +- Give Zarr-Python users excellent performance, out of the box. +- 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. +- Support 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 have converged on the same architectural patterns — +sync-first codec APIs, per-codec concurrency budgets, adaptive sharded-read +strategies, request deduplication, conditional reads. We treat them as +complementary rather than competitive: Zarr-Python aims to be the best +pure-Python Zarr implementation *and* the best wrapper around the +compiled-language implementations, so that users who need native throughput can +get it without leaving the Zarr-Python API surface. + +## 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** — domain-specific schemas built on top of Zarr (OME-NGFF, + GeoZarr, anndata-zarr). +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: + +- **A focused package per level** — `zarr-metadata`, `zarr-store`, + `zarr-codec`, `zarr-dtype`, with `zarr` as the facade that composes them. + The first of these, + [`zarr-metadata`](https://pypi.org/project/zarr-metadata/), is already + published. +- **A documented interface per level** — capability protocols for stores, a + small stateless codec API, pure-data dtypes. +- **A conformance suite per level** — so that alternative implementations of a + level can verify they behave correctly. +- **Engine pluggability at the chunk-decoding level** — alternative engines + (zarrs, TensorStore) can take over IO without re-implementing hierarchy + traversal, indexing, or metadata handling. + +## What we intend to change + +Each theme below is backed by a detailed technical proposal; the summaries +here describe the intended end state. + +### Foundation: a functional core + +Refactor the internals around a *functional core* — pure data structures and +pure functions for the algebra of Zarr (metadata, chunk layouts, slice +planning, codec walking) — with the side-effecting protocols (stores, codecs) +at the edges. This is an internal change that makes the per-level package split +implementable and provides a clean substrate for engine pluggability. + +### Foundation: a formal hierarchy layer + +Name and specify the layer that sits between the store API (key-agnostic +bytes) and the user-facing `Array` / `Group` facade, as a small set of typed +verbs (`read_array_metadata`, `write_chunk`, `list_children`, +`read_selection`, ...). Alternative engines implement the verbs end-to-end; +hierarchy-aware caching wraps them; chunk-introspection APIs expose them. + +### Codecs + +The current codec API wraps every codec in an unnecessary async layer (a +profiling hotspot), bakes batching into every signature, and forces output +allocation even when the caller has a buffer ready. Rewrite the codec API as a +small, stateless capability bundle — sync-first encode/decode, single-element +signatures, optional `decode_into`, capability flags — decoupled from the rest +of the library, with a compatibility shim for existing codecs and clear paths +for migrating Zarr V2 codecs that still have no V3 equivalent. + +### Stores + +The store abstraction conflates lifecycle, path handling, sync/async, +capability advertisement, and read-only semantics into one inheritance +hierarchy, and the resulting friction has produced a recurring stream of +regressions. Redesign stores as composable capability protocols (`Get`, `Put`, +`List`, ...) with composable wrappers (caching, range coalescing, retries), +transactional semantics, and a shared conformance suite that backends and +wrappers parameterize. + +### Performance + +A cross-cutting theme that ties the codec, store, and functional-core work +into one performance story: typed, library-owned concurrency resources with +dask-safe defaults; synchronous codec encode/decode on the default read path; +range coalescing; pre-allocated decode buffers; in-flight request +deduplication; ETag-style conditional reads; a unified caching substrate with +sensible defaults; an adaptive whole-shard-vs-coalesced read strategy; and +pluggable high-performance backends (zarrs, TensorStore) selectable with a +keyword argument, so the same `Array` and `Group` — and the same Xarray, Dask, +and napari integrations — work at native throughput. A benchmark suite for the +target access patterns lands first, so every performance lever ships with +before/after numbers. + +### Lazy indexing + +`Array.__getitem__` performs IO eagerly and returns NumPy, which makes Zarr +arrays the odd one out among modern array libraries and blocks participation in +the [Python Array API](https://data-apis.org/array-api/) ecosystem. Add an +opt-in `array.lazy[...]` accessor backed by a stable coordinate-mapping algebra +(the `IndexTransform` work in +[#3906](https://github.com/zarr-developers/zarr-python/pull/3906)), plus a +small query planner that turns chained selections into a single IO plan before +any chunks are fetched. No new array type is introduced. Whether the *default* +of bare `array[...]` ever flips from eager to lazy is an explicit, separate +decision — see [decision points](#decision-points) below. + +### 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), using the exact identifiers +registered in `zarr-extensions` so the data stays readable by other +implementations. Ragged arrays, variable-length strings, and an investigation +of Apache Arrow as a substrate for the dtypes the Array API cannot express are +follow-on work on the same substrate. + +### 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. + +### Observability + +Two pillars: **performance metrics and tracing** (a small library-owned +`Metrics` object plus OpenTelemetry auto-instrumentation across stores, codecs, +caches, and the engine boundary) and **stored-state introspection** (public +APIs for asking about chunk-level structure, materialization, byte ranges, and +storage footprint without reading the chunks — the surface projects like +VirtualiZarr and Kerchunk have been asking for). + +### 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. This substrate is +where the performance-lever defaults (concurrency, caching, engine selection) +will live, so it lands early. + +### Consolidated metadata + +Consolidated metadata is essential for performance on high-latency storage and +widely used downstream, but the current support has open design questions +around codec/dtype/grid representations, write-time invalidation, and V2/V3 +migration. A stored V3 representation is a *format* decision, so the design +pass routes through the Zarr spec process (ZEP), co-designed with Xarray. + +### Coordinated and distributed writes + +Give the two patterns that actually produce large Zarr archives — parallel +disjoint-region writes and append-along-axis growth — a design home: disjoint +chunk-aligned region writes with alignment *checked* rather than assumed, a +create-then-hand-out-regions primitive, and single-writer resize/append, all on +plain Zarr V3. Stronger guarantees (atomicity, reader isolation, concurrent +appenders) are enabled through the seam a transactional engine such as +[Icechunk](https://icechunk.io/) builds on, rather than implemented in +Zarr-Python itself. + +### Missing APIs + +User-facing conveniences that users have been asking for, in some cases for +years: hierarchy navigation helpers, chunk introspection, explicit constructors +replacing `mode=`, a typed exception hierarchy, rich reprs, context-manager +support, data copying, and an in-library rechunking primitive. + +## How the work will be released + +**"v4" names this whole body of work, delivered across many releases — it is +not a single "4.0" feature release.** The work is organized into three streams +that run in parallel: + +| Stream | Release vehicle | Scope | +|---|---|---| +| **Additive value** | 3.x minor releases, shipping continuously | The overwhelming majority of the plan, including the entire foundation. No migration required. | +| **Deprecation accumulation** | Warnings across the 3.x line | Each surface is deprecated only *after* its additive replacement has shipped, so users always have a migration target before they see a warning. | +| **Breaking removals** | One minimal, late major release (4.0.0) | Removal of the deprecated surfaces, and *only* those, after deprecation windows have elapsed and downstream libraries have had release windows to adapt. | + +The additive stream is itself roughly ordered: + +1. **Ship-now wins** — dependency-free improvements that land first: the + benchmark suite, store-layer range coalescing, in-flight request + deduplication, the sync codec path on default reads, ML dtype support, + constructor and display UX. +2. **Foundation** — the functional-core refactor, the per-level package split, + the new stores API, the hierarchy verbs, the typed configuration substrate, + the full concurrency and caching rework, and the codec API rewrite. Mostly + invisible to users, all additive. +3. **User-facing surface** — opt-in lazy indexing and the query planner, + device-agnostic IO, observability, chunk introspection, and the zarrs and + TensorStore engine wrappers, built on the foundation. + +The eventual 4.0.0 release contains only removals whose replacements shipped +earlier: the legacy `Store` ABC and the `Buffer`/`prototype` read contract, the +`mode=` constructors, the internal `sync()` bridge, and — conditionally — the +eager `array[...]` path. Nothing new is delivered there; it is the only release +downstream maintainers must treat as breaking, and it arrives after the value +has already been delivered additively. + +### Backwards-compatibility commitments + +The v4 work changes the public API: methods will be renamed, signatures will +change, deprecated patterns will be removed, and the codec and store APIs will +be rewritten. We believe the changes are worth the cost, and we commit to the +following: + +- **Conformance with community standards.** Where a relevant cross-language + standard exists, we conform to it: the Python Array API at the array surface, + the Zarr V3 spec and its extensions at the storage layer, OpenTelemetry for + tracing, and standard buffer-protocol and device-interop conventions for + device-agnostic IO. +- **Functional coverage.** Anything you can do in Zarr-Python 3.x you will + still be able to do once the v4 work has landed — sometimes through a renamed + API, but the capability is preserved. We will not remove the ability to read + or write any Zarr-format data that 3.x supports. +- **A deprecation window for every change.** Renames and removals land through + deprecation cycles, and downstream libraries (Xarray, Dask, napari) get + release windows to absorb each change before the next one lands. + +### Decision points + +Flipping the default of bare `array[...]` from eager to lazy is the single +highest-migration-cost item in the plan, so it is handled as an explicit +decision, not bundled into the additive work. The opt-in `array.lazy[...]` +accessor ships first, with no default change. Whether the default ever flips +hinges on whether Array API conformance at the bare-`__getitem__` surface turns +out to be a hard requirement; if it does, the flip happens as a long-window +deprecation with an explicit eager escape hatch and downstream coordination — +never as a reason to adopt a major version. + +### Out of scope + +- **Persisted hierarchy links** (HDF5-style soft/hard/external links) — would + require defining a new on-disk format unilaterally; needs a Zarr Enhancement + Proposal first. +- **Declarative hierarchy schema validation** as a shipped feature — likely a + separate package layered on `zarr-metadata`, deferred. +- **Cross-process shared-memory caching** — the caching substrate is designed + not to foreclose it, but it does not ship now. +- **Further V2→V3 migration tooling** — the 2.x → 3.x transition is + effectively resolved. + +## 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. +- **Participate in the spec process.** Several open questions (persisted + hierarchy links, ML dtype identifiers, consolidated metadata) ultimately need + [Zarr Enhancement Proposals](https://zarr.dev/zeps/). diff --git a/mkdocs.yml b/mkdocs.yml index 7a4bfa35ef..01d7d96ec7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -72,6 +72,7 @@ nav: - api/zarr/testing/strategies.md - api/zarr/testing/utils.md - release-notes.md + - roadmap.md - contributing.md watch: - src/zarr From 9304a064db03f1a390ed99e9c081219b9bffa0d7 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 15 Jul 2026 22:03:11 +0200 Subject: [PATCH 02/32] docs: refine roadmap --- docs/roadmap.md | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index b68c86ad5e..7a0c770b78 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -154,9 +154,9 @@ before/after numbers. ### Lazy indexing -`Array.__getitem__` performs IO eagerly and returns NumPy, which makes Zarr -arrays the odd one out among modern array libraries and blocks participation in -the [Python Array API](https://data-apis.org/array-api/) ecosystem. Add an +`Array.__getitem__` performs IO eagerly and returns NumPy arrays, which makes Zarr +arrays the odd one out among modern array libraries and blocks compliance with +the [Python Array API](https://data-apis.org/array-api/) standard. Add an opt-in `array.lazy[...]` accessor backed by a stable coordinate-mapping algebra (the `IndexTransform` work in [#3906](https://github.com/zarr-developers/zarr-python/pull/3906)), plus a @@ -204,13 +204,6 @@ deliberately, and named profiles replacing global mutators. This substrate is where the performance-lever defaults (concurrency, caching, engine selection) will live, so it lands early. -### Consolidated metadata - -Consolidated metadata is essential for performance on high-latency storage and -widely used downstream, but the current support has open design questions -around codec/dtype/grid representations, write-time invalidation, and V2/V3 -migration. A stored V3 representation is a *format* decision, so the design -pass routes through the Zarr spec process (ZEP), co-designed with Xarray. ### Coordinated and distributed writes @@ -282,6 +275,7 @@ following: - **A deprecation window for every change.** Renames and removals land through deprecation cycles, and downstream libraries (Xarray, Dask, napari) get release windows to absorb each change before the next one lands. +- **Generous legacy support** If necessary, we can keep old code around in a `legacy` module. Pydantic used a similar strategy to manage their 2.0 release: see https://pydantic.dev/docs/validation/dev/get-started/migration/#using-pydantic-v1-features-in-a-v1v2-environment. ### Decision points @@ -294,18 +288,6 @@ out to be a hard requirement; if it does, the flip happens as a long-window deprecation with an explicit eager escape hatch and downstream coordination — never as a reason to adopt a major version. -### Out of scope - -- **Persisted hierarchy links** (HDF5-style soft/hard/external links) — would - require defining a new on-disk format unilaterally; needs a Zarr Enhancement - Proposal first. -- **Declarative hierarchy schema validation** as a shipped feature — likely a - separate package layered on `zarr-metadata`, deferred. -- **Cross-process shared-memory caching** — the caching substrate is designed - not to foreclose it, but it does not ship now. -- **Further V2→V3 migration tooling** — the 2.x → 3.x transition is - effectively resolved. - ## How to get involved - **Discuss the plans.** Comments and counter-proposals on any of the themes @@ -318,7 +300,4 @@ never as a reason to adopt a major version. - **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. -- **Participate in the spec process.** Several open questions (persisted - hierarchy links, ML dtype identifiers, consolidated metadata) ultimately need - [Zarr Enhancement Proposals](https://zarr.dev/zeps/). + workloads or patterns that don't fit. \ No newline at end of file From bd0f1f1dcecc08c44477567f6048448ae1d7d1e3 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Fri, 17 Jul 2026 17:26:39 +0200 Subject: [PATCH 03/32] Update roadmap.md Co-authored-by: Max Jones <14077947+maxrjones@users.noreply.github.com> --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 7a0c770b78..9f59e40187 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -46,7 +46,7 @@ directions: - 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. -- Support the growth of Python tools across all levels of the Zarr stack. +- 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. From 641d5c092dcddb180f2067623e9ab9b26bb32702 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Fri, 17 Jul 2026 17:27:25 +0200 Subject: [PATCH 04/32] Update roadmap.md Co-authored-by: Max Jones <14077947+maxrjones@users.noreply.github.com> --- docs/roadmap.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 9f59e40187..e18f49d6f5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -67,8 +67,8 @@ 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** — domain-specific schemas built on top of Zarr (OME-NGFF, - GeoZarr, anndata-zarr). +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. From 649f60b23cb02bdcb87fd069a1c0bce3b10d34c3 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Fri, 17 Jul 2026 17:27:36 +0200 Subject: [PATCH 05/32] Update roadmap.md Co-authored-by: Max Jones <14077947+maxrjones@users.noreply.github.com> --- docs/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index e18f49d6f5..cbe7ce79d6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -37,7 +37,7 @@ call **"v4"** — is that overdue investment. 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: -**"support a Zarr-based Python ecosystem for chunked arrays"**. Zarr-Python +**"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: From 8072b42fe9dd42074729f9fd35943f1be308b7fd Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Tue, 21 Jul 2026 20:31:56 +0200 Subject: [PATCH 06/32] Update docs/roadmap.md Co-authored-by: Max Jones <14077947+maxrjones@users.noreply.github.com> --- docs/roadmap.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index cbe7ce79d6..10bea05768 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -154,11 +154,16 @@ before/after numbers. ### Lazy indexing -`Array.__getitem__` performs IO eagerly and returns NumPy arrays, which makes Zarr -arrays the odd one out among modern array libraries and blocks compliance with -the [Python Array API](https://data-apis.org/array-api/) standard. Add an -opt-in `array.lazy[...]` accessor backed by a stable coordinate-mapping algebra -(the `IndexTransform` work in +The Zarr-Python Array API was initially designed to mirror NumPy, with eager +syntax. `Array.__getitem__` performs IO eagerly and returns a NumPy arrays. +That was helpful to the dominant use-case at the time of its creation, but it +means deferred I/O and computation currently require an external library +such as Dask. It means there is no build-in support for representing multi +step reads as a single deferred plan. Further, it means that every chained +selection round-trips to storage independently. + +To solve this limitation, Add an opt-in `array.lazy[...]` accessor backed by a +stable coordinate-mapping algebra (the `IndexTransform` work in [#3906](https://github.com/zarr-developers/zarr-python/pull/3906)), plus a small query planner that turns chained selections into a single IO plan before any chunks are fetched. No new array type is introduced. Whether the *default* From e0623e1166c9d504af1d331c5b3a4ed2d9a88650 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 21 Jul 2026 20:42:42 +0200 Subject: [PATCH 07/32] docs: remove stale roadmap.md redirect so the new roadmap page renders The mkdocs-redirects plugin was generating a redirect stub for roadmap.md pointing at the old v3.0.8 docs, which clobbered the new roadmap page added in this PR. The developers/roadmap.html redirect is kept so old links to the historical v3 design roadmap still resolve. Assisted-by: ClaudeCode:claude-fable-5 --- mkdocs.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 25516a56dd..767c177118 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -202,7 +202,6 @@ plugins: 'search.html.md': 'index.md' 'tutorial.md': 'user-guide/installation.md' 'getting-started.md': 'quick-start.md' - 'roadmap.md': 'https://zarr.readthedocs.io/en/v3.0.8/developers/roadmap.html' 'installation.md': 'user-guide/installation.md' 'release.md': 'release-notes.md' 'about.html.md': 'index.md' From c0e1f1fd8d38c858459d055dae0a435af06ec61f Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:35:57 -0400 Subject: [PATCH 08/32] Rename +roadmap.doc.md to 4149.doc.md --- changes/{+roadmap.doc.md => 4149.doc.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changes/{+roadmap.doc.md => 4149.doc.md} (100%) diff --git a/changes/+roadmap.doc.md b/changes/4149.doc.md similarity index 100% rename from changes/+roadmap.doc.md rename to changes/4149.doc.md From 80880a0425243406e145a69b722d7411c1dc6563 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Wed, 29 Jul 2026 17:54:21 +0200 Subject: [PATCH 09/32] fix: fused pipeline falls back for partial-mixin codecs without sync partial methods (#4201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The partial dispatch in FusedCodecPipeline.read_sync/write_sync asserted the private _decode_partial_sync/_encode_partial_sync methods, which only ShardingCodec implements. A codec advertising the public partial mixins (ArrayBytesCodecPartialDecodeMixin/-EncodeMixin) with only the documented async partial methods died with a bare AssertionError — or, under python -O, an AttributeError mid-IO. The asserts are now capability gates: codecs without the sync partial methods take the full-chunk sync path instead. The related crash for sharded arrays with async-only inner codecs is fixed separately in zarr-developers/zarr-python#4179. Assisted-by: ClaudeCode:claude-fable-5 --- changes/4201.bugfix.md | 1 + src/zarr/core/codec_pipeline.py | 24 ++++-- tests/test_fused_pipeline.py | 141 +++++++++++++++++++++++++++++++- 3 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 changes/4201.bugfix.md diff --git a/changes/4201.bugfix.md b/changes/4201.bugfix.md new file mode 100644 index 0000000000..d837a8a9e2 --- /dev/null +++ b/changes/4201.bugfix.md @@ -0,0 +1 @@ +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. diff --git a/src/zarr/core/codec_pipeline.py b/src/zarr/core/codec_pipeline.py index 4b8831bc7b..56a06b906c 100644 --- a/src/zarr/core/codec_pipeline.py +++ b/src/zarr/core/codec_pipeline.py @@ -1039,10 +1039,14 @@ def read_sync( # Partial-decode fast path: the AB codec owns IO (read only the # byte ranges needed for the requested selection). Same condition - # and dispatch as BatchedCodecPipeline.read_batch. - if self.supports_partial_decode: - codec = self.array_bytes_codec - assert hasattr(codec, "_decode_partial_sync") + # and dispatch as BatchedCodecPipeline.read_batch, plus a gate on the + # sync partial method: the public partial-decode contract + # (`ArrayBytesCodecPartialDecodeMixin`) only requires the async + # `_decode_partial_single`, so a codec may support partial decode + # without `_decode_partial_sync` — such codecs take the full-chunk + # path below instead. + codec = self.array_bytes_codec + if self.supports_partial_decode and hasattr(codec, "_decode_partial_sync"): def _read_one( item: tuple[Any, ArraySpec, SelectorTuple, SelectorTuple, bool], @@ -1111,10 +1115,14 @@ def write_sync( # Partial-encode path: the AB codec owns IO (read, merge, encode, # write). Same condition and calling convention as - # BatchedCodecPipeline.write_batch. - if self.supports_partial_encode: - codec = self.array_bytes_codec - assert hasattr(codec, "_encode_partial_sync") + # BatchedCodecPipeline.write_batch, plus a gate on the sync partial + # method: the public partial-encode contract + # (`ArrayBytesCodecPartialEncodeMixin`) only requires the async + # `_encode_partial_single`, so a codec may support partial encode + # without `_encode_partial_sync` — such codecs take the full-chunk + # path below instead. + codec = self.array_bytes_codec + if self.supports_partial_encode and hasattr(codec, "_encode_partial_sync"): scalar = len(value.shape) == 0 def _write_one( diff --git a/tests/test_fused_pipeline.py b/tests/test_fused_pipeline.py index 02b4026fd9..fd86936853 100644 --- a/tests/test_fused_pipeline.py +++ b/tests/test_fused_pipeline.py @@ -2,21 +2,32 @@ from __future__ import annotations -from typing import Any +from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, Any import numpy as np import pytest import zarr -from zarr.abc.codec import BytesBytesCodec +from zarr.abc.codec import ( + ArrayBytesCodec, + ArrayBytesCodecPartialDecodeMixin, + ArrayBytesCodecPartialEncodeMixin, + BytesBytesCodec, +) from zarr.codecs.bytes import BytesCodec from zarr.codecs.gzip import GzipCodec from zarr.codecs.transpose import TransposeCodec from zarr.codecs.zstd import ZstdCodec from zarr.core.codec_pipeline import FusedCodecPipeline from zarr.core.config import config as zarr_config +from zarr.registry import register_codec from zarr.storage import MemoryStore, StorePath +if TYPE_CHECKING: + from zarr.core.array_spec import ArraySpec + from zarr.core.buffer import Buffer, NDBuffer + @pytest.mark.parametrize( "codecs", @@ -261,7 +272,7 @@ def test_chunk_transform_uses_runtime_prototype() -> None: """ from zarr.abc.codec import BytesBytesCodec from zarr.core.array_spec import ArrayConfig, ArraySpec - from zarr.core.buffer import Buffer, BufferPrototype, default_buffer_prototype + from zarr.core.buffer import BufferPrototype, default_buffer_prototype from zarr.core.chunk_utils import ChunkTransform from zarr.core.dtype import get_data_type_from_native_dtype @@ -831,3 +842,127 @@ def test_async_decode_encode_passes_through_none_chunks() -> None: assert decoded[1] is None assert decoded[0] is not None np.testing.assert_array_equal(decoded[0].as_numpy_array(), data) + + +# --------------------------------------------------------------------------- +# Graceful fallback for partial-mixin codecs without private sync-partial hooks +# +# The public partial-decode/encode contract (`ArrayBytesCodecPartialDecodeMixin` +# / `ArrayBytesCodecPartialEncodeMixin`) only requires the async +# `_decode_partial_single` / `_encode_partial_single`. The fused pipeline must +# route such codecs through its full-chunk sync path instead of asserting on +# the private `_decode_partial_sync` / `_encode_partial_sync` hooks. The double +# below is a minimal conforming implementer of that contract; it guards the +# public extension API, so it must not grow the private sync-partial methods. +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PartialMixinCodec( + ArrayBytesCodec, ArrayBytesCodecPartialDecodeMixin, ArrayBytesCodecPartialEncodeMixin +): + """Serializer with sync whole-chunk methods plus ONLY async partial methods. + + This is the pre-fused public contract for partial-capable codecs: the + mixins' `_decode_partial_single` / `_encode_partial_single`. It must not + implement `_decode_partial_sync` / `_encode_partial_sync`. + """ + + inner: BytesCodec = field(default_factory=BytesCodec) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> PartialMixinCodec: + return cls() + + def to_dict(self) -> dict[str, Any]: + return {"name": "test-partial-mixin"} + + def evolve_from_array_spec(self, array_spec: ArraySpec) -> PartialMixinCodec: + return replace(self, inner=self.inner.evolve_from_array_spec(array_spec)) + + def compute_encoded_size(self, input_byte_length: int, chunk_spec: ArraySpec) -> int: + return self.inner.compute_encoded_size(input_byte_length, chunk_spec) + + def _decode_sync(self, chunk_bytes: Buffer, chunk_spec: ArraySpec) -> NDBuffer: + return self.inner._decode_sync(chunk_bytes, chunk_spec) + + def _encode_sync(self, chunk_array: NDBuffer, chunk_spec: ArraySpec) -> Buffer | None: + return self.inner._encode_sync(chunk_array, chunk_spec) + + async def _decode_single(self, chunk_bytes: Buffer, chunk_spec: ArraySpec) -> NDBuffer: + return self._decode_sync(chunk_bytes, chunk_spec) + + async def _encode_single(self, chunk_array: NDBuffer, chunk_spec: ArraySpec) -> Buffer | None: + return self._encode_sync(chunk_array, chunk_spec) + + async def _decode_partial_single( + self, byte_getter: Any, selection: Any, chunk_spec: ArraySpec + ) -> NDBuffer | None: + chunk_bytes = await byte_getter.get(prototype=chunk_spec.prototype) + if chunk_bytes is None: + return None + return self._decode_sync(chunk_bytes, chunk_spec)[selection] + + async def _encode_partial_single( + self, byte_setter: Any, chunk_array: NDBuffer, selection: Any, chunk_spec: ArraySpec + ) -> None: + existing = await byte_setter.get(prototype=chunk_spec.prototype) + if existing is None: + full = chunk_spec.prototype.nd_buffer.create( + shape=chunk_spec.shape, + dtype=chunk_spec.dtype.to_native_dtype(), + fill_value=chunk_spec.fill_value, + ) + else: + full = self._decode_sync(existing, chunk_spec) + full[selection] = chunk_array + encoded = self._encode_sync(full, chunk_spec) + assert encoded is not None + await byte_setter.set(encoded) + + +register_codec("test-partial-mixin", PartialMixinCodec) + +_FUSED = {"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"} +_BATCHED = {"codec_pipeline.path": "zarr.core.codec_pipeline.BatchedCodecPipeline"} + + +@pytest.mark.filterwarnings("ignore::zarr.errors.UnstableSpecificationWarning") +@pytest.mark.parametrize("dtype", ["uint8", "float64"]) +def test_partial_mixin_codec_async_partial_only_round_trip(dtype: str) -> None: + """A serializer advertising the partial mixins with only async partial + methods must round-trip under the fused pipeline: full write, full read, + partial read, partial write, plus cross-pipeline parity with + BatchedCodecPipeline.""" + data = np.arange(64, dtype=dtype).reshape(8, 8) + + with zarr_config.set(_FUSED): + store = MemoryStore() + arr = zarr.create_array( + store, + shape=(8, 8), + chunks=(4, 4), + dtype=dtype, + serializer=PartialMixinCodec(), + compressors=None, + filters=None, + fill_value=0, + ) + + pipeline = arr._async_array.codec_pipeline + assert isinstance(pipeline, FusedCodecPipeline) + assert pipeline.supports_partial_decode + assert pipeline.supports_partial_encode + assert pipeline.sync_transform is not None + + arr[:] = data + np.testing.assert_array_equal(arr[:], data) + np.testing.assert_array_equal(arr[1:5, 2:7], data[1:5, 2:7]) + + expected = data.copy() + expected[2:6, 1:3] = 7 + arr[2:6, 1:3] = expected[2:6, 1:3] + np.testing.assert_array_equal(arr[:], expected) + + with zarr_config.set(_BATCHED): + np.testing.assert_array_equal(zarr.open_array(store, mode="r")[:], expected) From ba832363cd8b517a4545528c54230c43a2c5f95e Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Wed, 29 Jul 2026 17:55:26 +0200 Subject: [PATCH 10/32] fix: FusedCodecPipeline must apply outer AA/BB codecs on partial paths (#4202) FusedCodecPipeline.supports_partial_decode/supports_partial_encode passed require_no_aa_bb=False, unlike BatchedCodecPipeline (True). With an outer array-array or bytes-bytes codec around a sharding serializer (e.g. compressors=[GzipCodec()], or filters=[TransposeCodec()]), the fused pipeline's partial read/write branches called ShardingCodec's partial sync methods directly on the raw stored value, skipping those outer codecs entirely. That wrote non-conforming bytes for an outer BB codec (unreadable by BatchedCodecPipeline or any conforming reader) and silently produced wrong data for an outer AA codec. Pass require_no_aa_bb=True in both fused properties so these chains fall through to the full-chunk fused path instead, matching batched behavior. Adds cross-pipeline parity coverage (full and partial read/write) for sharding with an outer compressor and with an outer transpose filter, and removes the "known limitation" exclusion that previously kept the sharding+compressor case out of the nested-sharding parity matrix. Assisted-by: ClaudeCode:claude-sonnet-5 --- changes/4202.bugfix.md | 10 +++ src/zarr/core/codec_pipeline.py | 18 ++--- tests/test_pipeline_parity.py | 133 ++++++++++++++++++++++++++++---- 3 files changed, 135 insertions(+), 26 deletions(-) create mode 100644 changes/4202.bugfix.md diff --git a/changes/4202.bugfix.md b/changes/4202.bugfix.md new file mode 100644 index 0000000000..6130fc5b33 --- /dev/null +++ b/changes/4202.bugfix.md @@ -0,0 +1,10 @@ +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. diff --git a/src/zarr/core/codec_pipeline.py b/src/zarr/core/codec_pipeline.py index 56a06b906c..ca760ece59 100644 --- a/src/zarr/core/codec_pipeline.py +++ b/src/zarr/core/codec_pipeline.py @@ -143,10 +143,10 @@ def pipeline_supports_partial_decode( selection non-contiguous, a BB codec can rewrite the bytes), making partial decode infeasible. - NOTE: the two pipelines currently pass different ``require_no_aa_bb`` values - (Batched: True; Fused: False). That divergence is intentional-for-now and - tracked separately; this function centralizes the predicate without changing - either pipeline's behavior. + Both pipelines pass `require_no_aa_bb=True`: an outer AA/BB codec (e.g. a + compressor wrapping a sharding serializer) must see every byte of the + chunk, so a partial branch that only re-decodes/re-encodes the inner + sharding codec would silently bypass it. """ if require_no_aa_bb and (len(array_array_codecs) + len(bytes_bytes_codecs)) != 0: return False @@ -162,8 +162,7 @@ def pipeline_supports_partial_encode( ) -> bool: """Whether a codec pipeline can encode a partial selection without a full rewrite. - Mirror of ``pipeline_supports_partial_decode`` for encoding. See its note re: - the per-pipeline ``require_no_aa_bb`` divergence. + Mirror of `pipeline_supports_partial_decode` for encoding. """ if require_no_aa_bb and (len(array_array_codecs) + len(bytes_bytes_codecs)) != 0: return False @@ -934,14 +933,11 @@ def __iter__(self) -> Iterator[Codec]: @property def supports_partial_decode(self) -> bool: - # NOTE: unlike BatchedCodecPipeline this does NOT require the AA/BB codec - # lists to be empty (require_no_aa_bb=False). That divergence is tracked - # separately; see pipeline_supports_partial_decode. return pipeline_supports_partial_decode( self.array_bytes_codec, array_array_codecs=self.array_array_codecs, bytes_bytes_codecs=self.bytes_bytes_codecs, - require_no_aa_bb=False, + require_no_aa_bb=True, ) @property @@ -950,7 +946,7 @@ def supports_partial_encode(self) -> bool: self.array_bytes_codec, array_array_codecs=self.array_array_codecs, bytes_bytes_codecs=self.bytes_bytes_codecs, - require_no_aa_bb=False, + require_no_aa_bb=True, ) def validate( diff --git a/tests/test_pipeline_parity.py b/tests/test_pipeline_parity.py index 717f0f48f1..94d95c4c24 100644 --- a/tests/test_pipeline_parity.py +++ b/tests/test_pipeline_parity.py @@ -33,6 +33,8 @@ from __future__ import annotations +import warnings +from contextlib import contextmanager from typing import TYPE_CHECKING, Any import numpy as np @@ -48,7 +50,9 @@ ShardingCodec, SubchunkWriteOrder, ) +from zarr.codecs.transpose import TransposeCodec from zarr.core.config import config as zarr_config +from zarr.errors import ZarrUserWarning from zarr.storage import MemoryStore if TYPE_CHECKING: @@ -107,11 +111,15 @@ def _store_snapshot(store: MemoryStore) -> dict[str, bytes]: ("2d-unsharded", {"shape": (20, 20), "chunks": (5, 5), "shards": None}), ("2d-sharded", {"shape": (20, 20), "chunks": (5, 5), "shards": (10, 10)}), # Nested sharding: outer chunk (10,10) sharded into inner chunks (5,5). - # Restricted to bytes-only codec because combining an outer ShardingCodec - # with a compressor (gzip) triggers a ZarrUserWarning and results in a - # checksum mismatch inside the inner shard index — a known limitation, not - # a pipeline-parity bug. The bytes-only path still exercises the full - # two-level shard encoding/decoding in both pipelines. + # Restricted to the codec configs that don't set their own `serializer` + # (bytes-only, gzip): this layout supplies an explicit nested-ShardingCodec + # `serializer`, and a codec config that also sets `serializer` (e.g. + # bytes-big-endian) would silently clobber it via dict merge, dropping + # sharding from the test entirely rather than exercising it. The gzip + # config applies as an outer bytes-bytes codec around the outer + # ShardingCodec -- this is the regression coverage for the fused pipeline + # applying outer AA/BB codecs around sharding (see + # `pipeline_supports_partial_decode`/`pipeline_supports_partial_encode`). ( "2d-nested-sharded", { @@ -122,9 +130,7 @@ def _store_snapshot(store: MemoryStore) -> dict[str, bytes]: chunk_shape=(10, 10), codecs=[ShardingCodec(chunk_shape=(5, 5))], ), - # Only run with the bytes-only codec config; gzip is incompatible - # with nested sharding (see comment above). - "_codec_ids": {"bytes-only"}, + "_codec_ids": {"bytes-only", "gzip"}, }, ), ] @@ -226,6 +232,23 @@ def _matrix() -> Iterator[Any]: # --------------------------------------------------------------------------- +@contextmanager +def _ignore_sharding_combo_warning() -> Iterator[None]: + """Suppress the "combining sharding_indexed disables partial reads" warning. + + Only the nested-sharded-plus-outer-codec matrix cell emits this; scoping the + ignore filter to just its message/category (rather than blanket-disabling + warnings) keeps every other warning in the run promoted to an error as usual. + """ + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=r"Combining a `sharding_indexed` codec.*", + category=ZarrUserWarning, + ) + yield + + def _write_under_pipeline( pipeline_path: str, codec_kwargs: CodecConfig, @@ -244,12 +267,13 @@ def _write_under_pipeline( create_kwargs = {"dtype": "float64", **array_layout, **codec_kwargs} store = MemoryStore() with zarr_config.set({"codec_pipeline.path": pipeline_path}): - arr = zarr.create_array( - store=store, - fill_value=0, - config={"write_empty_chunks": write_empty_chunks}, - **create_kwargs, - ) + with _ignore_sharding_combo_warning(): + arr = zarr.create_array( + store=store, + fill_value=0, + config={"write_empty_chunks": write_empty_chunks}, + **create_kwargs, + ) for sel, val in sequence: arr[sel] = val contents = arr[...] @@ -259,7 +283,8 @@ def _write_under_pipeline( def _read_under_pipeline(pipeline_path: str, store: MemoryStore) -> Any: """Re-open an existing store under the chosen pipeline and read it whole.""" with zarr_config.set({"codec_pipeline.path": pipeline_path}): - arr = zarr.open_array(store=store, mode="r") + with _ignore_sharding_combo_warning(): + arr = zarr.open_array(store=store, mode="r") return arr[...] @@ -418,3 +443,81 @@ def run(pipeline_path: str) -> tuple[dict[str, bytes], Any]: f"(index_location={index_location!r}) — byte-range write fast path likely assumed " f"the wrong physical chunk order" ) + + +# --------------------------------------------------------------------------- +# Outer array-array / bytes-bytes codecs around a sharding serializer +# --------------------------------------------------------------------------- +# +# Regression coverage for FusedCodecPipeline.supports_partial_decode/encode: +# it used to allow AA/BB codecs outside the sharding codec, so its partial +# branches called ShardingCodec._decode_partial_sync/_encode_partial_sync +# directly on the raw stored value, skipping any outer filter/compressor. +# That corrupted on-disk bytes for an outer bytes-bytes codec (unreadable by +# the other pipeline) and silently produced wrong data for an outer +# array-array codec. Both configs below force the partial branches: a +# region write and a region read are included alongside the full ones. + +_OUTER_AA_BB_CONFIGS: list[tuple[str, CodecConfig]] = [ + ( + "outer-gzip-around-sharding", + { + "serializer": ShardingCodec(chunk_shape=(2, 2)), + "compressors": [GzipCodec(level=1)], + }, + ), + ( + "outer-transpose-around-sharding", + { + "filters": [TransposeCodec(order=(1, 0))], + "serializer": ShardingCodec(chunk_shape=(2, 2)), + "compressors": None, + }, + ), +] + + +@pytest.mark.parametrize(("config_id", "codec_kwargs"), _OUTER_AA_BB_CONFIGS) +@pytest.mark.parametrize( + ("writer", "reader"), + [(_BATCHED, _FUSED), (_FUSED, _BATCHED)], + ids=["batched-write-fused-read", "fused-write-batched-read"], +) +def test_pipeline_parity_outer_aa_bb_codecs( + config_id: str, + codec_kwargs: CodecConfig, + writer: str, + reader: str, +) -> None: + """Data written under one pipeline with outer AA/BB codecs must read back + correctly under the other, including through a partial write and a + partial read. + """ + shape = (8, 8) + data = (np.arange(int(np.prod(shape))).reshape(shape) + 1).astype("uint16") + store = MemoryStore() + + with zarr_config.set({"codec_pipeline.path": writer}): + with _ignore_sharding_combo_warning(): + arr = zarr.create_array( + store=store, + shape=shape, + chunks=(4, 4), + dtype=data.dtype, + fill_value=0, + **codec_kwargs, + ) + arr[...] = data + arr[2:5, 1:3] = 99 # region write -- exercises the partial-encode branch + + expected = data.copy() + expected[2:5, 1:3] = 99 + + with zarr_config.set({"codec_pipeline.path": reader}): + with _ignore_sharding_combo_warning(): + arr2 = zarr.open_array(store=store, mode="r") + full = arr2[...] + partial = arr2[1:3, 2:7] # region read -- exercises the partial-decode branch + + np.testing.assert_array_equal(full, expected) + np.testing.assert_array_equal(partial, expected[1:3, 2:7]) From bf818318d25c216721d8078d8a585dc734192be6 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Wed, 29 Jul 2026 19:27:52 +0200 Subject: [PATCH 11/32] fix: gate bulk full-shard decode on identity reads; harden shard-index density check (#4203) The FusedCodecPipeline's vectorized whole-shard decode accepted any indexer without `sel_shape` whose output shape matched the shard shape. An OrthogonalIndexer from `arr[perm, :]` / `arr.oindex[...]` satisfies that, so reordering or duplicating fancy-index reads on uncompressed, crc-free sharded arrays silently returned the shard in natural order. The bulk path now requires an identity full read: one whole-dimension, step-1 slice per dimension (structural, not a BasicIndexer type check, since `arr[:]` arrives as an OrthogonalIndexer). Also: - decline structured dtypes in the bulk path, which lacks the Struct byte-order branch of BytesCodec._decode_sync (latent until #3054 is fixed); - `_ShardIndex.is_dense` now requires offsets to exactly tile the data section instead of merely being unique, so corrupt indexes with overlapping or out-of-range offsets (e.g. pointing into an index_location='start' index region) cannot be served as array data. Assisted-by: ClaudeCode:claude-fable-5 --- changes/4203.bugfix.md | 1 + src/zarr/codecs/sharding.py | 96 +++++++--- tests/test_codecs/test_sharding_unit.py | 234 +++++++++++++++++++++++- tests/test_fastpath_equivalence.py | 90 +++++---- 4 files changed, 358 insertions(+), 63 deletions(-) create mode 100644 changes/4203.bugfix.md diff --git a/changes/4203.bugfix.md b/changes/4203.bugfix.md new file mode 100644 index 0000000000..42ca977193 --- /dev/null +++ b/changes/4203.bugfix.md @@ -0,0 +1 @@ +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. diff --git a/src/zarr/codecs/sharding.py b/src/zarr/codecs/sharding.py index 8f23606011..cdfdae6c89 100644 --- a/src/zarr/codecs/sharding.py +++ b/src/zarr/codecs/sharding.py @@ -53,10 +53,12 @@ from zarr.core.config import config as zarr_config from zarr.core.dtype.common import HasEndianness from zarr.core.dtype.npy.int import UInt64 +from zarr.core.dtype.npy.structured import Struct from zarr.core.indexing import ( BasicIndexer, ChunkProjection, SelectorTuple, + SliceDimIndexer, _lexicographic_order, colexicographic_order_coords, get_indexer, @@ -109,6 +111,32 @@ class ShardingCodecIndexLocation(metaclass=_DeprecatedStrEnumMeta): ) +def _is_identity_full_read(indexer: Any, shard_shape: tuple[int, ...]) -> bool: + """True when `indexer` selects every element of a `shard_shape` array in + natural order: one whole-dimension, step-1 `SliceDimIndexer` per dimension. + + Structural on purpose, not `isinstance(indexer, BasicIndexer)`: a full + `arr[:]` read reaches the shard as an `OrthogonalIndexer`, so a type gate + would silently disable the bulk fast path for the most common case. Any + gather (integer-array / boolean / coordinate selection), subset, strided, + or integer-scalar selection fails the per-dimension check — output shape + alone is not enough, because a reordering or duplicating selection can have + the same shape as the shard while requiring `chunk_selection` / + `out_selection` to be honored. + """ + dim_indexers = getattr(indexer, "dim_indexers", None) + if dim_indexers is None or len(dim_indexers) != len(shard_shape): + return False + return all( + isinstance(dim_indexer, SliceDimIndexer) + and dim_indexer.dim_len == dim_len + and dim_indexer.start == 0 + and dim_indexer.stop == dim_len + and dim_indexer.step == 1 + for dim_indexer, dim_len in zip(dim_indexers, shard_shape, strict=True) + ) + + def _parse_index_location(data: object) -> IndexLocation: if isinstance(data, str) and data in INDEX_LOCATION: return data # type: ignore[return-value] @@ -192,12 +220,17 @@ def is_all_empty(self) -> bool: def get_full_chunk_map(self) -> npt.NDArray[np.bool_]: return np.not_equal(self.offsets_and_lengths[..., 0], MAX_UINT_64) - def is_dense(self, chunk_byte_length: int) -> bool: - """True when every chunk is present, fixed-length, and uniquely placed. - - Used to gate the vectorized whole-shard decode: a dense fixed-size shard - is a regular grid of equal-length payloads, so it can be reshaped/scattered - in bulk rather than decoded chunk-by-chunk. + def is_dense(self, chunk_byte_length: int, *, data_section_start: int) -> bool: + """True when the chunk payloads exactly tile the shard's data section. + + Every chunk must be present with length `chunk_byte_length`, and the + sorted offsets must be exactly `data_section_start + i * chunk_byte_length` + for `i` in `0..n_chunks-1`: no gaps, no overlaps, and nothing outside the + data section (a corrupt index could otherwise point chunks into the + index region or out of the blob). Used to gate the vectorized + whole-shard decode: a dense fixed-size shard is a regular grid of + equal-length payloads, so it can be reshaped/scattered in bulk rather + than decoded chunk-by-chunk. """ offsets = self.offsets_and_lengths[..., 0].reshape(-1) lengths = self.offsets_and_lengths[..., 1].reshape(-1) @@ -207,8 +240,10 @@ def is_dense(self, chunk_byte_length: int) -> bool: # all the same fixed length if not bool(np.all(lengths == chunk_byte_length)): return False - # offsets unique (no two chunks share a slot) - return int(np.unique(offsets).size) == int(offsets.size) + expected = np.uint64(data_section_start) + np.arange( + offsets.size, dtype=np.uint64 + ) * np.uint64(chunk_byte_length) + return bool(np.array_equal(np.sort(offsets), expected)) def get_chunk_slice(self, chunk_coords: tuple[int, ...]) -> tuple[int, int] | None: localized_chunk = self._localize_chunk(chunk_coords) @@ -1086,8 +1121,12 @@ def _decode_full_shard_bulk_if_uncompressed( dtype/endian view with no reordering. A trailing crc32c is NOT accepted (the bulk path can't verify per-chunk checksums, so crc shards keep the per-chunk path's corruption detection); + - the data type is not structured (the byte-order handling below has no + `Struct` branch); + - `indexer` is an identity full-shard read (`_is_identity_full_read`); - the stored index is dense (every chunk present, equal fixed length, - contiguous) so the data section is a regular grid of chunk payloads. + exactly tiling the data section) so the data section is a regular + grid of chunk payloads. Chunk positions are read from the stored index, so this is correct for any `subchunk_write_order` (morton / lexicographic / colexicographic / @@ -1107,27 +1146,29 @@ def _decode_full_shard_bulk_if_uncompressed( return None ab_codec = self.codecs[0] + # The byte-order handling below lacks the structured-dtype branch of + # `BytesCodec._decode_sync` (which applies `newbyteorder` to multi-byte + # struct fields), so structured dtypes must take the per-chunk path. + if isinstance(shard_spec.dtype, Struct): + return None + chunks_per_shard = self._get_chunks_per_shard(shard_spec) chunk_spec = self._get_chunk_spec(shard_spec) n_chunks = product(chunks_per_shard) if n_chunks == 0: return None - # Only valid for a plain contiguous full-shard read, where each chunk - # lands at its natural grid position. The `sel_shape` check is - # load-bearing: a gather indexer (CoordinateIndexer, from vindex / an - # oindex with an integer-array selection) reorders points and exposes - # `sel_shape`, but its `.shape` is the FLATTENED point count, which can - # equal the shard shape by coincidence (trivially in 1-D). Gating on - # shape alone lets such a selection through, and the bulk path then - # returns the shard in natural order, silently dropping the reordering. - # A contiguous full read (BasicIndexer, or a non-gathering - # OrthogonalIndexer from `arr[:]`) has no `sel_shape` and is served here. - # Anything that gathers must fall through to the per-chunk path so + # Only valid for an identity full-shard read, where each chunk lands at + # its natural grid position. The per-dimension check is load-bearing: + # a gather selection (an `OrthogonalIndexer` with an integer-array or + # boolean dimension, from `arr[perm, :]` / `arr.oindex[...]`, or a + # `CoordinateIndexer` from vindex) can have an output `.shape` equal to + # the shard shape while reordering or duplicating points — serving it + # from the bulk path would return the shard in natural order, silently + # dropping the reordering. Anything that is not a full-slice-per- + # dimension read falls through to the per-chunk path so # chunk_selection / out_selection are honored. - if getattr(indexer, "sel_shape", None) is not None: - return None - if tuple(indexer.shape) != tuple(shard_spec.shape): + if not _is_identity_full_read(indexer, shard_spec.shape): return None chunk_byte_length = self._inner_chunk_byte_length(chunk_spec) @@ -1141,7 +1182,8 @@ def _decode_full_shard_bulk_if_uncompressed( else: index_bytes = shard_bytes[-shard_index_size:] index = self._decode_shard_index_sync(index_bytes, chunks_per_shard) - if not index.is_dense(chunk_byte_length): + data_section_start = shard_index_size if self.index_location == "start" else 0 + if not index.is_dense(chunk_byte_length, data_section_start=data_section_start): return None # --- bulk reconstruct --- @@ -1227,9 +1269,9 @@ def _decode_partial_sync( return None bulk = self._decode_full_shard_bulk_if_uncompressed(shard_bytes, shard_spec, indexer) if bulk is not None: - # The bulk path only fires for a contiguous full-shard read (it - # returns None for any gather indexer that exposes `sel_shape`), - # so the result is already shard-shaped — no reshape needed. + # The bulk path only fires for an identity full-shard read + # (`_is_identity_full_read`), so the result is already + # shard-shaped and in natural order — no reshape needed. return bulk shard_reader = self._shard_reader_from_bytes_sync(shard_bytes, chunks_per_shard) shard_dict: ShardMapping = shard_reader diff --git a/tests/test_codecs/test_sharding_unit.py b/tests/test_codecs/test_sharding_unit.py index 34d468fa05..d8b8242a28 100644 --- a/tests/test_codecs/test_sharding_unit.py +++ b/tests/test_codecs/test_sharding_unit.py @@ -2,12 +2,14 @@ import asyncio from dataclasses import dataclass, replace -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast from unittest.mock import AsyncMock import numpy as np +import numpy.typing as npt import pytest +import zarr from zarr.abc.codec import ArrayArrayCodec, ArrayBytesCodec from zarr.codecs.bytes import BytesCodec from zarr.codecs.crc32c_ import Crc32cCodec @@ -24,8 +26,10 @@ from zarr.core.buffer import NDBuffer, default_buffer_prototype from zarr.core.buffer.cpu import Buffer from zarr.core.buffer.cpu import NDBuffer as CPUNDBuffer +from zarr.core.chunk_grids import ChunkGrid from zarr.core.config import config from zarr.core.dtype import get_data_type_from_native_dtype +from zarr.core.indexing import BasicIndexer from zarr.storage._common import StorePath from zarr.storage._memory import MemoryStore @@ -910,3 +914,231 @@ async def mock_load_index( kwargs = store_mock.get_ranges.call_args.kwargs assert kwargs["max_gap_bytes"] == 12345 assert kwargs["max_coalesced_bytes"] == 67890 + + +# ============================================================================ +# Bulk full-shard decode: identity-read gating and dtype gating +# ============================================================================ + +_FUSED_PIPELINE = "zarr.core.codec_pipeline.FusedCodecPipeline" + + +def _fused_uncompressed_array( + index_location: Literal["start", "end"], +) -> tuple[Any, npt.NDArray[np.int32]]: + """Sharded `(8, 8)` array whose inner chain is a bare BytesCodec (no crc), + one shard covering the whole array, filled with `arange` data. Callers must + be inside a config context selecting the fused pipeline.""" + shards: ShardsConfigParam = {"shape": (8, 8), "index_location": index_location} + arr = zarr.create_array( + store=MemoryStore(), + shape=(8, 8), + chunks=(2, 2), + shards=shards, + dtype="int32", + compressors=None, + filters=None, + fill_value=0, + config={"write_empty_chunks": True}, + ) + ref = np.arange(64, dtype="int32").reshape(8, 8) + arr[:] = ref + return arr, ref + + +def _spy_on_bulk_decode(monkeypatch: pytest.MonkeyPatch) -> list[bool]: + """Record, per call, whether `_decode_full_shard_bulk_if_uncompressed` + engaged (returned non-None).""" + engaged: list[bool] = [] + orig = ShardingCodec._decode_full_shard_bulk_if_uncompressed + + def spy(self: ShardingCodec, shard_bytes: Any, shard_spec: Any, indexer: Any) -> Any: + result = orig(self, shard_bytes, shard_spec, indexer) + engaged.append(result is not None) + return result + + monkeypatch.setattr(ShardingCodec, "_decode_full_shard_bulk_if_uncompressed", spy) + return engaged + + +_PERM_8 = np.array([7, 2, 5, 0, 3, 6, 1, 4]) +_DUP_8 = np.array([0, 0, 1, 2, 3, 4, 5, 6]) + + +@pytest.mark.parametrize("index_location", ["start", "end"]) +@pytest.mark.parametrize( + ("read", "expected", "expect_bulk"), + [ + pytest.param(lambda a: a[:], lambda r: r, True, id="full-slice"), + pytest.param(lambda a: a[...], lambda r: r, True, id="ellipsis"), + pytest.param( + lambda a: a[_PERM_8, :], lambda r: r[_PERM_8, :], False, id="fancy-permutation" + ), + pytest.param(lambda a: a[_DUP_8, :], lambda r: r[_DUP_8, :], False, id="fancy-duplicates"), + pytest.param( + lambda a: a.oindex[_PERM_8, :], + lambda r: r[_PERM_8, :], + False, + id="oindex-permutation", + ), + pytest.param( + lambda a: a.oindex[_DUP_8, :], lambda r: r[_DUP_8, :], False, id="oindex-duplicates" + ), + pytest.param(lambda a: a[1:7, :], lambda r: r[1:7, :], False, id="subset-slice"), + ], +) +def test_bulk_decode_engagement_and_correctness( + monkeypatch: pytest.MonkeyPatch, + index_location: Literal["start", "end"], + read: Any, + expected: Any, + expect_bulk: bool, +) -> None: + """Under the fused pipeline on an uncompressed crc-free shard, the bulk + whole-shard decode fires exactly for identity full reads — and every read + returns what numpy returns. The engagement assertions keep the correctness + half non-vacuous: a gate that simply disabled the fast path would pass the + value checks but fail here.""" + engaged = _spy_on_bulk_decode(monkeypatch) + with config.set({"codec_pipeline.path": _FUSED_PIPELINE}): + arr, ref = _fused_uncompressed_array(index_location) + engaged.clear() + np.testing.assert_array_equal(read(arr), expected(ref)) + if expect_bulk: + assert len(engaged) > 0, "bulk fast path was never reached for a full read" + assert all(engaged), "bulk fast path did not engage for a full read" + else: + assert not any(engaged), "bulk fast path engaged for a non-identity selection" + + +@pytest.mark.parametrize("index_location", ["start", "end"]) +def test_multi_shard_permutation_read(index_location: Literal["start", "end"]) -> None: + """A row permutation crossing shard boundaries must return permuted data + under the fused pipeline (each shard sees a gather selection whose shape + coincides with the shard shape).""" + shards: ShardsConfigParam = {"shape": (8, 8), "index_location": index_location} + with config.set({"codec_pipeline.path": _FUSED_PIPELINE}): + arr = zarr.create_array( + store=MemoryStore(), + shape=(16, 16), + chunks=(2, 2), + shards=shards, + dtype="int32", + compressors=None, + filters=None, + fill_value=0, + config={"write_empty_chunks": True}, + ) + ref = np.arange(256, dtype="int32").reshape(16, 16) + arr[:] = ref + perm = np.array([9, 3, 12, 0, 15, 6, 10, 1, 14, 5, 8, 2, 13, 7, 11, 4]) + np.testing.assert_array_equal(arr[perm, :8], ref[perm, :8]) + np.testing.assert_array_equal(arr.oindex[perm, :8], ref[perm, :8]) + + +def _dense_shard_blob( + codec: ShardingCodec, data: np.ndarray[Any, np.dtype[Any]], chunk_len: int +) -> Buffer: + """Hand-assemble a dense `index_location="end"` shard blob for 1-D `data`: + natural-order chunk payloads followed by the encoded index.""" + n_chunks = data.shape[0] // chunk_len + chunk_nbytes = chunk_len * data.dtype.itemsize + index = _ShardIndex.create_empty((n_chunks,)) + for i in range(n_chunks): + index.set_chunk_slice((i,), slice(i * chunk_nbytes, (i + 1) * chunk_nbytes)) + index_bytes = codec._encode_shard_index_sync(index) + return Buffer.from_bytes(data.tobytes() + index_bytes.to_bytes()) + + +def _identity_indexer(shape: tuple[int, ...], chunk_shape: tuple[int, ...]) -> BasicIndexer: + return BasicIndexer( + tuple(slice(0, s) for s in shape), + shape=shape, + chunk_grid=ChunkGrid.from_sizes(shape, chunk_shape), + ) + + +def _spec_for(data: np.ndarray[Any, np.dtype[Any]]) -> ArraySpec: + zdt = get_data_type_from_native_dtype(data.dtype) + return ArraySpec( + shape=data.shape, + dtype=zdt, + fill_value=zdt.cast_scalar(0), + config=ArrayConfig(order="C", write_empty_chunks=True), + prototype=default_buffer_prototype(), + ) + + +def test_bulk_decode_declines_structured_dtype() -> None: + """The bulk path has no structured-dtype byte-order handling (the `Struct` + branch of `BytesCodec._decode_sync`), so it must decline structured specs. + The plain-dtype control on an identically constructed blob proves the + decline comes from the dtype gate, not from a malformed blob.""" + codec = ShardingCodec(chunk_shape=(2,), codecs=[BytesCodec(endian="little")]) + + # control: same construction with a plain dtype engages the bulk path + plain = np.arange(4, dtype=" None: + """`is_dense` accepts every layout whose fixed-size payloads exactly tile + the data section, wherever that section starts and in whatever order the + chunks were laid out.""" + chunk_len = 24 + n = int(np.prod(chunks_per_shard)) + slots = np.arange(n) + if layout_order == "reversed": + slots = slots[::-1] + offsets = data_section_start + slots * chunk_len + index = _ShardIndex.create_empty(chunks_per_shard) + for coord, off in zip(np.ndindex(chunks_per_shard), offsets, strict=True): + index.set_chunk_slice(tuple(coord), slice(int(off), int(off) + chunk_len)) + assert index.is_dense(chunk_len, data_section_start=data_section_start) is True + + +def test_shard_index_is_dense_rejects_overlapping_offsets() -> None: + """Unique but overlapping offsets (second payload starts inside the first) + are not dense.""" + chunk_len = 24 + index = _ShardIndex.create_empty((2,)) + index.set_chunk_slice((0,), slice(0, chunk_len)) + index.set_chunk_slice((1,), slice(12, 12 + chunk_len)) + assert index.is_dense(chunk_len, data_section_start=0) is False + + +def test_shard_index_is_dense_rejects_out_of_range_offsets() -> None: + """An offset outside the data section (here: chunk 0 pointing into an + `index_location="start"` index region) is not dense, even though offsets + are unique and non-overlapping.""" + chunk_len = 24 + data_section_start = 16 + index = _ShardIndex.create_empty((2,)) + index.set_chunk_slice((0,), slice(0, chunk_len)) + index.set_chunk_slice( + (1,), slice(data_section_start + chunk_len, data_section_start + 2 * chunk_len) + ) + assert index.is_dense(chunk_len, data_section_start=data_section_start) is False diff --git a/tests/test_fastpath_equivalence.py b/tests/test_fastpath_equivalence.py index 317b8742f1..9a2f782d13 100644 --- a/tests/test_fastpath_equivalence.py +++ b/tests/test_fastpath_equivalence.py @@ -196,34 +196,43 @@ def test_merge_complete_chunk_returns_view_and_write_does_not_mutate_source() -> # --------------------------------------------------------------------------- # Whole-shard bulk decode under arbitrary indexing: the bulk decode only fires -# for a *contiguous full-shard* read, but it is reached through the partial-read -# path (`_decode_partial_sync`), whose only gate is `indexer.shape == -# shard_spec.shape`. A reordering coordinate/orthogonal selection that happens -# to touch every chunk (so the flattened point count equals the shard shape) -# must NOT be served by the bulk path in natural order — it must honor the -# selection. This pins the END-TO-END read (the gate lives in the array read -# path, not in `_decode_full_shard_bulk_if_uncompressed` itself), which -# `test_bulk_shard_decode_equals_general_decode` (BasicIndexer only) cannot -# reach. See the vindex-on-uncompressed-shard corruption bug. +# for an *identity full-shard* read (every dimension a whole-dim step-1 slice), +# but it is reached through the partial-read path (`_decode_partial_sync`) for +# any indexer. A reordering or duplicating coordinate/orthogonal selection can +# have an output shape equal to the shard shape — trivially in 1-D (any +# selection of `shard_len` points), and in >=2-D whenever an axis-0 index array +# has exactly `shard_shape[0]` entries — and must NOT be served by the bulk +# path in natural order; it must honor the selection. This pins the END-TO-END +# read, which `test_bulk_shard_decode_equals_general_decode` (identity +# BasicIndexer only) cannot reach. See the vindex- and +# oindex-on-uncompressed-shard corruption bugs. # --------------------------------------------------------------------------- @st.composite def _uncompressed_shard_index_cases(draw: st.DrawFn) -> dict[str, Any]: - # 1-D is where the trigger is easiest: a CoordinateIndexer's `.shape` is the - # flattened point count, which equals a 1-D shard shape exactly when the - # selection visits `shard_len` points. - chunk = draw(st.integers(1, 4)) - grid = draw(st.integers(1, 4)) - shard_len = chunk * grid + ndim = draw(st.integers(1, 2)) + chunk_shape = tuple(draw(st.integers(1, 4)) for _ in range(ndim)) + grid = tuple(draw(st.integers(1, 4)) for _ in range(ndim)) + shard_shape = tuple(c * g for c, g in zip(chunk_shape, grid, strict=True)) dtype = draw(_DTYPES) - data = draw(npst.arrays(dtype=np.dtype(dtype), shape=(shard_len,))) - perm = draw(st.permutations(list(range(shard_len)))) + data = draw(npst.arrays(dtype=np.dtype(dtype), shape=shard_shape)) + dim0 = shard_shape[0] + # axis-0 index array sized to the dimension: either a permutation + # (reordering, no duplicates) or an arbitrary list (duplicates likely) — + # both keep the output shape equal to the shard shape. + if draw(st.booleans()): + idx = np.array(draw(st.permutations(list(range(dim0)))), dtype=np.intp) + else: + idx = np.array( + draw(st.lists(st.integers(0, dim0 - 1), min_size=dim0, max_size=dim0)), + dtype=np.intp, + ) return { - "chunk": chunk, - "shard_len": shard_len, + "chunk_shape": chunk_shape, + "shard_shape": shard_shape, "data": data, - "perm": np.array(perm), + "idx": idx, "endian": draw(st.sampled_from(["little", "big"])), "index_location": draw(st.sampled_from(["start", "end"])), "subchunk_write_order": draw( @@ -235,33 +244,44 @@ def _uncompressed_shard_index_cases(draw: st.DrawFn) -> dict[str, Any]: @settings(max_examples=200, deadline=None) @given(case=_uncompressed_shard_index_cases()) def test_reordering_read_on_uncompressed_shard_honors_selection(case: dict[str, Any]) -> None: - """A reordering vindex/oindex over a full uncompressed shard must return the - permuted data, not the shard in natural order — under the Fused pipeline - (where the bulk-decode fast path engages) exactly as under numpy.""" - perm = case["perm"] + """A reordering or duplicating fancy/vindex/oindex read over a full + uncompressed shard must return the selected data, not the shard in natural + order — under the Fused pipeline (where the bulk-decode fast path engages) + exactly as under numpy.""" + idx = case["idx"] data = case["data"] + ndim = data.ndim serializer = BytesCodec(endian=case["endian"]) with zarr.config.set({"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"}): arr = zarr.create_array( store=MemoryStore(), - shape=(case["shard_len"],), - chunks=(case["chunk"],), - shards=(case["shard_len"],), + shape=case["shard_shape"], + chunks=case["chunk_shape"], + shards={"shape": case["shard_shape"], "index_location": case["index_location"]}, dtype=data.dtype, serializer=serializer, compressors=None, filters=None, fill_value=0, ) - arr[:] = data - - # vindex with a full-coverage permutation: flattened point count == - # shard shape, so the buggy gate would mis-classify this as a contiguous - # full-shard read and return data unpermuted. - np.testing.assert_array_equal(arr.vindex[perm], data[perm]) - # oindex with a single reordering index list along the only axis. - np.testing.assert_array_equal(arr.oindex[perm], data[perm]) + arr[...] = data + + # axis-0 index array (rest full slices): an OrthogonalIndexer whose + # output shape equals the shard shape but which reorders/duplicates rows. + rest = (slice(None),) * (ndim - 1) + np.testing.assert_array_equal(arr[(idx, *rest)], data[idx]) + np.testing.assert_array_equal(arr.oindex[(idx, *rest)], data[idx]) + if ndim == 1: + # coordinate selection: flattened point count == shard shape. + np.testing.assert_array_equal(arr.vindex[idx], data[idx]) + else: + # 2-D broadcast index arrays: full-coverage coordinate selection + # whose shape equals the shard shape. + cols = np.arange(case["shard_shape"][1]) + np.testing.assert_array_equal( + arr.vindex[idx[:, None], cols[None, :]], data[idx[:, None], cols[None, :]] + ) # --------------------------------------------------------------------------- From b2ece6f82c5281d02a8a4408fb13f791870c65bd Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Wed, 29 Jul 2026 19:40:02 +0200 Subject: [PATCH 12/32] docs(zarr-metadata): standalone documentation site; add package justfile (#4208) * docs(zarr-metadata): add API reference to the docs site Add mkdocstrings pages for every public zarr_metadata module (model, pydantic, v2, and v3 with its chunk_grid, chunk_key_encoding, codec, and data_type subpackages) under a new zarr-metadata group in the API Reference nav. griffe documents the package statically from packages/zarr-metadata/src, so the docs build environment does not need the package installed. Point the package's Documentation URL at the rendered reference instead of the README. Assisted-by: ClaudeCode:claude-fable-5 * chore(zarr-metadata): add justfile with package-scoped dev recipes Recipes mirror the zarr-metadata CI jobs (pytest, ruff, pyright pinned to the version CI uses, on CI's python) plus changelog-draft and docs-serve conveniences. Recipes run from the package directory regardless of where just is invoked, and remain reachable from the repo root as 'just packages/zarr-metadata/'; a future root justfile can namespace them with a 'mod' declaration. Assisted-by: ClaudeCode:claude-fable-5 * chore(zarr-metadata): make docs-serve robust to a busy port With no argument, docs-serve now binds port 8000 if free and otherwise falls back to an ephemeral free port. An explicitly requested port is used as-is so a conflict fails loudly. Assisted-by: ClaudeCode:claude-fable-5 * chore(zarr-metadata): point docs-serve at the package docs, fix cleanup Print the zarr-metadata API reference URL once the server accepts connections, since mkdocs's own 'Serving on' line points at the zarr-python site root. Run the server in its own process group so stopping the recipe kills the whole uv->mkdocs tree instead of leaving an orphaned server holding the port. Assisted-by: ClaudeCode:claude-fable-5 * docs(zarr-metadata): make the package docs a standalone site Move the zarr-metadata API reference out of the zarr-python site into a self-contained mkdocs site under packages/zarr-metadata (own mkdocs.yml, landing page, and .readthedocs.yaml for a dedicated RTD project), so the package presents as a separate project with docs versioned by its own zarr_metadata-v* release tags rather than zarr-python's. The zarr-python API Reference nav now links out to the standalone site instead of embedding the pages. The package gains a pinned docs dependency group, a docs build job in its CI workflow, and docs-check / docs-serve justfile recipes targeting the package site. Assisted-by: ClaudeCode:claude-fable-5 * update index.md * ci(zarr-metadata): delegate workflow steps to the justfile The workflow duplicated every command the justfile defines; jobs now run 'just test/lint/typecheck/docs-check' so the justfile is the single source of truth for the package's verbs. CI keeps only its own concerns: the python matrix sync for pytest, and uv caching. The pyright job's python/sync steps are dropped because the typecheck recipe pins the interpreter and pyright version itself. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/zarr-metadata.yml | 45 ++++++-- mkdocs.yml | 1 + packages/zarr-metadata/.readthedocs.yaml | 20 ++++ packages/zarr-metadata/README.md | 19 ++++ packages/zarr-metadata/docs/api/index.md | 31 ++++++ packages/zarr-metadata/docs/api/model.md | 5 + packages/zarr-metadata/docs/api/pydantic.md | 5 + packages/zarr-metadata/docs/api/v2.md | 17 +++ .../zarr-metadata/docs/api/v3/chunk_grid.md | 11 ++ .../docs/api/v3/chunk_key_encoding.md | 11 ++ packages/zarr-metadata/docs/api/v3/codec.md | 25 +++++ .../zarr-metadata/docs/api/v3/data_type.md | 45 ++++++++ packages/zarr-metadata/docs/api/v3/index.md | 15 +++ packages/zarr-metadata/docs/index.md | 102 +++++++++++++++++ packages/zarr-metadata/justfile | 58 ++++++++++ packages/zarr-metadata/mkdocs.yml | 103 ++++++++++++++++++ packages/zarr-metadata/pyproject.toml | 13 ++- 17 files changed, 514 insertions(+), 12 deletions(-) create mode 100644 packages/zarr-metadata/.readthedocs.yaml create mode 100644 packages/zarr-metadata/docs/api/index.md create mode 100644 packages/zarr-metadata/docs/api/model.md create mode 100644 packages/zarr-metadata/docs/api/pydantic.md create mode 100644 packages/zarr-metadata/docs/api/v2.md create mode 100644 packages/zarr-metadata/docs/api/v3/chunk_grid.md create mode 100644 packages/zarr-metadata/docs/api/v3/chunk_key_encoding.md create mode 100644 packages/zarr-metadata/docs/api/v3/codec.md create mode 100644 packages/zarr-metadata/docs/api/v3/data_type.md create mode 100644 packages/zarr-metadata/docs/api/v3/index.md create mode 100644 packages/zarr-metadata/docs/index.md create mode 100644 packages/zarr-metadata/justfile create mode 100644 packages/zarr-metadata/mkdocs.yml diff --git a/.github/workflows/zarr-metadata.yml b/.github/workflows/zarr-metadata.yml index df7d96cc1c..b5f56dd508 100644 --- a/.github/workflows/zarr-metadata.yml +++ b/.github/workflows/zarr-metadata.yml @@ -1,5 +1,8 @@ name: zarr-metadata +# Job steps delegate to packages/zarr-metadata/justfile, the single source of +# truth for this package's verbs; CI owns only the python matrix and caching. + on: push: branches: [main] @@ -39,12 +42,14 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true + - name: Install just + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} - name: Sync test dependency group run: uv sync --group test --python ${{ matrix.python-version }} - name: Run pytest - run: uv run --group test pytest tests + run: just test ruff: name: ruff @@ -59,8 +64,10 @@ jobs: persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + - name: Install just + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 - name: Run ruff - run: uvx ruff check . + run: just lint pyright: name: pyright @@ -77,19 +84,35 @@ jobs: uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 with: enable-cache: true - - name: Set up Python - run: uv python install 3.11 - - name: Sync test dependency group - run: uv sync --group test --python 3.11 + - name: Install just + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 - name: Run pyright - # Pinned to the last version that types PEP 661 sentinels in class - # attributes correctly; 1.1.405+ regressed (microsoft/pyright#11115). - # Unpin when the fix lands. - run: uv run --group test --with 'pyright==1.1.404' pyright src + # The pyright version and interpreter pins live in the justfile. + run: just typecheck + + docs: + name: docs + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: packages/zarr-metadata + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: true + - name: Install just + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + - name: Build docs + run: just docs-check zarr-metadata-complete: name: zarr-metadata complete - needs: [test, ruff, pyright] + needs: [test, ruff, pyright, docs] if: always() runs-on: ubuntu-latest steps: diff --git a/mkdocs.yml b/mkdocs.yml index 46bfc1764c..87aaf23430 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -92,6 +92,7 @@ nav: - ' zarr.testing.utils': api/zarr/testing/utils.md - ' zarr.zeros': api/zarr/functions/zeros.md - ' zarr.zeros_like': api/zarr/functions/zeros_like.md + - 'zarr-metadata ↪': https://zarr-metadata.readthedocs.io/ - release-notes.md - contributing.md hooks: diff --git a/packages/zarr-metadata/.readthedocs.yaml b/packages/zarr-metadata/.readthedocs.yaml new file mode 100644 index 0000000000..b89846f570 --- /dev/null +++ b/packages/zarr-metadata/.readthedocs.yaml @@ -0,0 +1,20 @@ +# 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: + install: + - pip install --upgrade pip + - pip install ./packages/zarr-metadata --group packages/zarr-metadata/pyproject.toml:docs + build: + html: + - mkdocs build --strict -f packages/zarr-metadata/mkdocs.yml --site-dir $READTHEDOCS_OUTPUT/html + +mkdocs: + configuration: packages/zarr-metadata/mkdocs.yml diff --git a/packages/zarr-metadata/README.md b/packages/zarr-metadata/README.md index 69b80d7332..6b6b172aec 100644 --- a/packages/zarr-metadata/README.md +++ b/packages/zarr-metadata/README.md @@ -2,6 +2,8 @@ Python types, models, and validators for Zarr v2 and v3 metadata. +Documentation: + ## What this is Two layers and an optional integration: @@ -82,6 +84,23 @@ 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 diff --git a/packages/zarr-metadata/docs/api/index.md b/packages/zarr-metadata/docs/api/index.md new file mode 100644 index 0000000000..2aa39ab161 --- /dev/null +++ b/packages/zarr-metadata/docs/api/index.md @@ -0,0 +1,31 @@ +--- +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) + +Every public name is also re-exported at the top level, so +`from zarr_metadata import ZarrV3ArrayMetadataJSON` and +`from zarr_metadata.v3.array import ZarrV3ArrayMetadataJSON` are equivalent. + +## 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..2004f2dc54 --- /dev/null +++ b/packages/zarr-metadata/docs/index.md @@ -0,0 +1,102 @@ +--- +title: zarr-metadata +--- + +# 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..40912d6251 --- /dev/null +++ b/packages/zarr-metadata/mkdocs.yml @@ -0,0 +1,103 @@ +site_name: zarr-metadata +repo_name: zarr-developers/zarr-python +repo_url: https://github.com/zarr-developers/zarr-python +edit_uri: 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 + +watch: + - src + +theme: + language: en + name: material + + 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 index edc4b696a6..6e97d26409 100644 --- a/packages/zarr-metadata/pyproject.toml +++ b/packages/zarr-metadata/pyproject.toml @@ -44,10 +44,21 @@ 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://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-metadata/README.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" From 69ca264664bd74ba55c594490acdd49cd70b7fe8 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Wed, 29 Jul 2026 21:36:59 +0200 Subject: [PATCH 13/32] docs(zarr-metadata): docs-site polish: repo link, titles, RTD build skips, branding (#4210) * docs(zarr-metadata): point the site's repo link at the package directory The material header source widget linked to the zarr-python repository root, presenting the site as zarr-python's. Link the package directory and label it zarr-python/packages/zarr-metadata instead. edit_uri becomes absolute because mkdocs would append it to repo_url's subpath. Assisted-by: ClaudeCode:claude-fable-5 * docs(zarr-metadata): drop redundant frontmatter title on the homepage Material appends the site name to explicit frontmatter titles, so the homepage browser title rendered as 'zarr-metadata - zarr-metadata'. Without the frontmatter it falls back to the site name alone. Assisted-by: ClaudeCode:claude-fable-5 * docs(zarr-metadata): skip unrelated RTD PR builds; add site logo and favicon Both Read the Docs projects rebuilt on every pull request regardless of what changed. Each config now cancels PR builds via exit code 183 when the diff against origin/main does not touch its half of the repo: the zarr-metadata project skips PRs that leave packages/zarr-metadata untouched, and the zarr-python project skips PRs confined to it. Scoped to external versions because origin/main is only a meaningful diff base for PR builds. The package site also gets the zarr logo and favicon, copied from the zarr-python docs, instead of stock Material icons. Assisted-by: ClaudeCode:claude-fable-5 * fix(docs): quote-free exclude pathspec in RTD build-skip rule Read the Docs strips shell quoting from build commands, so the quoted ':(exclude)packages/zarr-metadata' pathspec reached /bin/sh unquoted and the bare parenthesis was a syntax error, failing every zarr PR build. Use git's quote-free :! exclude form, which survives the stripping; reproduced the mangling and verified both forms against dash locally. Assisted-by: ClaudeCode:claude-fable-5 --- .readthedocs.yaml | 13 +++++++++++++ packages/zarr-metadata/.readthedocs.yaml | 10 ++++++++++ .../docs/_static/favicon-96x96.png | Bin 0 -> 12714 bytes packages/zarr-metadata/docs/_static/logo_bw.png | Bin 0 -> 45208 bytes packages/zarr-metadata/docs/index.md | 4 ---- packages/zarr-metadata/mkdocs.yml | 11 ++++++++--- 6 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 packages/zarr-metadata/docs/_static/favicon-96x96.png create mode 100644 packages/zarr-metadata/docs/_static/logo_bw.png diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 1edd099ebd..55b5d6fed0 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -5,6 +5,19 @@ build: tools: python: "3.12" jobs: + post_checkout: + # Cancel pull request builds whose changes are confined to the + # zarr-metadata package, which has its own Read the Docs project. 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. Read the Docs strips shell quoting from + # commands, so the exclude pathspec must use the quote-free :! form, + # not ':(exclude)'. + - | + if [ "$READTHEDOCS_VERSION_TYPE" = "external" ] && git diff --quiet origin/main -- :!packages/zarr-metadata; + then + exit 183; + fi install: - pip install --upgrade pip - pip install .[remote] --group docs diff --git a/packages/zarr-metadata/.readthedocs.yaml b/packages/zarr-metadata/.readthedocs.yaml index b89846f570..ace6ccddfd 100644 --- a/packages/zarr-metadata/.readthedocs.yaml +++ b/packages/zarr-metadata/.readthedocs.yaml @@ -9,6 +9,16 @@ build: 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 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 0000000000000000000000000000000000000000..e77977ccf41426c35a768ea73ed20e05d2676dd5 GIT binary patch literal 12714 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..df1979d3cc3317a36feaf5e7aab7c32998bdbfc7 GIT binary patch literal 45208 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 Date: Wed, 29 Jul 2026 22:16:48 +0200 Subject: [PATCH 14/32] chore(zarr-metadata): build 0.4.0 changelog (#4211) Consume the pending news fragments (the #4119 model layer's feature and removal notes, plus a new doc fragment for the standalone documentation site and justfile from #4208/#4210) into CHANGELOG.md via towncrier for the zarr_metadata-v0.4.0 release. Assisted-by: ClaudeCode:claude-fable-5 --- packages/zarr-metadata/CHANGELOG.md | 153 ++++++++++++++++++ .../zarr-metadata/changes/4119.feature.md | 92 ----------- .../zarr-metadata/changes/4119.removal.md | 41 ----- 3 files changed, 153 insertions(+), 133 deletions(-) delete mode 100644 packages/zarr-metadata/changes/4119.feature.md delete mode 100644 packages/zarr-metadata/changes/4119.removal.md diff --git a/packages/zarr-metadata/CHANGELOG.md b/packages/zarr-metadata/CHANGELOG.md index a3ff1177a0..981c7706bf 100644 --- a/packages/zarr-metadata/CHANGELOG.md +++ b/packages/zarr-metadata/CHANGELOG.md @@ -2,6 +2,159 @@ +## 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 diff --git a/packages/zarr-metadata/changes/4119.feature.md b/packages/zarr-metadata/changes/4119.feature.md deleted file mode 100644 index b9d0bb508c..0000000000 --- a/packages/zarr-metadata/changes/4119.feature.md +++ /dev/null @@ -1,92 +0,0 @@ -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. diff --git a/packages/zarr-metadata/changes/4119.removal.md b/packages/zarr-metadata/changes/4119.removal.md deleted file mode 100644 index 2a9a6f84c4..0000000000 --- a/packages/zarr-metadata/changes/4119.removal.md +++ /dev/null @@ -1,41 +0,0 @@ -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`. From 020e5c3494c7bb794da1a94b970c326ddbdbca7a Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:45:46 -0400 Subject: [PATCH 15/32] docs: fix link checker error and update redirected links; run checker weekly (#4214) --- .github/workflows/links.yml | 2 +- README.md | 22 +- docs/contributing.md | 2 +- docs/quick-start.md | 4 +- docs/release-notes.md | 406 +++++++++++++------------- docs/user-guide/arrays.md | 6 +- docs/user-guide/installation.md | 8 +- docs/user-guide/storage.md | 11 +- packages/zarr-metadata/CHANGELOG.md | 20 +- packages/zarr-metadata/README.md | 2 +- packages/zarr-metadata/pyproject.toml | 2 +- pyproject.toml | 2 +- 12 files changed, 242 insertions(+), 245 deletions(-) diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index f606f12a3e..0af76deece 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -5,7 +5,7 @@ on: workflow_dispatch: # pull_request: schedule: - - cron: "00 18 * * *" + - cron: "00 18 * * 1" # weekly, Mondays at 18:00 UTC jobs: linkChecker: diff --git a/README.md b/README.md index fb0890d18c..7557936b6f 100644 --- a/README.md +++ b/README.md @@ -8,24 +8,24 @@ [![CondaForge](https://anaconda.org/conda-forge/zarr/badges/version.svg)](https://anaconda.org/anaconda/zarr/) [![Package Status](https://img.shields.io/pypi/status/zarr.svg)](https://pypi.org/project/zarr/) [![License](https://img.shields.io/pypi/l/zarr.svg)](https://github.com/zarr-developers/zarr-python/blob/main/LICENSE.txt) -[![Coverage](https://codecov.io/gh/zarr-developers/zarr-python/branch/main/graph/badge.svg)](https://codecov.io/gh/zarr-developers/zarr-python) -[![Downloads](https://pepy.tech/badge/zarr)](https://zarr.readthedocs.io) +[![Coverage](https://codecov.io/gh/zarr-developers/zarr-python/branch/main/graph/badge.svg)](https://app.codecov.io/gh/zarr-developers/zarr-python) +[![Downloads](https://static.pepy.tech/badge/zarr)](https://zarr.readthedocs.io/en/stable/) [![Developer Chat](https://img.shields.io/badge/zulip-join_chat-brightgreen.svg)](https://ossci.zulipchat.com/#narrow/channel/423692-Zarr-Python) [![Citation](https://zenodo.org/badge/DOI/10.5281/zenodo.3773450.svg)](https://doi.org/10.5281/zenodo.3773450) ## What is it? -Zarr is a Python package providing an implementation of compressed, chunked, N-dimensional arrays, designed for use in parallel computing. See the [documentation](https://zarr.readthedocs.io) for more information. +Zarr is a Python package providing an implementation of compressed, chunked, N-dimensional arrays, designed for use in parallel computing. See the [documentation](https://zarr.readthedocs.io/en/stable/) for more information. ## Main Features -- [**Create**](https://zarr.readthedocs.io/en/stable/user-guide/arrays.html#creating-an-array) N-dimensional arrays with any NumPy `dtype`. -- [**Chunk arrays**](https://zarr.readthedocs.io/en/stable/user-guide/performance.html#chunk-optimizations) along any dimension. -- [**Compress**](https://zarr.readthedocs.io/en/stable/user-guide/arrays.html#compressors) and/or filter chunks using any NumCodecs codec. -- [**Store arrays**](https://zarr.readthedocs.io/en/stable/user-guide/storage.html) in memory, on disk, inside a zip file, on S3, etc... -- [**Read**](https://zarr.readthedocs.io/en/stable/user-guide/arrays.html#reading-and-writing-data) an array [**concurrently**](https://zarr.readthedocs.io/en/stable/user-guide/performance.html#parallel-computing-and-synchronization) from multiple threads or processes. -- [**Write**](https://zarr.readthedocs.io/en/stable/user-guide/arrays.html#reading-and-writing-data) to an array concurrently from multiple threads or processes. -- Organize arrays into hierarchies via [**groups**](https://zarr.readthedocs.io/en/stable/quickstart.html#hierarchical-groups). +- [**Create**](https://zarr.readthedocs.io/en/stable/user-guide/arrays/#creating-an-array) N-dimensional arrays with any NumPy `dtype`. +- [**Chunk arrays**](https://zarr.readthedocs.io/en/stable/user-guide/performance/#chunk-optimizations) along any dimension. +- [**Compress**](https://zarr.readthedocs.io/en/stable/user-guide/arrays/#compressors) and/or filter chunks using any NumCodecs codec. +- [**Store arrays**](https://zarr.readthedocs.io/en/stable/user-guide/storage/) in memory, on disk, inside a zip file, on S3, etc... +- [**Read**](https://zarr.readthedocs.io/en/stable/user-guide/arrays/#reading-and-writing-data) an array [**concurrently**](https://zarr.readthedocs.io/en/stable/user-guide/performance/#parallel-computing-and-synchronization) from multiple threads or processes. +- [**Write**](https://zarr.readthedocs.io/en/stable/user-guide/arrays/#reading-and-writing-data) to an array concurrently from multiple threads or processes. +- Organize arrays into hierarchies via [**groups**](https://zarr.readthedocs.io/en/stable/quick-start/#hierarchical-groups). ## Where to get it @@ -41,4 +41,4 @@ or via `conda`: conda install -c conda-forge zarr ``` -For more details, including how to install from source, see the [installation documentation](https://zarr.readthedocs.io/en/stable/index.html#installation). +For more details, including how to install from source, see the [installation documentation](https://zarr.readthedocs.io/en/stable/#installation). diff --git a/docs/contributing.md b/docs/contributing.md index aeb88e6ce1..dea7256c36 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -419,4 +419,4 @@ performance benchmarks as part of our test suite. The benchmarks are found in `t By default pytest is configured to run these benchmarks as plain tests (i.e., no benchmarking). To run a benchmark with timing measurements, use the `--benchmark-enable` when invoking `pytest`. -The benchmarks are run as part of the continuous integration suite through [codspeed](https://codspeed.io/zarr-developers/zarr-python). +The benchmarks are run as part of the continuous integration suite through [codspeed](https://app.codspeed.io/zarr-developers/zarr-python). diff --git a/docs/quick-start.md b/docs/quick-start.md index 17cb1c599a..123f05d5e9 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -164,8 +164,8 @@ 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) or -[gcsfs](https://gcsfs.readthedocs.io). Remote storage support requires the `remote` +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: diff --git a/docs/release-notes.md b/docs/release-notes.md index 3fd8a5f360..7b147a30bd 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -11,12 +11,12 @@ - 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/issues/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/issues/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/issues/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 (up to ~24x writes / ~14x reads on many-chunks-per-shard layouts, more with compression) and no regressions on compute-bound workloads. 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/issues/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/issues/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/issues/3987)) + 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 (up to ~24x writes / ~14x reads on many-chunks-per-shard layouts, more with compression) and no regressions on compute-bound workloads. 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)) ### Bugfixes @@ -27,24 +27,24 @@ - 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/issues/3417)) + 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/issues/3885)) +- 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/issues/3899)) + 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/issues/3955)) + 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/issues/3966)) + 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.from_url()` and `from_mapper()` now close the async filesystem they create when `store.close()` is called. Previously the underlying aiohttp @@ -64,7 +64,7 @@ s3fs with ``cache_regions=True``) may internally refresh and replace their client during I/O operations, abandoning prior sessions before ``store.close()`` is invoked. Those intermediate sessions are outside the scope of this fix and - are an issue in the upstream filesystem library. ([#4003](https://github.com/zarr-developers/zarr-python/issues/4003)) + are an issue in the upstream filesystem library. ([#4003](https://github.com/zarr-developers/zarr-python/pull/4003)) - 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)) @@ -77,9 +77,9 @@ - `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/issues/4074)) + ([#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/issues/4116)) +- 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)) ### Improved Documentation @@ -88,7 +88,7 @@ - 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/issues/3984)) + 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. @@ -109,16 +109,16 @@ `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/issues/4052)) + 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/issues/4053)) +- 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/issues/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/issues/4133)) + 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)) ### Deprecations and Removals @@ -133,7 +133,7 @@ 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/issues/3963)) + 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 @@ -156,13 +156,13 @@ 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/issues/3968)) + ([#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/issues/3973)) +- 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 -- [#214](https://github.com/zarr-developers/zarr-python/issues/214), [#215](https://github.com/zarr-developers/zarr-python/issues/215), [#3908](https://github.com/zarr-developers/zarr-python/issues/3908), [#3972](https://github.com/zarr-developers/zarr-python/issues/3972), [#3975](https://github.com/zarr-developers/zarr-python/issues/3975), [#3979](https://github.com/zarr-developers/zarr-python/issues/3979), [#3990](https://github.com/zarr-developers/zarr-python/issues/3990), [#3998](https://github.com/zarr-developers/zarr-python/issues/3998), [#4000](https://github.com/zarr-developers/zarr-python/issues/4000), [#4001](https://github.com/zarr-developers/zarr-python/issues/4001), [#4046](https://github.com/zarr-developers/zarr-python/issues/4046), [#4054](https://github.com/zarr-developers/zarr-python/issues/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/issues/4138) +- [#214](https://github.com/zarr-developers/zarr-python/issues/214), [#215](https://github.com/zarr-developers/zarr-python/pull/215), [#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), [#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) ## 3.2.1 (2026-05-05) @@ -172,17 +172,17 @@ - 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/issues/3938)) + 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/issues/3941)) + `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/issues/3944)) +a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr-python/pull/3944)) ## 3.2.0 (2026-04-30) @@ -190,9 +190,9 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr - 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/issues/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/issues/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/issues/3781)) + 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})`. @@ -208,37 +208,37 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr **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/issues/3802)) + 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/issues/3874)) +- 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/issues/3797)) + 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/issues/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/issues/3920)) +- 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/issues/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/issues/3845)) + 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/issues/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/issues/3901)) -- Remove deprecated `Group` methods `array`, `require_dataset`, and `create_dataset`. ([#3902](https://github.com/zarr-developers/zarr-python/issues/3902)) -- Remove deprecated `AsyncArray.create` and `Array.create` methods. ([#3903](https://github.com/zarr-developers/zarr-python/issues/3903)) +- 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/issues/3793), [#3800](https://github.com/zarr-developers/zarr-python/issues/3800), [#3828](https://github.com/zarr-developers/zarr-python/issues/3828), [#3830](https://github.com/zarr-developers/zarr-python/issues/3830), [#3833](https://github.com/zarr-developers/zarr-python/issues/3833), [#3837](https://github.com/zarr-developers/zarr-python/issues/3837), [#3897](https://github.com/zarr-developers/zarr-python/issues/3897) +- [#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) @@ -246,42 +246,42 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr ### 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/issues/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/issues/3668)) + `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/issues/3603)) -- Fixed a bug in the sharding codec that prevented nested shard reads in certain cases. ([#3655](https://github.com/zarr-developers/zarr-python/issues/3655)) -- Fix obstore `_transform_list_dir` implementation to correctly relativize paths (removing `lstrip` usage). ([#3657](https://github.com/zarr-developers/zarr-python/issues/3657)) -- Raise error when trying to encode `numpy.dtypes.StringDType` with `na_object` set. ([#3695](https://github.com/zarr-developers/zarr-python/issues/3695)) -- `CacheStore`, `LoggingStore` and `LatencyStore` now support with_read_only. ([#3700](https://github.com/zarr-developers/zarr-python/issues/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/issues/3702)) -- Fix a performance bug in morton curve generation. ([#3705](https://github.com/zarr-developers/zarr-python/issues/3705)) -- Add a dedicated in-memory cache for byte-range requests to the experimental `CacheStore`. ([#3710](https://github.com/zarr-developers/zarr-python/issues/3710)) +- 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/issues/3763)) -- Set `copy=False` in reshape operation. ([#3649](https://github.com/zarr-developers/zarr-python/issues/3649)) -- Validate that dask-style chunks have regular shapes. ([#3779](https://github.com/zarr-developers/zarr-python/issues/3779)) +- 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/issues/3769)) -- Added a glossary. ([#3767](https://github.com/zarr-developers/zarr-python/issues/3767)) +- 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/issues/3562), [#3605](https://github.com/zarr-developers/zarr-python/issues/3605), [#3619](https://github.com/zarr-developers/zarr-python/issues/3619), [#3623](https://github.com/zarr-developers/zarr-python/issues/3623), [#3636](https://github.com/zarr-developers/zarr-python/issues/3636), [#3648](https://github.com/zarr-developers/zarr-python/issues/3648), [#3656](https://github.com/zarr-developers/zarr-python/issues/3656), [#3658](https://github.com/zarr-developers/zarr-python/issues/3658), [#3673](https://github.com/zarr-developers/zarr-python/issues/3673), [#3704](https://github.com/zarr-developers/zarr-python/issues/3704), [#3706](https://github.com/zarr-developers/zarr-python/issues/3706), [#3708](https://github.com/zarr-developers/zarr-python/issues/3708), [#3712](https://github.com/zarr-developers/zarr-python/issues/3712), [#3713](https://github.com/zarr-developers/zarr-python/issues/3713), [#3717](https://github.com/zarr-developers/zarr-python/issues/3717), [#3721](https://github.com/zarr-developers/zarr-python/issues/3721), [#3728](https://github.com/zarr-developers/zarr-python/issues/3728), [#3778](https://github.com/zarr-developers/zarr-python/issues/3778) +- [#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/issues/3594)) +- 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) @@ -289,31 +289,31 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr ### 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/issues/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/issues/3366)) -- Adds a `zarr.experimental` module for unstable user-facing features. ([#3490](https://github.com/zarr-developers/zarr-python/issues/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/issues/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/issues/3556)) + 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/issues/3483)) +- 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/issues/3530)) + 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/issues/3535)) + 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/issues/3118)) +- 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/issues/3502)) +- 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/issues/3515), [#3532](https://github.com/zarr-developers/zarr-python/issues/3532), [#3533](https://github.com/zarr-developers/zarr-python/issues/3533), [#3553](https://github.com/zarr-developers/zarr-python/issues/3553) +- [#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) @@ -321,20 +321,20 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr ### 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/issues/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/issues/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/issues/3444)) +- 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/issues/2992)) +- 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/issues/3422)) -- Fix ChunkGrid definition (broken in 3.1.2) ([#3425](https://github.com/zarr-developers/zarr-python/issues/3425)) -- Ensure syntax like `root['/subgroup']` works equivalently to `root['subgroup']` when using consolidated metadata. ([#3428](https://github.com/zarr-developers/zarr-python/issues/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/issues/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/issues/3448)) +- 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 @@ -342,56 +342,56 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr ### Misc -- [#3376](https://github.com/zarr-developers/zarr-python/issues/3376), [#3390](https://github.com/zarr-developers/zarr-python/issues/3390), [#3403](https://github.com/zarr-developers/zarr-python/issues/3403), [#3449](https://github.com/zarr-developers/zarr-python/issues/3449) +- [#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/issues/3083)) -- Make config param optional in init_array ([#3391](https://github.com/zarr-developers/zarr-python/issues/3391)) +- 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/issues/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/issues/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/issues/3367)) -- Ensure passing `config` is handled properly when `open`ing an existing array. ([#3378](https://github.com/zarr-developers/zarr-python/issues/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/issues/3395)) +- 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/issues/3098), [#3288](https://github.com/zarr-developers/zarr-python/issues/3288), [#3318](https://github.com/zarr-developers/zarr-python/issues/3318), [#3368](https://github.com/zarr-developers/zarr-python/issues/3368), [#3371](https://github.com/zarr-developers/zarr-python/issues/3371), [#3372](https://github.com/zarr-developers/zarr-python/issues/3372), [#3374](https://github.com/zarr-developers/zarr-python/issues/3374) +- [#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/issues/3227)) +- 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/issues/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/issues/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/issues/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/issues/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/issues/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/issues/3251)) -- Register 'gpu' marker with pytest for downstream StoreTests. ([#3258](https://github.com/zarr-developers/zarr-python/issues/3258)) +- 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/issues/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/issues/3280)) -- Fixes [`Group.nmembers()`][zarr.Group.nmembers] ignoring depth when using consolidated metadata. ([#3287](https://github.com/zarr-developers/zarr-python/issues/3287)) +- 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/issues/3249)) -- Add a section on codecs to the migration guide. ([#3273](https://github.com/zarr-developers/zarr-python/issues/3273)) +- 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/issues/3268)) +- 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) @@ -444,13 +444,13 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr 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/issues/2874)) + 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/issues/3191)) +- 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/issues/3226)) +- 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/issues/3233)) +- Add an alternate `from_array_metadata_and_store` constructor to `CodecPipeline`. ([#3233](https://github.com/zarr-developers/zarr-python/pull/3233)) ### Bugfixes @@ -459,28 +459,28 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr - 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/issues/3170)) + - 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/issues/3190)) +- 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/issues/3198)) + 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/issues/3232)) + 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/issues/3157)) + 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/issues/3212)) + 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 @@ -499,7 +499,7 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr 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/issues/3228)) + 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`, @@ -516,27 +516,27 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr ### 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/issues/3193)) + 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/issues/3195)) +- 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/issues/2774)) +- 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/issues/3021)) +- 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/issues/3066)) +- 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/issues/3081)) +- 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/issues/3082)) +- 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). ([#3130](https://github.com/zarr-developers/zarr-python/issues/3130)) +- 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/issues/3138)) +- 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 @@ -544,7 +544,7 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr - 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/issues/3100)) + 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. @@ -552,12 +552,12 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr 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/issues/3103)) + `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/issues/3127)) + 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/issues/3128)) +- 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. @@ -566,9 +566,9 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr 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/issues/3140)) + 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/issues/3156)) +- 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) @@ -578,195 +578,195 @@ a `ManagedMemoryStore` is used. ([#3944](https://github.com/zarr-developers/zarr ### Features -- Added a `print_debug_info` function for bug reports. ([#2913](https://github.com/zarr-developers/zarr-python/issues/2913)) +- 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/issues/2862)) -- Fixed sharding with GPU buffers. ([#2978](https://github.com/zarr-developers/zarr-python/issues/2978)) +- 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/issues/3039)) + 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/issues/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/issues/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/issues/2962)) +- 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/issues/2972), [#3027](https://github.com/zarr-developers/zarr-python/issues/3027), [#3049](https://github.com/zarr-developers/zarr-python/issues/3049) +- [#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/issues/1661)) -- Add `zarr.from_array` using concurrent streaming of source data ([#2622](https://github.com/zarr-developers/zarr-python/issues/2622)) +- 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/issues/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/issues/2802)) + `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/issues/2996)) + 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/issues/2997)) +- 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/issues/2910)) + 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/issues/2714)) -- Avoid an unnecessary memory copy when writing Zarr to a local file ([#2944](https://github.com/zarr-developers/zarr-python/issues/2944)) + 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/issues/2908)) +- 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/issues/2850)) -- Prevent update_attributes calls from deleting old attributes ([#2870](https://github.com/zarr-developers/zarr-python/issues/2870)) + 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/issues/2796) +- [#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/issues/2665)) +- 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/issues/2847)) -- Fix a bug when setting values of a smaller last chunk. ([#2851](https://github.com/zarr-developers/zarr-python/issues/2851)) +- 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/issues/2828) +- [#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/issues/2661)) -- Added `zarr.config.enable_gpu` to update Zarr's configuration to use GPUs. ([#2751](https://github.com/zarr-developers/zarr-python/issues/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/issues/2784)) -- `LocalStore` learned to `delete_dir`. This makes array and group deletes more efficient. ([#2804](https://github.com/zarr-developers/zarr-python/issues/2804)) -- Add `zarr.testing.strategies.array_metadata` to generate ArrayV2Metadata and ArrayV3Metadata instances. ([#2813](https://github.com/zarr-developers/zarr-python/issues/2813)) -- Add arbitrary `shards` to Hypothesis strategy for generating arrays. ([#2822](https://github.com/zarr-developers/zarr-python/issues/2822)) +- 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/issues/2751)) +- 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/issues/2755)) -- Fix zip-store path checking for stores with directories listed as files. ([#2758](https://github.com/zarr-developers/zarr-python/issues/2758)) -- Use removeprefix rather than replace when removing filename prefixes in `FsspecStore.list` ([#2778](https://github.com/zarr-developers/zarr-python/issues/2778)) -- Enable automatic removal of `needs release notes` with labeler action ([#2781](https://github.com/zarr-developers/zarr-python/issues/2781)) -- Use the proper label config ([#2785](https://github.com/zarr-developers/zarr-python/issues/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/issues/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/issues/2799)) -- Ensure utf8 compliant strings are used to construct numpy arrays in property-based tests ([#2801](https://github.com/zarr-developers/zarr-python/issues/2801)) -- Fix pickling for ZipStore ([#2807](https://github.com/zarr-developers/zarr-python/issues/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/issues/2811)) -- Fix fancy indexing (e.g. arr[5, [0, 1]]) with the sharding codec ([#2817](https://github.com/zarr-developers/zarr-python/issues/2817)) + 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/issues/2751)) +- 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/issues/2693)) -- Test that a `ValueError` is raised for invalid byte range syntax in `StoreTests`. ([#2693](https://github.com/zarr-developers/zarr-python/issues/2693)) -- Separate instantiating and opening a store in `StoreTests`. ([#2693](https://github.com/zarr-developers/zarr-python/issues/2693)) -- Add a test for using Stores as context managers in `StoreTests`. ([#2693](https://github.com/zarr-developers/zarr-python/issues/2693)) -- Implemented `LoggingStore.open()`. ([#2693](https://github.com/zarr-developers/zarr-python/issues/2693)) -- `LoggingStore` is now a generic class. ([#2693](https://github.com/zarr-developers/zarr-python/issues/2693)) +- 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/issues/2693)) -- Test the error raised for invalid buffer arguments in `StoreTests`. ([#2693](https://github.com/zarr-developers/zarr-python/issues/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/issues/2693)) + 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/issues/2761)) + 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/issues/2533)) -- Added backwards compatibility for Zarr format 2 structured arrays. ([#2681](https://github.com/zarr-developers/zarr-python/issues/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/issues/2693)) -- Ensure that `ZipStore` is open before getting or setting any values. ([#2693](https://github.com/zarr-developers/zarr-python/issues/2693)) -- Use stdout rather than stderr as the default stream for `LoggingStore`. ([#2693](https://github.com/zarr-developers/zarr-python/issues/2693)) -- Match the errors raised by read only stores in `StoreTests`. ([#2693](https://github.com/zarr-developers/zarr-python/issues/2693)) +- 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/issues/2762)) -- Updated the optional test dependencies to include `botocore` and `fsspec`. ([#2768](https://github.com/zarr-developers/zarr-python/issues/2768)) + 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/issues/2768)) -- Optimize full chunk writes. ([#2782](https://github.com/zarr-developers/zarr-python/issues/2782)) + 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/issues/2736)) + 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/issues/2763)) + 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/issues/2622)). +* 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/issues/2679)). +* 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/issues/2695)). + 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/issues/2677)). -* Quickstart guide alignment with V3 API ([#2697](https://github.com/zarr-developers/zarr-python/issues/2697)). -* Fix doctest failures related to numcodecs 0.15 ([#2727](https://github.com/zarr-developers/zarr-python/issues/2727)). +* 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/issues/2686)). -* Enable codecov in GitHub actions ([#2682](https://github.com/zarr-developers/zarr-python/issues/2682)). -* Speed up hypothesis tests ([#2650](https://github.com/zarr-developers/zarr-python/issues/2650)). -* Remove multiple imports for an import name ([#2723](https://github.com/zarr-developers/zarr-python/issues/2723)). + 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) diff --git a/docs/user-guide/arrays.md b/docs/user-guide/arrays.md index 51b2fa1a17..a192845f9e 100644 --- a/docs/user-guide/arrays.md +++ b/docs/user-guide/arrays.md @@ -199,7 +199,7 @@ print(arr_f.config) 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/) which provides various +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: @@ -256,7 +256,7 @@ z[:] = data print(f"Compressors: {z.compressors}") ``` -Here is an example using LZMA from [NumCodecs](https://numcodecs.readthedocs.io/) with a custom filter pipeline including LZMA's +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" @@ -295,7 +295,7 @@ z = zarr.create_array(store='data/example-9.zarr', shape=data.shape, dtype=data. print(z.info_complete()) ``` -For more information about available filter codecs, see the [Numcodecs](https://numcodecs.readthedocs.io/) documentation. +For more information about available filter codecs, see the [Numcodecs](https://numcodecs.readthedocs.io/en/stable/) documentation. ## Advanced indexing diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index 4af7667a44..a7487e83c8 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -5,12 +5,12 @@ Required dependencies include: - [Python](https://docs.python.org/3/) (3.12 or later) -- [packaging](https://packaging.pypa.io) (22.0 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) (0.14 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) (4.14 or later) -- [donfig](https://donfig.readthedocs.io) (0.8 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 diff --git a/docs/user-guide/storage.md b/docs/user-guide/storage.md index 0ba6202c76..b288c9976d 100644 --- a/docs/user-guide/storage.md +++ b/docs/user-guide/storage.md @@ -1,7 +1,7 @@ # Storage guide Zarr-Python supports multiple storage backends, including: local file systems, -Zip files, remote stores via [fsspec](https://filesystem-spec.readthedocs.io) (S3, HTTP, etc.), and in-memory stores. In +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`][]. @@ -12,7 +12,7 @@ Zarr-Python 3, stores must implement the abstract store API from ## Implicit Store Creation In most cases, it is not required to create a `Store` object explicitly. Passing a string -(or other [StoreLike value](#storelike)) to Zarr's top level API will result in the store +(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" @@ -41,10 +41,7 @@ group = zarr.create_group(store=data) print(group) ``` - -[](){#user-guide-store-like} - -### StoreLike +### StoreLike {#user-guide-store-like} `StoreLike` values can be: @@ -142,7 +139,7 @@ f.close() 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) and can support any backend +[`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: diff --git a/packages/zarr-metadata/CHANGELOG.md b/packages/zarr-metadata/CHANGELOG.md index 981c7706bf..ac4ad3535a 100644 --- a/packages/zarr-metadata/CHANGELOG.md +++ b/packages/zarr-metadata/CHANGELOG.md @@ -159,7 +159,7 @@ ### 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/issues/4037)) +- 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 @@ -180,7 +180,7 @@ `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/issues/4083)) + 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) @@ -193,13 +193,13 @@ 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/issues/3978)) + ([#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/issues/3978)) + ([#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` @@ -207,7 +207,7 @@ `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/issues/3962)) + ([#3962](https://github.com/zarr-developers/zarr-python/pull/3962)) ### Features @@ -219,7 +219,7 @@ (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/issues/3982)) + ([#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 @@ -227,13 +227,13 @@ 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/issues/3962)) + ([#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/issues/3978)) + ([#3978](https://github.com/zarr-developers/zarr-python/pull/3978)) ## 0.1.1 (2026-05-06) @@ -242,7 +242,7 @@ - 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/issues/3949)) + ([#3949](https://github.com/zarr-developers/zarr-python/pull/3949)) ## 0.1.0 (2026-05-01) @@ -253,4 +253,4 @@ 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/issues/3919)) + ([#3919](https://github.com/zarr-developers/zarr-python/pull/3919)) diff --git a/packages/zarr-metadata/README.md b/packages/zarr-metadata/README.md index 6b6b172aec..34c53988db 100644 --- a/packages/zarr-metadata/README.md +++ b/packages/zarr-metadata/README.md @@ -2,7 +2,7 @@ Python types, models, and validators for Zarr v2 and v3 metadata. -Documentation: +Documentation: ## What this is diff --git a/packages/zarr-metadata/pyproject.toml b/packages/zarr-metadata/pyproject.toml index 6e97d26409..1ef1c31624 100644 --- a/packages/zarr-metadata/pyproject.toml +++ b/packages/zarr-metadata/pyproject.toml @@ -115,5 +115,5 @@ filename = "CHANGELOG.md" package = "zarr_metadata" underlines = ["", "", ""] title_format = "## {version} ({project_date})" -issue_format = "[#{issue}](https://github.com/zarr-developers/zarr-python/issues/{issue})" +issue_format = "[#{issue}](https://github.com/zarr-developers/zarr-python/pull/{issue})" start_string = "\n" diff --git a/pyproject.toml b/pyproject.toml index 727071b5a3..1927ce4d7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -514,7 +514,7 @@ directory = 'changes' filename = "docs/release-notes.md" underlines = ["", "", ""] title_format = "## {version} ({project_date})" -issue_format = "[#{issue}](https://github.com/zarr-developers/zarr-python/issues/{issue})" +issue_format = "[#{issue}](https://github.com/zarr-developers/zarr-python/pull/{issue})" start_string = "\n" [tool.codespell] From a88f88951e7b1bc19f19adad2fb364158851076a Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Thu, 30 Jul 2026 07:10:15 +0200 Subject: [PATCH 16/32] fix: make ManagedMemoryStore/GpuMemoryStore sync methods parity-safe (#4204) ManagedMemoryStore inherited get_sync/set_sync/delete_sync from MemoryStore, which use the raw key, while every async method prefixed keys with self.path. Any code taking the sync fast path (e.g. FusedCodecPipeline) wrote/read chunks outside the store's path prefix, so a fresh handle re-reading through the prefix silently got fill values. Override the three sync methods to prefix like their async counterparts. GpuMemoryStore.set_sync gets the same treatment: it now converts its value to a gpu.Buffer like set does, preserving the store's all-values-are-gpu invariant for the sync API. Also fix ManagedMemoryStore.get_partial_values, which applied self.path twice whenever path was non-empty (it pre-prefixed keys, then delegated to MemoryStore.get_partial_values, which itself dispatches through the already-overridden self.get) -- this made it return None for every key. Discovered via the strengthened test fixture below. Add sync/async parity laws to the shared StoreTests suite so every store subclass exercises this invariant: set through one API and read through the other (including byte_range variants), and confirm delete_sync is visible to async get. These are the tests that would have caught the ManagedMemoryStore bug. TestManagedMemoryStore's raw set/get test helpers now respect self.path, and store_kwargs uses a non-empty path, so prefix handling is actually exercised instead of passing vacuously. Add an end-to-end regression with FusedCodecPipeline writing to a ManagedMemoryStore(path=...) sharing a dict with a fresh handle. Add a LocalStore.delete_sync directory-branch test. Assisted-by: ClaudeCode:claude-sonnet-5 --- changes/4204.bugfix.md | 16 +++++++ src/zarr/storage/_memory.py | 44 +++++++++++++++---- src/zarr/testing/store.py | 65 ++++++++++++++++++++++++++++ tests/test_store/test_local.py | 14 ++++++ tests/test_store/test_memory.py | 76 ++++++++++++++++++++++++++++++--- 5 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 changes/4204.bugfix.md diff --git a/changes/4204.bugfix.md b/changes/4204.bugfix.md new file mode 100644 index 0000000000..90101d1059 --- /dev/null +++ b/changes/4204.bugfix.md @@ -0,0 +1,16 @@ +`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. diff --git a/src/zarr/storage/_memory.py b/src/zarr/storage/_memory.py index 97dd355515..f42c38df69 100644 --- a/src/zarr/storage/_memory.py +++ b/src/zarr/storage/_memory.py @@ -314,6 +314,19 @@ async def set(self, key: str, value: Buffer, byte_range: tuple[int, int] | None gpu_value = value if isinstance(value, gpu.Buffer) else gpu.Buffer.from_buffer(value) await super().set(key, gpu_value, byte_range=byte_range) + def set_sync(self, key: str, value: Buffer) -> None: + # docstring inherited + self._check_writable() + assert isinstance(key, str) + if not isinstance(value, Buffer): + raise TypeError( + f"GpuMemoryStore.set(): `value` must be a Buffer instance. Got an instance of {type(value)} instead." + ) + # Convert to gpu.Buffer, mirroring `set` above: every value in this store's + # backing dict must be a gpu.Buffer, regardless of which API wrote it. + gpu_value = value if isinstance(value, gpu.Buffer) else gpu.Buffer.from_buffer(value) + super().set_sync(key, gpu_value) + # ----------------------------------------------------------------------------- # ManagedMemoryStore and its registry @@ -572,25 +585,40 @@ def from_url(cls, url: str, *, read_only: bool = False) -> ManagedMemoryStore: # Override MemoryStore methods to use path prefix and check process - async def get( + def get_sync( self, key: str, + *, prototype: BufferPrototype | None = None, byte_range: ByteRequest | None = None, ) -> Buffer | None: # docstring inherited - return await super().get( + return super().get_sync( _join_paths([self.path, key]), prototype=prototype, byte_range=byte_range ) - async def get_partial_values( + def set_sync(self, key: str, value: Buffer) -> None: + # docstring inherited + super().set_sync(_join_paths([self.path, key]), value) + + def delete_sync(self, key: str) -> None: + # docstring inherited + super().delete_sync(_join_paths([self.path, key])) + + async def get( self, - prototype: BufferPrototype, - key_ranges: Iterable[tuple[str, ByteRequest | None]], - ) -> list[Buffer | None]: + key: str, + prototype: BufferPrototype | None = None, + byte_range: ByteRequest | None = None, + ) -> Buffer | None: # docstring inherited - key_ranges = [(_join_paths([self.path, key]), byte_range) for key, byte_range in key_ranges] - return await super().get_partial_values(prototype, key_ranges) + return await super().get( + _join_paths([self.path, key]), prototype=prototype, byte_range=byte_range + ) + + # get_partial_values is intentionally NOT overridden here: MemoryStore.get_partial_values + # dispatches per-key through `self.get`, which already resolves to the override above. + # Re-prefixing the keys here as well would apply `self.path` twice. async def exists(self, key: str) -> bool: # docstring inherited diff --git a/src/zarr/testing/store.py b/src/zarr/testing/store.py index 46287ccffb..d7011440e0 100644 --- a/src/zarr/testing/store.py +++ b/src/zarr/testing/store.py @@ -618,6 +618,71 @@ def test_delete_sync_missing(self, store: S) -> None: # should not raise deleter.delete_sync("nonexistent_sync") + # ------------------------------------------------------------------- + # Sync/async parity laws + # ------------------------------------------------------------------- + # A store's sync and async methods must observe the same key the same + # way. This is stronger than the individual test_get_sync/test_set_sync/ + # test_delete_sync tests above: those write and read back through the + # *same* API (sync-only or, via `self.set`/`self.get`, bypassing the + # store entirely), so a sync method that skips logic the async method + # applies (e.g. a path prefix) can still pass them. These laws write + # through one API and observe through the other. + + @pytest.mark.parametrize("direction", ["set_async_get_sync", "set_sync_get_async"]) + async def test_sync_async_set_get_parity(self, store: S, direction: str) -> None: + setter = self._require_set_sync(store) + getter = self._require_get_sync(store) + data_buf = self.buffer_cls.from_bytes(b"\x01\x02\x03\x04") + key = "parity_set_get" + if direction == "set_async_get_sync": + await store.set(key, data_buf) + result = getter.get_sync(key) + else: + setter.set_sync(key, data_buf) + result = await store.get(key, prototype=default_buffer_prototype()) + assert result is not None + assert_bytes_equal(result, data_buf) + + async def test_delete_sync_visible_to_async_get(self, store: S) -> None: + deleter = self._require_delete_sync(store) + if not store.supports_deletes: + pytest.skip("store does not support deletes") + data_buf = self.buffer_cls.from_bytes(b"\x01\x02\x03\x04") + key = "parity_delete" + await store.set(key, data_buf) + deleter.delete_sync(key) + result = await store.get(key, prototype=default_buffer_prototype()) + assert result is None + + @pytest.mark.parametrize( + "byte_range", + [ + None, + RangeByteRequest(1, 4), + OffsetByteRequest(1), + SuffixByteRequest(1), + RangeByteRequest(10, 20), + ], + ids=["none", "range", "offset", "suffix", "range-past-eof"], + ) + async def test_get_sync_byte_range_parity( + self, store: S, byte_range: ByteRequest | None + ) -> None: + getter = self._require_get_sync(store) + data_buf = self.buffer_cls.from_bytes(b"\x01\x02\x03\x04") + key = "parity_byte_range" + await store.set(key, data_buf) + sync_result = getter.get_sync(key, byte_range=byte_range) + async_result = await store.get( + key, prototype=default_buffer_prototype(), byte_range=byte_range + ) + if async_result is None: + assert sync_result is None + else: + assert sync_result is not None + assert_bytes_equal(sync_result, async_result) + class LatencyStore(WrapperStore[Store]): """ diff --git a/tests/test_store/test_local.py b/tests/test_store/test_local.py index f65f618d65..61e48a269f 100644 --- a/tests/test_store/test_local.py +++ b/tests/test_store/test_local.py @@ -46,6 +46,20 @@ async def test_empty_with_empty_subdir(self, store: LocalStore) -> None: (store.root / "foo/bar").mkdir(parents=True) assert await store.is_empty("") + def test_delete_sync_directory(self, store: LocalStore) -> None: + """`delete_sync` on a key that is a directory must remove the whole tree. + + Mirrors the async `delete_dir` behavior: deleting `"foo"` where + `"foo"` is a directory containing further nested paths should remove + everything under it, not just fail or delete a single file. + """ + (store.root / "foo" / "bar").mkdir(parents=True) + (store.root / "foo" / "bar" / "baz").write_bytes(b"data") + + store.delete_sync("foo") + + assert not (store.root / "foo").exists() + def test_creates_new_directory(self, tmp_path: pathlib.Path) -> None: target = tmp_path.joinpath("a", "b", "c") assert not target.exists() diff --git a/tests/test_store/test_memory.py b/tests/test_store/test_memory.py index 36265423e6..a976f3738e 100644 --- a/tests/test_store/test_memory.py +++ b/tests/test_store/test_memory.py @@ -11,6 +11,7 @@ from zarr.core.buffer import Buffer, cpu, default_buffer_prototype, gpu from zarr.errors import ZarrUserWarning from zarr.storage import GpuMemoryStore, ManagedMemoryStore, MemoryStore +from zarr.storage._utils import _join_paths from zarr.testing.store import StoreTests from zarr.testing.utils import gpu_test @@ -233,31 +234,48 @@ def test_from_dict(self) -> None: for v in result._store_dict.values(): assert type(v) is gpu.Buffer + def test_set_sync_converts_to_gpu_buffer(self, store: GpuMemoryStore) -> None: + """`set_sync` must convert its value to a `gpu.Buffer`, mirroring `set`. + + `GpuMemoryStore`'s invariant is that every stored value is a + `gpu.Buffer`. Without this override, the inherited `MemoryStore.set_sync` + would store the CPU buffer it was given as-is, breaking that invariant + for whichever code path (e.g. the fused pipeline) uses the sync API. + """ + cpu_value = cpu.Buffer.from_bytes(b"aaaa") + msg = "Creating a zarr.buffer.gpu.Buffer with an array that does not support the __cuda_array_interface__ for zero-copy transfers, falling back to slow copy based path" + with pytest.warns(ZarrUserWarning, match=msg): + store.set_sync("k", cpu_value) + assert type(store._store_dict["k"]) is gpu.Buffer + class TestManagedMemoryStore(StoreTests[ManagedMemoryStore, cpu.Buffer]): store_cls = ManagedMemoryStore buffer_cls = cpu.Buffer async def set(self, store: ManagedMemoryStore, key: str, value: Buffer) -> None: - store._store_dict[key] = value + store._store_dict[_join_paths([store.path, key])] = value async def get(self, store: ManagedMemoryStore, key: str) -> Buffer: - return store._store_dict[key] + return store._store_dict[_join_paths([store.path, key])] @pytest.fixture def store_kwargs(self, request: pytest.FixtureRequest) -> dict[str, Any]: # Use a unique name per test to avoid sharing state between tests # but ensure the name is deterministic for equality tests # Replace '/' with '-' since store names cannot contain '/' + # A non-empty path exercises prefix handling; a store with an + # unprefixed key in its backing dict would pass these tests + # vacuously with path="". sanitized_name = request.node.name.replace("/", "-") - return {"name": f"test-{sanitized_name}"} + return {"name": f"test-{sanitized_name}", "path": "prefix"} @pytest.fixture async def store(self, store_kwargs: dict[str, Any]) -> ManagedMemoryStore: return self.store_cls(**store_kwargs) def test_store_repr(self, store: ManagedMemoryStore) -> None: - assert str(store) == f"memory://{store.name}" + assert str(store) == _join_paths([f"memory://{store.name}", store.path]) async def test_serializable_store(self, store: ManagedMemoryStore) -> None: """ @@ -383,7 +401,10 @@ def test_from_url(self, store: ManagedMemoryStore) -> None: def test_from_url_with_path(self, store: ManagedMemoryStore) -> None: """Test that from_url extracts path component from URL.""" - url = f"{store}/some/path" + # Reconnect to the fixture's dict via its name, but with an empty + # path, so appending "/some/path" below yields exactly that path. + base = ManagedMemoryStore(name=store.name) + url = f"{base}/some/path" store2 = ManagedMemoryStore.from_url(url) assert store2._store_dict is store._store_dict assert store2.path == "some/path" @@ -512,3 +533,48 @@ def test_garbage_collection(self) -> None: # URL should no longer resolve with pytest.raises(ValueError, match="garbage collected"): ManagedMemoryStore.from_url(url) + + def test_sync_methods_respect_path_prefix(self) -> None: + """`get_sync`/`set_sync`/`delete_sync` must prefix keys with `self.path`, + exactly like the async `get`/`set`/`delete` methods. + + `ManagedMemoryStore` used to inherit these from `MemoryStore`, which + writes/reads the raw key. Two stores sharing a dict with different + `path` values would then cross-talk through the sync API. + """ + store = ManagedMemoryStore(name="sync-prefix-test", path="subdir") + data_buf = self.buffer_cls.from_bytes(b"value") + + store.set_sync("key", data_buf) + assert "subdir/key" in store._store_dict + assert "key" not in store._store_dict + + result = store.get_sync("key") + assert result is not None + assert result.to_bytes() == b"value" + + store.delete_sync("key") + assert "subdir/key" not in store._store_dict + + def test_fused_pipeline_respects_path_prefix(self) -> None: + """End-to-end regression: the fused pipeline's sync store fast path must + write chunks under the store's path prefix. + + `FusedCodecPipeline` uses `set_sync`/`get_sync` when a store implements + the sync protocols. If those methods skip the prefix that the async + methods apply, chunk data lands outside `self.path` and a fresh handle + re-reading through the prefix silently sees fill values instead. + """ + with zarr.config.set( + {"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"} + ): + store = ManagedMemoryStore(name="fused-prefix-test", path="subdir") + arr = zarr.create_array(store, shape=(4,), chunks=(4,), dtype="uint8", zarr_format=3) + arr[:] = np.arange(4, dtype="uint8") + + bad_keys = [k for k in store._store_dict if not k.startswith("subdir/")] + assert bad_keys == [], f"keys written outside the store's path prefix: {bad_keys}" + + store2 = ManagedMemoryStore.from_url("memory://fused-prefix-test/subdir") + arr2 = zarr.open_array(store2, mode="r") + np.testing.assert_array_equal(arr2[:], np.arange(4, dtype="uint8")) From 6f9724cb14686e0737ed144be5d945fe1fbde578 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Thu, 30 Jul 2026 11:08:29 +0200 Subject: [PATCH 17/32] fix: minor correctness and hygiene fixes from the sync-pipeline audit (#4205) - Codec construction warnings (e.g. sharding's "disables partial reads") fired twice per array open, and on every decode/encode through the fused pipeline's async fallback. Re-constructions of an already-validated codec chain now go through codecs_from_list_unchecked, which validates structure without repeating first-construction advisory warnings; each warning fires exactly once per open under both pipelines. - concurrent_iter returned a lazy generator while its docstring promised eagerly scheduled tasks; it now materializes the task list so awaiting one at a time cannot serialize the batch. - A garbage codec_pipeline.max_workers value (e.g. from the environment) raised ValueError mid-read; it now warns and falls back to the default, consistent with tolerant handling of config input. - The as-completed pipeline helpers abandoned in-flight tasks when one failed, leaving stray background writes and "Task exception was never retrieved" warnings; failures now cancel and drain outstanding tasks. - Benchmarks: seed the data generator for reproducibility; fix a copy-pasted docstring. - Remove dead commented-out test blocks referencing the removed set_range API. Assisted-by: ClaudeCode:claude-sonnet-5 --- changes/4205.bugfix.md | 9 ++ src/zarr/core/array.py | 6 ++ src/zarr/core/chunk_utils.py | 8 +- src/zarr/core/codec_pipeline.py | 145 +++++++++++++++++++++++++------ src/zarr/core/common.py | 20 ++--- tests/benchmarks/test_e2e.py | 5 +- tests/test_codecs/test_codecs.py | 46 +++++++++- tests/test_common.py | 28 ++++++ tests/test_fused_pipeline.py | 101 ++++++++++++++++++++- tests/test_store/test_local.py | 52 ----------- tests/test_store/test_memory.py | 53 ----------- 11 files changed, 325 insertions(+), 148 deletions(-) create mode 100644 changes/4205.bugfix.md diff --git a/changes/4205.bugfix.md b/changes/4205.bugfix.md new file mode 100644 index 0000000000..0492febb7d --- /dev/null +++ b/changes/4205.bugfix.md @@ -0,0 +1,9 @@ +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. diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py index f75ef72415..cd51dad50c 100644 --- a/src/zarr/core/array.py +++ b/src/zarr/core/array.py @@ -228,6 +228,12 @@ def create_codec_pipeline(metadata: ArrayMetadata, *, store: Store | None = None pass if isinstance(metadata, ArrayV3Metadata): + # The pipeline built here is a throwaway: `evolve_from_array_spec` below + # reconstructs codecs against the evolved spec. `from_codecs` is the + # chain's first construction, so its advisory warnings (e.g. sharding's + # "disables partial reads" warning) fire here; `evolve_from_array_spec` + # re-splits the same already-warned-about chain via + # `codecs_from_list_unchecked`, so it does not re-emit them. pipeline = get_pipeline_class().from_codecs(metadata.codecs) from zarr.core.metadata.v3 import RegularChunkGridMetadata diff --git a/src/zarr/core/chunk_utils.py b/src/zarr/core/chunk_utils.py index d93793f853..b26d5478b2 100644 --- a/src/zarr/core/chunk_utils.py +++ b/src/zarr/core/chunk_utils.py @@ -238,7 +238,7 @@ class ChunkTransform: ) def __post_init__(self) -> None: - from zarr.core.codec_pipeline import codecs_from_list + from zarr.core.codec_pipeline import codecs_from_list_unchecked # _codec_supports_sync, not a bare isinstance check: a codec can satisfy # the SupportsSyncCodec protocol structurally yet be unable to run @@ -253,7 +253,11 @@ def __post_init__(self) -> None: f"All codecs must implement SupportsSyncCodec. The following do not: {names}" ) - aa, ab, bb = codecs_from_list(list(self.codecs)) + # `ChunkTransform` is built from a codec chain that already went + # through `codecs_from_list` when the owning pipeline was constructed + # (see `FusedCodecPipeline.evolve_from_array_spec`), so re-splitting it + # here must not re-emit that chain's advisory warnings. + aa, ab, bb = codecs_from_list_unchecked(list(self.codecs)) # SupportsSyncCodec was verified above; the cast is purely for mypy. self._aa_codecs = cast("tuple[SupportsSyncCodec[NDBuffer, NDBuffer], ...]", tuple(aa)) self._ab_codec = cast("SupportsSyncCodec[NDBuffer, Buffer]", ab) diff --git a/src/zarr/core/codec_pipeline.py b/src/zarr/core/codec_pipeline.py index ca760ece59..92fd0970fe 100644 --- a/src/zarr/core/codec_pipeline.py +++ b/src/zarr/core/codec_pipeline.py @@ -4,7 +4,7 @@ import threading from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field -from itertools import batched, pairwise +from itertools import batched, chain, pairwise from typing import TYPE_CHECKING, Any, cast from warnings import warn @@ -54,10 +54,23 @@ def _resolve_max_workers() -> int: """Helper for getting the maximum number of workers available to the `FusedCodecPipeline`""" import os as _os + default = _os.cpu_count() or 1 cfg = config.get("codec_pipeline.max_workers", default=None) if cfg is None: - return _os.cpu_count() or 1 - return max(1, int(cfg)) + return default + try: + return max(1, int(cfg)) + except (TypeError, ValueError): + # This value arrives via the config/env layer (e.g. + # `ZARR_CODEC_PIPELINE__MAX_WORKERS`), so tolerate bad input here + # instead of raising mid-read. + warn( + f"Ignoring invalid `codec_pipeline.max_workers` config value {cfg!r}; " + f"falling back to {default}.", + category=ZarrUserWarning, + stacklevel=2, + ) + return default def _get_pool(max_workers: int) -> ThreadPoolExecutor: @@ -169,6 +182,23 @@ def pipeline_supports_partial_encode( return isinstance(array_bytes_codec, ArrayBytesCodecPartialEncodeMixin) +async def _cancel_and_drain(futures: Iterable[asyncio.Future[Any]]) -> None: + """Cancel every not-yet-done future/task and await its outcome. + + Used to clean up work spawned by a drain loop (`asyncio.as_completed` + + `await`) when the loop exits early via exception. Without this, tasks + already spawned keep running unattended after the caller has moved on, + and an eventual failure surfaces as an unraisable "exception was never + retrieved" warning instead of being observed here. + """ + pending = [f for f in futures if not f.done()] + if len(pending) == 0: + return + for f in pending: + f.cancel() + await asyncio.gather(*pending, return_exceptions=True) + + async def _fetch_and_decode_as_completed( batch: Sequence[tuple[ByteGetter | None, ArraySpec]], transform: ChunkTransform, @@ -201,20 +231,29 @@ def _decode(buffer: Buffer | None, chunk_spec: ArraySpec) -> NDBuffer | None: _fetch, config.get("async.concurrency"), ) - for fetch_coro in asyncio.as_completed(fetch_tasks): - idx, buffer = await fetch_coro - chunk_spec = batch[idx][1] - # Bridge both paths to asyncio.Future so the final collection loop - # can `await` uniformly without blocking the event loop. For the - # pool path that means `wrap_future` (not `pool.submit(...).result()`, - # which would block the loop thread for the duration of every decode - # — freezing any unrelated coroutines sharing this loop). - if pool is None: - decode_futures[idx].set_result(_decode(buffer, chunk_spec)) - else: - decode_futures[idx] = asyncio.wrap_future(pool.submit(_decode, buffer, chunk_spec)) + try: + for fetch_coro in asyncio.as_completed(fetch_tasks): + idx, buffer = await fetch_coro + chunk_spec = batch[idx][1] + # Bridge both paths to asyncio.Future so the final collection loop + # can `await` uniformly without blocking the event loop. For the + # pool path that means `wrap_future` (not `pool.submit(...).result()`, + # which would block the loop thread for the duration of every decode + # — freezing any unrelated coroutines sharing this loop). + if pool is None: + decode_futures[idx].set_result(_decode(buffer, chunk_spec)) + else: + decode_futures[idx] = asyncio.wrap_future(pool.submit(_decode, buffer, chunk_spec)) - return await asyncio.gather(*decode_futures) + return await asyncio.gather(*decode_futures) + finally: + # On the happy path every future here is already done, so this is a + # no-op; on failure it stops abandoned fetches/decodes from + # continuing to run unattended after this function has raised. A + # single call over both iterables (not two sequential calls) so that + # outer-task cancellation during the first drain can't skip the + # second, leaving its futures/tasks unobserved. + await _cancel_and_drain(chain(fetch_tasks, decode_futures)) async def _encode_and_write_as_completed( @@ -263,10 +302,20 @@ async def _write(idx: int, chunk_bytes: Buffer | None) -> None: # Kick off each chunk's write the instant its encode lands, so writes of # already-compressed chunks proceed while the rest are still encoding. write_tasks: list[asyncio.Task[None]] = [] - for encode_coro in asyncio.as_completed(encode_futures): - idx, chunk_bytes = await encode_coro - write_tasks.append(asyncio.ensure_future(_write(idx, chunk_bytes))) - await asyncio.gather(*write_tasks) + try: + for encode_coro in asyncio.as_completed(encode_futures): + idx, chunk_bytes = await encode_coro + write_tasks.append(asyncio.ensure_future(_write(idx, chunk_bytes))) + await asyncio.gather(*write_tasks) + finally: + # On the happy path every future here is already done, so this is a + # no-op; on failure (an encode or a write raising) it stops + # already-spawned writes from continuing in the background after + # this function has raised. A single call over both iterables (not + # two sequential calls) so that outer-task cancellation during the + # first drain can't skip the second, leaving its futures/tasks + # unobserved. + await _cancel_and_drain(chain(write_tasks, encode_futures)) async def _async_read_fallback( @@ -468,7 +517,11 @@ class AsyncChunkTransform: _bb_codecs: tuple[BytesBytesCodec, ...] = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: - aa, ab, bb = codecs_from_list(list(self.codecs)) + # `AsyncChunkTransform` is (re)constructed per decode/encode call from a + # codec chain that already went through `codecs_from_list` when the + # pipeline itself was built, so re-splitting it here must not re-emit + # that chain's advisory warnings on every call. + aa, ab, bb = codecs_from_list_unchecked(list(self.codecs)) self._aa_codecs = aa self._ab_codec = ab self._bb_codecs = bb @@ -532,7 +585,19 @@ class BatchedCodecPipeline(CodecPipeline): batch_size: int def evolve_from_array_spec(self, array_spec: ArraySpec) -> Self: - return type(self).from_codecs(evolve_codecs(self, array_spec)) + # Re-splits an already-`codecs_from_list`-validated (and warned-about) + # chain against the evolved spec, so this uses the quiet variant rather + # than routing through `from_codecs` (which would re-warn). + evolved_codecs = evolve_codecs(self, array_spec) + array_array_codecs, array_bytes_codec, bytes_bytes_codecs = codecs_from_list_unchecked( + evolved_codecs + ) + return type(self)( + array_array_codecs=array_array_codecs, + array_bytes_codec=array_bytes_codec, + bytes_bytes_codecs=bytes_bytes_codecs, + batch_size=self.batch_size, + ) @classmethod def from_codecs(cls, codecs: Iterable[Codec], *, batch_size: int | None = None) -> Self: @@ -794,14 +859,20 @@ async def write( def codecs_from_list( codecs: Iterable[Codec], ) -> tuple[tuple[ArrayArrayCodec, ...], ArrayBytesCodec, tuple[BytesBytesCodec, ...]]: + """Split `codecs` into `(array_array, array_bytes, bytes_bytes)`, validating order. + + Emits user-facing advisory warnings about the codec chain (e.g. sharding's + "disables partial reads" warning). Use this for the FIRST construction of a + codec chain from user-supplied codecs. Use `codecs_from_list_unchecked` when + re-splitting a chain that was already validated and warned about by a prior + `codecs_from_list` call (e.g. `evolve_from_array_spec` re-splitting the same + codecs against an evolved spec) — re-warning there would fire the same + advisory once per reconstruction instead of once per user-facing chain. + """ from zarr.codecs.sharding import ShardingCodec codecs = tuple(codecs) # materialize to avoid generator consumption issues - array_array: tuple[ArrayArrayCodec, ...] = () - array_bytes_maybe: ArrayBytesCodec | None = None - bytes_bytes: tuple[BytesBytesCodec, ...] = () - if any(isinstance(codec, ShardingCodec) for codec in codecs) and len(codecs) > 1: warn( "Combining a `sharding_indexed` codec disables partial reads and " @@ -809,6 +880,23 @@ def codecs_from_list( category=ZarrUserWarning, stacklevel=3, ) + return codecs_from_list_unchecked(codecs) + + +def codecs_from_list_unchecked( + codecs: Iterable[Codec], +) -> tuple[tuple[ArrayArrayCodec, ...], ArrayBytesCodec, tuple[BytesBytesCodec, ...]]: + """Split `codecs` into `(array_array, array_bytes, bytes_bytes)`, validating order. + + Same structural validation as `codecs_from_list` (raises on bad codec + ordering or a missing/duplicate array-bytes codec) but does NOT emit + user-facing advisory warnings. See `codecs_from_list` for when to use each. + """ + codecs = tuple(codecs) # materialize to avoid generator consumption issues + + array_array: tuple[ArrayArrayCodec, ...] = () + array_bytes_maybe: ArrayBytesCodec | None = None + bytes_bytes: tuple[BytesBytesCodec, ...] = () for prev_codec, cur_codec in pairwise((None, *codecs)): if isinstance(cur_codec, ArrayArrayCodec): @@ -911,8 +999,11 @@ def from_codecs(cls, codecs: Iterable[Codec], *, batch_size: int | None = None) ) def evolve_from_array_spec(self, array_spec: ArraySpec) -> Self: + # Re-splits an already-`codecs_from_list`-validated (and warned-about) + # chain against the evolved spec, so this uses the quiet variant to + # avoid re-emitting the same advisory warning on every array open. evolved_codecs = evolve_codecs(self.codecs, array_spec) - aa, ab, bb = codecs_from_list(evolved_codecs) + aa, ab, bb = codecs_from_list_unchecked(evolved_codecs) try: sync_transform: ChunkTransform | None = ChunkTransform(codecs=evolved_codecs) diff --git a/src/zarr/core/common.py b/src/zarr/core/common.py index 4114cb7645..1541683b09 100644 --- a/src/zarr/core/common.py +++ b/src/zarr/core/common.py @@ -93,26 +93,26 @@ def concurrent_iter[T: tuple[Any, ...], V]( items: Iterable[T], func: Callable[..., Awaitable[V]], limit: int | None = None, -) -> Iterator[asyncio.Task[V]]: +) -> list[asyncio.Task[V]]: """Launch `func(*item)` for each item concurrently, returning the tasks. When `limit` is set, no more than `limit` calls are in flight at once. Tasks are returned in input order; callers that want completion order should wrap the result in `asyncio.as_completed`. - Note on `ensure_future`: when the result is passed to `asyncio.gather` or - `asyncio.as_completed`, those already wrap awaitables into tasks, so the - `ensure_future` here is redundant. It matters for callers that iterate and - await tasks one at a time — without eager scheduling, each coroutine would - only start when individually awaited, serializing the work and defeating - the semaphore. It also makes the return type honest (real `Task`s support - `.cancel()`, `.done()`, callbacks) rather than bare coroutines. + Every task is scheduled (via `ensure_future`) before this function + returns, not on first iteration of the result. That matters for callers + that await the returned tasks one at a time — without eager scheduling, + each coroutine would only start when individually awaited, serializing + the work and defeating the semaphore. It also makes the return type + honest (real `Task`s support `.cancel()`, `.done()`, callbacks) rather + than bare coroutines. See https://docs.python.org/3/library/asyncio-task.html#coroutines: "Note that simply calling a coroutine will not schedule it to be executed:" """ if limit is None: - return (asyncio.ensure_future(func(*item)) for item in items) + return [asyncio.ensure_future(func(*item)) for item in items] sem = asyncio.Semaphore(limit) @@ -120,7 +120,7 @@ async def run(item: T) -> V: async with sem: return await func(*item) - return (asyncio.ensure_future(run(item)) for item in items) + return [asyncio.ensure_future(run(item)) for item in items] async def concurrent_map[T: tuple[Any, ...], V]( diff --git a/tests/benchmarks/test_e2e.py b/tests/benchmarks/test_e2e.py index de69fca59b..9720778d8f 100644 --- a/tests/benchmarks/test_e2e.py +++ b/tests/benchmarks/test_e2e.py @@ -63,7 +63,8 @@ def _data(shape: tuple[int]) -> np.ndarray: noise_level = 1 pattern = (np.sin(np.linspace(0, 2 * np.pi, period)) * 50 + 128).round().astype(np.uint8) data = np.tile(pattern, int(np.ceil(n / period)))[:n].astype(np.int16) - data += np.random.randint(-noise_level, noise_level + 1, size=n, dtype=np.int16) + rng = np.random.default_rng(0) + data += rng.integers(-noise_level, noise_level + 1, size=n, dtype=np.int16) return np.clip(data, 0, 255).astype(np.uint8) @@ -189,7 +190,7 @@ def test_read_array( get_data: Callable[[tuple[int]], np.ndarray | int], ) -> None: """ - Test the time required to fill an array with a single value + Test the time required to read the entirety of an array """ arr = create_array( bench_store, diff --git a/tests/test_codecs/test_codecs.py b/tests/test_codecs/test_codecs.py index 01ac02920f..8b4585503c 100644 --- a/tests/test_codecs/test_codecs.py +++ b/tests/test_codecs/test_codecs.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import warnings from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -22,7 +23,7 @@ from zarr.core.metadata.v3 import ArrayV3Metadata from zarr.dtype import UInt8 from zarr.errors import ZarrUserWarning -from zarr.storage import StorePath +from zarr.storage import MemoryStore, StorePath if TYPE_CHECKING: from zarr.abc.codec import Codec @@ -375,6 +376,49 @@ def test_invalid_metadata_create_array() -> None: ) +@pytest.mark.parametrize( + "pipeline_path", + [ + "zarr.core.codec_pipeline.BatchedCodecPipeline", + "zarr.core.codec_pipeline.FusedCodecPipeline", + ], +) +def test_sharding_warning_fires_once_per_open(pipeline_path: str) -> None: + """Construction-time codec warnings (e.g. sharding's partial-reads warning) + must fire exactly once per array open, not once per internal codec-chain + reconstruction. + + `create_codec_pipeline` builds a throwaway pipeline via `from_codecs` (which + warns) and then calls `evolve_from_array_spec` on it, which re-splits the + (already-warned-about) codec chain against the evolved spec. That re-split + goes through `codecs_from_list_unchecked` rather than `codecs_from_list`, so + it does not re-emit the warning. `FusedCodecPipeline` additionally builds a + `ChunkTransform` (and, on the async fallback path, an `AsyncChunkTransform` + per call) from the same evolved codec chain, which must use the same quiet + variant. + """ + with config.set({"codec_pipeline.path": pipeline_path}): + store = MemoryStore() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + zarr.create_array( + store, + shape=(16, 16), + chunks=(16, 16), + dtype=np.dtype("uint8"), + fill_value=0, + serializer=ShardingCodec(chunk_shape=(8, 8)), + compressors=[GzipCodec()], + ) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + zarr.open_array(store, mode="r") + + matches = [w for w in caught if "disables partial reads" in str(w.message)] + assert len(matches) == 1 + + @pytest.mark.parametrize("store", ["local", "memory"], indirect=["store"]) async def test_resize(store: Store) -> None: data = np.zeros((16, 18), dtype="uint16") diff --git a/tests/test_common.py b/tests/test_common.py index 2fe0743e14..5d8df326da 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from collections.abc import Iterable from typing import TYPE_CHECKING, get_args @@ -9,6 +10,7 @@ from zarr.core.common import ( ANY_ACCESS_MODE, AccessModeLiteral, + concurrent_iter, parse_int, parse_name, parse_shapelike, @@ -32,6 +34,32 @@ def test_access_modes() -> None: assert set(ANY_ACCESS_MODE) == set(get_args(AccessModeLiteral)) +async def test_concurrent_iter_schedules_eagerly() -> None: + """`concurrent_iter` must return already-scheduled tasks, not a lazy generator. + + Its docstring promises `func(*item)` is launched concurrently for every + item up front; a caller that awaits the returned tasks one at a time + (rather than via `gather`/`as_completed`, which force iteration) relies + on that eager scheduling to get any overlap at all. + """ + started = [False, False, False] + + async def mark(i: int) -> int: + started[i] = True + return i + + tasks = concurrent_iter([(0,), (1,), (2,)], mark) + + # Give the event loop one chance to run before awaiting anything + # individually. If `concurrent_iter` were lazy, nothing would have been + # scheduled yet and `started` would still be all-False here. + await asyncio.sleep(0) + assert started == [True, True, True] + + results = [await t for t in tasks] + assert results == [0, 1, 2] + + # todo: test def test_concurrent_map() -> None: ... diff --git a/tests/test_fused_pipeline.py b/tests/test_fused_pipeline.py index fd86936853..7fa3ef2277 100644 --- a/tests/test_fused_pipeline.py +++ b/tests/test_fused_pipeline.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING, Any @@ -25,8 +26,9 @@ from zarr.storage import MemoryStore, StorePath if TYPE_CHECKING: + from zarr.abc.store import ByteRequest from zarr.core.array_spec import ArraySpec - from zarr.core.buffer import Buffer, NDBuffer + from zarr.core.buffer import Buffer, BufferPrototype, NDBuffer @pytest.mark.parametrize( @@ -439,6 +441,103 @@ def test_thread_pool_read_worker_exception_propagates() -> None: arr[:] +def test_resolve_max_workers_warns_and_falls_back_on_invalid_config() -> None: + """`codec_pipeline.max_workers` arrives via the config/env layer (e.g. + `ZARR_CODEC_PIPELINE__MAX_WORKERS`), so garbage input should warn and fall + back to the default rather than raising mid-read. + """ + import os + + import zarr.core.codec_pipeline as cp_mod + from zarr.errors import ZarrUserWarning + + default = os.cpu_count() or 1 + with zarr_config.set({"codec_pipeline.max_workers": "fast"}): + with pytest.warns(ZarrUserWarning, match="max_workers"): + result = cp_mod._resolve_max_workers() + assert result == default + + +async def test_encode_and_write_as_completed_cancels_stray_writes_on_failure() -> None: + """A failing write must not leave sibling writes running in the background. + + `_encode_and_write_as_completed` fires one write task per chunk as soon as + its encode completes, then `gather`s them. Plain `gather` (without + `return_exceptions=True`) re-raises the first exception without cancelling + the other in-flight tasks, so a still-running write would keep going after + the caller has already seen the exception -- and its eventual outcome is + never retrieved (an unraisable "Task exception was never retrieved" + warning if it later fails). + """ + from zarr.core.array_spec import ArrayConfig, ArraySpec + from zarr.core.buffer import default_buffer_prototype + from zarr.core.buffer.cpu import NDBuffer as CPUNDBuffer + from zarr.core.chunk_utils import ChunkTransform + from zarr.core.codec_pipeline import _encode_and_write_as_completed + from zarr.core.dtype import get_data_type_from_native_dtype + + write_started = asyncio.Event() + write_finished = False + + class _SlowByteSetter: + async def get( + self, prototype: BufferPrototype, byte_range: ByteRequest | None = None + ) -> Buffer | None: + return None + + async def set(self, value: Buffer) -> None: + nonlocal write_finished + write_started.set() + await asyncio.sleep(0.2) + write_finished = True + + async def delete(self) -> None: + pass + + async def set_if_not_exists(self, default: Buffer) -> None: + pass + + class _FailingByteSetter: + async def get( + self, prototype: BufferPrototype, byte_range: ByteRequest | None = None + ) -> Buffer | None: + return None + + async def set(self, value: Buffer) -> None: + raise RuntimeError("simulated write failure") + + async def delete(self) -> None: + pass + + async def set_if_not_exists(self, default: Buffer) -> None: + pass + + zdtype = get_data_type_from_native_dtype(np.dtype("uint8")) + chunk_spec = ArraySpec( + shape=(1,), + dtype=zdtype, + fill_value=zdtype.cast_scalar(0), + config=ArrayConfig(order="C", write_empty_chunks=True), + prototype=default_buffer_prototype(), + ) + chunk_array = CPUNDBuffer.from_numpy_array(np.zeros(1, dtype="uint8")) + transform = ChunkTransform(codecs=(BytesCodec(),)) + + batch = [ + (_SlowByteSetter(), chunk_array, chunk_spec), + (_FailingByteSetter(), chunk_array, chunk_spec), + ] + + with pytest.raises(RuntimeError, match="simulated write failure"): + await _encode_and_write_as_completed(batch, transform) # type: ignore[arg-type] + + assert write_started.is_set() + # Give the slow write's sleep long enough to finish if it were left + # running unattended in the background instead of being cancelled. + await asyncio.sleep(0.3) + assert not write_finished, "the slow write should have been cancelled, not left running" + + def test_concurrent_reads_shared_transform_with_pool() -> None: """Concurrent decode through the shared ChunkTransform produces correct data. diff --git a/tests/test_store/test_local.py b/tests/test_store/test_local.py index 61e48a269f..90d214ee2c 100644 --- a/tests/test_store/test_local.py +++ b/tests/test_store/test_local.py @@ -122,58 +122,6 @@ async def test_move( ): await store2.move(destination) - # --- byte-range-write tests: disabled --- - # Byte-range-write support (set_range / set_range_sync / SupportsSetRange) - # was removed from this PR pending a decision on the store interface. These - # tests are known-good and kept commented out to restore once that lands. - # def test_supports_set_range(self, store: LocalStore) -> None: - # """LocalStore should implement SupportsSetRange.""" - # assert isinstance(store, SupportsSetRange) - # - # @pytest.mark.parametrize( - # ("start", "patch", "expected"), - # [ - # (0, b"XX", b"XXAAAAAAAA"), - # (3, b"XX", b"AAAXXAAAAA"), - # (8, b"XX", b"AAAAAAAAXX"), - # (0, b"ZZZZZZZZZZ", b"ZZZZZZZZZZ"), - # (5, b"B", b"AAAAABAAAA"), - # (0, b"BCDE", b"BCDEAAAAAA"), - # ], - # ids=["start", "middle", "end", "full-overwrite", "single-byte", "multi-byte-start"], - # ) - # async def test_set_range( - # self, store: LocalStore, start: int, patch: bytes, expected: bytes - # ) -> None: - # """set_range should overwrite bytes at the given offset.""" - # await store.set("test/key", cpu.Buffer.from_bytes(b"AAAAAAAAAA")) - # await store.set_range("test/key", cpu.Buffer.from_bytes(patch), start=start) - # result = await store.get("test/key", prototype=cpu.buffer_prototype) - # assert result is not None - # assert result.to_bytes() == expected - # - # @pytest.mark.parametrize( - # ("start", "patch", "expected"), - # [ - # (0, b"XX", b"XXAAAAAAAA"), - # (3, b"XX", b"AAAXXAAAAA"), - # (8, b"XX", b"AAAAAAAAXX"), - # (0, b"ZZZZZZZZZZ", b"ZZZZZZZZZZ"), - # (5, b"B", b"AAAAABAAAA"), - # (0, b"BCDE", b"BCDEAAAAAA"), - # ], - # ids=["start", "middle", "end", "full-overwrite", "single-byte", "multi-byte-start"], - # ) - # def test_set_range_sync( - # self, store: LocalStore, start: int, patch: bytes, expected: bytes - # ) -> None: - # """set_range_sync should overwrite bytes at the given offset.""" - # sync(store.set("test/key", cpu.Buffer.from_bytes(b"AAAAAAAAAA"))) - # store.set_range_sync("test/key", cpu.Buffer.from_bytes(patch), start=start) - # result = store.get_sync(key="test/key", prototype=cpu.buffer_prototype) - # assert result is not None - # assert result.to_bytes() == expected - @pytest.mark.parametrize("exclusive", [True, False]) def test_atomic_write_successful(tmp_path: pathlib.Path, exclusive: bool) -> None: diff --git a/tests/test_store/test_memory.py b/tests/test_store/test_memory.py index a976f3738e..013dae7044 100644 --- a/tests/test_store/test_memory.py +++ b/tests/test_store/test_memory.py @@ -126,59 +126,6 @@ def test_write_does_not_alias_source_array( np.testing.assert_array_equal(array[:], expected) - # --- byte-range-write tests: disabled --- - # Byte-range-write support (set_range / set_range_sync / SupportsSetRange) - # was removed from this PR pending a decision on the store interface. These - # tests are known-good and kept commented out to restore once that lands. - # def test_supports_set_range(self, store: MemoryStore) -> None: - # """MemoryStore should implement SupportsSetRange.""" - # assert isinstance(store, SupportsSetRange) - # - # @pytest.mark.parametrize( - # ("start", "patch", "expected"), - # [ - # (0, b"XX", b"XXAAAAAAAA"), - # (3, b"XX", b"AAAXXAAAAA"), - # (8, b"XX", b"AAAAAAAAXX"), - # (0, b"ZZZZZZZZZZ", b"ZZZZZZZZZZ"), - # (5, b"B", b"AAAAABAAAA"), - # (0, b"BCDE", b"BCDEAAAAAA"), - # ], - # ids=["start", "middle", "end", "full-overwrite", "single-byte", "multi-byte-start"], - # ) - # async def test_set_range( - # self, store: MemoryStore, start: int, patch: bytes, expected: bytes - # ) -> None: - # """set_range should overwrite bytes at the given offset.""" - # await store.set("test/key", cpu.Buffer.from_bytes(b"AAAAAAAAAA")) - # await store.set_range("test/key", cpu.Buffer.from_bytes(patch), start=start) - # result = await store.get("test/key", prototype=cpu.buffer_prototype) - # assert result is not None - # assert result.to_bytes() == expected - # - # @pytest.mark.parametrize( - # ("start", "patch", "expected"), - # [ - # (0, b"XX", b"XXAAAAAAAA"), - # (3, b"XX", b"AAAXXAAAAA"), - # (8, b"XX", b"AAAAAAAAXX"), - # (0, b"ZZZZZZZZZZ", b"ZZZZZZZZZZ"), - # (5, b"B", b"AAAAABAAAA"), - # (0, b"BCDE", b"BCDEAAAAAA"), - # ], - # ids=["start", "middle", "end", "full-overwrite", "single-byte", "multi-byte-start"], - # ) - # def test_set_range_sync( - # self, store: MemoryStore, start: int, patch: bytes, expected: bytes - # ) -> None: - # """set_range_sync should overwrite bytes at the given offset.""" - # store._is_open = True - # store._store_dict["test/key"] = cpu.Buffer.from_bytes(b"AAAAAAAAAA") - # store.set_range_sync("test/key", cpu.Buffer.from_bytes(patch), start=start) - # result = store.get_sync(key="test/key", prototype=cpu.buffer_prototype) - # assert result is not None - # assert result.to_bytes() == expected - # TODO: fix this warning @pytest.mark.filterwarnings("ignore:Unclosed client session:ResourceWarning") From ec8e70ad86990e4c1bf57478fe13769885e31882 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:00:36 +0000 Subject: [PATCH 18/32] chore(deps): bump the python-dependencies group across 1 directory with 11 updates (#4216) * chore(deps): bump the python-dependencies group across 1 directory with 11 updates Bumps the python-dependencies group with 11 updates in the / directory: | Package | From | To | | --- | --- | --- | | [numpy](https://github.com/numpy/numpy) | `2.5.0` | `2.5.1` | | [typer](https://github.com/fastapi/typer) | `0.26.8` | `0.27.0` | | [coverage](https://github.com/coveragepy/coveragepy) | `7.14.3` | `7.15.2` | | [hypothesis](https://github.com/HypothesisWorks/hypothesis) | `6.155.7` | `6.160.0` | | [tomlkit](https://github.com/python-poetry/tomlkit) | `0.15.0` | `0.15.1` | | [uv](https://github.com/astral-sh/uv) | `0.11.26` | `0.11.31` | | [mkdocs-material[imaging]](https://github.com/squidfunk/mkdocs-material) | `9.7.6` | `9.7.7` | | [mkdocstrings](https://github.com/mkdocstrings/mkdocstrings) | `1.0.4` | `1.0.6` | | [markdown-exec[ansi]](https://github.com/pawamoy/markdown-exec) | `1.12.1` | `1.12.3` | | [ruff](https://github.com/astral-sh/ruff) | `0.15.20` | `0.15.22` | | [mypy](https://github.com/python/mypy) | `2.1.0` | `2.3.0` | Updates `numpy` from 2.5.0 to 2.5.1 - [Release notes](https://github.com/numpy/numpy/releases) - [Changelog](https://github.com/numpy/numpy/blob/main/doc/RELEASE_WALKTHROUGH.rst) - [Commits](https://github.com/numpy/numpy/compare/v2.5.0...v2.5.1) Updates `typer` from 0.26.8 to 0.27.0 - [Release notes](https://github.com/fastapi/typer/releases) - [Changelog](https://github.com/fastapi/typer/blob/master/docs/release-notes.md) - [Commits](https://github.com/fastapi/typer/compare/0.26.8...0.27.0) Updates `coverage` from 7.14.3 to 7.15.2 - [Release notes](https://github.com/coveragepy/coveragepy/releases) - [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst) - [Commits](https://github.com/coveragepy/coveragepy/compare/7.14.3...7.15.2) Updates `hypothesis` from 6.155.7 to 6.160.0 - [Release notes](https://github.com/HypothesisWorks/hypothesis/releases) - [Commits](https://github.com/HypothesisWorks/hypothesis/compare/v6.155.7...v6.160.0) Updates `tomlkit` from 0.15.0 to 0.15.1 - [Release notes](https://github.com/python-poetry/tomlkit/releases) - [Changelog](https://github.com/python-poetry/tomlkit/blob/master/CHANGELOG.md) - [Commits](https://github.com/python-poetry/tomlkit/compare/0.15.0...0.15.1) Updates `uv` from 0.11.26 to 0.11.31 - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/0.11.31/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.11.26...0.11.31) Updates `mkdocs-material[imaging]` from 9.7.6 to 9.7.7 - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.7.6...9.7.7) Updates `mkdocstrings` from 1.0.4 to 1.0.6 - [Release notes](https://github.com/mkdocstrings/mkdocstrings/releases) - [Changelog](https://github.com/mkdocstrings/mkdocstrings/blob/main/CHANGELOG.md) - [Commits](https://github.com/mkdocstrings/mkdocstrings/compare/1.0.4...1.0.6) Updates `markdown-exec[ansi]` from 1.12.1 to 1.12.3 - [Release notes](https://github.com/pawamoy/markdown-exec/releases) - [Changelog](https://github.com/pawamoy/markdown-exec/blob/main/CHANGELOG.md) - [Commits](https://github.com/pawamoy/markdown-exec/compare/1.12.1...1.12.3) Updates `ruff` from 0.15.20 to 0.15.22 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/0.15.22/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.20...0.15.22) Updates `mypy` from 2.1.0 to 2.3.0 - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v2.1.0...v2.3.0) --- updated-dependencies: - dependency-name: coverage dependency-version: 7.15.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: hypothesis dependency-version: 6.160.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: markdown-exec[ansi] dependency-version: 1.12.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-dependencies - dependency-name: mkdocs-material[imaging] dependency-version: 9.7.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-dependencies - dependency-name: mkdocstrings dependency-version: 1.0.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-dependencies - dependency-name: mypy dependency-version: 2.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: numpy dependency-version: 2.5.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-dependencies - dependency-name: ruff dependency-version: 0.15.22 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-dependencies - dependency-name: tomlkit dependency-version: 0.15.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-dependencies - dependency-name: typer dependency-version: 0.27.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-dependencies - dependency-name: uv dependency-version: 0.11.31 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-dependencies ... Signed-off-by: dependabot[bot] * fix: satisfy numpy 2.5.1 type stubs in indexing selection normalization numpy 2.5.1 stubs infer np.asarray() as a float64 array, so mypy now rejects the untyped asarray calls in replace_lists and CoordinateIndexer. Pass dtype=np.intp where the integer dtype is guaranteed, and cast to ArrayOfIntOrBool where the list contents are only known at runtime. Assisted-by: ClaudeCode:claude-fable-5 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Davis Bennett --- pyproject.toml | 18 +- src/zarr/core/indexing.py | 5 +- uv.lock | 675 ++++++++++++++++++++------------------ 3 files changed, 371 insertions(+), 327 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1927ce4d7c..684ac80b77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,18 +94,18 @@ homepage = "https://github.com/zarr-developers/zarr-python" # pins deliberately, e.g. via dependabot or `uv lock --upgrade`. [dependency-groups] test = [ - "coverage==7.14.3", + "coverage==7.15.2", "pytest==9.1.1", "pytest-asyncio==1.4.0", "pytest-cov==7.1.0", "pytest-accept==0.3.0", "numpydoc==1.10.0", - "hypothesis==6.155.7", + "hypothesis==6.160.0", "pytest-xdist==3.8.0", "pytest-benchmark==5.2.3", "pytest-codspeed==5.0.3", - "tomlkit==0.15.0", - "uv==0.11.26", + "tomlkit==0.15.1", + "uv==0.11.31", ] remote-tests = [ {include-group = "test"}, @@ -121,15 +121,15 @@ release = [ ] docs = [ # Doc building - "mkdocs-material[imaging]==9.7.6", + "mkdocs-material[imaging]==9.7.7", "mkdocs==1.6.1", - "mkdocstrings==1.0.4", + "mkdocstrings==1.0.6", "mkdocstrings-python==2.0.5", "mike==2.2.0", "mkdocs-redirects==1.2.3", - "markdown-exec[ansi]==1.12.1", + "markdown-exec[ansi]==1.12.3", "griffe-inherited-docstrings==1.1.3", - "ruff==0.15.20", + "ruff==0.15.22", # Changelog generation {include-group = "release"}, # Optional dependencies to run examples @@ -143,7 +143,7 @@ dev = [ {include-group = "remote-tests"}, {include-group = "docs"}, "universal-pathlib", - "mypy==2.1.0", + "mypy==2.3.0", ] [tool.coverage.report] diff --git a/src/zarr/core/indexing.py b/src/zarr/core/indexing.py index 875c22fbd3..a1b050cb7b 100644 --- a/src/zarr/core/indexing.py +++ b/src/zarr/core/indexing.py @@ -512,7 +512,8 @@ def replace_ellipsis(selection: Any, shape: tuple[int, ...]) -> SelectionNormali def replace_lists(selection: SelectionNormalized) -> SelectionNormalized: return tuple( - np.asarray(dim_sel) if isinstance(dim_sel, list) else dim_sel for dim_sel in selection + cast("ArrayOfIntOrBool", np.asarray(dim_sel)) if isinstance(dim_sel, list) else dim_sel + for dim_sel in selection ) @@ -1193,7 +1194,7 @@ def __init__( # some initial normalization selection_normalized = cast("CoordinateSelectionNormalized", ensure_tuple(selection)) selection_normalized = tuple( - np.asarray([i]) if is_integer(i) else i for i in selection_normalized + np.asarray([i], dtype=np.intp) if is_integer(i) else i for i in selection_normalized ) selection_normalized = cast( "CoordinateSelectionNormalized", replace_lists(selection_normalized) diff --git a/uv.lock b/uv.lock index 6035acc616..8eac71caa7 100644 --- a/uv.lock +++ b/uv.lock @@ -193,40 +193,43 @@ wheels = [ [[package]] name = "ast-serialize" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" }, - { url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" }, - { url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" }, - { url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" }, - { url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" }, - { url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" }, - { url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" }, - { url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" }, - { url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" }, - { url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" }, - { url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" }, - { url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" }, - { url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" }, - { url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" }, - { url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" }, - { url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" }, - { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" }, +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] [[package]] @@ -618,71 +621,71 @@ wheels = [ [[package]] name = "coverage" -version = "7.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, - { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, - { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, - { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, - { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, - { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, - { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, - { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, - { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, - { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, - { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, - { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, - { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, - { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, - { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, - { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, - { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, - { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, - { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, - { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, - { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, - { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, - { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, - { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, - { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, - { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, - { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, - { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, - { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, - { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, - { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, - { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, - { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, - { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, - { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, - { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, - { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, - { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, ] [[package]] @@ -1029,14 +1032,51 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.155.7" +version = "6.160.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/55/983b6bc1b6b343a5ff6020388f9d0680ab477be59a731517e6c4a0387100/hypothesis-6.155.7.tar.gz", hash = "sha256:d8d6091753d0669db3c90c5e5b346cb37c72f3dd9378c8413acb1fd5da63f7ea", size = 478291, upload-time = "2026-06-21T05:54:31.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/f8/c151e196d4f397ed9436a071e52666c70a2f021138dea828b0a461e245db/hypothesis-6.155.7-py3-none-any.whl", hash = "sha256:9f634bdb1f9e9b8ab6ba09431cf2deedb750c96978125a6fb3c5a0f6c6db4131", size = 544762, upload-time = "2026-06-21T05:54:29.506Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/27/18/824aedbd4117d769862a2722ea2371aa61433a38bfb5355e5dc113b564c2/hypothesis-6.160.0.tar.gz", hash = "sha256:149400acbb7382e2ce6810a52e86a9fd6d4e5c4a47660818abb438cde76aa5d1", size = 485677, upload-time = "2026-07-22T14:12:13.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/c6/39fa718992b7529d1f68532a3554b9479f27f6a46aa5859c0d909bde0a40/hypothesis-6.160.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:69e1511325901fcd570fbd88779882e30cb280aeedd9708093aab4b25f7cdbf5", size = 766096, upload-time = "2026-07-22T14:11:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/94/1b/81b54dbf97baa4026034579ce63b56d3d35c0d22b72b032c68e23bbda92b/hypothesis-6.160.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:1ba0f1dd0f2872b7f7230a3884a0d739917d57262d0e9e3c8ee34b775f95a553", size = 761752, upload-time = "2026-07-22T14:11:42.682Z" }, + { url = "https://files.pythonhosted.org/packages/ea/02/fa35cf37fd801d1e952e2168c0b5542f99c77024098f954cd515f2101910/hypothesis-6.160.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f9116a80ed96060a7fbc8d50cc5e93dec10d72f70f61e9184628dbcba2f9a2f", size = 1090928, upload-time = "2026-07-22T14:12:05.158Z" }, + { url = "https://files.pythonhosted.org/packages/a7/35/f2422a4287bbac99d6317a10e7add5f24abe069952c503cb3512e91bebc0/hypothesis-6.160.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:065cfed699889b6c05265ca4f97e8c7bb85800d3d3146f4741b68ef7be1fed18", size = 1140474, upload-time = "2026-07-22T14:11:50.558Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ef/7504f31be0c9dfd8c69b1e068564e0c1126a82ab753abcb20c4bacd1544b/hypothesis-6.160.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:52e0cdc8fcd34b121a213205f239545fec38142014114afc721d1c867ac34834", size = 1132509, upload-time = "2026-07-22T14:11:48.702Z" }, + { url = "https://files.pythonhosted.org/packages/b4/39/8c7a5cfc336e0bdd7b7ae1d8807028b2b46c03979a5d82e8992b4ba2b81c/hypothesis-6.160.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4868821ffba805970441fec1b0635ea123f01aa6b71fc8f2d9550ee782f1ecd7", size = 1264762, upload-time = "2026-07-22T14:10:30.068Z" }, + { url = "https://files.pythonhosted.org/packages/ac/00/0d47e996ccbfa1eceb66d285b6fbf248c7c020e4e18b1bea09b18f05f6f5/hypothesis-6.160.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:64cf59670080aeb3c6048d62df0f6352586410745d14d7045a692eb5d2245110", size = 1307495, upload-time = "2026-07-22T14:11:33.978Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b8/01f731cfcf9fc475adbde3c328d0c8f1d24952b4dd2a5049e7156aa64d9c/hypothesis-6.160.0-cp310-abi3-win32.whl", hash = "sha256:993c26c81e9cc9f291cdb64f54aa8f31507d2d472d0f1334f8ba9e7d77666911", size = 651991, upload-time = "2026-07-22T14:11:21.375Z" }, + { url = "https://files.pythonhosted.org/packages/87/12/95216fe9a84cafc9bc721b4352cf9b78bf0e9089f278811fbd58c76dbe3f/hypothesis-6.160.0-cp310-abi3-win_amd64.whl", hash = "sha256:95a4b0e1faa366d0cc9d7ce261773cec69f4f130b845ca33b71c22c85493c35d", size = 658114, upload-time = "2026-07-22T14:10:54.298Z" }, + { url = "https://files.pythonhosted.org/packages/81/b2/bc800c4925c1f47b61c17f78e57bb58a8743d03da28de13f59cba148daf2/hypothesis-6.160.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:18e058b34f4514da8b2ce15ebee9e6e98d3a95067665accf394415824934f790", size = 767730, upload-time = "2026-07-22T14:11:56.44Z" }, + { url = "https://files.pythonhosted.org/packages/37/b6/d34a7f990eb0a38933a7f6b14d261fda990faef37122e71797b0043fa371/hypothesis-6.160.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5b38697f797e9406e20e03cd79e1a69c7ac714e7e244f13121d39b44f27f7ed3", size = 759362, upload-time = "2026-07-22T14:11:05.77Z" }, + { url = "https://files.pythonhosted.org/packages/df/bf/48bd2bf246d22f188c82dbf3682832fc14fa4e6069c5415b1e8a473397a7/hypothesis-6.160.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef7d9e8022a8dd2afa2bfbf6580f21a7fd8b4798d20c027f4afb048d780414fd", size = 1089731, upload-time = "2026-07-22T14:11:39.069Z" }, + { url = "https://files.pythonhosted.org/packages/76/a0/d557bd44f611ec2516c69b6ada1e65f96c4d9d1dbad63f12b1799ca682b8/hypothesis-6.160.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4716ceb2adc72ea20138cd6a5600d102895f46fe95a42d915e032eed54b77ee6", size = 1139776, upload-time = "2026-07-22T14:11:19.164Z" }, + { url = "https://files.pythonhosted.org/packages/32/99/cad454acb11e027773bdba5cb95cb181a46cd1cabb8bfe2f2042e29dc0c5/hypothesis-6.160.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d186b17a25eaf51ebf0376ea9d702dddb4f62cc11c0b5230e0aae77b44f49d3", size = 1262564, upload-time = "2026-07-22T14:11:02.444Z" }, + { url = "https://files.pythonhosted.org/packages/67/e7/61b2e1b6c2f75fa3b791040ba4baf2b617ffaf62ffbafad9463869baf521/hypothesis-6.160.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e5e959bb18ec9b285dcc1d6f455c8860da919b9341842530847e820ed18dbbb", size = 1306756, upload-time = "2026-07-22T14:11:54.464Z" }, + { url = "https://files.pythonhosted.org/packages/89/79/6e9f2da0f298f891930a9fc1ed0559818d4ba840f47ed736c89152fd962e/hypothesis-6.160.0-cp312-cp312-win_amd64.whl", hash = "sha256:ded91bbdd0c3a84903bda3dc08d639b3b3e28c03fb83b568af8e13039042c3c4", size = 655265, upload-time = "2026-07-22T14:10:58.076Z" }, + { url = "https://files.pythonhosted.org/packages/85/05/a05ba058a37681d2aa872abcff9bd7a50c61c6347aedf2e3f5a15b8e932b/hypothesis-6.160.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:cb6cd703d38d881505a00e1901844d70d250e90824caa55e0dfaed6c8c7e0244", size = 767604, upload-time = "2026-07-22T14:11:11.346Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a1/33dde1810a52698802fe2e28cfd2696b6aefafdc721cc456dfbc85875bb2/hypothesis-6.160.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9561298d687f9fca38aab451e8eb8a9f18b65a57f81f7331eff5234f0f065dc0", size = 759264, upload-time = "2026-07-22T14:10:40.271Z" }, + { url = "https://files.pythonhosted.org/packages/34/84/573402093577ef0fd86c8156d4c4ecd03b0a5e368e8925074fe565f9faba/hypothesis-6.160.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e19f91119e2e19603210b849508695efabd2a35d6af9ac4d637c1b9a514a52b", size = 1089653, upload-time = "2026-07-22T14:11:37.333Z" }, + { url = "https://files.pythonhosted.org/packages/da/05/c85a35fef75214fc08a27e5099ae51d713c6550252ef7ce4c156780433f1/hypothesis-6.160.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6b73076bb3fbf02001a439a5eb45cdd3db17e2cf6d95f453cfb1f5a97713f5", size = 1139592, upload-time = "2026-07-22T14:12:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/59/53/8f9996fa3a6352edec2c17b743630b6c5f62486db6b43594168a1c0b7571/hypothesis-6.160.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c0dcde9c08f3bdd5318026c57155ce4bfe7615fd27d3eca77a7453cb3ffbba64", size = 1262616, upload-time = "2026-07-22T14:11:14.754Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f7/8b2699131893dd7bcecfe3be9ee758d3939cc8af68374700e68d9df2281b/hypothesis-6.160.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78cb5fcf8518f3a10e888cdff545fa733931e2ff843b02a54e5e0b01b3142f94", size = 1306470, upload-time = "2026-07-22T14:11:23.203Z" }, + { url = "https://files.pythonhosted.org/packages/88/ba/9764eaff70d2a54aa072f709a121f98cf8766fc1591a063f8fab2117b6cf/hypothesis-6.160.0-cp313-cp313-win_amd64.whl", hash = "sha256:e95c3ce8e9c5abd2256854a2e53395fdd91d16cdce8d1621eca8caf5c7a2b1a2", size = 655209, upload-time = "2026-07-22T14:11:17.33Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/3b92edf73785218f084521c2be9506ce6e5c63a64662cda074e588ff3071/hypothesis-6.160.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9bd3d333a501f1faf8611159a998eb1bb28c43b620822ba6c8b2463f5de2a136", size = 767796, upload-time = "2026-07-22T14:11:28.865Z" }, + { url = "https://files.pythonhosted.org/packages/12/c7/eefd510bffc66320015169e2c6669e3a08ea29dda84d81655ecc1c6cbd8c/hypothesis-6.160.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:21ee82802c25282d692eaec7d3b960176c10eb6dc70853b152c5bc6b3b6faf02", size = 759410, upload-time = "2026-07-22T14:10:31.902Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e4/6ad1e558d2df6900b0ad9d17081fbed4a74ffb01d86e64813cab4eaf45f1/hypothesis-6.160.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d71e85548be9dd3a6eb59904daa85d5879e337cb69ad42cc2267c05a17ab26", size = 1090131, upload-time = "2026-07-22T14:11:44.448Z" }, + { url = "https://files.pythonhosted.org/packages/69/94/0d2fef37f9ff89b38b943cc38e12b45fda47cd06704d09bdeb890063d3bc/hypothesis-6.160.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4af833bb623f37b185e53ad7c62292272fc9fec3c7567d0703e3fdd3dcc90945", size = 1139829, upload-time = "2026-07-22T14:12:02.462Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f3/216b8af797eda74af68b0d8ee37d8452adf0cf5b924dd25780e5c3b6296f/hypothesis-6.160.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5789a0cd225f216690d7d99159bbd5d01a6d42cb6c4a07233739b4bf59c7fa37", size = 1262992, upload-time = "2026-07-22T14:10:34.529Z" }, + { url = "https://files.pythonhosted.org/packages/d8/14/63f14de37f41ed09d56593d9c03e8389a3bffcdbdf71bf05d30b5e3b1e4f/hypothesis-6.160.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57f6e370e24c3ca4b9bb6cb132baa471745ca3d598f6328a602f590fe531b1e7", size = 1306760, upload-time = "2026-07-22T14:10:59.825Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/a94eb847dd98edf233aefb7dbe88bd7bf7506840896454ed03827f844907/hypothesis-6.160.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:5df6d4768d7a2d0bd82cd8704c2732cf80fd13089217a3b0ff7b330b59eb50c6", size = 599306, upload-time = "2026-07-22T14:11:09.704Z" }, + { url = "https://files.pythonhosted.org/packages/cb/10/01a5545d22d61320e5d9507a252cef37a138af97d5c17bcad8ea08bfa936/hypothesis-6.160.0-cp314-cp314-win_amd64.whl", hash = "sha256:bdafeab25029d1261786f68ce7aedaa5c0be3ad4accfb13b32ff206ef6dfaa40", size = 655149, upload-time = "2026-07-22T14:11:12.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/d7/b170ae2dfeea3bc0edb99f361ccd725ce00120ddd2065590ed4281ffd29d/hypothesis-6.160.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:285f6763461d58ef1b9b75efd69b559ba3b91055c7c6fb34b1513b3666106a62", size = 766374, upload-time = "2026-07-22T14:10:37.579Z" }, + { url = "https://files.pythonhosted.org/packages/85/30/64e3ca8d5132688bed13bf0c35b4cb1061975f7bba9201c718c394b14fbb/hypothesis-6.160.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7cefc720eaf6d80f4ee0be59a12e301f3d16a5941fdbefe11295ca7e567b0c2", size = 757876, upload-time = "2026-07-22T14:11:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/25/a2/219da3305b412dc265be7ecdd846882ff4e399f84896ff561982bb9be0d3/hypothesis-6.160.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ec6ff81bace8494b12b6c2096e8fb18a769e861613a02138700a2cb5e4c1ccd", size = 1088723, upload-time = "2026-07-22T14:10:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/8a/29/c1879c3a25f3069b1102d17bf2b6f6a7c0667128f1fb2efb2e9964bc17c1/hypothesis-6.160.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c32bed39ecff19f68e37fef7ee4bcd1d13a82378fcd321b61d0cd2f1a360c8", size = 1138696, upload-time = "2026-07-22T14:10:52.712Z" }, + { url = "https://files.pythonhosted.org/packages/b6/15/16239bfc9aad85aa0a0166f61b8aa4eddc69ee57b0c68188f191f4ef0b00/hypothesis-6.160.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d04e56812e135c3223cd06cd0016f61466ce7c56720167046d91123534240f5", size = 1261184, upload-time = "2026-07-22T14:10:43.241Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b8/aa6f06d42d1505b2dab0f82d133d84853391437f34a15c4c39cbcda04f6a/hypothesis-6.160.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c18c5eb6260bda6e56689429723d5b62b62cedee88c95de03976799645c9b0ce", size = 1305573, upload-time = "2026-07-22T14:10:51.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/13/645f8c95070a21fa1257f0d4cf68b938d7ec60e8371d79402ce7cb50d3c9/hypothesis-6.160.0-cp314-cp314t-win_amd64.whl", hash = "sha256:deabcb5645076988ac52237a7c3ee8fca2fbd4f859461537374911fbe0e99817", size = 655308, upload-time = "2026-07-22T14:10:38.969Z" }, ] [[package]] @@ -1213,62 +1253,64 @@ wheels = [ [[package]] name = "librt" -version = "0.11.0" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, - { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, - { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, - { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, - { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, - { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, - { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, - { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, - { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, - { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, - { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, - { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, - { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, - { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, - { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, - { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, - { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, - { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, - { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, - { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, - { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, - { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, - { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, - { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, - { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, - { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, - { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, - { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, - { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, - { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, - { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, - { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, - { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, ] [[package]] @@ -1282,14 +1324,14 @@ wheels = [ [[package]] name = "markdown-exec" -version = "1.12.1" +version = "1.12.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/73/1f20927d075c83c0e2bc814d3b8f9bd254d919069f78c5423224b4407944/markdown_exec-1.12.1.tar.gz", hash = "sha256:eee8ba0df99a5400092eeda80212ba3968f3cbbf3a33f86f1cd25161538e6534", size = 78105, upload-time = "2025-11-11T19:25:05.44Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/76/c47da8edb6a12b066728432fb3724109d9d91de5331df5073d12d272493f/markdown_exec-1.12.3.tar.gz", hash = "sha256:006b9cac46470a9499797bc9c579305ae4719e0a8e495e5401dfbf1e66ce7fb4", size = 77841, upload-time = "2026-07-07T09:53:13.838Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/22/7b684ddb01b423b79eaba9726954bbe559540d510abc7a72a84d8eee1b26/markdown_exec-1.12.1-py3-none-any.whl", hash = "sha256:a645dce411fee297f5b4a4169c245ec51e20061d5b71e225bef006e87f3e465f", size = 38046, upload-time = "2025-11-11T19:25:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a7/0279016386d611183ccc508c5688eb1e2133e8182164d7a2c6213b176f69/markdown_exec-1.12.3-py3-none-any.whl", hash = "sha256:48ac12a565f3f4331b1acd9efc48a0773e717eb7ca7c38e23c1d72ee61660de6", size = 37995, upload-time = "2026-07-07T09:53:12.619Z" }, ] [package.optional-dependencies] @@ -1461,7 +1503,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.7.6" +version = "9.7.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -1476,9 +1518,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } +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/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, + { 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.optional-dependencies] @@ -1511,7 +1553,7 @@ wheels = [ [[package]] name = "mkdocstrings" -version = "1.0.4" +version = "1.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -1521,9 +1563,9 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/5d/f888d4d3eb31359b327bc9b17a212d6ef03fe0b0682fbb3fc2cb849fb12b/mkdocstrings-1.0.4.tar.gz", hash = "sha256:3969a6515b77db65fd097b53c1b7aa4ae840bd71a2ee62a6a3e89503446d7172", size = 100088, upload-time = "2026-04-15T09:16:53.376Z" } +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/6e/94/be70f8ee9c45f2f62b39a1f0e9303bc20e138a8f3b8e50ffd89498e177e1/mkdocstrings-1.0.4-py3-none-any.whl", hash = "sha256:63464b4b29053514f32a1dbbf604e52876d5e638111b0c295ab7ed3cac73ca9b", size = 35560, upload-time = "2026-04-15T09:16:51.436Z" }, + { 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]] @@ -1742,7 +1784,7 @@ wheels = [ [[package]] name = "mypy" -version = "2.1.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ast-serialize" }, @@ -1751,37 +1793,38 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, - { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, - { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, - { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, - { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, - { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, - { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, - { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, - { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, - { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, - { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, - { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, - { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, - { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, - { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, - { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, - { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, - { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, - { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, - { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, - { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, ] [[package]] @@ -1836,53 +1879,53 @@ msgpack = [ [[package]] name = "numpy" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" }, - { url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" }, - { url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" }, - { url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" }, - { url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" }, - { url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" }, - { url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" }, - { url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" }, - { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" }, - { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" }, - { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" }, - { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" }, - { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" }, - { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" }, - { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" }, - { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" }, - { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" }, - { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" }, - { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" }, - { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" }, - { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" }, - { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" }, - { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" }, - { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" }, - { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" }, - { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" }, - { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" }, - { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" }, - { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" }, - { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" }, - { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" }, - { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" }, - { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" }, +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]] @@ -2845,27 +2888,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, - { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, - { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, - { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, - { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, - { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, - { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, - { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +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]] @@ -3047,11 +3090,11 @@ wheels = [ [[package]] name = "tomlkit" -version = "0.15.0" +version = "0.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, ] [[package]] @@ -3069,7 +3112,7 @@ wheels = [ [[package]] name = "typer" -version = "0.26.8" +version = "0.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -3077,9 +3120,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" } +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" }, + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, ] [[package]] @@ -3127,28 +3170,28 @@ wheels = [ [[package]] name = "uv" -version = "0.11.26" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/cb/5efc713948ddb10b00abfb51bfd429221c720175557f9c7965fea2448fe4/uv-0.11.26.tar.gz", hash = "sha256:2a433ece2ace088dd572d8abb0e6bd9a4ecb0e10bc9856447bbb37545f384f29", size = 4331220, upload-time = "2026-06-30T14:52:03.77Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/71/86dbffac9e26df28a16639c426cf4ba572aaf43d9231463e0dca337895b2/uv-0.11.26-py3-none-linux_armv6l.whl", hash = "sha256:fb97bf04512dfe16d86084e75d8129701fc8da9fb40de8746b73c3aa617c5897", size = 25197324, upload-time = "2026-06-30T14:50:51.75Z" }, - { url = "https://files.pythonhosted.org/packages/ec/80/525b73c8188e7052343e7109466a08fcd5195055aff4b0346ce3622e48cb/uv-0.11.26-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a58a06e5a4b0035538d3ab4160ad74c716076ea7148eb3317171c6276ac020b4", size = 24179172, upload-time = "2026-06-30T14:50:56.52Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5e/cf7b94ed3b1932c2a62573dcd388ad6c1da5c52111cd71ab7f20faa4a0aa/uv-0.11.26-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7b6d078d2ce83897884c2330c0676f27be4bf3d223fb2a409460f579fb5f0a98", size = 22949576, upload-time = "2026-06-30T14:51:00.538Z" }, - { url = "https://files.pythonhosted.org/packages/bf/fd/71fa021f6909c4139d8354bea623b5e0ef0ce4a08da250da1a1645528da2/uv-0.11.26-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:1cd9ba4951681ce17f1703106266fcbe27aaa7d37f07d53cce8b5686d68a8755", size = 24936673, upload-time = "2026-06-30T14:51:04.496Z" }, - { url = "https://files.pythonhosted.org/packages/7d/5e/273425e58a8812423e3d1f6c5da1015e636fbf13a83d104317ca37e16304/uv-0.11.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:e4f4c3268e69ac96f01972274a62f5f930c03cbc680adba6f21e63237ba3a639", size = 24719617, upload-time = "2026-06-30T14:51:08.419Z" }, - { url = "https://files.pythonhosted.org/packages/81/f8/1601e2acc7c54963814b4831eab996d8599e690712722c5acec5114860be/uv-0.11.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:efcbe0e187846f5ddba23bcaed17e4f9cd2463da5c45bdb5869616f686d713ff", size = 24734176, upload-time = "2026-06-30T14:51:12.685Z" }, - { url = "https://files.pythonhosted.org/packages/88/d2/a8a422e54c08cf4b8d51bedb9dbdd3cc233aa290ad8b3ee0438c0c02a3a5/uv-0.11.26-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:120ab2de93164d08cf5950f7fe18cbebe3ff670865ae41a292452bab2346477f", size = 26158780, upload-time = "2026-06-30T14:51:16.514Z" }, - { url = "https://files.pythonhosted.org/packages/db/e6/647fe5fdc888a3d27f79977877ce4e88052fe9be5398371e51bb134fc262/uv-0.11.26-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9052bf27c7ee426901f35a48715fa9288ce631c1878b91c9a6c950288f4b8633", size = 27009550, upload-time = "2026-06-30T14:51:20.659Z" }, - { url = "https://files.pythonhosted.org/packages/72/c2/85d8e762ad83b0f14fae2255b0578c4fd7dc915746f81b64ed786342627a/uv-0.11.26-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:efdddfcc9b1b790c5f7985c5c183c851682ced165b44ffa914f4947f5cad1fbf", size = 26183777, upload-time = "2026-06-30T14:51:24.715Z" }, - { url = "https://files.pythonhosted.org/packages/d3/00/478c3a870dcac690b8c337ee950a60a952e817f574945e85155c3cc0ab34/uv-0.11.26-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dcf4e0b5b5cbdc242dcb002f1f8d99e7cf8c043609869228a9ce15e095c0b18", size = 26260589, upload-time = "2026-06-30T14:51:28.809Z" }, - { url = "https://files.pythonhosted.org/packages/a7/51/e4e43e106fb8cdc026b97491ea4600f4194a9c4da0b4e4e30c2a7dceb268/uv-0.11.26-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:866ae8d28f7381c15de0906a284c1e97916424c635bf40f7960b3fc889cd725e", size = 25073850, upload-time = "2026-06-30T14:51:32.717Z" }, - { url = "https://files.pythonhosted.org/packages/f2/c2/e772b7e6c8a835e8bf6739a391cdfc8e8e244c5c496d9b40625068b59ff4/uv-0.11.26-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:22f6d62e794b252ff3a1e2dfe5010cc76208f90b2c906e54971a0223ad6f16bc", size = 25682609, upload-time = "2026-06-30T14:51:36.888Z" }, - { url = "https://files.pythonhosted.org/packages/1a/69/ea77209a224a23a399cb7f6414f77ef032bd9e083e01199a0ebebf0d3ff2/uv-0.11.26-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:edd0c12b75141a6d830d138a91e366ad66e630f1c1dcaf83b8325b80cbacfcbb", size = 25800556, upload-time = "2026-06-30T14:51:40.937Z" }, - { url = "https://files.pythonhosted.org/packages/77/60/b6c0c03d2538a016b6624fa251960012e564ea02f841e958c7d60e974685/uv-0.11.26-py3-none-musllinux_1_1_i686.whl", hash = "sha256:af6a45b11a569cc4d2437e89a25a53dcf753f2a02a8f2de96be09b9b942cb3ec", size = 25385658, upload-time = "2026-06-30T14:51:45.103Z" }, - { url = "https://files.pythonhosted.org/packages/8d/e7/46881ff9164aa2e7c649901837d58eee3c57beb3b0fcc0fea6a4e40cf8f3/uv-0.11.26-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:c28822517d03aebbe9549aaaecc88ad580e4b2b6a927abffe5774a74d6ba09f6", size = 26551013, upload-time = "2026-06-30T14:51:49.062Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/380dad6c2bbe12417025aacd12cfc08322ed4c9dd8f760bff7035b86f22d/uv-0.11.26-py3-none-win32.whl", hash = "sha256:79e5c1b3410047e1962290c3b7b8f512d2c1bb95200c60b016f7729287cf34c0", size = 23947180, upload-time = "2026-06-30T14:51:53.065Z" }, - { url = "https://files.pythonhosted.org/packages/d0/13/9c588226d5b478328d739e654944430719f3ffe8999d6a24d425ec9664ab/uv-0.11.26-py3-none-win_amd64.whl", hash = "sha256:d95567e9470dc48ff03265f420c3c6973f6437f18a79d5e00b6eb4b2d9379907", size = 26909320, upload-time = "2026-06-30T14:51:57.235Z" }, - { url = "https://files.pythonhosted.org/packages/21/1d/ea66b12813878797126e2b3aca124b1c9c5ef53120702d1c00172f90a21d/uv-0.11.26-py3-none-win_arm64.whl", hash = "sha256:7e69d1569afbb936e7bf4e4ab2f72d606405f4a68f380f088a0b2233e84e056a", size = 25176820, upload-time = "2026-06-30T14:52:01.05Z" }, +version = "0.11.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f0/501fe8a234ac96ea8869e84cb47b3bd77e39a0e80ee01950713e24fe1c4a/uv-0.11.31.tar.gz", hash = "sha256:763609d59721af5b8522e16deac6cffe8055f82bb837740c708917506f305185", size = 6045932, upload-time = "2026-07-22T01:48:45.407Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/6a/065e1e7feaf375eee8d1bb05e5276185708149dd48c27a230f320a0fc8bf/uv-0.11.31-py3-none-linux_armv6l.whl", hash = "sha256:6adaaf151f53fef04dec685f0816d304c09a091b2b609746f86ee7c55ada6bcd", size = 25838313, upload-time = "2026-07-22T01:47:21.787Z" }, + { url = "https://files.pythonhosted.org/packages/e1/15/529b573723a36badbda1e13a432c3b21a7554b8ddef3b20a2200037051c2/uv-0.11.31-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2d84b6dd6b1eaf42fc923203d21a5efd052e1982e4f961eccecc2a6905ffbecd", size = 24795386, upload-time = "2026-07-22T01:47:26.882Z" }, + { url = "https://files.pythonhosted.org/packages/52/be/a809b3fe20c3d37bc667de33f38475c4c94f860979d07049ccddb6d91801/uv-0.11.31-py3-none-macosx_11_0_arm64.whl", hash = "sha256:335f3262c4350c004cf6e3b7061200148d670e579bcee7ba0e31c7535f125018", size = 23410594, upload-time = "2026-07-22T01:47:31.43Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9a/ebaacd8b7713fd755d23623e0e8de78dfd001f6abc818034f2e9058035c7/uv-0.11.31-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:e1cf5803c39221387b2fe8be2b522b0529ac732831a2e52a92330e053539995e", size = 25358933, upload-time = "2026-07-22T01:47:36.544Z" }, + { url = "https://files.pythonhosted.org/packages/81/34/c30568a0f9e556be766c341106bf6ca2ef5c8067be6c11665a53df0549f1/uv-0.11.31-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:68ae6974ffbd04703e138654e83220a16e7b0b679271a8f209f928928dd399f8", size = 25346175, upload-time = "2026-07-22T01:47:41.132Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/18467b66f578dc121ec6d4af78074a0db06b27627b072fc433226a99a384/uv-0.11.31-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48f7ec906eaebf9717a01ba0f7635cd0cac648ff5c8fff3a57b8805e6bd49078", size = 25381240, upload-time = "2026-07-22T01:47:45.659Z" }, + { url = "https://files.pythonhosted.org/packages/b8/43/b51d6b8ad1307f51dd75154d623d6a527c6de600086bb0446251047d2e5e/uv-0.11.31-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5a2cfd1638420f9a2a7dbca71c808edaf3929b6d8f4ec2ceac2f27014150d0e3", size = 26661822, upload-time = "2026-07-22T01:47:50.42Z" }, + { url = "https://files.pythonhosted.org/packages/30/9f/008c859ea3fc0d25d6ac32e1293a0795c737b0a472a8603b5e511b56659c/uv-0.11.31-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aec65d8f54403e60f32c50e44d98b6420de55211ad22a340927efc5db6ef4205", size = 27594901, upload-time = "2026-07-22T01:47:55.444Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/65a2856e79a208f8a1ece0ac077fbee531db7455608c06ab677b2513cbc4/uv-0.11.31-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5610fea306dc6ce5021482d272e6372f0c3dfd1e24ec061f90b1b9287263ac58", size = 26708620, upload-time = "2026-07-22T01:48:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c3/019ecbf3564d909c55fcf065592aff90b8b386d679e379caf356de4473f9/uv-0.11.31-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44ac79fca5807122676701279a1f36d7917a922f25a0ab5c5cf58a252f666e7e", size = 26894006, upload-time = "2026-07-22T01:48:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/b67aa8736f9f82a9f99cec93c28d66d77ff42126914784a3f680bc737b56/uv-0.11.31-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c4d4b34264017dc9047d0d49f09363a5e20b388481cddc39d5c44b16b3c2a57c", size = 25504398, upload-time = "2026-07-22T01:48:09.859Z" }, + { url = "https://files.pythonhosted.org/packages/44/d1/37e3a30f55e1c623fca484efbb80b6e157b922ee79f5cb7b1c0ff5005f0f/uv-0.11.31-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:9ce168c7323aee61ef07220c815f1b3e3a1b74241acb9f56c0b7fc4794dad600", size = 26307040, upload-time = "2026-07-22T01:48:14.555Z" }, + { url = "https://files.pythonhosted.org/packages/00/cc/f607ba28a93100c55b3e048838f85481f8b55a24e3a338e42c151f5884ae/uv-0.11.31-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f3f8f58030ba4f711542d581b5fc3cde54db75a773fc873178f7b353f68f8711", size = 26425088, upload-time = "2026-07-22T01:48:19.204Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ae/dd865e1d680799f05ff32895689700a23f37e905aac9807c93521fc76d8c/uv-0.11.31-py3-none-musllinux_1_1_i686.whl", hash = "sha256:b1384887f8a4a0b0dfb8c6c81b2f819d1771015a96c70f89ef12559df8206b28", size = 25920399, upload-time = "2026-07-22T01:48:23.866Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0a/45ebfd783235a7a39ae1e99dc0bf26c083ea24a37584e530c7fbb6e38a21/uv-0.11.31-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:c6e052de498086b2014020536829b7e2b6f173ba95b07e55e9e0f85ac00a3927", size = 27126383, upload-time = "2026-07-22T01:48:28.376Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c7/4cf78823c123efd3bdac50eb26f4b8fc2c222962d47918a7bb2b465b6522/uv-0.11.31-py3-none-win32.whl", hash = "sha256:03e18e463ecf0e1c347f901f9a8739059d07e2e2ebce72c0f8f1b9328a349c6f", size = 24644301, upload-time = "2026-07-22T01:48:33.094Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4f/f2c3d0993ebab255a2dd7c476678c0307da03d890fb98761e8221d7bb043/uv-0.11.31-py3-none-win_amd64.whl", hash = "sha256:1a4bb0030d9070a4831a4f3115c5489998da7ca936e569a72696c90af469177a", size = 27699662, upload-time = "2026-07-22T01:48:37.708Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8b/259e12b510c655f743f9a0e3171e6e9276dfd35a058d04d6aeef1fc4a897/uv-0.11.31-py3-none-win_arm64.whl", hash = "sha256:88ab5fdbeff4ab10ac890ab2dd01b7ad62b92251665423e4f68b1cf977fbe635", size = 25849721, upload-time = "2026-07-22T01:48:42.513Z" }, ] [[package]] @@ -3522,19 +3565,19 @@ provides-extras = ["cast-value-rs", "cli", "gpu", "optional", "remote"] dev = [ { name = "astroid", specifier = "==4.1.2" }, { name = "botocore" }, - { name = "coverage", specifier = "==7.14.3" }, + { name = "coverage", specifier = "==7.15.2" }, { name = "fsspec", specifier = ">=2023.10.0" }, { name = "griffe-inherited-docstrings", specifier = "==1.1.3" }, - { name = "hypothesis", specifier = "==6.155.7" }, - { name = "markdown-exec", extras = ["ansi"], specifier = "==1.12.1" }, + { name = "hypothesis", specifier = "==6.160.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.6" }, + { name = "mkdocs-material", extras = ["imaging"], specifier = "==9.7.7" }, { name = "mkdocs-redirects", specifier = "==1.2.3" }, - { name = "mkdocstrings", specifier = "==1.0.4" }, + { 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.1.0" }, + { name = "mypy", specifier = "==2.3.0" }, { name = "numcodecs", extras = ["msgpack"] }, { name = "numpydoc", specifier = "==1.10.0" }, { name = "obstore", specifier = ">=0.5.1" }, @@ -3546,35 +3589,35 @@ dev = [ { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-xdist", specifier = "==3.8.0" }, { name = "requests", specifier = "==2.34.2" }, - { name = "ruff", specifier = "==0.15.20" }, + { name = "ruff", specifier = "==0.15.22" }, { name = "s3fs", specifier = ">=2023.10.0" }, - { name = "tomlkit", specifier = "==0.15.0" }, + { name = "tomlkit", specifier = "==0.15.1" }, { name = "towncrier", specifier = "==25.8.0" }, { name = "universal-pathlib" }, - { name = "uv", specifier = "==0.11.26" }, + { name = "uv", specifier = "==0.11.31" }, ] docs = [ { name = "astroid", specifier = "==4.1.2" }, { name = "griffe-inherited-docstrings", specifier = "==1.1.3" }, - { name = "markdown-exec", extras = ["ansi"], specifier = "==1.12.1" }, + { 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.6" }, + { name = "mkdocs-material", extras = ["imaging"], specifier = "==9.7.7" }, { name = "mkdocs-redirects", specifier = "==1.2.3" }, - { name = "mkdocstrings", specifier = "==1.0.4" }, + { 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.15.20" }, + { name = "ruff", specifier = "==0.15.22" }, { 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.14.3" }, + { name = "coverage", specifier = "==7.15.2" }, { name = "fsspec", specifier = ">=2023.10.0" }, - { name = "hypothesis", specifier = "==6.155.7" }, + { name = "hypothesis", specifier = "==6.160.0" }, { name = "moto", extras = ["s3", "server"], specifier = "==5.2.2" }, { name = "numpydoc", specifier = "==1.10.0" }, { name = "obstore", specifier = ">=0.5.1" }, @@ -3587,12 +3630,12 @@ remote-tests = [ { name = "pytest-xdist", specifier = "==3.8.0" }, { name = "requests", specifier = "==2.34.2" }, { name = "s3fs", specifier = ">=2023.10.0" }, - { name = "tomlkit", specifier = "==0.15.0" }, - { name = "uv", specifier = "==0.11.26" }, + { name = "tomlkit", specifier = "==0.15.1" }, + { name = "uv", specifier = "==0.11.31" }, ] test = [ - { name = "coverage", specifier = "==7.14.3" }, - { name = "hypothesis", specifier = "==6.155.7" }, + { name = "coverage", specifier = "==7.15.2" }, + { name = "hypothesis", specifier = "==6.160.0" }, { name = "numpydoc", specifier = "==1.10.0" }, { name = "pytest", specifier = "==9.1.1" }, { name = "pytest-accept", specifier = "==0.3.0" }, @@ -3601,6 +3644,6 @@ test = [ { 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.0" }, - { name = "uv", specifier = "==0.11.26" }, + { name = "tomlkit", specifier = "==0.15.1" }, + { name = "uv", specifier = "==0.11.31" }, ] From 57e66d92ed26eb02ca3931f253de052c0a890042 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Thu, 30 Jul 2026 14:28:22 +0200 Subject: [PATCH 19/32] fix: reject malformed chunk keys in DefaultChunkKeyEncoding (#4219) * fix: reject malformed chunk keys in DefaultChunkKeyEncoding decode_chunk_key stripped a single leading character and split the rest, so any key at all decoded to something. "0/1" silently became (1,) -- the "0" was eaten as if it were the "c" prefix -- and a key written with one separator decoded wrongly under an encoding configured with the other. Validate the "c" prefix and raise ValueError when it is absent, so a key that is not a chunk key for this encoding is reported rather than silently misread. Adds the tests this method never had, covering the round trip for both separators and each way a key can fail to carry the prefix. Assisted-by: ClaudeCode:claude-fable-5 * Rename 250.bugfix.md to 4219.bugfix.md --- changes/4219.bugfix.md | 3 ++ src/zarr/core/chunk_key_encodings.py | 6 ++- tests/test_chunk_key_encodings.py | 65 ++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 changes/4219.bugfix.md create mode 100644 tests/test_chunk_key_encodings.py diff --git a/changes/4219.bugfix.md b/changes/4219.bugfix.md new file mode 100644 index 0000000000..728e8a7e3a --- /dev/null +++ b/changes/4219.bugfix.md @@ -0,0 +1,3 @@ +`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. diff --git a/src/zarr/core/chunk_key_encodings.py b/src/zarr/core/chunk_key_encodings.py index 098f2c8981..fb2fd95dee 100644 --- a/src/zarr/core/chunk_key_encodings.py +++ b/src/zarr/core/chunk_key_encodings.py @@ -79,7 +79,11 @@ def __post_init__(self) -> None: def decode_chunk_key(self, chunk_key: str) -> tuple[int, ...]: if chunk_key == "c": return () - return tuple(map(int, chunk_key[1:].split(self.separator))) + # Strip the "c" prefix (e.g. "c/" or "c.") before splitting. + prefix = "c" + self.separator + if chunk_key.startswith(prefix): + return tuple(map(int, chunk_key[len(prefix) :].split(self.separator))) + raise ValueError(f"Invalid chunk key for default encoding: {chunk_key!r}") def encode_chunk_key(self, chunk_coords: tuple[int, ...]) -> str: return self.separator.join(map(str, ("c",) + chunk_coords)) diff --git a/tests/test_chunk_key_encodings.py b/tests/test_chunk_key_encodings.py new file mode 100644 index 0000000000..dcc93b9249 --- /dev/null +++ b/tests/test_chunk_key_encodings.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import pytest + +from zarr.core.chunk_key_encodings import DefaultChunkKeyEncoding, V2ChunkKeyEncoding + + +@pytest.mark.parametrize("separator", ["/", "."]) +@pytest.mark.parametrize( + "coords", + [(), (0,), (1, 2), (10, 0, 3)], +) +def test_default_encoding_round_trips(separator: str, coords: tuple[int, ...]) -> None: + """Encoding coordinates and decoding the result returns the coordinates.""" + encoding = DefaultChunkKeyEncoding(separator=separator) # type: ignore[arg-type] + + key = encoding.encode_chunk_key(coords) + assert encoding.decode_chunk_key(key) == coords + + +@pytest.mark.parametrize("separator", ["/", "."]) +@pytest.mark.parametrize("coords", [(0,), (1, 2), (10, 0, 3)]) +def test_v2_encoding_round_trips(separator: str, coords: tuple[int, ...]) -> None: + """The v2 encoding round-trips coordinates for either separator.""" + encoding = V2ChunkKeyEncoding(separator=separator) # type: ignore[arg-type] + + key = encoding.encode_chunk_key(coords) + assert encoding.decode_chunk_key(key) == coords + + +@pytest.mark.parametrize("separator", ["/", "."]) +def test_v2_zero_dimensional_key_is_ambiguous(separator: str) -> None: + """A 0-d v2 array stores its sole chunk under `"0"`, the same key a 1-d + array uses for chunk 0, so decoding cannot recover the empty tuple on its + own -- the array's dimensionality is what disambiguates it.""" + encoding = V2ChunkKeyEncoding(separator=separator) # type: ignore[arg-type] + + assert encoding.encode_chunk_key(()) == "0" + assert encoding.decode_chunk_key("0") == (0,) + + +@pytest.mark.parametrize( + "chunk_key", + [ + "0/1", # no "c" prefix at all + "c0/1", # "c" not followed by the separator + "x/0/1", # wrong prefix character + "", + ], +) +def test_default_encoding_rejects_key_without_prefix(chunk_key: str) -> None: + """A key that does not carry the `c` prefix is not a chunk key + for this encoding, and must be rejected rather than silently decoded.""" + encoding = DefaultChunkKeyEncoding(separator="/") + + with pytest.raises(ValueError, match="Invalid chunk key"): + encoding.decode_chunk_key(chunk_key) + + +def test_default_encoding_rejects_key_using_the_other_separator() -> None: + """A key encoded with `.` is not valid for a `/`-separated encoding.""" + encoding = DefaultChunkKeyEncoding(separator="/") + + with pytest.raises(ValueError, match="Invalid chunk key"): + encoding.decode_chunk_key("c.0.1") From 6f52da5b8ce4e28031f4ad6c3287795fec971105 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Thu, 30 Jul 2026 17:44:37 +0200 Subject: [PATCH 20/32] fix: gate fused sync fast paths on full store sync capability; wrappers forward it (#4206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fused write gate checked only SupportsSetSync, but write_sync also needs get_sync (partial-chunk read-modify-write) and delete_sync (all-fill chunk cleanup): a set-sync-only store passed the gate, wrote some chunks, then died mid-batch with TypeError. And WrapperStore forwarded no *_sync method, so every wrapped store (e.g. LatencyStore) silently lost the sync fast path — latency benchmarks measured the async fallback while claiming to measure the fused sync path. Both gates now consult _store_supports_sync_io: structural membership in SupportsSyncStore (the full get/set/delete sync surface) combined with a per-instance _supports_sync_io opt-out (absent means capable). This is a private, interim convention pending a formal sync/async store architecture — the store-side twin of the codec-side _sync_capable convention from #4179 — deliberately not new public API. WrapperStore delegates the three sync methods and forwards the wrapped store's capability, so wrapping a sync store keeps the fast path and wrapping an async-only store falls back cleanly; LoggingStore logs the delegated sync calls. LatencyStore fixes: sync reads/writes now sleep the configured latency on the worker thread; get_ranges/get_partial_values route through the latency-injecting get instead of bypassing the wrapper; _with_store passes the raw (loc, scale) latency config instead of a single sampled float, so derived stores keep the distribution. Assisted-by: ClaudeCode:claude-fable-5 --- changes/4206.bugfix.md | 1 + src/zarr/abc/store.py | 47 +++++- src/zarr/codecs/sharding.py | 4 +- src/zarr/core/codec_pipeline.py | 32 ++-- src/zarr/experimental/cache_store.py | 10 ++ src/zarr/storage/_logging.py | 21 +++ src/zarr/storage/_wrapper.py | 42 ++++- src/zarr/testing/store.py | 80 ++++++++- tests/test_codec_pipeline_suite.py | 18 ++- tests/test_experimental/test_cache_store.py | 37 +++++ tests/test_fused_pipeline.py | 170 +++++++++++++++++++- tests/test_store/test_get_ranges.py | 8 +- tests/test_store/test_latency.py | 108 +++++++++++++ tests/test_store/test_wrapper.py | 50 +++++- 14 files changed, 596 insertions(+), 32 deletions(-) create mode 100644 changes/4206.bugfix.md diff --git a/changes/4206.bugfix.md b/changes/4206.bugfix.md new file mode 100644 index 0000000000..a01c969449 --- /dev/null +++ b/changes/4206.bugfix.md @@ -0,0 +1 @@ +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. diff --git a/src/zarr/abc/store.py b/src/zarr/abc/store.py index c60d2468c5..af528ec533 100644 --- a/src/zarr/abc/store.py +++ b/src/zarr/abc/store.py @@ -662,6 +662,15 @@ def delete_sync(self) -> None: ... @runtime_checkable class SupportsGetSync(Protocol): + """Store protocol for synchronous reads (`get_sync`). + + The store sync surface is all-or-nothing: a store implementing any of the + `*_sync` methods must implement all of them (`SupportsSyncStore`), because + consumers mix sync reads, writes, and deletes within one operation. + Capability-gated callers consult `_store_supports_sync_io` rather than the + individual protocols. + """ + def get_sync( self, key: str, @@ -673,16 +682,52 @@ def get_sync( @runtime_checkable class SupportsSetSync(Protocol): + """Store protocol for synchronous writes (`set_sync`). + + See `SupportsGetSync` for the all-or-nothing contract on the store sync + surface. + """ + def set_sync(self, key: str, value: Buffer) -> None: ... @runtime_checkable class SupportsDeleteSync(Protocol): + """Store protocol for synchronous deletes (`delete_sync`). + + See `SupportsGetSync` for the all-or-nothing contract on the store sync + surface. + """ + def delete_sync(self, key: str) -> None: ... @runtime_checkable -class SupportsSyncStore(SupportsGetSync, SupportsSetSync, SupportsDeleteSync, Protocol): ... +class SupportsSyncStore(SupportsGetSync, SupportsSetSync, SupportsDeleteSync, Protocol): + """The full store sync surface: `get_sync`, `set_sync`, and `delete_sync`.""" + + +def _store_supports_sync_io(store: object) -> bool: + """Whether `store` can serve the full synchronous IO surface right now. + + Structural membership in `SupportsSyncStore` is necessary but not always + sufficient: a store can present the `*_sync` methods while its ability to + run them depends on runtime state the type system cannot see. Wrapper + stores are the canonical case — `WrapperStore` delegates the sync methods + to the store it wraps, so they only work when the wrapped store is itself + sync-capable. Such stores opt out dynamically via a `_supports_sync_io` + attribute/property (absent means capable). + + This is an interim, private convention pending a formal sync/async store + architecture — the store-side twin of the codec-side `_sync_capable` + convention consulted by `zarr.abc.codec._codec_supports_sync`. + + Synchronous IO is all-or-nothing: consumers such as the fused codec + pipeline mix synchronous reads, writes, and deletes within one batch + (e.g. a partial-chunk write reads existing bytes and an all-fill chunk is + deleted), so a partial sync surface never satisfies this predicate. + """ + return isinstance(store, SupportsSyncStore) and getattr(store, "_supports_sync_io", True) async def set_or_delete(byte_setter: ByteSetter, value: Buffer | None) -> None: diff --git a/src/zarr/codecs/sharding.py b/src/zarr/codecs/sharding.py index cdfdae6c89..d8ca8bdf62 100644 --- a/src/zarr/codecs/sharding.py +++ b/src/zarr/codecs/sharding.py @@ -23,7 +23,7 @@ RangeByteRequest, Store, SuffixByteRequest, - SupportsGetSync, + _store_supports_sync_io, ) from zarr.codecs._deprecated_enum import _coerce_enum_input, _DeprecatedStrEnumMeta from zarr.codecs.bytes import BytesCodec @@ -1721,7 +1721,7 @@ def _load_partial_shard_maybe_sync( shard_dict: ShardMutableMapping = {} store = byte_getter.store if hasattr(byte_getter, "store") else None - if isinstance(store, Store) and isinstance(store, SupportsGetSync): + if isinstance(store, Store) and _store_supports_sync_io(store): # External store: coalesce via get_ranges_sync (mirrors get_ranges). byte_ranges = [byte_range for _, byte_range in chunk_coord_byte_ranges] try: diff --git a/src/zarr/core/codec_pipeline.py b/src/zarr/core/codec_pipeline.py index 92fd0970fe..597f338c42 100644 --- a/src/zarr/core/codec_pipeline.py +++ b/src/zarr/core/codec_pipeline.py @@ -331,8 +331,8 @@ async def _async_read_fallback( then scatters each decoded chunk into `out` at its `out_selection`. Used by both `BatchedCodecPipeline.read_batch` (non-partial-decode - branch) and `FusedCodecPipeline.read` (when the store is not a - `SupportsGetSync` / sync transform is unavailable). + branch) and `FusedCodecPipeline.read` (when the store does not advertise + sync IO / sync transform is unavailable). """ chunk_array_batch: list[NDBuffer | None] @@ -393,8 +393,8 @@ async def _async_write_fallback( if encoding produced `None` or the chunk dropped). Used by both `BatchedCodecPipeline.write_batch` (non-partial-encode - branch) and `FusedCodecPipeline.write` (when the store is not a - `SupportsSetSync` / sync transform is unavailable). + branch) and `FusedCodecPipeline.write` (when the store does not advertise + sync IO / sync transform is unavailable). """ if use_sync := ( @@ -1265,16 +1265,17 @@ async def read( return () # Fast path: sync transform plus synchronous IO. For StorePath the gate - # is on the STORE's sync support (StorePath always has a get_sync - # method, but it only works when its store does); for other byte - # getters (e.g. the sharding codec's in-memory _ShardingByteGetter) the - # SyncByteGetter protocol is the gate. - from zarr.abc.store import SupportsGetSync, SyncByteGetter + # is the STORE's sync-IO capability (`_store_supports_sync_io`) (StorePath always has a + # get_sync method, but it only works when its store implements the full + # sync surface); for other byte getters (e.g. the sharding codec's + # in-memory _ShardingByteGetter) the SyncByteGetter protocol is the + # gate. + from zarr.abc.store import SyncByteGetter, _store_supports_sync_io from zarr.storage._common import StorePath first_bg = batch[0][0] if self.sync_transform is not None and ( - (isinstance(first_bg, StorePath) and isinstance(first_bg.store, SupportsGetSync)) + (isinstance(first_bg, StorePath) and _store_supports_sync_io(first_bg.store)) or (not isinstance(first_bg, StorePath) and isinstance(first_bg, SyncByteGetter)) ): # One thread hop for the WHOLE batch — not per chunk, so the fused @@ -1328,14 +1329,17 @@ async def write( return # Fast path: sync transform plus synchronous IO. Mirrors `read`: gate - # StorePath on the store's sync support, other byte setters (e.g. the - # sharding codec's in-memory _ShardingByteSetter) on SyncByteSetter. - from zarr.abc.store import SupportsSetSync, SyncByteSetter + # StorePath on the store's sync-IO capability (`_store_supports_sync_io`) — write_sync + # needs the FULL sync surface (get_sync for partial-chunk + # read-modify-write, delete_sync for all-fill chunks), not just + # set_sync — and other byte setters (e.g. the sharding codec's + # in-memory _ShardingByteSetter) on SyncByteSetter. + from zarr.abc.store import SyncByteSetter, _store_supports_sync_io from zarr.storage._common import StorePath first_bs = batch[0][0] if self.sync_transform is not None and ( - (isinstance(first_bs, StorePath) and isinstance(first_bs.store, SupportsSetSync)) + (isinstance(first_bs, StorePath) and _store_supports_sync_io(first_bs.store)) or (not isinstance(first_bs, StorePath) and isinstance(first_bs, SyncByteSetter)) ): # One thread hop for the whole batch; see the matching comment in diff --git a/src/zarr/experimental/cache_store.py b/src/zarr/experimental/cache_store.py index dd50693ad9..20cb4d4c0f 100644 --- a/src/zarr/experimental/cache_store.py +++ b/src/zarr/experimental/cache_store.py @@ -329,6 +329,16 @@ async def _get_no_cache( await self._cache_miss(key, byte_range, result) return result + @property + def _supports_sync_io(self) -> bool: + # The caching logic lives only in the async get/set/delete overrides; + # the sync methods inherited from `WrapperStore` delegate straight to + # the source store, so a sync-capable consumer (the fused codec + # pipeline) would write and delete around the cache, leaving stale + # entries that later async reads serve as current data. Opt out of + # sync IO until the sync surface is cache-aware. + return False + async def get( self, key: str, diff --git a/src/zarr/storage/_logging.py b/src/zarr/storage/_logging.py index c6f58ccd61..cdf0731430 100644 --- a/src/zarr/storage/_logging.py +++ b/src/zarr/storage/_logging.py @@ -204,6 +204,27 @@ async def delete(self, key: str) -> None: with self.log(key): return await self._store.delete(key=key) + def get_sync( + self, + key: str, + *, + prototype: BufferPrototype | None = None, + byte_range: ByteRequest | None = None, + ) -> Buffer | None: + # docstring inherited + with self.log(key): + return super().get_sync(key, prototype=prototype, byte_range=byte_range) + + def set_sync(self, key: str, value: Buffer) -> None: + # docstring inherited + with self.log(key): + return super().set_sync(key, value) + + def delete_sync(self, key: str) -> None: + # docstring inherited + with self.log(key): + return super().delete_sync(key) + async def list(self) -> AsyncGenerator[str, None]: # docstring inherited with self.log(): diff --git a/src/zarr/storage/_wrapper.py b/src/zarr/storage/_wrapper.py index 37aeb8166f..6f498a655d 100644 --- a/src/zarr/storage/_wrapper.py +++ b/src/zarr/storage/_wrapper.py @@ -11,7 +11,13 @@ from zarr.abc.store import ByteRequest from zarr.core.buffer import BufferPrototype -from zarr.abc.store import Store +from zarr.abc.store import ( + Store, + SupportsDeleteSync, + SupportsGetSync, + SupportsSetSync, + _store_supports_sync_io, +) class WrapperStore[T_Store: Store](Store): @@ -149,6 +155,40 @@ def supports_writes(self) -> bool: def supports_deletes(self) -> bool: return self._store.supports_deletes + @property + def _supports_sync_io(self) -> bool: + # The delegating `*_sync` methods below make every wrapper structurally + # satisfy `SupportsSyncStore`; whether they can actually run depends on + # the wrapped store, so forward its capability (see + # `zarr.abc.store._store_supports_sync_io`). + return _store_supports_sync_io(self._store) + + def get_sync( + self, + key: str, + *, + prototype: BufferPrototype | None = None, + byte_range: ByteRequest | None = None, + ) -> Buffer | None: + """Forward `get_sync` to the wrapped store.""" + if not isinstance(self._store, SupportsGetSync): + raise TypeError(f"Store {type(self._store).__name__} does not support synchronous get.") + return self._store.get_sync(key, prototype=prototype, byte_range=byte_range) # type: ignore[unreachable] + + def set_sync(self, key: str, value: Buffer) -> None: + """Forward `set_sync` to the wrapped store.""" + if not isinstance(self._store, SupportsSetSync): + raise TypeError(f"Store {type(self._store).__name__} does not support synchronous set.") + self._store.set_sync(key, value) # type: ignore[unreachable] + + def delete_sync(self, key: str) -> None: + """Forward `delete_sync` to the wrapped store.""" + if not isinstance(self._store, SupportsDeleteSync): + raise TypeError( + f"Store {type(self._store).__name__} does not support synchronous delete." + ) + self._store.delete_sync(key) # type: ignore[unreachable] + async def delete(self, key: str) -> None: await self._store.delete(key) diff --git a/src/zarr/testing/store.py b/src/zarr/testing/store.py index d7011440e0..f64d8e9364 100644 --- a/src/zarr/testing/store.py +++ b/src/zarr/testing/store.py @@ -2,6 +2,7 @@ import asyncio import pickle +import time from abc import abstractmethod from typing import TYPE_CHECKING, Self @@ -10,6 +11,7 @@ from zarr.storage import WrapperStore if TYPE_CHECKING: + from collections.abc import AsyncIterator, Iterable, Sequence from typing import Any from zarr.core.buffer.core import BufferPrototype @@ -718,7 +720,10 @@ def set_latency(self) -> float: return max(0.0, np.random.normal(loc=self._set_latency[0], scale=self._set_latency[1])) def _with_store(self, store: Store) -> Self: - return type(self)(store, get_latency=self.get_latency, set_latency=self.set_latency) + # Pass the raw latency config, not the sampled `get_latency`/`set_latency` + # properties — sampling would freeze a `(loc, scale)` distribution into + # one fixed float on derived stores (e.g. via `with_read_only`). + return type(self)(store, get_latency=self._get_latency, set_latency=self._set_latency) async def set(self, key: str, value: Buffer) -> None: """ @@ -763,3 +768,76 @@ async def get( """ await asyncio.sleep(self.get_latency) return await self._store.get(key, prototype=prototype, byte_range=byte_range) + + def get_sync( + self, + key: str, + *, + prototype: BufferPrototype | None = None, + byte_range: ByteRequest | None = None, + ) -> Buffer | None: + """Add latency to `get_sync`. + + Sleeps `self.get_latency` on the calling thread (the sync path runs on + worker threads, not the event loop) before delegating to the wrapped + store. + """ + time.sleep(self.get_latency) + return super().get_sync(key, prototype=prototype, byte_range=byte_range) + + def set_sync(self, key: str, value: Buffer) -> None: + """Add latency to `set_sync`. + + Sleeps `self.set_latency` on the calling thread (the sync path runs on + worker threads, not the event loop) before delegating to the wrapped + store. + """ + time.sleep(self.set_latency) + super().set_sync(key, value) + + async def get_ranges( + self, + key: str, + byte_ranges: Sequence[ByteRequest | None], + *, + prototype: BufferPrototype, + max_concurrency: int | None = None, + max_gap_bytes: int | None = None, + max_coalesced_bytes: int | None = None, + ) -> AsyncIterator[Sequence[tuple[int, Buffer | None]]]: + """Byte-range reads built on `self.get`, so each fetch pays latency. + + Routes through the coalescing `Store.get_ranges` default instead of the + `WrapperStore` delegation, which would bypass this wrapper's `get` and + therefore the synthetic latency. `None` for a coalescing kwarg means + "use the `Store` default". + """ + kwargs: dict[str, int] = {} + if max_concurrency is not None: + kwargs["max_concurrency"] = max_concurrency + if max_gap_bytes is not None: + kwargs["max_gap_bytes"] = max_gap_bytes + if max_coalesced_bytes is not None: + kwargs["max_coalesced_bytes"] = max_coalesced_bytes + async for group in Store.get_ranges(self, key, byte_ranges, prototype=prototype, **kwargs): + yield group + + async def get_partial_values( + self, + prototype: BufferPrototype, + key_ranges: Iterable[tuple[str, ByteRequest | None]], + ) -> list[Buffer | None]: + """Partial-value reads built on `self.get`, so each fetch pays latency. + + Issues one `self.get` per `(key, byte_range)` pair instead of the + `WrapperStore` delegation, which would bypass this wrapper's `get` and + therefore the synthetic latency. + """ + return list( + await asyncio.gather( + *( + self.get(key, prototype=prototype, byte_range=byte_range) + for key, byte_range in key_ranges + ) + ) + ) diff --git a/tests/test_codec_pipeline_suite.py b/tests/test_codec_pipeline_suite.py index 07e1aa2ec4..f0376d185a 100644 --- a/tests/test_codec_pipeline_suite.py +++ b/tests/test_codec_pipeline_suite.py @@ -9,8 +9,8 @@ Each test also runs over a *store axis* that exercises both code paths the synchronous pipelines branch on: -* ``sync`` -> ``MemoryStore`` (supports ``get_sync``/``set_sync``: fast path) -* ``async`` -> ``LatencyStore(MemoryStore())`` (NOT sync-capable: async fallback) +* ``sync`` -> ``MemoryStore`` (full sync surface: fast path) +* ``async`` -> ``_NoSyncIOStore(MemoryStore())`` (NOT sync-capable: async fallback) The async axis is deliberate: a regression that only affects the async fallback of the default pipeline (e.g. a codec-spec-evolution bug that surfaces only on @@ -50,14 +50,22 @@ STORE_KINDS = ["sync", "async"] +class _NoSyncIOStore(LatencyStore): + """An in-memory store that advertises no sync IO capability, so a + synchronous pipeline must fall back to its async path. (A plain wrapper + won't do: `WrapperStore` forwards the wrapped store's sync capability.)""" + + @property + def _supports_sync_io(self) -> bool: + return False + + def _make_store(kind: str) -> Store: if kind == "sync": # MemoryStore supports get_sync/set_sync -> synchronous fast path. return MemoryStore() if kind == "async": - # LatencyStore is NOT SupportsGetSync/SupportsSetSync, so a synchronous - # pipeline must fall back to its async path. Zero latency keeps it fast. - return LatencyStore(MemoryStore(), get_latency=0.0, set_latency=0.0) + return _NoSyncIOStore(MemoryStore(), get_latency=0.0, set_latency=0.0) raise AssertionError(kind) diff --git a/tests/test_experimental/test_cache_store.py b/tests/test_experimental/test_cache_store.py index 5ad56a4335..f688a6ca02 100644 --- a/tests/test_experimental/test_cache_store.py +++ b/tests/test_experimental/test_cache_store.py @@ -1036,3 +1036,40 @@ async def test_delete_invalidates_cached_byte_ranges(self) -> None: # Key is gone from source result = await cached_store.get("key", proto) assert result is None + + +def test_cache_store_opts_out_of_sync_io() -> None: + """`CacheStore` must not advertise sync IO capability. + + Its caching logic lives only in the async `get`/`set`/`delete` overrides, + while the inherited `WrapperStore` sync methods delegate straight to the + source store. If the fused codec pipeline took the sync fast path, writes + and deletes would bypass the cache and later async reads would serve stale + entries. The opt-out forces sync-capable consumers onto the async path, + which keeps the cache coherent. + """ + from zarr.abc.store import _store_supports_sync_io + from zarr.storage import MemoryStore + + cached = CacheStore(MemoryStore(), cache_store=MemoryStore()) + assert _store_supports_sync_io(cached) is False + + +async def test_cache_coherent_after_fused_pipeline_write() -> None: + """Writing through the fused pipeline must not leave stale cache entries.""" + import numpy as np + + import zarr + from zarr.core.config import config as zarr_config + from zarr.storage import MemoryStore + + source = MemoryStore() + cached = CacheStore(source, cache_store=MemoryStore()) + with zarr_config.set({"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"}): + arr = zarr.create_array(cached, shape=(8,), chunks=(8,), dtype="int32", fill_value=0) + arr[:] = np.arange(8, dtype="int32") + np.testing.assert_array_equal(arr[:], np.arange(8)) + # Overwrite, then read back through the same cached handle: the read + # must observe the overwrite, not a cached copy of the first write. + arr[:] = np.arange(100, 108, dtype="int32") + np.testing.assert_array_equal(arr[:], np.arange(100, 108)) diff --git a/tests/test_fused_pipeline.py b/tests/test_fused_pipeline.py index 7fa3ef2277..5c712fa97a 100644 --- a/tests/test_fused_pipeline.py +++ b/tests/test_fused_pipeline.py @@ -16,6 +16,7 @@ ArrayBytesCodecPartialEncodeMixin, BytesBytesCodec, ) +from zarr.abc.store import Store, _store_supports_sync_io from zarr.codecs.bytes import BytesCodec from zarr.codecs.gzip import GzipCodec from zarr.codecs.transpose import TransposeCodec @@ -23,9 +24,13 @@ from zarr.core.codec_pipeline import FusedCodecPipeline from zarr.core.config import config as zarr_config from zarr.registry import register_codec -from zarr.storage import MemoryStore, StorePath +from zarr.storage import MemoryStore, StorePath, WrapperStore +from zarr.storage._utils import _normalize_byte_range_index +from zarr.testing.store import LatencyStore if TYPE_CHECKING: + from collections.abc import AsyncIterator, Callable, Iterable + from zarr.abc.store import ByteRequest from zarr.core.array_spec import ArraySpec from zarr.core.buffer import Buffer, BufferPrototype, NDBuffer @@ -1065,3 +1070,166 @@ def test_partial_mixin_codec_async_partial_only_round_trip(dtype: str) -> None: with zarr_config.set(_BATCHED): np.testing.assert_array_equal(zarr.open_array(store, mode="r")[:], expected) + + +# Sync-IO capability gating (`zarr.abc.store._store_supports_sync_io`) +# +# The fused read/write fast paths must engage iff the store advertises the +# FULL synchronous IO surface (get_sync + set_sync + delete_sync): write_sync +# needs get_sync for partial-chunk read-modify-write and delete_sync for +# all-fill chunk cleanup, so gating on any single protocol can crash +# mid-batch. Wrappers must forward the capability of the wrapped store. +# --------------------------------------------------------------------------- + + +class AsyncOnlyStore(Store): + """Dict-backed store implementing only the async `Store` surface (no `*_sync`).""" + + def __init__(self) -> None: + super().__init__(read_only=False) + self._data: dict[str, Buffer] = {} + + def __eq__(self, other: object) -> bool: + return other is self + + @property + def supports_writes(self) -> bool: + return True + + @property + def supports_deletes(self) -> bool: + return True + + @property + def supports_listing(self) -> bool: + return True + + async def get( + self, key: str, prototype: BufferPrototype, byte_range: ByteRequest | None = None + ) -> Buffer | None: + try: + value = self._data[key] + except KeyError: + return None + start, stop = _normalize_byte_range_index(value, byte_range) + return prototype.buffer.from_buffer(value[start:stop]) + + async def get_partial_values( + self, + prototype: BufferPrototype, + key_ranges: Iterable[tuple[str, ByteRequest | None]], + ) -> list[Buffer | None]: + return [await self.get(key, prototype, byte_range) for key, byte_range in key_ranges] + + async def exists(self, key: str) -> bool: + return key in self._data + + async def set(self, key: str, value: Buffer) -> None: + self._check_writable() + self._data[key] = value + + async def delete(self, key: str) -> None: + self._check_writable() + self._data.pop(key, None) + + async def list(self) -> AsyncIterator[str]: + for key in list(self._data): + yield key + + async def list_prefix(self, prefix: str) -> AsyncIterator[str]: + for key in list(self._data): + if key.startswith(prefix): + yield key + + async def list_dir(self, prefix: str) -> AsyncIterator[str]: + if prefix and not prefix.endswith("/"): + prefix += "/" + seen: set[str] = set() + for key in list(self._data): + if key.startswith(prefix): + head = key.removeprefix(prefix).split("/")[0] + if head not in seen: + seen.add(head) + yield head + + +class SetOnlySyncStore(AsyncOnlyStore): + """Implements `set_sync` but not `get_sync`/`delete_sync` (partial sync surface).""" + + def set_sync(self, key: str, value: Buffer) -> None: + self._check_writable() + self._data[key] = value + + +@pytest.mark.parametrize( + ("store_factory", "expect_sync_path"), + [ + (MemoryStore, True), + (lambda: WrapperStore(MemoryStore()), True), + (lambda: LatencyStore(MemoryStore()), True), + (SetOnlySyncStore, False), + (lambda: WrapperStore(AsyncOnlyStore()), False), + ], + ids=[ + "full-sync", + "wrapper-of-sync", + "latency-wrapper-of-sync", + "set-sync-only", + "wrapper-of-async-only", + ], +) +def test_sync_io_capability_gates_fused_paths( + store_factory: Callable[[], Store], expect_sync_path: bool +) -> None: + """The fused pipeline takes the sync fast path iff the store satisfies + `_store_supports_sync_io`, + and every store round-trips correctly through full writes, partial + (read-modify-write) writes, and all-fill (delete) writes — a store with a + partial sync surface must get a clean async fallback, never a mid-batch + error.""" + from unittest.mock import patch + + store = store_factory() + assert _store_supports_sync_io(store) is expect_sync_path + + calls = {"read_sync": 0, "write_sync": 0} + orig_read_sync = FusedCodecPipeline.read_sync + orig_write_sync = FusedCodecPipeline.write_sync + + def spy_read_sync(self: FusedCodecPipeline, *args: Any, **kwargs: Any) -> Any: + calls["read_sync"] += 1 + return orig_read_sync(self, *args, **kwargs) + + def spy_write_sync(self: FusedCodecPipeline, *args: Any, **kwargs: Any) -> Any: + calls["write_sync"] += 1 + return orig_write_sync(self, *args, **kwargs) + + with zarr_config.set({"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"}): + arr = zarr.create_array( + store=store, + shape=(8,), + chunks=(4,), + dtype="uint8", + compressors=None, + fill_value=0, + ) + assert isinstance(arr._async_array.codec_pipeline, FusedCodecPipeline) + with ( + patch.object(FusedCodecPipeline, "read_sync", spy_read_sync), + patch.object(FusedCodecPipeline, "write_sync", spy_write_sync), + ): + data = np.arange(8, dtype="uint8") + arr[:] = data # complete-chunk writes + arr[:3] = 7 # partial write -> read-modify-write needs get + data[:3] = 7 + np.testing.assert_array_equal(arr[:], data) + arr[4:8] = 0 # all-fill chunk -> delete needed + data[4:8] = 0 + np.testing.assert_array_equal(arr[:], data) + + if expect_sync_path: + assert calls["write_sync"] > 0, "sync-capable store did not take the sync write path" + assert calls["read_sync"] > 0, "sync-capable store did not take the sync read path" + else: + assert calls["write_sync"] == 0, "non-sync store took the sync write path" + assert calls["read_sync"] == 0, "non-sync store took the sync read path" diff --git a/tests/test_store/test_get_ranges.py b/tests/test_store/test_get_ranges.py index f04251adf4..522d6565aa 100644 --- a/tests/test_store/test_get_ranges.py +++ b/tests/test_store/test_get_ranges.py @@ -16,12 +16,12 @@ from zarr.abc.store import RangeByteRequest from zarr.core.buffer import default_buffer_prototype -from zarr.storage import MemoryStore +from zarr.storage import MemoryStore, ZipStore from zarr.storage._wrapper import WrapperStore -from zarr.testing.store import LatencyStore if TYPE_CHECKING: from collections.abc import AsyncIterator, Sequence + from pathlib import Path from zarr.abc.store import ByteRequest from zarr.core.buffer import Buffer, BufferPrototype @@ -89,11 +89,11 @@ def test_get_ranges_sync_missing_key_raises() -> None: store.get_ranges_sync("does-not-exist", [RangeByteRequest(0, 10)], prototype=proto) -def test_get_ranges_sync_on_non_sync_store_raises_type_error() -> None: +def test_get_ranges_sync_on_non_sync_store_raises_type_error(tmp_path: Path) -> None: """`get_ranges_sync` requires the store to support synchronous reads (`SupportsGetSync`); a non-sync store raises TypeError rather than silently falling back.""" - store = LatencyStore(MemoryStore(), get_latency=0.0, set_latency=0.0) + store = ZipStore(tmp_path / "store.zip", mode="w") proto = default_buffer_prototype() with pytest.raises(TypeError, match="does not support synchronous reads"): store.get_ranges_sync("k", [RangeByteRequest(0, 10)], prototype=proto) diff --git a/tests/test_store/test_latency.py b/tests/test_store/test_latency.py index 38ffb17dd6..9cb71fd6a0 100644 --- a/tests/test_store/test_latency.py +++ b/tests/test_store/test_latency.py @@ -1,8 +1,16 @@ from __future__ import annotations +import time +from unittest.mock import patch + +import numpy as np import pytest +import zarr +from zarr.abc.store import RangeByteRequest from zarr.core.buffer import default_buffer_prototype +from zarr.core.codec_pipeline import FusedCodecPipeline +from zarr.core.config import config as zarr_config from zarr.storage import MemoryStore from zarr.testing.store import LatencyStore @@ -55,3 +63,103 @@ async def test_latency_store_with_read_only_round_trip() -> None: # The original read-only wrapper remains read-only assert latency_ro.read_only + + +@pytest.mark.parametrize( + ("get_latency", "set_latency"), + [ + (0.01, 0.02), + ((0.1, 0.05), (0.2, 0.01)), + ], + ids=["scalar", "distribution"], +) +def test_with_store_preserves_latency_config( + get_latency: float | tuple[float, float], set_latency: float | tuple[float, float] +) -> None: + """Derived stores (e.g. via `with_read_only`) keep the raw latency config — + a `(loc, scale)` distribution must not collapse to one sampled float.""" + store = LatencyStore(MemoryStore(), get_latency=get_latency, set_latency=set_latency) + derived = store.with_read_only(True) + assert derived._get_latency == store._get_latency + assert derived._set_latency == store._set_latency + + +def test_sync_methods_inject_latency(monkeypatch: pytest.MonkeyPatch) -> None: + """`get_sync`/`set_sync` sleep the configured latency on the calling thread + before delegating to the wrapped store.""" + sleeps: list[float] = [] + monkeypatch.setattr(time, "sleep", sleeps.append) + + store = LatencyStore(MemoryStore(), get_latency=0.123, set_latency=0.456) + buf = default_buffer_prototype().buffer.from_bytes(b"abcd") + store.set_sync("key", buf) + assert sleeps == [pytest.approx(0.456)] + out = store.get_sync("key", prototype=default_buffer_prototype()) + assert out is not None + assert out.to_bytes() == b"abcd" + assert sleeps == [pytest.approx(0.456), pytest.approx(0.123)] + + +async def test_get_ranges_pays_latency_per_fetch() -> None: + """`get_ranges` routes through the coalescing default built on `self.get`, + so each merged fetch pays the configured latency instead of bypassing it + via WrapperStore delegation. Two ranges further apart than `max_gap_bytes` + cannot coalesce -> exactly two `get` calls.""" + proto = default_buffer_prototype() + inner = MemoryStore() + await inner.set("blob", proto.buffer.from_bytes(bytes(4 << 20))) + store = LatencyStore(inner, get_latency=0.0) + + requests = [RangeByteRequest(0, 10), RangeByteRequest(2 << 20, (2 << 20) + 10)] + results: list[tuple[int, object]] = [] + with patch.object(store, "get", wraps=store.get) as get_spy: + async for group in store.get_ranges("blob", requests, prototype=proto): + results.extend(group) + assert get_spy.await_count == 2 + assert sorted(idx for idx, _ in results) == [0, 1] + for _, buf in results: + assert buf is not None + assert len(buf) == 10 # type: ignore[arg-type] + + +async def test_get_partial_values_routes_through_get() -> None: + """`get_partial_values` issues one `self.get` per key-range so each fetch + pays the configured latency instead of bypassing it via WrapperStore + delegation.""" + proto = default_buffer_prototype() + inner = MemoryStore() + await inner.set("blob", proto.buffer.from_bytes(b"0123456789")) + store = LatencyStore(inner, get_latency=0.0) + + with patch.object(store, "get", wraps=store.get) as get_spy: + results = await store.get_partial_values( + proto, [("blob", RangeByteRequest(0, 4)), ("blob", None)] + ) + assert get_spy.await_count == 2 + assert results[0] is not None + assert results[0].to_bytes() == b"0123" + assert results[1] is not None + assert results[1].to_bytes() == b"0123456789" + + +def test_latency_store_engages_fused_sync_path() -> None: + """A LatencyStore wrapping a sync-capable store must take the fused sync + fast path: reads go through the inner store's `get_sync`, not the async + fallback.""" + inner = MemoryStore() + store = LatencyStore(inner, get_latency=0.0, set_latency=0.0) + with zarr_config.set({"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"}): + arr = zarr.create_array( + store=store, + shape=(8,), + chunks=(4,), + dtype="uint8", + compressors=None, + fill_value=0, + ) + assert isinstance(arr._async_array.codec_pipeline, FusedCodecPipeline) + data = np.arange(8, dtype="uint8") + arr[:] = data + with patch.object(inner, "get_sync", wraps=inner.get_sync) as get_sync_spy: + np.testing.assert_array_equal(arr[:], data) + assert get_sync_spy.call_count == 2 # one per chunk diff --git a/tests/test_store/test_wrapper.py b/tests/test_store/test_wrapper.py index b34a63d5d0..e556a108c5 100644 --- a/tests/test_store/test_wrapper.py +++ b/tests/test_store/test_wrapper.py @@ -4,12 +4,12 @@ import pytest -from zarr.abc.store import ByteRequest, Store +from zarr.abc.store import ByteRequest, Store, _store_supports_sync_io from zarr.core.buffer import Buffer from zarr.core.buffer.cpu import Buffer as CPUBuffer from zarr.core.buffer.cpu import buffer_prototype -from zarr.storage import LocalStore, WrapperStore -from zarr.testing.store import StoreTests +from zarr.storage import LocalStore, MemoryStore, WrapperStore, ZipStore +from zarr.testing.store import LatencyStore, StoreTests if TYPE_CHECKING: from pathlib import Path @@ -123,3 +123,47 @@ async def get( await store_wrapped.get(key, buffer_prototype) captured = capsys.readouterr() assert f"getting {key}" in captured.out + + +@pytest.mark.parametrize( + ("store_factory", "expected"), + [ + (lambda tmp: MemoryStore(), True), + (lambda tmp: LocalStore(str(tmp)), True), + (lambda tmp: WrapperStore(MemoryStore()), True), + (lambda tmp: LatencyStore(MemoryStore()), True), + (lambda tmp: ZipStore(tmp / "store.zip", mode="w"), False), + (lambda tmp: WrapperStore(ZipStore(tmp / "store.zip", mode="w")), False), + ], + ids=[ + "memory", + "local", + "wrapper-of-memory", + "latency-wrapper-of-memory", + "zip", + "wrapper-of-zip", + ], +) +def test_supports_sync_io(store_factory: Any, expected: bool, tmp_path: Path | Any) -> None: + """`_store_supports_sync_io` is True only for stores implementing the full + sync surface (get_sync + set_sync + delete_sync); wrappers forward the + wrapped store's capability via `_supports_sync_io`.""" + assert _store_supports_sync_io(store_factory(tmp_path)) is expected + + +def test_wrapper_get_sync_without_inner_sync_raises(tmp_path: Any) -> None: + store = WrapperStore(ZipStore(tmp_path / "store.zip", mode="w")) + with pytest.raises(TypeError, match="does not support synchronous get"): + store.get_sync("key") + + +def test_wrapper_set_sync_without_inner_sync_raises(tmp_path: Any) -> None: + store = WrapperStore(ZipStore(tmp_path / "store.zip", mode="w")) + with pytest.raises(TypeError, match="does not support synchronous set"): + store.set_sync("key", CPUBuffer.from_bytes(b"data")) + + +def test_wrapper_delete_sync_without_inner_sync_raises(tmp_path: Any) -> None: + store = WrapperStore(ZipStore(tmp_path / "store.zip", mode="w")) + with pytest.raises(TypeError, match="does not support synchronous delete"): + store.delete_sync("key") From 53e6dc66b834988912194985a1051ad4db4f0141 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Thu, 30 Jul 2026 18:32:00 +0200 Subject: [PATCH 21/32] docs: dev blog, performance examples, and compiled 3.3.0 release notes (#4191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a development blog to the documentation site with a 3.3.0 release post covering the FusedCodecPipeline and sharded partial-read coalescing, plus two runnable examples referenced by the post (examples/codec_pipeline_performance, examples/sharding_coalescing). Compiles every pending changelog fragment into a single 3.3.0 release notes section dated 2026-07-30, merging it with the section compiled earlier in #4148, and empties changes/ — making this commit suitable to tag as v3.3.0. Assisted-by: ClaudeCode:claude-fable-5 --- changes/3352.bugfix.md | 3 - changes/4128.feature.md | 1 - changes/4157.bugfix.md | 11 - changes/4172.misc.md | 7 - changes/4179.bugfix.md | 1 - changes/4183.bugfix.md | 3 - changes/4187.feature.md | 4 - changes/4194.bugfix.md | 10 - changes/4199.bugfix.md | 1 - changes/4201.bugfix.md | 1 - changes/4202.bugfix.md | 10 - changes/4203.bugfix.md | 1 - changes/4204.bugfix.md | 16 -- changes/4205.bugfix.md | 9 - changes/4206.bugfix.md | 1 - changes/4219.bugfix.md | 3 - docs/blog/.authors.yml | 6 + docs/blog/index.md | 3 + docs/blog/posts/3.3.0-release.md | 169 +++++++++++++ docs/release-notes.md | 134 +++++++++-- .../examples/codec_pipeline_performance.md | 7 + .../examples/sharding_coalescing.md | 7 + examples/codec_pipeline_performance/README.md | 59 +++++ .../codec_pipeline_performance.py | 214 +++++++++++++++++ examples/sharding_coalescing/README.md | 63 +++++ .../sharding_coalescing.py | 226 ++++++++++++++++++ mkdocs.yml | 12 + 27 files changed, 876 insertions(+), 106 deletions(-) delete mode 100644 changes/3352.bugfix.md delete mode 100644 changes/4128.feature.md delete mode 100644 changes/4157.bugfix.md delete mode 100644 changes/4172.misc.md delete mode 100644 changes/4179.bugfix.md delete mode 100644 changes/4183.bugfix.md delete mode 100644 changes/4187.feature.md delete mode 100644 changes/4194.bugfix.md delete mode 100644 changes/4199.bugfix.md delete mode 100644 changes/4201.bugfix.md delete mode 100644 changes/4202.bugfix.md delete mode 100644 changes/4203.bugfix.md delete mode 100644 changes/4204.bugfix.md delete mode 100644 changes/4205.bugfix.md delete mode 100644 changes/4206.bugfix.md delete mode 100644 changes/4219.bugfix.md create mode 100644 docs/blog/.authors.yml create mode 100644 docs/blog/index.md create mode 100644 docs/blog/posts/3.3.0-release.md create mode 100644 docs/user-guide/examples/codec_pipeline_performance.md create mode 100644 docs/user-guide/examples/sharding_coalescing.md create mode 100644 examples/codec_pipeline_performance/README.md create mode 100644 examples/codec_pipeline_performance/codec_pipeline_performance.py create mode 100644 examples/sharding_coalescing/README.md create mode 100644 examples/sharding_coalescing/sharding_coalescing.py diff --git a/changes/3352.bugfix.md b/changes/3352.bugfix.md deleted file mode 100644 index 7461486776..0000000000 --- a/changes/3352.bugfix.md +++ /dev/null @@ -1,3 +0,0 @@ -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. diff --git a/changes/4128.feature.md b/changes/4128.feature.md deleted file mode 100644 index c62a615ac2..0000000000 --- a/changes/4128.feature.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/changes/4157.bugfix.md b/changes/4157.bugfix.md deleted file mode 100644 index 6b0d0fcc67..0000000000 --- a/changes/4157.bugfix.md +++ /dev/null @@ -1,11 +0,0 @@ -`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. diff --git a/changes/4172.misc.md b/changes/4172.misc.md deleted file mode 100644 index 0be7226476..0000000000 --- a/changes/4172.misc.md +++ /dev/null @@ -1,7 +0,0 @@ -Improved `CoordinateIndexer` construction for large, sorted, in-bounds, one-dimensional integer -coordinate selections over regular chunk grids (e.g. `arr.get_coordinate_selection(sorted_idx)`, -`arr.vindex[sorted_idx]`, and the gather behind sparse/CSR row selections). When boundary searching -is estimated to be cheaper than processing every coordinate, per-chunk projections are now built -with `searchsorted`, making index construction ~15x faster for large gathers. Sparse sorted -selections spanning many chunks relative to their coordinate count, as well as unsorted, negative, -multi-dimensional, and irregular-grid selections, continue to use the existing implementation. diff --git a/changes/4179.bugfix.md b/changes/4179.bugfix.md deleted file mode 100644 index e02523114c..0000000000 --- a/changes/4179.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/changes/4183.bugfix.md b/changes/4183.bugfix.md deleted file mode 100644 index 809708f596..0000000000 --- a/changes/4183.bugfix.md +++ /dev/null @@ -1,3 +0,0 @@ -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. diff --git a/changes/4187.feature.md b/changes/4187.feature.md deleted file mode 100644 index 87133e2034..0000000000 --- a/changes/4187.feature.md +++ /dev/null @@ -1,4 +0,0 @@ -`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. diff --git a/changes/4194.bugfix.md b/changes/4194.bugfix.md deleted file mode 100644 index 21a4924664..0000000000 --- a/changes/4194.bugfix.md +++ /dev/null @@ -1,10 +0,0 @@ -`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 zstd data are ~4.5x faster than before -and now scale with reader threads; single-threaded performance is unchanged. diff --git a/changes/4199.bugfix.md b/changes/4199.bugfix.md deleted file mode 100644 index d0c522cd7e..0000000000 --- a/changes/4199.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/changes/4201.bugfix.md b/changes/4201.bugfix.md deleted file mode 100644 index d837a8a9e2..0000000000 --- a/changes/4201.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/changes/4202.bugfix.md b/changes/4202.bugfix.md deleted file mode 100644 index 6130fc5b33..0000000000 --- a/changes/4202.bugfix.md +++ /dev/null @@ -1,10 +0,0 @@ -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. diff --git a/changes/4203.bugfix.md b/changes/4203.bugfix.md deleted file mode 100644 index 42ca977193..0000000000 --- a/changes/4203.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/changes/4204.bugfix.md b/changes/4204.bugfix.md deleted file mode 100644 index 90101d1059..0000000000 --- a/changes/4204.bugfix.md +++ /dev/null @@ -1,16 +0,0 @@ -`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. diff --git a/changes/4205.bugfix.md b/changes/4205.bugfix.md deleted file mode 100644 index 0492febb7d..0000000000 --- a/changes/4205.bugfix.md +++ /dev/null @@ -1,9 +0,0 @@ -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. diff --git a/changes/4206.bugfix.md b/changes/4206.bugfix.md deleted file mode 100644 index a01c969449..0000000000 --- a/changes/4206.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -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. diff --git a/changes/4219.bugfix.md b/changes/4219.bugfix.md deleted file mode 100644 index 728e8a7e3a..0000000000 --- a/changes/4219.bugfix.md +++ /dev/null @@ -1,3 +0,0 @@ -`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. diff --git a/docs/blog/.authors.yml b/docs/blog/.authors.yml new file mode 100644 index 0000000000..10ce423cfc --- /dev/null +++ b/docs/blog/.authors.yml @@ -0,0 +1,6 @@ +authors: + d-v-b: + name: Davis Bennett + description: Core developer + avatar: https://github.com/d-v-b.png + url: https://github.com/d-v-b diff --git a/docs/blog/index.md b/docs/blog/index.md new file mode 100644 index 0000000000..fca29e2578 --- /dev/null +++ b/docs/blog/index.md @@ -0,0 +1,3 @@ +# Blog + +News, release highlights, and design notes from the Zarr-Python developers. diff --git a/docs/blog/posts/3.3.0-release.md b/docs/blog/posts/3.3.0-release.md new file mode 100644 index 0000000000..13368848ae --- /dev/null +++ b/docs/blog/posts/3.3.0-release.md @@ -0,0 +1,169 @@ +--- +date: 2026-07-30 +authors: + - d-v-b +categories: + - Release +--- + +# Zarr-Python 3.3.0 + +We're happy to announce the release of version 3.3.0 of Zarr-Python. It's been a while since our last release ([3.2.1](https://github.com/zarr-developers/zarr-python/releases/tag/v3.2.1) dropped in May of this year), +and we're bringing some exciting additions to the latest version. For the full release notes, see the [3.3.0 release notes](../../release-notes.md), otherwise stick around for an overview of two performance-centric highlights of this release. + + + +## Faster low-latency storage + +Relevant issues and pull requests: + +- [#3524](https://github.com/zarr-developers/zarr-python/issues/3524) -- the performance report that started this work +- [#3885](https://github.com/zarr-developers/zarr-python/pull/3885) -- synchronous codec APIs and the `FusedCodecPipeline` + +### The cost of async overhead + +Zarr-Python 3.x uses async routines for fetching data and decoding chunks. In terms of code, this means our store (data fetching) and codec (chunk decoding) APIs are both async. This makes +I/O against high-latency storage backends like cloud object storage efficient. But for *low-latency* storage, like in-process memory or the file system, async routines add measurable overhead and offer no benefit. Async only adds value when there's work to be done while waiting for I/O to complete, but when I/O latency is low, it completes too quickly to run anything while waiting, and we are left paying the performance bill for obligatory async task scheduling that offered no value. + +This performance problem became acute when Zarr-Python users reported that in-memory array indexing workloads ran *slower* in Zarr-Python 3.1.3 relative to Zarr-Python 2.18.7 ([#3524](https://github.com/zarr-developers/zarr-python/issues/3524)). Fortunately this performance regression had a straightforward fix (I don't say "easy" because it was a lot of work). + +### Synchronous execution restores performance + +If async overhead makes low-latency storage slow, does *removing* that overhead restore performance? Yes, it does! + +In [#3885](https://github.com/zarr-developers/zarr-python/pull/3885) we defined synchronous versions of our storage and codec APIs -- the `SyncByteGetter` and `SyncByteSetter` protocols, plus a `get_ranges_sync` method on the `Store` ABC -- and then combined them in a new codec orchestration class called `FusedCodecPipeline`. The `FusedCodecPipeline` is an opt-in alternative to the default (the `BatchedCodecPipeline`) that gives large speedups for low-latency storage. It is currently marked [experimental](../../user-guide/experimental.md), so we may change it as we learn more; the default pipeline is untouched, and existing code keeps working unless you opt in. + +The win here is *not* a faster compressor. It is the removal of async scheduling overhead (including some [nasty `asyncio.to_thread` overhead](https://github.com/python/cpython/issues/136084)), plus a few vectorized fast paths for dense, uncompressed shards. And we only expect this new pipeline to accelerate workloads targeting a subset of storage backends, namely any store with methods that advertise low latency. + +On this author's 10-core Apple M4 laptop, the `FusedCodecPipeline` delivers the following results against memory-backed arrays: + +- uncompressed writes are *~4 times faster* +- uncompressed reads are *~5 times faster* +- compressed writes are *~2 times faster* +- compressed reads are *~2 times faster* + +These numbers came from a [runnable example](../../user-guide/examples/codec_pipeline_performance.md) that ships with the documentation. Run it yourself to get a sense of how the `FusedCodecPipeline` behaves on your system -- when and how you use it depends on your hardware, your array layout, and how your chunks are compressed. What's certain is that for in-memory arrays, and arrays saved to the local file system, the `FusedCodecPipeline` is worth a try. + +Getting good numbers requires choosing the right level of thread-based parallelism for your workload, which is part of the configuration of the `FusedCodecPipeline`. For uncompressed chunks there's no CPU-bound work to do after fetching a chunk and so +thread-based parallelism is worse than useless and slows things down. But for compressed chunks, threading offers a substantial payoff. + +### How to use it + +Select the pipeline through the [runtime configuration](../../user-guide/config.md) by setting `codec_pipeline.path`. Set it globally to affect every array created or opened afterwards: + +```python exec="true" session="blog-330" source="above" +import zarr + +zarr.config.set( + {"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"} +) +``` + +Or scope it to a block of code by using `zarr.config.set` as a context manager, which is the safer choice if you only want the new pipeline for part of your program: + +```python exec="true" session="blog-330" source="above" result="ansi" +import numpy as np +import zarr +from zarr.storage import MemoryStore + +with zarr.config.set( + {"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"} +): + 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) +``` + +Thread-based parallelism is configured separately, via `codec_pipeline.max_workers`. It defaults to `None`, meaning a pool sized to `os.cpu_count()`. Note that this setting is read *only* by the `FusedCodecPipeline` -- the default `BatchedCodecPipeline` ignores it, so tuning it without opting in above does nothing. + +As noted, memory-backed and uncompressed workloads often do better with a single worker, which runs everything inline on the calling thread: + +```python exec="true" session="blog-330" source="above" +import zarr + +# No thread pool: run codec compute inline. Often best for uncompressed, +# memory-backed arrays, where there's no CPU-bound work to overlap. +zarr.config.set({"codec_pipeline.max_workers": 1}) + +# A fixed-size thread pool, which pays off once compression is in play. +zarr.config.set({"codec_pipeline.max_workers": 8}) + +# Or back to the default, sized to the number of CPUs. +zarr.config.set({"codec_pipeline.max_workers": None}) +``` + +To return to the default pipeline, set `codec_pipeline.path` back to the batched implementation: + +```python exec="true" session="blog-330" source="above" +import zarr + +zarr.config.set( + {"codec_pipeline.path": "zarr.core.codec_pipeline.BatchedCodecPipeline"} +) +``` + +## Faster sharded reads + +Relevant issues and pull requests: + +- [#3004](https://github.com/zarr-developers/zarr-python/pull/3004) -- optimize partial shard reads +- [#3925](https://github.com/zarr-developers/zarr-python/pull/3925) -- `Store.get_ranges` for concurrent, coalesced multi-range reads +- [#3987](https://github.com/zarr-developers/zarr-python/pull/3987) -- control coalescing through `ArrayConfig` and the runtime config + +### How sharding works + +Chunks encoded with the `sharding_indexed` codec contain a secondary level of chunking, called subchunks. For example, if the `chunk_grid` field of the array metadata declares an "outer chunk" size of, say `(10, 10)`, a `sharding_indexed` codec in the `codecs` field could declare an "inner chunk" size of `(5, 5)`. Readers accessing such a chunk will observe a stored object (a stream of bytes) that decodes to an array with size `(10, 10)` (the "outer chunk"), which is comprised of four separate, contiguous byte ranges that each decode to a `(5, 5)` inner chunk. Each inner chunk occupies its own byte range in the outer chunk. + +A reader can satisfy a request for all four inner chunks by issuing four separate byte-range requests, or by making a *single* request for a byte range that spans all four inner chunks. The latter option is nice because it cuts down on the number of requests we need. Historically Zarr-Python used this optimization when reading entire outer chunks; in 3.3.0, we use this optimization in more cases, resulting in more efficient I/O patterns for sharded reads. + +### Interval equivalence + +Byte ranges, being intervals, obey some combination rules: the values in two half-open intervals `[a, b), [b, c)` can be captured by the single interval `[a, c)`. That means a reader can get multiple inner chunks with *one* byte-range request by requesting a range of bytes starting with the first byte of the first subchunk and ending with the last byte of the last subchunk. When individual requests are expensive, this kind of optimization is worth a lot. + +The requested inner chunks are not necessarily contiguous -- there might be a byte range gap between them. As long as that gap is not too big, its often efficient to fetch the entire byte range, gap included, and pick out the inner chunk byte ranges after I/O is done. + +### Byte range coalescing + +We call this procedure -- merging adjacent byte ranges -- "byte range coalescing", and it's a new performance optimization shipping in Zarr-Python 3.3.0. Unlike the `FusedCodecPipeline`, this one is on by default with base settings we think are good, so most users won't need to tune anything. + +Two knobs control it, both documented in the [runtime configuration guide](../../user-guide/config.md). Nearby byte ranges in the same shard are merged into a single request when the gap between them is no larger than `array.sharding_coalesce_max_gap_bytes` (default 1 MiB) and the merged read stays within `array.sharding_coalesce_max_bytes` (default 16 MiB). The gap threshold is what trades wasted bytes against saved requests: raising it reads more data you didn't ask for, in exchange for fewer requests. + +For a runnable demonstration -- counting the store requests saved and timing them against a store with simulated latency -- see the [sharded read coalescing example](../../user-guide/examples/sharding_coalescing.md). + +You can set them globally, or per array by passing `config={...}` to [`zarr.create_array`][]: + +```python exec="true" session="blog-330" source="above" result="ansi" +import zarr +from zarr.storage import MemoryStore + +arr = zarr.create_array( + store=MemoryStore(), + shape=(1000, 1000), + chunks=(100, 100), + shards=(1000, 1000), + dtype="float32", + config={ + "sharding_coalesce_max_gap_bytes": 4 * 1024**2, # 4 MiB + "sharding_coalesce_max_bytes": 64 * 1024**2, # 64 MiB + }, +) +print(arr.shape) +``` + +## Tell us what you think + +We hope these new features are helpful, and we would appreciate any feedback that helps us improve them, or any other aspect of Zarr-Python. + +## Going faster + +The updates in this release are just the first step of a larger performance-oriented direction for Zarr-Python. Landing these two enhancements taught us a *lot* about the performance-sensitive areas of the library. We can and will invest more time in performance tuning, e.g. by adding or changing abstractions, writing code for special cases, etc. + +We plan to consider including compiled code that should enable significant performance improvements. The [`zarrs`](https://zarrs.dev/) project is an ecosystem of Zarr tools written in Rust, with [extremely high performance](https://book.zarrs.dev/#-zarrs-is-fast-). Is there a `zarrs` binding in Zarr-Python's future? I hope so! We are keenly observing development of [`zarrista`](https://developmentseed.org/zarrista/latest/) as a proof-of-concept for what a Python-`zarrs` binding layer might look like. Stay tuned! diff --git a/docs/release-notes.md b/docs/release-notes.md index 7b147a30bd..3b54ea993a 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -4,7 +4,7 @@ -## 3.3.0 (2026-07-15) +## 3.3.0 (2026-07-30) ### Features @@ -14,13 +14,19 @@ 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 (up to ~24x writes / ~14x reads on many-chunks-per-shard layouts, more with compression) and no regressions on compute-bound workloads. 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)) +- 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. ([#202](https://github.com/zarr-developers/zarr-python/issues/202)) +- 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)) @@ -46,25 +52,11 @@ - 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.from_url()` and `from_mapper()` now close the async filesystem - they create when `store.close()` is called. Previously the underlying aiohttp - `ClientSession` was left open until garbage collection, producing - `"Unclosed client session"` `ResourceWarning`s from aiohttp. - - The fix introduces `FsspecStore._owns_fs`, a boolean that is ``True`` only when - `FsspecStore` itself created the filesystem (via `from_url` or `from_mapper` - when a sync→async conversion was performed). When `_owns_fs` is ``True``, - `store.close()` calls the new `_close_fs()` helper, which invokes - `fs.set_session()` and closes the returned client. Callers who supply their own - filesystem instance to `FsspecStore()` directly remain responsible for its - lifecycle; `_owns_fs` is ``False`` for those stores. - - **Scope note**: This fix closes the S3 client session that is active at the time - `store.close()` is called. Some S3-backed filesystem implementations (e.g. - s3fs with ``cache_regions=True``) may internally refresh and replace their - client during I/O operations, abandoning prior sessions before ``store.close()`` - is invoked. Those intermediate sessions are outside the scope of this fix and - are an issue in the upstream filesystem library. ([#4003](https://github.com/zarr-developers/zarr-python/pull/4003)) +- `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)) @@ -82,6 +74,82 @@ - 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)) @@ -120,13 +188,30 @@ 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. ``BloscCodec.cname`` and + 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 @@ -162,8 +247,9 @@ ### Misc -- [#214](https://github.com/zarr-developers/zarr-python/issues/214), [#215](https://github.com/zarr-developers/zarr-python/pull/215), [#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), [#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) +- [#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) 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/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/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/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/mkdocs.yml b/mkdocs.yml index 87aaf23430..b414c73196 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,8 @@ nav: - 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 - API Reference: - api/zarr/index.md - ' zarr.abc': @@ -95,6 +97,8 @@ nav: - 'zarr-metadata ↪': https://zarr-metadata.readthedocs.io/ - release-notes.md - contributing.md + - Blog: + - blog/index.md hooks: - mkdocs_hooks.py @@ -153,6 +157,14 @@ 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: From a994a4fc972fed428eab6a26d4f14bb95d22c144 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Fri, 31 Jul 2026 11:23:10 +0200 Subject: [PATCH 22/32] feat: add the zarr-indexing package (TensorStore-style index transforms, ndsel wire format) (#4196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: byte-order handling for structured dtypes in the bytes codec (#220) * fix: byte-order handling for structured dtypes in the bytes codec The bytes codec neither byte-swapped structured-dtype fields to its configured endian on encode (numpy reports byteorder '|' for void dtypes, so the top-level byteorder comparison never detected a mismatch) nor honored its endian when decoding, silently corrupting any structured data whose field byte order differed from the stored one (e.g. virtual references to external big-endian data). Encode now detects byte-order mismatches by comparing full dtypes via newbyteorder, and decode reinterprets raw bytes in the stored byte order before converting to the data type's declared byte order, so the stored layout (codec state) and the in-memory layout (array data type) are independent. Closes #4141 Assisted-by: ClaudeCode:claude-fable-5 * test: fold structured byte-order cases into existing bytes codec tests Extend test_endian's parametrization with structured dtypes and test_bytes_codec_sync_roundtrip with endian/dtype parametrization plus stored-layout and decoded-dtype assertions, instead of adding parallel test functions for the same properties. Assisted-by: ClaudeCode:claude-fable-5 * refactor: rename stored_dtype to view_dtype in BytesCodec decode The variable is the dtype used to view the raw chunk bytes (byte order from the codec's endian configuration), not a property of the stored data or of the returned buffer, which always carries the array's declared dtype. Assisted-by: ClaudeCode:claude-fable-5 * docs: note that the decode-side byte-order conversion copies the chunk Assisted-by: ClaudeCode:claude-fable-5 * feat: add the zarr-indexing package (TensorStore-style index transforms) Standalone workspace package extracted from the lazy-indexing branch (zarr-developers#3906): composable, lazy coordinate transforms (IndexTransform / IndexDomain / output maps), dependency-aware chunk resolution against a DimensionGridLike protocol, and an ndsel-conformant JSON wire format validated against the vendored conformance corpus. zarr itself does not depend on zarr-indexing yet — the runtime wiring lands separately once 0.1.0 is published. The package is numpy-only; its tests exercise chunk resolution against zarr's concrete ChunkGrid, so they run from the workspace root (uv sync --all-packages). Assisted-by: ClaudeCode:claude-fable-5 * style: conventional submodule import in the chunk-resolution tests Assisted-by: ClaudeCode:claude-fable-5 * perf(zarr-indexing): joint chunk enumeration for correlated vindex maps Candidate-chunk enumeration took the cartesian product of each correlated ArrayMap's per-dimension distinct chunk ids and relied on intersect() to filter untouched combinations. For a diagonal selection of P scattered points that is P**2 intersect calls — quadratic in the number of selected points, the same workload shape as zarr-developers#4174 (400 points: ~2.6s; 10k points: ~30min). Group correlated maps jointly instead: broadcast their per-point chunk ids, take the distinct rows (np.unique(axis=0), O(P log P)), and enumerate exactly the touched combinations. Candidate slots now carry chunk-coordinate tuples covering one or more output dimensions; orthogonal/constant/slice dimensions keep their existing per-dimension candidates. 400-point diagonal resolution drops from 2628ms to 14ms and scales linearly. Assisted-by: ClaudeCode:claude-fable-5 * docs(zarr-indexing): standalone documentation site; add package justfile Mirror the treatment zarr-metadata received in #4208/#4210 onto zarr-indexing: a self-contained mkdocs site under the package (own mkdocs.yml, landing page, ndsel wire-format guide, mkdocstrings page per module, and .readthedocs.yaml for a dedicated RTD project), so the package presents as a separate project with docs versioned by its own zarr_indexing-v* release tags rather than zarr-python's. The zarr-python site's API Reference nav links out to it, and each RTD project now skips PR builds that do not touch its half of the repo. The package gains a pinned docs dependency group, a docs build job in its CI workflow, and a justfile with package-scoped dev recipes. Two recipes deviate from the zarr-metadata original by design: - `test` runs against the workspace-root environment (`uv run --project ../.. --all-packages --group test`), because the chunk-resolution tests exercise this package against zarr's chunk grids and `zarr` is deliberately not a dependency of this package. - `typecheck` uses plain `pyright`, unpinned and on the default interpreter, mirroring this package's own CI invocation. The zarr-metadata pin exists for a PEP 661 sentinel regression that zarr-indexing's sources do not hit. composition.py gains the module docstring the other modules already have, since mkdocstrings renders it as the page introduction. Assisted-by: ClaudeCode:claude-fable-5 * chore: drop the already-released 4141 changelog fragment The bytes-codec byte-order fix this fragment describes shipped upstream and its entry is already in docs/release-notes.md; the fragment survived on this branch only as a rebase remnant, and would emit a duplicate entry in the next release. Assisted-by: ClaudeCode:claude-fable-5 * docs(zarr-indexing): canonicalize ndsel references to zarr-developers/ndsel Also aligns the zarr-indexing workflow's setup-uv pin (v8.3.2) with the rest of the repo. The vendored-corpus sha is present upstream; the historical d-v-b/ndsel#1 PR reference stays as provenance. Assisted-by: ClaudeCode:claude-fable-5 * chore: drop the root uv-workspace wiring for zarr-indexing Per review: the root pyproject.toml should not change in this PR. The package now operates fully standalone (like zarr-metadata); the test invocations layer the package into the repo-root environment as an editable overlay instead (python -m pytest, since a base-env console script would not see the overlay). Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/check_changelogs.yml | 3 + .github/workflows/zarr-indexing-release.yml | 117 ++ .github/workflows/zarr-indexing.yml | 123 ++ .readthedocs.yaml | 15 +- mkdocs.yml | 1 + packages/zarr-indexing/.readthedocs.yaml | 30 + packages/zarr-indexing/CHANGELOG.md | 3 + packages/zarr-indexing/LICENSE.txt | 21 + packages/zarr-indexing/README.md | 53 + .../zarr-indexing/changes/3906.feature.md | 1 + packages/zarr-indexing/changes/README.md | 25 + .../docs/_static/favicon-96x96.png | Bin 0 -> 12714 bytes .../zarr-indexing/docs/_static/logo_bw.png | Bin 0 -> 45208 bytes .../docs/api/chunk_resolution.md | 5 + .../zarr-indexing/docs/api/composition.md | 5 + packages/zarr-indexing/docs/api/domain.md | 5 + packages/zarr-indexing/docs/api/errors.md | 5 + packages/zarr-indexing/docs/api/grid.md | 5 + packages/zarr-indexing/docs/api/index.md | 47 + packages/zarr-indexing/docs/api/json.md | 5 + packages/zarr-indexing/docs/api/messages.md | 5 + packages/zarr-indexing/docs/api/output_map.md | 5 + packages/zarr-indexing/docs/api/transform.md | 5 + packages/zarr-indexing/docs/index.md | 150 ++ packages/zarr-indexing/docs/ndsel.md | 156 ++ packages/zarr-indexing/justfile | 58 + packages/zarr-indexing/mkdocs.yml | 110 ++ packages/zarr-indexing/pyproject.toml | 124 ++ .../src/zarr_indexing/__init__.py | 74 + .../src/zarr_indexing/chunk_resolution.py | 380 +++++ .../src/zarr_indexing/composition.py | 133 ++ .../zarr-indexing/src/zarr_indexing/domain.py | 189 +++ .../zarr-indexing/src/zarr_indexing/errors.py | 21 + .../zarr-indexing/src/zarr_indexing/grid.py | 25 + .../zarr-indexing/src/zarr_indexing/json.py | 325 ++++ .../src/zarr_indexing/messages.py | 657 +++++++++ .../src/zarr_indexing/output_map.py | 105 ++ .../zarr-indexing/src/zarr_indexing/py.typed | 0 .../src/zarr_indexing/transform.py | 1311 +++++++++++++++++ .../tests/conformance/PROVENANCE.md | 20 + .../zarr-indexing/tests/conformance/README.md | 16 + .../zarr-indexing/tests/conformance/box.json | 50 + .../tests/conformance/errors.json | 23 + .../tests/conformance/point.json | 30 + .../tests/conformance/points.json | 34 + .../tests/conformance/slice.json | 61 + .../tests/conformance/transform.json | 57 + .../tests/test_chunk_resolution.py | 521 +++++++ .../zarr-indexing/tests/test_composition.py | 166 +++ .../zarr-indexing/tests/test_conformance.py | 55 + packages/zarr-indexing/tests/test_domain.py | 202 +++ packages/zarr-indexing/tests/test_json.py | 336 +++++ packages/zarr-indexing/tests/test_messages.py | 91 ++ .../tests/test_ndsel_tensorstore.py | 52 + .../zarr-indexing/tests/test_output_map.py | 56 + .../tests/test_tensorstore_parity.py | 263 ++++ .../zarr-indexing/tests/test_transform.py | 628 ++++++++ 57 files changed, 6955 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/zarr-indexing-release.yml create mode 100644 .github/workflows/zarr-indexing.yml create mode 100644 packages/zarr-indexing/.readthedocs.yaml create mode 100644 packages/zarr-indexing/CHANGELOG.md create mode 100644 packages/zarr-indexing/LICENSE.txt create mode 100644 packages/zarr-indexing/README.md create mode 100644 packages/zarr-indexing/changes/3906.feature.md create mode 100644 packages/zarr-indexing/changes/README.md create mode 100644 packages/zarr-indexing/docs/_static/favicon-96x96.png create mode 100644 packages/zarr-indexing/docs/_static/logo_bw.png create mode 100644 packages/zarr-indexing/docs/api/chunk_resolution.md create mode 100644 packages/zarr-indexing/docs/api/composition.md create mode 100644 packages/zarr-indexing/docs/api/domain.md create mode 100644 packages/zarr-indexing/docs/api/errors.md create mode 100644 packages/zarr-indexing/docs/api/grid.md create mode 100644 packages/zarr-indexing/docs/api/index.md create mode 100644 packages/zarr-indexing/docs/api/json.md create mode 100644 packages/zarr-indexing/docs/api/messages.md create mode 100644 packages/zarr-indexing/docs/api/output_map.md create mode 100644 packages/zarr-indexing/docs/api/transform.md create mode 100644 packages/zarr-indexing/docs/index.md create mode 100644 packages/zarr-indexing/docs/ndsel.md create mode 100644 packages/zarr-indexing/justfile create mode 100644 packages/zarr-indexing/mkdocs.yml create mode 100644 packages/zarr-indexing/pyproject.toml create mode 100644 packages/zarr-indexing/src/zarr_indexing/__init__.py create mode 100644 packages/zarr-indexing/src/zarr_indexing/chunk_resolution.py create mode 100644 packages/zarr-indexing/src/zarr_indexing/composition.py create mode 100644 packages/zarr-indexing/src/zarr_indexing/domain.py create mode 100644 packages/zarr-indexing/src/zarr_indexing/errors.py create mode 100644 packages/zarr-indexing/src/zarr_indexing/grid.py create mode 100644 packages/zarr-indexing/src/zarr_indexing/json.py create mode 100644 packages/zarr-indexing/src/zarr_indexing/messages.py create mode 100644 packages/zarr-indexing/src/zarr_indexing/output_map.py create mode 100644 packages/zarr-indexing/src/zarr_indexing/py.typed create mode 100644 packages/zarr-indexing/src/zarr_indexing/transform.py create mode 100644 packages/zarr-indexing/tests/conformance/PROVENANCE.md create mode 100644 packages/zarr-indexing/tests/conformance/README.md create mode 100644 packages/zarr-indexing/tests/conformance/box.json create mode 100644 packages/zarr-indexing/tests/conformance/errors.json create mode 100644 packages/zarr-indexing/tests/conformance/point.json create mode 100644 packages/zarr-indexing/tests/conformance/points.json create mode 100644 packages/zarr-indexing/tests/conformance/slice.json create mode 100644 packages/zarr-indexing/tests/conformance/transform.json create mode 100644 packages/zarr-indexing/tests/test_chunk_resolution.py create mode 100644 packages/zarr-indexing/tests/test_composition.py create mode 100644 packages/zarr-indexing/tests/test_conformance.py create mode 100644 packages/zarr-indexing/tests/test_domain.py create mode 100644 packages/zarr-indexing/tests/test_json.py create mode 100644 packages/zarr-indexing/tests/test_messages.py create mode 100644 packages/zarr-indexing/tests/test_ndsel_tensorstore.py create mode 100644 packages/zarr-indexing/tests/test_output_map.py create mode 100644 packages/zarr-indexing/tests/test_tensorstore_parity.py create mode 100644 packages/zarr-indexing/tests/test_transform.py diff --git a/.github/workflows/check_changelogs.yml b/.github/workflows/check_changelogs.yml index 0033b43db2..d7a54fc2c4 100644 --- a/.github/workflows/check_changelogs.yml +++ b/.github/workflows/check_changelogs.yml @@ -29,3 +29,6 @@ jobs: - name: Check zarr-metadata changelog entries run: uv run --no-sync python ci/check_changelog_entries.py packages/zarr-metadata/changes + + - name: Check zarr-indexing changelog entries + run: uv run --no-sync python ci/check_changelog_entries.py packages/zarr-indexing/changes diff --git a/.github/workflows/zarr-indexing-release.yml b/.github/workflows/zarr-indexing-release.yml new file mode 100644 index 0000000000..7cfd571eae --- /dev/null +++ b/.github/workflows/zarr-indexing-release.yml @@ -0,0 +1,117 @@ +name: zarr-indexing release + +on: + workflow_dispatch: + push: + tags: + - 'zarr_indexing-v*' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build wheel and sdist + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: packages/zarr-indexing + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + fetch-depth: 0 # hatch-vcs needs full history + tags + + - name: Install Hatch + uses: pypa/hatch@257e27e51a6a5616ed08a39a408a21c35c9931bc + with: + version: '1.16.5' + + - name: Build + run: hatch build + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: zarr-indexing-dist + path: packages/zarr-indexing/dist + + test_artifacts: + name: Test built artifacts + needs: [build] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: zarr-indexing-dist + path: dist + + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: false + + - name: Set up Python + run: uv python install 3.12 + + - name: Install built wheel and run import smoke test + run: | + wheel=$(ls dist/*.whl) + uv run --with "${wheel}" --python 3.12 --no-project \ + python -c "import zarr_indexing; print('zarr_indexing', zarr_indexing.__version__)" + + upload_pypi: + name: Upload to PyPI + needs: [build, test_artifacts] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/zarr_indexing-v') + runs-on: ubuntu-latest + environment: + name: zarr-indexing-releases + url: https://pypi.org/p/zarr-indexing + permissions: + id-token: write # required for OIDC trusted publishing + attestations: write # required for artifact attestations + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: zarr-indexing-dist + path: dist + + - name: Generate artifact attestation + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-path: dist/* + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + + upload_testpypi: + name: Upload to TestPyPI + needs: [build, test_artifacts] + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + environment: + name: zarr-indexing-releases-test + url: https://test.pypi.org/p/zarr-indexing + permissions: + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: zarr-indexing-dist + path: dist + + - name: Generate artifact attestation + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + with: + subject-path: dist/* + + - name: Publish package to TestPyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/zarr-indexing.yml b/.github/workflows/zarr-indexing.yml new file mode 100644 index 0000000000..2106b10916 --- /dev/null +++ b/.github/workflows/zarr-indexing.yml @@ -0,0 +1,123 @@ +name: zarr-indexing + +on: + push: + branches: [main] + paths: + - 'packages/zarr-indexing/**' + - '.github/workflows/zarr-indexing.yml' + pull_request: + paths: + - 'packages/zarr-indexing/**' + - '.github/workflows/zarr-indexing.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: pytest py=${{ matrix.python-version }} + runs-on: ubuntu-latest + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + python-version: ['3.12', '3.13', '3.14'] + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: true + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + # The transform tests exercise chunk resolution against zarr's ChunkGrid, + # so they run from the repo root against the root environment (which + # provides `zarr`) with this package as an editable overlay rather than in + # package isolation. + - name: Sync test dependency group + run: uv sync --group test --python ${{ matrix.python-version }} + - name: Run pytest + run: uv run --no-sync --group test --with-editable ./packages/zarr-indexing python -m pytest packages/zarr-indexing/tests + + ruff: + name: ruff + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: packages/zarr-indexing + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + - name: Run ruff + run: uvx ruff check . + + pyright: + name: pyright + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: packages/zarr-indexing + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: true + - name: Set up Python + run: uv python install 3.12 + - name: Sync test dependency group + run: uv sync --group test --python 3.12 + - name: Run pyright + run: uv run --group test --with pyright pyright src + + docs: + name: docs + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: packages/zarr-indexing + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: true + - name: Install just + uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 + - name: Build docs + # The strict mkdocs build lives in packages/zarr-indexing/justfile. + run: just docs-check + + zarr-indexing-complete: + name: zarr-indexing complete + needs: [test, ruff, pyright, docs] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check failure + if: | + contains(needs.*.result, 'failure') || + contains(needs.*.result, 'cancelled') + run: exit 1 + - name: Success + run: echo Success! diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 55b5d6fed0..dddf8449a4 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -6,15 +6,14 @@ build: python: "3.12" jobs: post_checkout: - # Cancel pull request builds whose changes are confined to the - # zarr-metadata package, which has its own Read the Docs project. 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. Read the Docs strips shell quoting from - # commands, so the exclude pathspec must use the quote-free :! form, - # not ':(exclude)'. + # Cancel pull request builds whose changes are confined to the packages + # that have their own Read the Docs projects. 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. Read the Docs strips shell quoting from commands, so the + # exclude pathspecs must use the quote-free :! form, not ':(exclude)'. - | - if [ "$READTHEDOCS_VERSION_TYPE" = "external" ] && git diff --quiet origin/main -- :!packages/zarr-metadata; + if [ "$READTHEDOCS_VERSION_TYPE" = "external" ] && git diff --quiet origin/main -- :!packages/zarr-metadata :!packages/zarr-indexing; then exit 183; fi diff --git a/mkdocs.yml b/mkdocs.yml index b414c73196..6a0d94052e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -95,6 +95,7 @@ nav: - ' zarr.zeros': api/zarr/functions/zeros.md - ' zarr.zeros_like': api/zarr/functions/zeros_like.md - 'zarr-metadata ↪': https://zarr-metadata.readthedocs.io/ + - 'zarr-indexing ↪': https://zarr-indexing.readthedocs.io/ - release-notes.md - contributing.md - Blog: diff --git a/packages/zarr-indexing/.readthedocs.yaml b/packages/zarr-indexing/.readthedocs.yaml new file mode 100644 index 0000000000..b8c7b76e2b --- /dev/null +++ b/packages/zarr-indexing/.readthedocs.yaml @@ -0,0 +1,30 @@ +# 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: + - mkdocs build --strict -f packages/zarr-indexing/mkdocs.yml --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/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..ccdfe595a5 --- /dev/null +++ b/packages/zarr-indexing/README.md @@ -0,0 +1,53 @@ +# 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: + +- `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 +- `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 +``` + +## 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, 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 diff --git a/packages/zarr-indexing/changes/3906.feature.md b/packages/zarr-indexing/changes/3906.feature.md new file mode 100644 index 0000000000..fa51b4438e --- /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. `index_transform_to_json`/`index_transform_from_json` (and the domain variants) now produce and consume the canonical body. 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/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 0000000000000000000000000000000000000000..e77977ccf41426c35a768ea73ed20e05d2676dd5 GIT binary patch literal 12714 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..df1979d3cc3317a36feaf5e7aab7c32998bdbfc7 GIT binary patch literal 45208 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 zarr_indexing.transform': api/transform.md + - ' zarr_indexing.domain': api/domain.md + - ' zarr_indexing.output_map': api/output_map.md + - ' zarr_indexing.composition': api/composition.md + - ' zarr_indexing.chunk_resolution': api/chunk_resolution.md + - ' zarr_indexing.grid': api/grid.md + - ' zarr_indexing.json': api/json.md + - ' zarr_indexing.messages': api/messages.md + - ' zarr_indexing.errors': api/errors.md + - Changelog: https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-indexing/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://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 diff --git a/packages/zarr-indexing/pyproject.toml b/packages/zarr-indexing/pyproject.toml new file mode 100644 index 0000000000..21ba4ef7e7 --- /dev/null +++ b/packages/zarr-indexing/pyproject.toml @@ -0,0 +1,124 @@ +[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.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. +test = ["pytest"] +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_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"] + +[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.pyright] +include = ["src"] +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..effe38ca88 --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/__init__.py @@ -0,0 +1,74 @@ +"""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`) +- `compose` — chain two transforms into one + +The chunk-resolution helpers (`iter_chunk_transforms`, +`sub_transform_to_selections`) and `selection_to_transform` are also exported +here: they form the surface the zarr integration layer (array indexing) depends +on. The `*Like` grid Protocols describe the chunk-grid surface chunk resolution +consumes without importing zarr. +""" + +from importlib.metadata import version + +from zarr_indexing.chunk_resolution import ( + iter_chunk_transforms, + sub_transform_to_selections, +) +from zarr_indexing.composition import compose +from zarr_indexing.domain import IndexDomain +from zarr_indexing.grid import DimensionGridLike +from zarr_indexing.json import ( + IndexDomainJSON, + IndexTransformJSON, + OutputIndexMapJSON, + index_domain_from_json, + index_domain_to_json, + index_transform_from_json, + index_transform_to_json, + transform_from_canonical, + transform_to_canonical, +) +from zarr_indexing.messages import NdselError, normalize_ndsel, parse_ndsel +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap, OutputIndexMap +from zarr_indexing.transform import IndexTransform, selection_to_transform + +__version__ = version("zarr-indexing") + +__all__ = [ + "ArrayMap", + "ConstantMap", + "DimensionGridLike", + "DimensionMap", + "IndexDomain", + "IndexDomainJSON", + "IndexTransform", + "IndexTransformJSON", + "NdselError", + "OutputIndexMap", + "OutputIndexMapJSON", + "__version__", + "compose", + "index_domain_from_json", + "index_domain_to_json", + "index_transform_from_json", + "index_transform_to_json", + "iter_chunk_transforms", + "normalize_ndsel", + "parse_ndsel", + "selection_to_transform", + "sub_transform_to_selections", + "transform_from_canonical", + "transform_to_canonical", +] 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..7aea86ad02 --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/chunk_resolution.py @@ -0,0 +1,380 @@ +"""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. **Yield** — produce `(chunk_coords, local_transform, surviving_indices)` + triples that the codec pipeline consumes. + +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. + +`sub_transform_to_selections` bridges from the transform representation +back to the raw `(chunk_selection, out_selection, drop_axes)` tuples that +the current codec pipeline expects. This bridge will go away when the codec +pipeline accepts transforms natively. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import numpy as np + +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, +] + + +def _one_dimensional_correlated_array_map( + transform: IndexTransform, +) -> tuple[ArrayMap, np.ndarray[Any, np.dtype[np.intp]]] | None: + """Return a nonempty correlated 1-D ArrayMap and its storage coordinates. + + A one-dimensional array selection has no cross-dimensional correlation to + preserve. 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.input_dimension is not None + or m.index_array.ndim != 1 + or m.index_array.size == 0 + ): + return None + + return m, 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 + dim_grid.chunk_size(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, + input_dimension=m.input_dimension, + ), + ), + ) + local = restricted.translate((-chunk_start,)) + surviving = np.arange(start, stop, dtype=np.intp) + + yield (chunk,), local, surviving + start = stop + + +def iter_chunk_transforms( + transform: IndexTransform, + dim_grids: Sequence[DimensionGridLike], +) -> Iterator[ChunkTransformResult]: + """Resolve a composed IndexTransform against per-dimension chunk grids. + + `dim_grids` holds one `DimensionGridLike` per output (storage) dimension — + for zarr this is the chunk grid's per-dimension sequence. Yields + `(chunk_coords, sub_transform, out_indices)` triples: + + - `chunk_coords`: which chunk to access. + - `sub_transform`: maps output buffer coords to chunk-local coords. + - `out_indices`: for vectorized/array indexing, the output scatter + indices (integer array). `None` for basic/slice indexing. + """ + + array_map_1d = _one_dimensional_correlated_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). + 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 + c = dg.index_to_chunk(m.offset) + 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 + if m.stride > 0: + s_min = m.offset + m.stride * dim_lo + s_max = m.offset + m.stride * (dim_hi - 1) + else: + s_min = m.offset + m.stride * (dim_hi - 1) + s_max = m.offset + m.stride * dim_lo + first = dg.index_to_chunk(s_min) + last = dg.index_to_chunk(s_max) + slot_dims.append((out_dim,)) + 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 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.astype(np.intp)) + if m.input_dimension is None: + correlated_dims.append(out_dim) + correlated_chunk_ids.append(chunk_ids) + else: + slot_dims.append((out_dim,)) + slot_candidates.append([(int(c),) for c in np.unique(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 = dg.chunk_size(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 sub_transform_to_selections( + sub_transform: IndexTransform, + out_indices: OutIndices = None, +) -> tuple[ + tuple[int | slice | np.ndarray[tuple[int, ...], np.dtype[np.intp]], ...], + tuple[slice | np.ndarray[tuple[int, ...], np.dtype[np.intp]], ...], + tuple[int, ...], +]: + """Convert a chunk-local sub-transform to raw selections for the codec pipeline. + + Parameters + ---------- + sub_transform + A chunk-local IndexTransform (output maps already translated to + chunk-local coordinates). + out_indices + For vectorized indexing: the output scatter indices for this chunk. + None for orthogonal/basic indexing. + + Returns + ------- + tuple + `(chunk_selection, out_selection, drop_axes)` + """ + inclusive_min = sub_transform.domain.inclusive_min + exclusive_max = sub_transform.domain.exclusive_max + + # Orthogonal outer product: >= 2 ArrayMaps each bound to a distinct input + # dimension. out_indices is a per-output-dim dict of surviving positions. The + # codec applies chunk_array[chunk_sel] / out[out_sel] with NumPy semantics, so + # build np.ix_-style selections (mirroring the legacy OrthogonalIndexer): one + # 1-D selector per dimension, expanded to an open mesh. ConstantMap dims are + # size-1 in chunk space and squeezed out via drop_axes. + if isinstance(out_indices, dict): + chunk_arrays: list[np.ndarray[Any, np.dtype[np.intp]]] = [] + out_arrays: list[np.ndarray[Any, np.dtype[np.intp]]] = [] + drop_axes: list[int] = [] + for out_dim, m in enumerate(sub_transform.output): + if isinstance(m, ConstantMap): + chunk_arrays.append(np.array([m.offset], dtype=np.intp)) + drop_axes.append(out_dim) + elif isinstance(m, DimensionMap): + rng = np.arange(inclusive_min[m.input_dimension], exclusive_max[m.input_dimension]) + chunk_arrays.append((m.offset + m.stride * rng).astype(np.intp)) + out_arrays.append(rng.astype(np.intp)) + else: # ArrayMap + idx = m.index_array.ravel() + chunk_arrays.append((m.offset + m.stride * idx).astype(np.intp)) + out_arrays.append(out_indices[out_dim]) + return np.ix_(*chunk_arrays), np.ix_(*out_arrays), tuple(drop_axes) + + # Correlated (vindex) sub-transforms carry ArrayMaps with `input_dimension` + # None. They scatter through a single flat index (`out_indices`) into the + # row-major-flattened output buffer; the chunk selection reads a + # (points, residual-slice) block via the raveled coordinate arrays and any + # residual DimensionMap slices. + correlated = any( + isinstance(m, ArrayMap) and m.input_dimension is None for m in sub_transform.output + ) + if correlated: + chunk_sel: list[int | slice | np.ndarray[tuple[int, ...], np.dtype[np.intp]]] = [] + for m in sub_transform.output: + if isinstance(m, ConstantMap): + chunk_sel.append(m.offset) + elif isinstance(m, DimensionMap): + d = m.input_dimension + start = m.offset + m.stride * inclusive_min[d] + stop = m.offset + m.stride * exclusive_max[d] + if m.stride < 0: + start, stop = stop + 1, start + 1 + chunk_sel.append(slice(start, stop, m.stride)) + else: # ArrayMap + idx = m.index_array.reshape(-1) + chunk_sel.append((m.offset + m.stride * idx).astype(np.intp)) + # Chunk resolution always supplies the flat scatter index for a + # correlated transform. Absent one (a bare sub-transform), fall back to an + # identity scatter over the whole flattened output buffer. + # `out_indices` is narrowed to a flat scatter array or None here (the + # per-dimension dict is an orthogonal outer product, handled above). + out_scatter: slice | np.ndarray[Any, np.dtype[np.intp]] + if out_indices is None: + n = 1 + for s in sub_transform.domain.shape: + n *= s + out_scatter = slice(0, n) + else: + out_scatter = out_indices + return tuple(chunk_sel), (out_scatter,), () + + chunk_sel = [] # annotated in the correlated branch above (same function scope) + out_sel: list[slice | np.ndarray[tuple[int, ...], np.dtype[np.intp]]] = [] + + # Single-pass build for the basic / single-orthogonal-array cases. + # ConstantMap dims are dropped (no out_sel entry). + for m in sub_transform.output: + if isinstance(m, ConstantMap): + chunk_sel.append(m.offset) + elif isinstance(m, DimensionMap): + d = m.input_dimension + dim_lo = inclusive_min[d] + dim_hi = exclusive_max[d] + start = m.offset + m.stride * dim_lo + stop = m.offset + m.stride * dim_hi + if m.stride < 0: + start, stop = stop + 1, start + 1 + chunk_sel.append(slice(start, stop, m.stride)) + out_sel.append(slice(dim_lo, dim_hi)) + else: # ArrayMap (orthogonal: full-rank, raveled to its 1-D fancy coords) + idx = m.index_array.reshape(-1) + if m.offset == 0 and m.stride == 1: + chunk_sel.append(idx) + else: + chunk_sel.append((m.offset + m.stride * idx).astype(np.intp)) + # Orthogonal ArrayMap: out_indices holds the surviving positions. + out_sel.append(out_indices if out_indices is not None else slice(0, idx.size)) + + return tuple(chunk_sel), tuple(out_sel), () 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..f5cc82599c --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/composition.py @@ -0,0 +1,133 @@ +"""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 storage, and the result maps user coordinates straight to +storage — 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 + +import numpy as np + +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap, OutputIndexMap +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 storage coords (rank p). + The result maps user coords (rank m) to storage coords (rank p). + + Precondition: `outer.output_rank == inner.domain.ndim`. + """ + 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})" + ) + + result_output = [_compose_single(outer, inner_map) for inner_map in inner.output] + + return IndexTransform(domain=outer.domain, output=tuple(result_output)) + + +def _compose_single(outer: IndexTransform, inner_map: OutputIndexMap) -> 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) + + +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=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; carry the orthogonal + # binding through unchanged. + return ArrayMap( + index_array=outer_map.index_array, + offset=offset_i + stride_i * outer_map.offset, + stride=stride_i * outer_map.stride, + input_dimension=outer_map.input_dimension, + ) + + +def _compose_array(outer: IndexTransform, inner_map: ArrayMap) -> 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. + """ + arr_i = inner_map.index_array + offset_i = inner_map.offset + stride_i = inner_map.stride + + # Check if all outer outputs are constant + all_constant = all(isinstance(m, ConstantMap) for m in outer.output) + + if all_constant: + # Evaluate arr_i at the single constant point + idx = tuple(m.offset for m in outer.output if isinstance(m, ConstantMap)) + value = int(arr_i[idx]) + return ConstantMap(offset=offset_i + stride_i * value) + + # For 1D inner array with a single outer output (simple case) + if arr_i.ndim == 1 and len(outer.output) == 1: + outer_map = outer.output[0] + + if isinstance(outer_map, DimensionMap): + dim_size = outer.domain.shape[outer_map.input_dimension] + user_indices = np.arange(dim_size, dtype=np.intp) + intermediate_vals = outer_map.offset + outer_map.stride * user_indices + new_arr = arr_i[intermediate_vals] + return ArrayMap(index_array=new_arr, offset=offset_i, stride=stride_i) + + if isinstance(outer_map, ArrayMap): + intermediate_vals = outer_map.offset + outer_map.stride * outer_map.index_array + new_arr = arr_i[intermediate_vals] + return ArrayMap(index_array=new_arr, offset=offset_i, stride=stride_i) + + # General multi-dim case: not yet implemented + raise NotImplementedError( + "Composing a multi-dimensional inner array map with non-constant outer maps " + "is not yet supported." + ) 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..f20d5bf7bd --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/domain.py @@ -0,0 +1,189 @@ +"""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 Any + + +@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`. + """ + + inclusive_min: tuple[int, ...] + exclusive_max: tuple[int, ...] + labels: tuple[str, ...] | None = None + # 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: + return len(self.inclusive_min) + + @property + def origin(self) -> tuple[int, ...]: + return self.inclusive_min + + @property + def shape(self) -> tuple[int, ...]: + 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: + 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: + 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: + 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: + 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. Integer indices produce length-1 extent. + Strided slices are not supported — use IndexTransform for strides. + """ + 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 IndexError( + 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 IndexError( + "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 + abs_start = max(abs_start, dim_lo) + abs_stop = min(abs_stop, dim_hi) + abs_stop = max(abs_stop, abs_start) + new_inclusive_min.append(abs_start) + new_exclusive_max.append(abs_stop) + return IndexDomain( + inclusive_min=tuple(new_inclusive_min), + exclusive_max=tuple(new_exclusive_max), + ) + + +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..fa2f6fc5d3 --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/errors.py @@ -0,0 +1,21 @@ +"""Canonical index-error types raised by the transform algebra. + +These are the authoritative class definitions. `zarr.errors` re-exports the +same objects (`from zarr_indexing.errors import ...`) so that, e.g., +`zarr.errors.BoundsCheckError is zarr_indexing.errors.BoundsCheckError`. +Both subclass the built-in `IndexError`, so existing `except IndexError` (or +`except zarr.errors.BoundsCheckError`) catch sites keep working unchanged. +""" + +from __future__ import annotations + +__all__ = [ + "BoundsCheckError", + "VindexInvalidSelectionError", +] + + +class VindexInvalidSelectionError(IndexError): ... + + +class BoundsCheckError(IndexError): ... 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..de1dae2dfc --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/grid.py @@ -0,0 +1,25 @@ +"""Structural typing for the chunk-grid surface used by chunk resolution. + +`chunk_resolution` needs only a narrow slice of a chunk grid: the per-dimension +mapping between storage indices and chunk coordinates, passed as one +`DimensionGridLike` per storage dimension. Rather than import zarr's concrete +grid types, we type against this Protocol; zarr's per-dimension grids satisfy +it structurally, so no zarr import is needed here. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + import numpy as np + import numpy.typing as npt + + +class DimensionGridLike(Protocol): + """The per-dimension chunk-mapping surface consumed by chunk resolution.""" + + def index_to_chunk(self, idx: int) -> int: ... + def chunk_offset(self, chunk_ix: int) -> int: ... + def chunk_size(self, chunk_ix: int) -> int: ... + def indices_to_chunks(self, indices: npt.NDArray[np.intp]) -> npt.NDArray[np.intp]: ... 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..c95696f309 --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/json.py @@ -0,0 +1,325 @@ +"""Lowering between canonical ndsel bodies and in-memory `IndexTransform`s. + +This is the **engine layer**. Where `messages.py` is pure JSON→JSON and imposes +no array constraints, this module converts a *canonical* ndsel transform body +(spec section 4.3, as produced by `zarr_indexing.messages.normalize_ndsel`) +into the numpy-backed `IndexTransform` the chunk engine runs on, and back. + +Two 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. + +## 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`. The in-memory `ArrayMap`, however, records +an `input_dimension` to pin the axis an orthogonal (`oindex`) array varies over. +This module bridges the gap: + +- **On serialize** (`transform_to_canonical`): + 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. This makes a length-1 `oindex` + selection round-trip *behaviorally* (an `ArrayMap` becomes a `ConstantMap`) + rather than by object identity. + 2. Non-degenerate `index_array` maps are emitted **without** `input_dimension`. + +- **On load** (`transform_from_canonical`): the in-memory `input_dimension` is + reconstructed from the full-rank array's dependency axes (its non-singleton + axes, see `transform._array_map_dependency_axes`). An array that solely owns a + single non-singleton axis is orthogonal (`input_dimension = that axis`); arrays + that share non-singleton axes, or vary over several, are correlated (`vindex`, + `input_dimension = None`). A single 1-D array over a rank-1 domain is + inherently ambiguous between the two flavours; it reconstructs as orthogonal, + which is behaviorally identical for the single-array case. + +`index_transform_to_json` / `index_transform_from_json` (and the `*_domain_*` +variants) are these canonical converters under their historical names. +""" + +from __future__ import annotations + +from collections import Counter +from typing import Any, Required, TypedDict + +import numpy as np + +from zarr_indexing.domain import IndexDomain +from zarr_indexing.messages import normalize_ndsel +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap, OutputIndexMap +from zarr_indexing.transform import ( + IndexTransform, + _array_map_dependency_axes, # pyright: ignore[reportPrivateUsage] +) + +# `_array_map_dependency_axes` is a leading-underscore helper in `transform.py`, +# but it is deliberately shared with this module (the engine-level JSON <-> +# `IndexTransform` lowering below needs the same dependency-axis logic that +# `transform.py`'s own array-reindexing helpers use). It is not part of the +# package's public API; pyright's `reportPrivateUsage` flags the cross-module +# import anyway. See `chunk_resolution.py`'s `_dimensions` suppression for the +# analogous rationale — whether to promote either symbol out of "private" is +# an open pre-publish API decision, not resolved here. + +# --------------------------------------------------------------------------- +# 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.""" + + input_inclusive_min: Required[list[BoundJSON]] + input_exclusive_max: Required[list[BoundJSON]] + input_labels: Required[list[str]] + + +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 + """ + + offset: int + stride: int + input_dimension: int + index_array: NestedIntList + index_array_bounds: list[IndexValueJSON] + + +class IndexTransformJSON(TypedDict, total=False): + """Canonical JSON representation of an IndexTransform (spec section 4.3).""" + + input_rank: Required[int] + input_inclusive_min: Required[list[BoundJSON]] + input_exclusive_max: Required[list[BoundJSON]] + input_labels: Required[list[str]] + output: Required[list[OutputIndexMapJSON]] + + +# --------------------------------------------------------------------------- +# Bound / label lowering (engine constraints) +# --------------------------------------------------------------------------- + + +def _lower_bound(bound: BoundJSON, where: str) -> int: + """Lower a canonical bound to a finite integer, rejecting infinities.""" + value = bound[0] if isinstance(bound, list) else bound + if value == "-inf" or value == "+inf": + raise ValueError( + f"{where} is infinite ({value!r}); an IndexDomain addresses a finite " + f"array and cannot lower an infinite bound" + ) + return int(value) + + +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) + + +# --------------------------------------------------------------------------- +# IndexDomain serialization +# --------------------------------------------------------------------------- + + +def index_domain_to_json(domain: IndexDomain) -> IndexDomainJSON: + """Convert an IndexDomain to its canonical JSON representation.""" + return { + "input_inclusive_min": list(domain.inclusive_min), + "input_exclusive_max": list(domain.exclusive_max), + "input_labels": _emit_labels(domain.labels, domain.ndim), + } + + +def index_domain_from_json(data: IndexDomainJSON) -> IndexDomain: + """Construct an IndexDomain from its canonical JSON representation.""" + inclusive_min = tuple( + _lower_bound(b, f"input_inclusive_min[{i}]") + for i, b in enumerate(data["input_inclusive_min"]) + ) + exclusive_max = tuple( + _lower_bound(b, f"input_exclusive_max[{i}]") + for i, b in enumerate(data["input_exclusive_max"]) + ) + labels = _lower_labels(list(data["input_labels"])) + return IndexDomain(inclusive_min=inclusive_min, exclusive_max=exclusive_max, labels=labels) + + +# --------------------------------------------------------------------------- +# OutputIndexMap serialization +# --------------------------------------------------------------------------- + + +def output_index_map_to_json(m: OutputIndexMap) -> OutputIndexMapJSON: + """Convert an output index map to its canonical JSON representation. + + A degenerate all-singleton `ArrayMap` collapses to a `constant` map; a + non-degenerate one is emitted without `input_dimension` (see the module + docstring on the wire format). + """ + if isinstance(m, ConstantMap): + return {"offset": m.offset} + + if isinstance(m, DimensionMap): + return {"offset": m.offset, "stride": m.stride, "input_dimension": m.input_dimension} + + # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + if m.index_array.size == 1: + value = int(m.index_array.reshape(-1)[0]) + return {"offset": m.offset + m.stride * value} + return { + "offset": m.offset, + "stride": m.stride, + "index_array": m.index_array.tolist(), + "index_array_bounds": ["-inf", "+inf"], + } + + +def output_index_map_from_json(data: OutputIndexMapJSON) -> OutputIndexMap: + """Construct an output index map from its canonical JSON representation. + + An `index_array` map's `input_dimension` is reconstructed from the array's + dependency axes in isolation (single non-singleton axis → orthogonal). The + transform-level loader classifies globally; use it when several maps may + share axes. + """ + if "index_array" in data: + arr = np.asarray(data["index_array"], dtype=np.intp) + return ArrayMap( + index_array=arr, + offset=data.get("offset", 0), + stride=data.get("stride", 1), + input_dimension=_solo_dependency_axis(arr), + ) + + 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 _solo_dependency_axis(arr: np.ndarray[Any, Any]) -> int | None: + """The single axis a lone `index_array` varies over, or `None` if not exactly one.""" + dep = _array_map_dependency_axes(arr) + return dep[0] if len(dep) == 1 else None + + +# --------------------------------------------------------------------------- +# IndexTransform serialization +# --------------------------------------------------------------------------- + + +def transform_to_canonical(transform: IndexTransform) -> IndexTransformJSON: + """Convert an IndexTransform to its canonical ndsel transform body. + + The result is fully explicit (spec section 4.3): `input_rank`, fully written + bounds and labels, and an explicit `output` with `offset`/`stride` present + on every affine and array map. + """ + return { + "input_rank": transform.domain.ndim, + "input_inclusive_min": list(transform.domain.inclusive_min), + "input_exclusive_max": list(transform.domain.exclusive_max), + "input_labels": _emit_labels(transform.domain.labels, transform.domain.ndim), + "output": [output_index_map_to_json(m) for m in transform.output], + } + + +def transform_from_canonical(data: IndexTransformJSON) -> IndexTransform: + """Construct an IndexTransform from a canonical (or canonicalizable) body. + + The body is first run through the message layer (`normalize_ndsel`) so that + omitted fields — identity `output`, default bounds/labels — are filled and + validated, then lowered to the engine representation. `index_array` maps' + `input_dimension` values are reconstructed by global dependency-axis + ownership (see the module docstring). + """ + body = normalize_ndsel({"kind": "transform", **data}) + + 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"]) + ) + domain = IndexDomain( + inclusive_min=inclusive_min, + exclusive_max=exclusive_max, + labels=_lower_labels(body["input_labels"]), + ) + + output_raw: list[dict[str, Any]] = body["output"] + + # Classify index_array maps globally: an axis owned by exactly one array map + # (and the map's sole non-singleton axis) marks that map orthogonal; shared + # or multiple non-singleton axes mark the maps correlated (vindex). + array_axes: dict[int, tuple[int, ...]] = {} + axis_owners: Counter[int] = Counter() + for i, om in enumerate(output_raw): + if "index_array" in om: + arr = np.asarray(om["index_array"], dtype=np.intp) + dep = _array_map_dependency_axes(arr) + array_axes[i] = dep + axis_owners.update(dep) + + output: list[OutputIndexMap] = [] + for i, om in enumerate(output_raw): + if "index_array" in om: + dep = array_axes[i] + input_dim = dep[0] if len(dep) == 1 and axis_owners[dep[0]] == 1 else None + output.append( + ArrayMap( + index_array=np.asarray(om["index_array"], dtype=np.intp), + offset=om.get("offset", 0), + stride=om.get("stride", 1), + input_dimension=input_dim, + ) + ) + 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))) + + return IndexTransform(domain=domain, output=tuple(output)) + + +# Historical names, now pointing at the canonical converters. +index_transform_to_json = transform_to_canonical +index_transform_from_json = transform_from_canonical 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..d5761d1a38 --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/messages.py @@ -0,0 +1,657 @@ +"""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", + "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`. + """ + + def __init__(self, reason: str, detail: str = "") -> None: + 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"} +) + + +# --------------------------------------------------------------------------- +# 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) + else: # shape + new = _shape_to_exclusive(_bound_value(inclusive_min[k]), value) + result.append(_rewrap(new, implicit=implicit)) + return result + + +def _inclusive_to_exclusive(value: int | str) -> int | str: + if value == "+inf" or value == "-inf": + return value + assert isinstance(value, int) + return value + 1 + + +def _shape_to_exclusive(min_value: int | str, shape_value: int | str) -> int | str: + 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 min_value + shape_value + + +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") + if s < 0: + raise NdselError("negative_step_unsupported", f"step[{k}] is negative ({s})") + + 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] + m = max(0, -(-(b - a) // s)) # ceil((b - a) / s) + o = _trunc_div(a, s) # trunc(a / s), toward zero + offset = a - s * o # lattice phase, in (-s, 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). + return { + "offset": offset, + "stride": stride, + "index_array": raw["index_array"], + "index_array_bounds": bounds, + } + + 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}", + ) + return [ + _check_index_value(value[0], f"{where}.index_array_bounds[0]"), + _check_index_value(value[1], f"{where}.index_array_bounds[1]"), + ] + + +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}") + + 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"])] + 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. + """ + 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. + """ + 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..581229bd22 --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/output_map.py @@ -0,0 +1,105 @@ +"""Output index maps — three representations of a set of integer coordinates. + +An output index map describes, for one dimension of storage, which coordinates +an array access will touch. Conceptually it is a **set of integers**. Three +representations cover the cases that arise in practice: + +- `ConstantMap(offset=5)` — a singleton set: `{5}` +- `DimensionMap(input_dimension=0, offset=3, stride=2)` over input `[0, 5)` + — an arithmetic progression: `{3, 5, 7, 9, 11}` +- `ArrayMap(index_array=[1, 5, 9])` — an explicit enumeration: `{1, 5, 9}` + +Every output map supports two set-theoretic operations (defined on +`IndexTransform`, which provides the input domain context these maps lack): + +- **intersect** — restrict to coordinates within a range (e.g., a chunk). + `{3, 5, 7, 9, 11} ∩ [4, 8) = {5, 7}` +- **translate** — shift every coordinate by a constant (e.g., make chunk-local). + `{5, 7} - 4 = {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 + +if TYPE_CHECKING: + import numpy as np + import numpy.typing as npt + + +@dataclass(frozen=True, slots=True) +class ConstantMap: + """A singleton set: one storage coordinate. + + Represents `{offset}`. Arises from integer indexing (e.g., `arr[5]` + fixes one dimension to coordinate 5). + """ + + offset: int = 0 + + +@dataclass(frozen=True, slots=True) +class DimensionMap: + """An arithmetic progression of storage coordinates. + + Represents `{offset + stride * i : i in input_range}`, 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). + """ + + input_dimension: int + offset: int = 0 + stride: int = 1 + + +@dataclass(frozen=True, slots=True) +class ArrayMap: + """An explicit enumeration of storage coordinates. + + Represents `{offset + stride * index_array[i] : i in input_range}`. + Arises from fancy indexing (e.g., `arr[[1, 5, 9]]` 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 + dependency axes are therefore derivable from the shape (see + `transform._array_map_dependency_axes`), which distinguishes the two flavours + 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. + + `input_dimension` records the single axis an orthogonal array varies over + (`None` for vectorized), binding it the way `DimensionMap` is bound. It is + usually redundant with the shape-derived classifier, but stays authoritative + for the shapes the classifier cannot distinguish: a length-1 orthogonal + selection normalizes to an all-singleton array (no non-singleton axis), and + length-1 vectorized arrays are equally degenerate. `None` therefore marks a + map as correlated, and an integer pins the dependency axis of a degenerate + orthogonal map (see `transform._array_map_dependent_axis`). + """ + + index_array: npt.NDArray[np.intp] + offset: int = 0 + stride: int = 1 + input_dimension: int | None = None + + +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/transform.py b/packages/zarr-indexing/src/zarr_indexing/transform.py new file mode 100644 index 0000000000..e1a3898b1d --- /dev/null +++ b/packages/zarr-indexing/src/zarr_indexing/transform.py @@ -0,0 +1,1311 @@ +"""Index transforms — composable, lazy coordinate mappings. + +An `IndexTransform` pairs an **input domain** (the coordinates a user sees) +with a tuple of **output maps** (the storage coordinates those inputs map to). +One output map per storage 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 storage 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." + +- **compose(outer, inner)** — chain two transforms. See `composition.py`. + +The transform is the atomic unit that connects user-facing indexing to +chunk-level I/O. Every `Array` holds a transform (identity by default). +`Array.lazy[...]` composes a new transform lazily. Reading resolves the +transform against the chunk grid via intersect + translate. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Literal, cast + +import numpy as np + +from zarr_indexing.domain import IndexDomain +from zarr_indexing.errors import BoundsCheckError, VindexInvalidSelectionError +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap, OutputIndexMap + + +@dataclass(frozen=True, slots=True) +class IndexTransform: + """A composable mapping from input coordinates to storage coordinates. + + An `IndexTransform` has: + + - `domain`: an `IndexDomain` describing the valid input coordinates + (the user-facing shape, possibly with non-zero origin). + - `output`: a tuple of output maps (one per storage dimension), each + describing which storage coordinates the inputs touch. + + For a freshly opened array, the transform is the identity: input + coordinate `i` maps to storage coordinate `i`. Indexing operations + compose new transforms without I/O. + """ + + domain: IndexDomain + output: tuple[OutputIndexMap, ...] + + 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) and m.index_array.ndim > self.domain.ndim: + # ArrayMap index arrays produced by indexing and chunk resolution + # are normalized to the full input rank (an axis the array varies + # over is full-sized, every other axis a singleton). A rank + # *exceeding* the domain is always a bug. A rank *below* it is + # tolerated: TensorStore-format JSON (external input) may supply a + # lower-rank index array that broadcasts against the input domain, + # and `_array_map_dependency_axes` treats any missing leading axes + # as singleton dependencies. + raise ValueError( + f"output[{i}].index_array has {m.index_array.ndim} dims " + f"but input domain has {self.domain.ndim} dims" + ) + + @property + def input_rank(self) -> int: + return self.domain.ndim + + @property + def output_rank(self) -> int: + return len(self.output) + + @classmethod + def identity(cls, domain: IndexDomain) -> IndexTransform: + 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: + return cls.identity(IndexDomain.from_shape(shape)) + + @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 + ): + """Restrict this transform to storage coordinates within output_domain. + + 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 all output coordinates by `shift`.""" + 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, + input_dimension=m.input_dimension, + ) + ) + return IndexTransform(domain=self.domain, output=tuple(new_output)) + + def __getitem__(self, selection: Any) -> IndexTransform: + 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: + return _OIndexHelper(self) + + @property + def vindex(self) -> _VIndexHelper: + return _VIndexHelper(self) + + +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 storage coordinates within + `[output_domain.inclusive_min[d], output_domain.exclusive_max[d])`. + + Two flavours of fancy indexing require different treatment, distinguished by + the ArrayMaps' dependency axes (see `_array_map_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 storage coordinates fall within the output domain; residual slice + dimensions are intersected independently, as in the orthogonal case. + + A `None` `input_dimension` marks a correlated map, so any such map routes the + whole transform through the correlated intersection. + + 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})" + ) + + correlated_dims = [ + i + for i, m in enumerate(transform.output) + if isinstance(m, ArrayMap) and m.input_dimension is None + ] + if len(correlated_dims) > 0: + return _intersect_correlated(transform, output_domain, correlated_dims) + 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 storage 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 storage coordinate. + """ + if input_lo >= input_hi: + return None + if m.stride > 0: + new_input_lo = max(input_lo, math.ceil((lo - m.offset) / m.stride)) + new_input_hi = min(input_hi, math.ceil((hi - m.offset) / m.stride)) + elif m.stride < 0: + new_input_lo = max(input_lo, math.ceil((hi - 1 - m.offset) / m.stride)) + new_input_hi = min(input_hi, math.ceil((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 _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 (its dependency + # axis, or `input_dimension` for a degenerate length-1 array). Filter + # along that axis and keep the array at full input rank so the + # singleton axes it broadcasts over are preserved. + d = _array_map_dependent_axis(m) + storage = 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, + input_dimension=m.input_dimension, + ) + ) + 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_correlated( + transform: IndexTransform, + output_domain: IndexDomain, + correlated_dims: list[int], +) -> tuple[IndexTransform, np.ndarray[Any, np.dtype[np.intp]]] | None: + """Intersect a correlated (vindex) transform with an output domain. + + The correlated ArrayMaps share their broadcast (dependency) axes; a broadcast + point survives only if ALL its storage 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. + + 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)`. + """ + corr_maps = [cast("ArrayMap", transform.output[i]) for i in correlated_dims] + + # Mixing correlated and orthogonal ArrayMaps in one transform is not produced + # by any single selection and is not supported here. + orthogonal_array_dims = [ + i + for i, m in enumerate(transform.output) + if isinstance(m, ArrayMap) and m.input_dimension is not None + ] + if len(orthogonal_array_dims) > 0: + raise NotImplementedError( + "intersecting a transform with both correlated and orthogonal " + "ArrayMaps is not supported" + ) + + # The broadcast (dependency) axes are shared by every correlated map; they are + # the leading axes of the domain, followed by the residual slice axes. + broadcast_axes = _array_map_dependency_axes(corr_maps[0].index_array) + broadcast_shape = tuple(corr_maps[0].index_array.shape[a] for a in broadcast_axes) + + # 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 = 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 + # The correlated maps are singleton on every non-broadcast axis, so the mask + # collapses (C-order) to the broadcast block. + combined_bcast = combined.reshape(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 = { + out_dim: cast("ArrayMap", transform.output[out_dim]) + .index_array.reshape(broadcast_shape) + .reshape(-1)[surviving] + for out_dim in correlated_dims + } + + # New domain: the collapsed broadcast axis, then one axis per residual slice. + new_min = [0] + new_max = [n_points] + new_input_dim_of = {} + for new_axis, (d, nlo, nhi, _full, _m) in enumerate(slice_dims, start=1): + 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 = (n_points,) + (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 row-major output buffer of shape + # (broadcast points, residual slice sizes...): flat = point * prod(slice) + + # (row-major offset within the slice block). + prod_slice = 1 + for _d, _lo, _hi, full, _m in slice_dims: + prod_slice *= full + out_indices: np.ndarray[Any, np.dtype[np.intp]] = (surviving * prod_slice).reshape( + (n_points,) + (1,) * n_slice + ) + running = 1 + for j in range(n_slice - 1, -1, -1): + _d, nlo, nhi, full, _m = slice_dims[j] + coords = np.arange(nlo, nhi, dtype=np.intp) * running + shape = [1] * (1 + n_slice) + shape[1 + j] = coords.size + out_indices = out_indices + coords.reshape(shape) + running *= full + 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 isinstance(sel, (int, np.integer)): + result.append(int(sel)) + 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 _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 + genuinely 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(_array_map_dependency_axes(m.index_array)) + if m.input_dimension is not None: + # Degenerate length-1 orthogonal selection: the recorded axis is a + # dependency even though its size (1) makes it look singleton. + dependent.add(m.input_dimension) + 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(slice(pos, pos + size * step, 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) + + +_FANCY_AFTER_FANCY_MSG = ( + "applying a fancy (orthogonal/vectorized) selection to a view that already " + "has a fancy-indexed axis is not supported (fancy-after-fancy composition): " + "the new coordinates would index a broadcast axis of the existing selection. " + "Materialize the view first with `.result()` and index the array, or reorder " + "the selections so the fancy step is applied last." +) + + +def _guard_fancy_after_fancy(m: ArrayMap, fancy_dims: set[int] | list[int]) -> None: + """Reject a fancy step that lands on a broadcast axis of an existing ArrayMap. + + A new orthogonal/vectorized selection can only be absorbed into an existing + ArrayMap along the axes that map genuinely varies over (its dependency axes, + plus the recorded `input_dimension` for a degenerate length-1 orthogonal + selection). A fancy index targeting any other axis — a singleton axis the map + merely broadcasts over — cannot be reindexed and used to leak a raw NumPy + `IndexError` at resolve time. Raise a clear `NotImplementedError` instead. + """ + dependent = set(_array_map_dependency_axes(m.index_array)) + if m.input_dimension is not None: + dependent.add(m.input_dimension) + for d in fancy_dims: + if d < m.index_array.ndim and d not in dependent: + raise NotImplementedError(_FANCY_AFTER_FANCY_MSG) + + +def _reindex_array_oindex( + arr: np.ndarray[Any, np.dtype[np.intp]], + normalized: tuple[Any, ...] | list[Any], + domain: IndexDomain, +) -> np.ndarray[Any, np.dtype[np.intp]]: + """Apply oindex/vindex selection to an existing ArrayMap's index_array. + + Each old input dimension gets either an array (fancy index that axis) + or a slice applied to the corresponding array axis. + """ + idx: list[Any] = [] + for old_dim, sel in enumerate(normalized): + if old_dim >= arr.ndim: + break + lo = domain.inclusive_min[old_dim] + if isinstance(sel, np.ndarray): + # Values are literal domain coordinates; the stored array is + # indexed positionally, so shift by the domain origin. + idx.append(sel - lo) + elif isinstance(sel, slice): + hi = domain.exclusive_max[old_dim] + start, step, _origin, size = _resolve_slice_ts(sel, old_dim, lo, hi) + pos = start - lo + idx.append(slice(pos, pos + size * step, step)) + else: + idx.append(slice(None)) + + result = arr[tuple(idx)] if idx else arr + return np.asarray(result, dtype=np.intp) + + +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) + new_arr = _reindex_array(m, normalized, transform.domain) + array_input_dim: int | None = None + if m.input_dimension is not None: + array_input_dim = old_to_new_dim.get(m.input_dimension, m.input_dimension) + new_output.append( + ArrayMap( + index_array=new_arr, + offset=m.offset, + stride=m.stride, + input_dimension=array_input_dim, + ) + ) + + return IndexTransform(domain=new_domain, output=tuple(new_output)) + + +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 non-singleton axes. An orthogonal (`oindex`) + array depends on a single axis; a vectorized (`vindex`) array depends on all + of the (shared) broadcast axes. + """ + return tuple(axis for axis, size in enumerate(index_array.shape) if size != 1) + + +def _array_map_dependent_axis(m: ArrayMap) -> int: + """Return the single input axis an orthogonal `ArrayMap` varies over. + + Normally this is the array's one non-singleton axis. A degenerate length-1 + orthogonal selection normalizes to an all-singleton shape (its dependency + axes are empty and indistinguishable by shape from a scalar), so + `input_dimension` breaks the tie — it records the axis the map binds. + """ + dep = _array_map_dependency_axes(m.index_array) + if len(dep) == 1: + return dep[0] + if m.input_dimension is not None: + return m.input_dimension + raise ValueError( + f"orthogonal ArrayMap must vary over exactly one axis; got dependency " + f"axes {dep} with input_dimension={m.input_dimension}" + ) + + +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 isinstance(sel, (int, np.integer)): + # Convert integer scalars to 1-element arrays for orthogonal indexing + result.append(np.array([int(sel)], dtype=np.intp)) + elif isinstance(sel, (list, tuple)): + 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). + """ + 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. The dependency + # axis is then derivable from the shape (a single non-singleton + # axis marks the selection orthogonal / outer-product rather than + # vectorized). `input_dimension` is kept populated as a + # compatibility shim for consumers not yet migrated to the + # shape-derived classifier. + full_arr = _reshape_to_axis(dim_array[d], new_axis, new_dim_idx) + new_output.append( + ArrayMap( + index_array=full_arr, + offset=m.offset, + stride=m.stride, + input_dimension=new_axis, + ) + ) + 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 (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + _guard_fancy_after_fancy(m, list(dim_array.keys())) + new_arr = _reindex_array_oindex(m.index_array, normalized, transform.domain) + array_input_dim: int | None = None + if m.input_dimension is not None: + array_input_dim = old_to_new_dim.get(m.input_dimension, m.input_dimension) + new_output.append( + ArrayMap( + index_array=new_arr, + offset=m.offset, + stride=m.stride, + input_dimension=array_input_dim, + ) + ) + + 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 _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. + """ + if not isinstance(selection, tuple): + selection = (selection,) + + # Expand ellipsis and count consumed dimensions + # Boolean arrays with ndim > 1 consume ndim dims + n_consumed = 0 + for s in selection: + if s is Ellipsis: + continue + if isinstance(s, np.ndarray) and s.dtype == np.bool_ and s.ndim > 1: + n_consumed += s.ndim + else: + n_consumed += 1 + 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 = 0 + for sel in expanded: + if isinstance(sel, np.ndarray) and sel.dtype == np.bool_ and sel.ndim > 1: + n_expanded_dims += sel.ndim + else: + n_expanded_dims += 1 + 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: + if isinstance(sel, np.ndarray) and sel.dtype == np.bool_: + indices_tuple = np.nonzero(sel) + 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 isinstance(sel, (int, np.integer)): + processed.append(np.array([int(sel)], 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 = () + + # Build new domain: broadcast dims first, then slice dims + new_inclusive_min: list[int] = [] + new_exclusive_max: list[int] = [] + + # Broadcast dimensions + for s in broadcast_shape: + new_inclusive_min.append(0) + new_exclusive_max.append(s) + + # Slice dimensions (preserved-domain literal semantics, like basic indexing) + slice_dim_params: dict[int, tuple[int, 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) + new_inclusive_min.append(origin) + new_exclusive_max.append(origin + size) + slice_dim_params[old_dim] = (start, step, origin) + + 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(broadcast_shape + (1,) * len(slice_dims)) + new_output.append( + ArrayMap( + index_array=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 + new_input_dim = n_broadcast_dims + slice_dims.index(d) + new_output.append( + DimensionMap( + input_dimension=new_input_dim, offset=new_offset, stride=new_stride + ) + ) + else: + # m: ArrayMap (OutputIndexMap = ConstantMap | DimensionMap | ArrayMap) + _guard_fancy_after_fancy(m, array_dims) + new_arr = _reindex_array_oindex(m.index_array, processed, transform.domain) + new_output.append( + ArrayMap( + index_array=new_arr, + offset=m.offset, + stride=m.stride, + input_dimension=m.input_dimension, + ) + ) + + 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. Rules (each verified against tensorstore 0.1.84): + + - defaults: `start = lo`, `stop = hi`; + - 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 (`start == stop`) is valid anywhere; + - reversed bounds (`start > stop` with positive step) are an error, not + an empty result; + - the result's domain origin is `trunc(start/step)` (rounded toward + zero) and coordinate `origin + k` maps to input `start + k*step`. + + Returns `(start, step, origin, size)` in domain coordinates. + """ + step = 1 if sel.step is None else sel.step + if step <= 0: + # Negative steps are valid in TensorStore but not yet supported here; + # step 0 is invalid everywhere. + raise IndexError("slice step must be positive") + start = lo if sel.start is None else sel.start + stop = hi if sel.stop is None else sel.stop + if stop < start: + raise IndexError( + f"slice interval [{start}, {stop}) with step {step} does not specify " + f"a valid interval for dimension {dim} (start > stop)" + ) + size = -(-(stop - start) // step) # ceil((stop - start) / step) + if size > 0 and (start < lo or stop > hi): + hint = _LITERAL_HINT if (start < 0 or stop < 0) and lo >= 0 else "" + raise BoundsCheckError( + f"slice interval [{start}, {stop}) is not contained within domain " + f"[{lo}, {hi}) for dimension {dim}{hint}" + ) + 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 isinstance(sel, (int, np.integer)): + continue + if isinstance(sel, (list, np.ndarray)): + 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, (int, np.integer, slice)): + continue + raise IndexError(f"unsupported selection type for basic indexing: {type(s)!r}") + + +def selection_to_transform( + selection: Any, + transform: IndexTransform, + mode: Literal["basic", "orthogonal", "vectorized"], +) -> 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. + """ + if mode == "basic": + _validate_basic_selection(selection) + return transform[selection] + elif mode == "orthogonal": + _validate_array_selection(selection, transform.domain.shape, mode) + return transform.oindex[selection] + elif mode == "vectorized": + _validate_array_selection(selection, transform.domain.shape, mode) + return transform.vindex[selection] + else: + raise ValueError(f"Unknown mode: {mode!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..0a6faef60b --- /dev/null +++ b/packages/zarr-indexing/tests/conformance/PROVENANCE.md @@ -0,0 +1,20 @@ +# 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#1, `fix/slice-origin-trunc`) +- **Commit:** `c59bc556c` (fixtures byte-identical to the previously vendored + `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. 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..e5e08bab3c --- /dev/null +++ b/packages/zarr-indexing/tests/conformance/errors.json @@ -0,0 +1,23 @@ +[ + { "name": "error/step-zero", "input": { "kind": "slice", "start": [0], "stop": [4], "step": [0] }, "error": "step_zero" }, + { "name": "error/negative-step", "input": { "kind": "slice", "start": [9], "stop": [0], "step": [-2] }, "error": "negative_step_unsupported" }, + { "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..2f1a0694ce --- /dev/null +++ b/packages/zarr-indexing/tests/conformance/slice.json @@ -0,0 +1,61 @@ +[ + { + "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 } ] + } + } +] diff --git a/packages/zarr-indexing/tests/conformance/transform.json b/packages/zarr-indexing/tests/conformance/transform.json new file mode 100644 index 0000000000..f26157aaab --- /dev/null +++ b/packages/zarr-indexing/tests/conformance/transform.json @@ -0,0 +1,57 @@ +[ + { + "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"] } + ] + } + } +] 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..0738384e2b --- /dev/null +++ b/packages/zarr-indexing/tests/test_chunk_resolution.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +from zarr.core.chunk_grids import ChunkGrid, FixedDimension, VaryingDimension + +from zarr_indexing import chunk_resolution +from zarr_indexing.chunk_resolution import iter_chunk_transforms, sub_transform_to_selections +from zarr_indexing.domain import IndexDomain +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap +from zarr_indexing.transform import IndexTransform + +if TYPE_CHECKING: + import pytest + + +class TestChunkResolutionIdentity: + def test_single_chunk(self) -> None: + """Array fits in one chunk.""" + t = IndexTransform.from_shape((10,)) + grid = ChunkGrid(dimensions=(FixedDimension(size=10, extent=10),)) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 1 + coords, sub_t, _ = results[0] + assert coords == (0,) + assert sub_t.domain.shape == (10,) + + def test_multiple_chunks_1d(self) -> None: + """1D array spanning 3 chunks.""" + t = IndexTransform.from_shape((30,)) + grid = ChunkGrid(dimensions=(FixedDimension(size=10, extent=30),)) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 3 + coords_list = [r[0] for r in results] + assert (0,) in coords_list + assert (1,) in coords_list + assert (2,) in coords_list + + def test_multiple_chunks_2d(self) -> None: + """2D array spanning 2x3 chunks.""" + t = IndexTransform.from_shape((20, 30)) + grid = ChunkGrid( + dimensions=( + FixedDimension(size=10, extent=20), + FixedDimension(size=10, extent=30), + ) + ) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 6 + coords_list = [r[0] for r in results] + assert (0, 0) in coords_list + assert (1, 2) in coords_list + + +class TestChunkResolutionSliced: + def test_slice_within_chunk(self) -> None: + """Slice that falls within a single chunk.""" + # Chunk resolution consumes zero-origin transforms: the I/O layer + # normalizes preserved (user-facing) domains via translate_domain_to + # before resolving, so mirror that contract here. + t = IndexTransform.from_shape((100,))[5:8].translate_domain_to((0,)) + grid = ChunkGrid(dimensions=(FixedDimension(size=10, extent=100),)) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 1 + coords, sub_t, _ = results[0] + assert coords == (0,) + assert isinstance(sub_t.output[0], DimensionMap) + assert sub_t.output[0].offset == 5 + + def test_slice_across_chunks(self) -> None: + """Slice that spans two chunks.""" + t = IndexTransform.from_shape((100,))[8:15] + grid = ChunkGrid(dimensions=(FixedDimension(size=10, extent=100),)) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 2 + coords_list = [r[0] for r in results] + assert (0,) in coords_list + assert (1,) in coords_list + + +class TestChunkResolutionConstant: + def test_integer_index(self) -> None: + """Integer index produces constant map — single chunk per constant dim.""" + t = IndexTransform.from_shape((100, 100))[25, :] + grid = ChunkGrid( + dimensions=( + FixedDimension(size=10, extent=100), + FixedDimension(size=10, extent=100), + ) + ) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 10 + for coords, _, _ in results: + assert coords[0] == 2 + + +class TestChunkResolutionArray: + def test_array_index(self) -> None: + """Array index map — chunks determined by array values.""" + idx = np.array([5, 15, 25], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=(ArrayMap(index_array=idx),), + ) + grid = ChunkGrid(dimensions=(FixedDimension(size=10, extent=30),)) + results = list(iter_chunk_transforms(t, grid._dimensions)) + coords_list = [r[0] for r in results] + assert (0,) in coords_list + assert (1,) in coords_list + assert (2,) in coords_list + + +class TestChunkResolutionSorted1D: + def test_matches_general_resolution_for_randomized_sorted_selections( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Direct partitioning matches the original resolver across varied inputs.""" + 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): + idx = np.sort(rng.integers(0, 30, size=int(rng.integers(1, 80)))).astype(np.intp) + transform = IndexTransform.from_shape((30,)).vindex[idx] + direct = list(iter_chunk_transforms(transform, grid._dimensions)) + + with monkeypatch.context() as context: + context.setattr( + chunk_resolution, + "_one_dimensional_correlated_array_map", + lambda _transform: None, + ) + general = list(iter_chunk_transforms(transform, grid._dimensions)) + + assert [result[0] for result in direct] == [result[0] for result in general] + for direct_result, general_result in zip(direct, general, strict=True): + _, direct_t, direct_out = direct_result + _, general_t, general_out = general_result + assert direct_t.domain == general_t.domain + + direct_chunk_sel, direct_out_sel, direct_drop = sub_transform_to_selections( + direct_t, direct_out + ) + general_chunk_sel, general_out_sel, general_drop = sub_transform_to_selections( + general_t, general_out + ) + assert direct_drop == general_drop + np.testing.assert_array_equal(direct_chunk_sel[0], general_chunk_sel[0]) + np.testing.assert_array_equal(direct_out_sel[0], general_out_sel[0]) + + def test_sorted_vindex_partitions_chunks_without_intersection( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Sorted vectorized coordinates are sliced directly per touched chunk.""" + idx = np.array([0, 3, 4, 4, 9, 11], dtype=np.intp) + t = IndexTransform.from_shape((12,)).vindex[idx] + grid = ChunkGrid(dimensions=(FixedDimension(size=4, extent=12),)) + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert [result[0] for result in results] == [(0,), (1,), (2,)] + assert calls["n"] == 0 + + expected_chunk_indices = ([0, 3], [0, 0], [1, 3]) + expected_out_indices = ([0, 1], [2, 3], [4, 5]) + for result, expected_chunk, expected_out in zip( + results, expected_chunk_indices, expected_out_indices, strict=True + ): + _, sub_t, out_indices = result + chunk_sel, out_sel, drop_axes = sub_transform_to_selections(sub_t, out_indices) + np.testing.assert_array_equal(chunk_sel[0], expected_chunk) + np.testing.assert_array_equal(out_sel[0], expected_out) + assert drop_axes == () + + def test_sorted_array_map_preserves_offset_and_stride(self) -> None: + """Storage partitioning retains the ArrayMap's offset and stride.""" + t = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=( + ArrayMap( + index_array=np.array([0, 1, 2], dtype=np.intp), + offset=1, + stride=3, + ), + ), + ) + grid = ChunkGrid(dimensions=(FixedDimension(size=4, extent=8),)) + + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert [result[0] for result in results] == [(0,), (1,)] + expected_chunk_indices = ([1], [0, 3]) + expected_out_indices = ([0], [1, 2]) + for result, expected_chunk, expected_out in zip( + results, expected_chunk_indices, expected_out_indices, strict=True + ): + _, sub_t, out_indices = result + chunk_sel, out_sel, _ = sub_transform_to_selections(sub_t, out_indices) + np.testing.assert_array_equal(chunk_sel[0], expected_chunk) + np.testing.assert_array_equal(out_sel[0], expected_out) + + def test_sorted_vindex_with_varying_chunks(self) -> None: + """Touched-boundary searches also support a non-uniform 1-D grid.""" + idx = np.array([0, 1, 2, 3, 5, 9], dtype=np.intp) + t = IndexTransform.from_shape((10,)).vindex[idx] + grid = ChunkGrid(dimensions=(VaryingDimension(edges=(2, 3, 5), extent=10),)) + + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert [result[0] for result in results] == [(0,), (1,), (2,)] + expected_chunk_indices = ([0, 1], [0, 1], [0, 4]) + for result, expected_chunk in zip(results, expected_chunk_indices, strict=True): + _, sub_t, out_indices = result + chunk_sel, _, _ = sub_transform_to_selections(sub_t, out_indices) + np.testing.assert_array_equal(chunk_sel[0], expected_chunk) + + def test_sorted_vindex_with_zero_sized_dimension_uses_general_resolution( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A zero-sized grid cannot be partitioned by touched boundaries.""" + t = IndexTransform.from_shape((10,)).vindex[np.array([1], dtype=np.intp)] + grid = ChunkGrid(dimensions=(FixedDimension(size=0, extent=10),)) + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert results == [] + assert calls["n"] == 1 + + def test_unsorted_vindex_uses_general_resolution(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Unsorted coordinates continue through the general intersection logic.""" + t = 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) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert [result[0] for result in results] == [(0,), (1,), (2,)] + assert calls["n"] == 3 + + def test_sorted_oindex_uses_general_resolution(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Orthogonal ArrayMaps retain their existing domain-aware resolution.""" + t = IndexTransform.from_shape((12,)).oindex[np.array([0, 4, 9], dtype=np.intp)] + grid = ChunkGrid(dimensions=(FixedDimension(size=4, extent=12),)) + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert [result[0] for result in results] == [(0,), (1,), (2,)] + assert calls["n"] == 3 + + +def _count_intersect_calls(monkeypatch: pytest.MonkeyPatch) -> dict[str, int]: + """Wrap `IndexTransform.intersect` with a call counter. + + Returns a mutable dict whose `"n"` entry is the number of times + `intersect` is invoked. Used to assert that candidate-chunk enumeration is + proportional to the *touched* chunks, not the dense bounding box between the + min and max touched chunk. + """ + 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 + + +class TestChunkResolutionTouchedOnly: + """`iter_chunk_transforms` must enumerate only the chunks a fancy selection + actually touches — never the dense `range(min_chunk, max_chunk + 1)` bounding + box. These guard against a regression to bounding-box enumeration, whose cost + scales with grid size rather than with the number of selected coordinates. + """ + + def test_1d_sparse_vindex_enumerates_only_touched_chunks( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Two far-apart coordinates on a 1000-chunk grid touch exactly 2 chunks. + + A dense bounding-box enumeration would intersect ~1000 candidate chunks; + touched-only enumeration intersects exactly 2. + """ + # 4000 elements, chunk size 4 -> 1000 chunks. coords 1 and 3997 land in + # chunk 0 and chunk 999 respectively (998 empty chunks between them). + grid = ChunkGrid(dimensions=(FixedDimension(size=4, extent=4000),)) + t = IndexTransform.from_shape((4000,)).vindex[np.array([1, 3997], dtype=np.intp)] + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + coords = sorted(r[0] for r in results) + assert coords == [(0,), (999,)] + # Sorted 1-D coordinates are partitioned directly, without intersecting + # either the touched chunks or the 998 empty chunks between them. + assert calls["n"] == 0 + + def test_2d_orthogonal_enumerates_only_touched_chunks( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Orthogonal outer product of two 2-coordinate arrays touches 2x2 chunks. + + Per-dimension distinct touched chunks: {0, 999} on each axis. The outer + product is 2*2 = 4 candidate chunks (all survive), versus ~1e6 for a + dense 1000x1000 bounding box. + """ + grid = ChunkGrid( + dimensions=( + FixedDimension(size=4, extent=4000), + FixedDimension(size=4, extent=4000), + ) + ) + t = IndexTransform.from_shape((4000, 4000)).oindex[ + np.array([1, 3997], dtype=np.intp), np.array([2, 3998], dtype=np.intp) + ] + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + coords = sorted(r[0] for r in results) + assert coords == [(0, 0), (0, 999), (999, 0), (999, 999)] + assert calls["n"] == 4 + + def test_2d_correlated_vindex_enumerates_joint_touched_chunks( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Two correlated (vindex) coordinate arrays scatter to 2 diagonal chunks. + + The two points (1, 2) and (3997, 3998) touch chunks (0, 0) and + (999, 999). Correlated coordinate arrays are grouped *jointly*, so + enumeration intersects exactly the 2 touched chunks — never the 2x2 + cartesian product of per-dimension distinct chunks, and never the dense + 1e6 grid. + """ + grid = ChunkGrid( + dimensions=( + FixedDimension(size=4, extent=4000), + FixedDimension(size=4, extent=4000), + ) + ) + t = IndexTransform.from_shape((4000, 4000)).vindex[ + np.array([1, 3997], dtype=np.intp), np.array([2, 3998], dtype=np.intp) + ] + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + coords = sorted(r[0] for r in results) + assert coords == [(0, 0), (999, 999)] + assert calls["n"] == 2 + + def test_2d_correlated_vindex_diagonal_is_linear_in_points( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A diagonal of P correlated points touches P chunks with O(P) intersections. + + Enumerating the cartesian product of per-dimension distinct chunk sets + would cost P**2 intersections (2500 here) — quadratic in the number of + selected points for the scattered selections of zarr-python gh-4174. + Joint grouping keeps resolution work proportional to the touched chunks. + """ + p = 50 + grid = ChunkGrid( + dimensions=( + FixedDimension(size=4, extent=4000), + FixedDimension(size=4, extent=4000), + ) + ) + # point i lands in chunk (2i, 2i): all per-dimension chunks distinct + coords_1d = np.arange(p, dtype=np.intp) * 8 + t = IndexTransform.from_shape((4000, 4000)).vindex[coords_1d, coords_1d] + + calls = _count_intersect_calls(monkeypatch) + results = list(iter_chunk_transforms(t, grid._dimensions)) + + assert sorted(r[0] for r in results) == [(2 * i, 2 * i) for i in range(p)] + assert calls["n"] == p + + +class TestSubTransformToSelections: + def test_constant_map(self) -> None: + """ConstantMap produces int selection + drop axis.""" + t = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=(ConstantMap(offset=5),), + ) + chunk_sel, out_sel, drop_axes = sub_transform_to_selections(t) + assert chunk_sel == (5,) + assert out_sel == () + assert drop_axes == () + + def test_dimension_map_stride_1(self) -> None: + """DimensionMap with stride=1 produces contiguous slice.""" + t = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=(DimensionMap(input_dimension=0, offset=3, stride=1),), + ) + chunk_sel, out_sel, drop_axes = sub_transform_to_selections(t) + assert chunk_sel == (slice(3, 13, 1),) + assert out_sel == (slice(0, 10),) + assert drop_axes == () + + def test_dimension_map_strided(self) -> None: + """DimensionMap with stride>1 produces strided slice.""" + t = IndexTransform( + domain=IndexDomain.from_shape((5,)), + output=(DimensionMap(input_dimension=0, offset=2, stride=3),), + ) + chunk_sel, out_sel, drop_axes = sub_transform_to_selections(t) + assert chunk_sel == (slice(2, 17, 3),) + assert out_sel == (slice(0, 5),) + assert drop_axes == () + + def test_array_map(self) -> None: + """ArrayMap produces integer array selection.""" + arr = np.array([1, 5, 9], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=(ArrayMap(index_array=arr, offset=0, stride=1),), + ) + chunk_sel, out_sel, drop_axes = sub_transform_to_selections(t) + assert isinstance(chunk_sel[0], np.ndarray) + np.testing.assert_array_equal(chunk_sel[0], arr) + # Without chunk_mask, out_sel falls back to domain-based slices + assert out_sel == (slice(0, 3),) + assert drop_axes == () + + def test_array_map_with_offset_stride(self) -> None: + """ArrayMap with offset and stride computes storage coords.""" + arr = np.array([0, 1, 2], dtype=np.intp) + t = IndexTransform( + domain=IndexDomain.from_shape((3,)), + output=(ArrayMap(index_array=arr, offset=10, stride=5),), + ) + chunk_sel, _out_sel, drop_axes = sub_transform_to_selections(t) + assert isinstance(chunk_sel[0], np.ndarray) + np.testing.assert_array_equal(chunk_sel[0], np.array([10, 15, 20])) + assert drop_axes == () + + def test_mixed_maps_2d(self) -> None: + """Mix of ConstantMap and DimensionMap.""" + t = IndexTransform( + domain=IndexDomain.from_shape((10,)), + output=( + ConstantMap(offset=5), + DimensionMap(input_dimension=0, offset=0, stride=1), + ), + ) + chunk_sel, _out_sel, drop_axes = sub_transform_to_selections(t) + assert chunk_sel[0] == 5 + assert chunk_sel[1] == slice(0, 10, 1) + # drop_axes is empty — integer in chunk_sel naturally drops the dim via numpy + assert drop_axes == () + + +class TestChunkResolutionArrayMapFlavours: + """Chunk resolution must yield outer-product (np.ix_) selectors for + orthogonal ArrayMaps and shared flat-scatter selectors for correlated ones, + and must return early for empty fancy selections.""" + + def test_empty_array_selection_yields_nothing(self) -> None: + """An empty ArrayMap selection produces no chunk transforms (no crash).""" + t = IndexTransform( + domain=IndexDomain.from_shape((0,)), + output=(ArrayMap(index_array=np.array([], dtype=np.intp)),), + ) + grid = ChunkGrid(dimensions=(FixedDimension(size=3, extent=10),)) + assert list(iter_chunk_transforms(t, grid._dimensions)) == [] + + def test_orthogonal_outer_product_selectors(self) -> None: + """Two independent arrays produce np.ix_-style (mesh) chunk/out selectors.""" + t = IndexTransform.from_shape((10, 10)).oindex[np.array([1, 3]), np.array([2, 4, 6])] + grid = ChunkGrid( + dimensions=(FixedDimension(size=10, extent=10), FixedDimension(size=10, extent=10)) + ) + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 1 + _coords, sub_t, out_indices = results[0] + chunk_sel, out_sel, drop_axes = sub_transform_to_selections(sub_t, out_indices) + # np.ix_ produces one 2-D open-mesh selector per axis, for both sides. + assert len(chunk_sel) == 2 + assert len(out_sel) == 2 + assert isinstance(chunk_sel[0], np.ndarray) + assert isinstance(chunk_sel[1], np.ndarray) + assert chunk_sel[0].shape == (2, 1) + assert chunk_sel[1].shape == (1, 3) + assert drop_axes == () + + def test_correlated_scatter_with_residual_slice(self) -> None: + """Correlated arrays + a residual slice dim scatter through a single flat + index whose shape matches the (points, slice) block read from the chunk.""" + t = IndexTransform.from_shape((4, 3, 5)).vindex[np.array([1, 3]), np.array([2, 0])] + grid = ChunkGrid( + dimensions=( + FixedDimension(size=4, extent=4), + FixedDimension(size=3, extent=3), + FixedDimension(size=5, extent=5), + ) + ) + # One chunk holds everything: both points survive, slice dim spans [0,5). + results = list(iter_chunk_transforms(t, grid._dimensions)) + assert len(results) == 1 + _coords, sub_t, out_indices = results[0] + chunk_sel, out_sel, _drop = sub_transform_to_selections(sub_t, out_indices) + # Chunk side: flat coordinate arrays for the two correlated dims plus a + # slice for the residual dim. + assert len(chunk_sel) == 3 + np.testing.assert_array_equal(np.asarray(chunk_sel[0]), [1, 3]) + np.testing.assert_array_equal(np.asarray(chunk_sel[1]), [2, 0]) + assert chunk_sel[2] == slice(0, 5, 1) + # Output side: a single flat scatter index of shape (points, slice) = (2, 5). + assert len(out_sel) == 1 + assert np.asarray(out_sel[0]).shape == (2, 5) diff --git a/packages/zarr-indexing/tests/test_composition.py b/packages/zarr-indexing/tests/test_composition.py new file mode 100644 index 0000000000..dd92f59b80 --- /dev/null +++ b/packages/zarr-indexing/tests/test_composition.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from zarr_indexing.composition import compose +from zarr_indexing.domain import IndexDomain +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 = compose(outer, 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 = compose(outer, inner) + assert isinstance(result.output[0], ConstantMap) + assert result.output[0].offset == 25 + + def test_dimension_inner_dimension_outer(self) -> None: + outer = IndexTransform( + domain=IndexDomain.from_shape((10,)), + 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 = compose(outer, 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, 2, 4], 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 = compose(outer, 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 = compose(outer, inner) + assert isinstance(result.output[0], ConstantMap) + assert result.output[0].offset == 20 + + 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 = compose(outer, 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) + + +class TestComposeMultiDim: + def test_2d_identity_compose(self) -> None: + a = IndexTransform.from_shape((10, 20)) + b = IndexTransform.from_shape((10, 20)) + result = compose(a, 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 = compose(outer, 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"): + compose(outer, inner) + + +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((100,)), + output=(DimensionMap(input_dimension=0, offset=5, stride=2),), + ) + bc = compose(b, c) + abc = compose(a, bc) + assert isinstance(abc.output[0], DimensionMap) + assert abc.output[0].offset == 25 + assert abc.output[0].stride == 2 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_domain.py b/packages/zarr-indexing/tests/test_domain.py new file mode 100644 index 0000000000..9664a0b08a --- /dev/null +++ b/packages/zarr-indexing/tests/test_domain.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import pytest + +from zarr_indexing.domain import IndexDomain + + +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(IndexError, 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(IndexError, match="out of bounds"): + d.narrow((4,)) + + def test_narrow_clamps_to_domain(self) -> None: + d = IndexDomain.from_shape((10,)) + result = d.narrow((slice(-5, 100),)) + assert result.inclusive_min == (0,) + assert result.exclusive_max == (10,) + + 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: + d = IndexDomain.from_shape((10,)) + with pytest.raises(IndexError, 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..42b59b2c30 --- /dev/null +++ b/packages/zarr-indexing/tests/test_json.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from zarr_indexing.domain import IndexDomain +from zarr_indexing.json import ( + IndexTransformJSON, + index_domain_from_json, + index_domain_to_json, + index_transform_from_json, + index_transform_to_json, + output_index_map_from_json, + output_index_map_to_json, +) +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap +from zarr_indexing.transform import IndexTransform + + +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 a.input_dimension == b.input_dimension + 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 = index_domain_to_json(domain) + assert json == { + "input_inclusive_min": [2, 5], + "input_exclusive_max": [10, 20], + "input_labels": ["", ""], + } + restored = index_domain_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 = index_domain_to_json(domain) + assert json["input_labels"] == ["x", "y"] + restored = index_domain_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 = index_domain_to_json(domain) + # Canonical form always writes labels; an unlabeled domain gets [""]*rank. + assert json["input_labels"] == [""] + restored = index_domain_from_json(json) + assert restored.labels is None + + def test_zero_origin(self) -> None: + domain = IndexDomain.from_shape((10, 20, 30)) + json = index_domain_to_json(domain) + assert json == { + "input_inclusive_min": [0, 0, 0], + "input_exclusive_max": [10, 20, 30], + "input_labels": ["", "", ""], + } + assert index_domain_from_json(json) == domain + + +class TestOutputIndexMapJSON: + def test_constant(self) -> None: + m = ConstantMap(offset=42) + json = output_index_map_to_json(m) + 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 = output_index_map_to_json(m) + 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 = output_index_map_to_json(m) + 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 = output_index_map_to_json(m) + 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 = output_index_map_to_json(m) + # 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 = output_index_map_to_json(m) + 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 = output_index_map_to_json(m) + 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 = output_index_map_to_json(m) + 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 = index_transform_to_json(t) + 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 = index_transform_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 = index_transform_to_json(t) + restored = index_transform_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 = index_transform_to_json(t) + restored = index_transform_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 = index_transform_to_json(t) + # The oindex array must not carry input_dimension on the wire. + assert "input_dimension" not in json["output"][0] + restored = index_transform_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)) + # input_dimension is reconstructed from the sole non-singleton axis. + assert restored.output[0].input_dimension == 0 + 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 = index_transform_from_json(index_transform_to_json(t)) + 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) + # Distinct, exclusively-owned axes -> reconstructed as orthogonal. + assert rest0.input_dimension == 0 + assert rest1.input_dimension == 1 + + def test_with_labels(self) -> None: + domain = IndexDomain(inclusive_min=(0, 0), exclusive_max=(10, 20), labels=("x", "y")) + t = IndexTransform.identity(domain) + json = index_transform_to_json(t) + assert json["input_labels"] == ["x", "y"] + restored = index_transform_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}, + {"offset": 0, "stride": 1, "index_array": [1, 2, 0]}, + ], + } + t = index_transform_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 = index_transform_to_json(t) + t_rt = index_transform_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 = index_transform_from_json(index_transform_to_json(t)) + 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 = index_transform_from_json(index_transform_to_json(t)) + 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 = index_transform_from_json(index_transform_to_json(t)) + 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 = index_transform_from_json(index_transform_to_json(t)) + assert _transforms_equal(rt, t) + + def test_length1_degenerate_oindex_collapses(self) -> None: + """A length-1 oindex array becomes an all-singleton ArrayMap; the JSON + round-trip collapses it to a ConstantMap (behaviorally identical).""" + t = IndexTransform.from_shape((10, 20)).oindex[np.array([7]), :] + m = t.output[0] + assert isinstance(m, ArrayMap) + assert m.index_array.size == 1 + + rt = index_transform_from_json(index_transform_to_json(t)) + # The degenerate array collapsed to a constant selecting the same cell. + 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 = index_transform_from_json(index_transform_to_json(t)) + assert _transforms_equal(rt, t) + + +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"): + index_transform_from_json(body) diff --git a/packages/zarr-indexing/tests/test_messages.py b/packages/zarr-indexing/tests/test_messages.py new file mode 100644 index 0000000000..14bed66448 --- /dev/null +++ b/packages/zarr-indexing/tests/test_messages.py @@ -0,0 +1,91 @@ +"""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" 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..794ac5f3d5 --- /dev/null +++ b/packages/zarr-indexing/tests/test_ndsel_tensorstore.py @@ -0,0 +1,52 @@ +"""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.json import transform_from_canonical, transform_to_canonical +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_canonical(transform) + + # (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 = transform_from_canonical(ts_json) + assert transform_to_canonical(reloaded) == transform_to_canonical(transform) 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..498101444e --- /dev/null +++ b/packages/zarr-indexing/tests/test_output_map.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import numpy as np + +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) 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..1ed99046e9 --- /dev/null +++ b/packages/zarr-indexing/tests/test_tensorstore_parity.py @@ -0,0 +1,263 @@ +"""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 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..baecd9ada2 --- /dev/null +++ b/packages/zarr-indexing/tests/test_transform.py @@ -0,0 +1,628 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from zarr_indexing.domain import IndexDomain +from zarr_indexing.output_map import ArrayMap, ConstantMap, DimensionMap +from zarr_indexing.transform import IndexTransform, selection_to_transform + + +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 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) + + +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_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,) + + +class TestSelectionToTransform: + def test_basic_slice(self) -> None: + t = IndexTransform.from_shape((10, 20)) + result = selection_to_transform((slice(2, 8), slice(5, 15)), t, "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 = selection_to_transform((3, slice(None)), t, "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 = selection_to_transform(Ellipsis, t, "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 = selection_to_transform((idx, slice(None)), t, "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 = selection_to_transform((idx0, idx1), t, "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 = selection_to_transform(slice(15, 30), t, "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,) + + 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: + """`_array_map_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: + from zarr_indexing.transform import _array_map_dependency_axes + + 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 _array_map_dependency_axes(m0.index_array) == (0,) + assert _array_map_dependency_axes(m1.index_array) == (1,) + + def test_vectorized_shares_axes(self) -> None: + from zarr_indexing.transform import _array_map_dependency_axes + + 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 _array_map_dependency_axes(m0.index_array) == (0,) + assert _array_map_dependency_axes(m1.index_array) == (0,) + + def test_scalar_array_has_no_dependency(self) -> None: + from zarr_indexing.transform import _array_map_dependency_axes + + assert _array_map_dependency_axes(np.ones((1, 1), dtype=np.intp)) == () + + +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_not_treated_as_correlated(self) -> None: + """A length-1 orthogonal array (all-singleton shape) is still an outer + product with the length-3 axis: out_indices is a dict, not a flat array.""" + t = IndexTransform.from_shape((6, 6)).oindex[np.array([2]), np.array([1, 3, 5])] + 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, dict) From 24f9ad19430dc88bc1d92b5e1936ac6b3e20f4fe Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Sun, 2 Aug 2026 15:09:12 +0200 Subject: [PATCH 23/32] fix: make consolidated metadata nesting independent of persisted key order (#4227) * fix: make consolidated metadata nesting independent of persisted key order `ConsolidatedMetadata._flat_to_nested` grouped the flat keys with `itertools.groupby` over keys sorted by depth alone. `groupby` only groups *consecutive* runs, so when a parent's children were not adjacent it emitted several runs for the same parent and the surrounding dict comprehension kept only the last one. Every child in the earlier runs was silently never re-parented, and lingered as a bogus slash-containing key at the top level, making it unreachable through the consolidated metadata. The persisted key order is arbitrary, so nesting must not depend on it. Group by parent with an accumulating mapping instead. This is reachable from zarr-python itself: `to_dict` sorts keys by `(depth, NFKC-casefold(key))`, so sibling subtrees whose names differ only by case interleave and trigger exactly this pattern. Fixes #4226 Assisted-by: ClaudeCode:claude-opus-5 * docs: add changelog entry for 273 Assisted-by: ClaudeCode:claude-opus-5 * docs: renumber changelog fragment to the upstream PR number Assisted-by: ClaudeCode:claude-opus-5 --- changes/4227.bugfix.md | 1 + src/zarr/core/group.py | 11 +++-- tests/test_metadata/test_consolidated.py | 62 ++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 changes/4227.bugfix.md diff --git a/changes/4227.bugfix.md b/changes/4227.bugfix.md new file mode 100644 index 0000000000..18293178bd --- /dev/null +++ b/changes/4227.bugfix.md @@ -0,0 +1 @@ +Consolidated metadata is now reconstructed independently of the order the keys appear in on disk. Previously, sibling subtrees whose keys were not adjacent in the persisted mapping lost their children, which made nodes unreachable through consolidated metadata -- most visibly for sibling groups whose names differ only by case. diff --git a/src/zarr/core/group.py b/src/zarr/core/group.py index 922eaf1498..65f7767a29 100644 --- a/src/zarr/core/group.py +++ b/src/zarr/core/group.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import itertools import logging import unicodedata import warnings @@ -237,10 +236,12 @@ def _flat_to_nested( # In the example, the group at `/a/b` will have consolidated metadata # for its children `array-0` and `array-1`. - keys = sorted(metadata, key=lambda k: k.count("/")) - grouped = { - k: list(v) for k, v in itertools.groupby(keys, key=lambda k: k.rsplit("/", 1)[0]) - } + # Group keys by their parent path. This must not rely on same-parent keys + # being adjacent: the persisted key order is arbitrary, so accumulate + # instead of using itertools.groupby, which only groups consecutive runs. + grouped: dict[str, list[str]] = defaultdict(list) + for k in sorted(metadata, key=lambda k: k.count("/")): + grouped[k.rsplit("/", 1)[0]].append(k) # we go top down and directly manipulate metadata. for key, children_keys in grouped.items(): diff --git a/tests/test_metadata/test_consolidated.py b/tests/test_metadata/test_consolidated.py index e6087435fe..cd0fd92d74 100644 --- a/tests/test_metadata/test_consolidated.py +++ b/tests/test_metadata/test_consolidated.py @@ -839,3 +839,65 @@ async def test_open_group_in_non_consolidating_stores() -> None: # Opening a group with use_consolidated=True should fail with pytest.raises(ValueError, match="doesn't support consolidated metadata"): await AsyncGroup.open(memory_store, use_consolidated=True) + + +@pytest.mark.parametrize( + "order", + [ + # keys grouped by parent, the order zarr-python used to write before it + # started sorting the persisted keys + ["a", "b", "a/x", "a/y", "b/x", "b/y"], + # sibling subtrees interleaved, which is what the (depth, casefold) sort + # produces for names differing only by case + ["a", "b", "a/x", "b/x", "a/y", "b/y"], + # reversed, to cover a parent appearing after its children in the mapping + ["b/y", "b/x", "a/y", "a/x", "b", "a"], + ], +) +def test_flat_to_nested_is_order_independent(order: list[str]) -> None: + """The persisted key order is arbitrary, so nesting must not depend on it.""" + group_metadata: dict[str, JSON] = {"zarr_format": 3, "node_type": "group", "attributes": {}} + consolidated = ConsolidatedMetadata.from_dict( + { + "kind": "inline", + "must_understand": False, + "metadata": dict.fromkeys(order, group_metadata), + } + ) + + assert sorted(consolidated.metadata) == ["a", "b"] + for name in ("a", "b"): + child = consolidated.metadata[name] + assert isinstance(child, GroupMetadata) + assert child.consolidated_metadata is not None + assert sorted(child.consolidated_metadata.metadata) == ["x", "y"] + + +async def test_consolidated_metadata_case_differing_siblings(memory_store: Store) -> None: + """Sibling nodes whose names differ only by case each keep their own children. + + Regression test for https://github.com/zarr-developers/zarr-python/issues/4226 + """ + root = await zarr.api.asynchronous.create_group(store=memory_store) + for name in ("Study", "study"): + child = await root.create_group(f"obs/{name}") + await child.create_array(name="categories", shape=(2,), dtype="uint8") + await child.create_array(name="codes", shape=(2,), dtype="uint8") + + with pytest.warns( + ZarrUserWarning, + match="Consolidated metadata is currently not part in the Zarr format 3 specification.", + ): + await consolidate_metadata(memory_store) + + consolidated = await open_consolidated(store=memory_store) + result = sorted([key async for key, _ in consolidated.members(max_depth=None)]) + assert result == [ + "obs", + "obs/Study", + "obs/Study/categories", + "obs/Study/codes", + "obs/study", + "obs/study/categories", + "obs/study/codes", + ] From 976be695a843dd2e5ebea5157f4cc7c22d9adef2 Mon Sep 17 00:00:00 2001 From: Joe Hamman Date: Mon, 3 Aug 2026 10:15:17 -0700 Subject: [PATCH 24/32] Convert rst double-backtick docstring markup to markdown (#4193) Docstring-only change: replace ``code`` (reStructuredText) with `code` (Markdown) in zarr.api.asynchronous, zarr.registry, and zarr.storage._common, matching the repo's mkdocs-based docs. Co-authored-by: Claude Fable 5 Co-authored-by: Davis Bennett --- changes/4193.doc.md | 4 + src/zarr/api/asynchronous.py | 72 +++++++++--------- src/zarr/api/synchronous.py | 140 +++++++++++++++++----------------- src/zarr/core/array.py | 142 +++++++++++++++++------------------ src/zarr/registry.py | 18 ++--- src/zarr/storage/_common.py | 18 ++--- 6 files changed, 199 insertions(+), 195 deletions(-) create mode 100644 changes/4193.doc.md diff --git a/changes/4193.doc.md b/changes/4193.doc.md new file mode 100644 index 0000000000..0972e8be2c --- /dev/null +++ b/changes/4193.doc.md @@ -0,0 +1,4 @@ +Converted remaining reStructuredText-style double-backtick markup to Markdown +single backticks in the docstrings of `zarr.api.asynchronous`, +`zarr.api.synchronous`, `zarr.core.array`, `zarr.registry`, and +`zarr.storage._common`. No functional changes. diff --git a/src/zarr/api/asynchronous.py b/src/zarr/api/asynchronous.py index f5e614a051..3bdc254ea5 100644 --- a/src/zarr/api/asynchronous.py +++ b/src/zarr/api/asynchronous.py @@ -103,7 +103,7 @@ def _infer_overwrite(mode: AccessModeLiteral) -> bool: """ - Check that an ``AccessModeLiteral`` is compatible with overwriting an existing Zarr node. + Check that an `AccessModeLiteral` is compatible with overwriting an existing Zarr node. """ return mode in _OVERWRITE_MODES @@ -112,9 +112,9 @@ def _warn_unimplemented_kwargs(kwargs: dict[str, Any]) -> None: """ Emit a "not yet implemented" warning for each provided keyword argument that is not None. - ``kwargs`` maps a keyword argument name to its supplied value. The ``stacklevel`` is chosen + `kwargs` maps a keyword argument name to its supplied value. The `stacklevel` is chosen so the warning points at the caller of the public API function (the same location as an - inline ``warnings.warn(..., stacklevel=2)`` would). + inline `warnings.warn(..., stacklevel=2)` would). """ for name, value in kwargs.items(): if value is not None: @@ -194,7 +194,7 @@ async def consolidate_metadata( Upon completion, the metadata of the root node in the Zarr hierarchy will be updated to include all the metadata of child nodes. For Stores that do - not support consolidated metadata, this operation raises a ``TypeError``. + not support consolidated metadata, this operation raises a `TypeError`. Parameters ---------- @@ -215,10 +215,10 @@ async def consolidate_metadata( Returns ------- group: AsyncGroup - The group, with the ``consolidated_metadata`` field set to include + The group, with the `consolidated_metadata` field set to include the metadata of each child node. If the Store doesn't support consolidated metadata, this function raises a `TypeError`. - See ``Store.supports_consolidated_metadata``. + See `Store.supports_consolidated_metadata`. """ store_path = await make_store_path(store, path=path) @@ -420,7 +420,7 @@ async def open_consolidated( *args: Any, use_consolidated: Literal[True] = True, **kwargs: Any ) -> AsyncGroup: """ - Alias for [`open_group`][zarr.api.asynchronous.open_group] with ``use_consolidated=True``. + Alias for [`open_group`][zarr.api.asynchronous.open_group] with `use_consolidated=True`. """ if use_consolidated is not True: raise TypeError( @@ -484,7 +484,7 @@ async def save_array( arr : ndarray NumPy array with data to save. zarr_format : {2, 3, None}, optional - The zarr format to use when saving. The default is ``None``, which will + The zarr format to use when saving. The default is `None`, which will use the default Zarr format defined in the global configuration object. path : str or None, optional The path within the store where the array will be saved. @@ -745,12 +745,12 @@ async def create_group( path : str, optional Group path within store. overwrite : bool, optional - If True, pre-existing data at ``path`` will be deleted before + If True, pre-existing data at `path` will be deleted before creating the group. zarr_format : {2, 3, None}, optional The zarr format to use when saving. - If no ``zarr_format`` is provided, the default format will be used. - This default can be changed by modifying the value of ``default_zarr_format`` + If no `zarr_format` is provided, the default format will be used. + This default can be changed by modifying the value of `default_zarr_format` in [`zarr.config`][zarr.config]. storage_options : dict If using an fsspec URL to create the store, these will be passed to @@ -828,17 +828,17 @@ async def open_group( Whether to use consolidated metadata. By default, consolidated metadata is used if it's present in the - store (in the ``zarr.json`` for Zarr format 3 and in the ``.zmetadata`` file + store (in the `zarr.json` for Zarr format 3 and in the `.zmetadata` file for Zarr format 2). - To explicitly require consolidated metadata, set ``use_consolidated=True``, + To explicitly require consolidated metadata, set `use_consolidated=True`, which will raise an exception if consolidated metadata is not found. - To explicitly *not* use consolidated metadata, set ``use_consolidated=False``, + To explicitly *not* use consolidated metadata, set `use_consolidated=False`, which will fall back to using the regular, non consolidated metadata. Zarr format 2 allowed configuring the key storing the consolidated metadata - (``.zmetadata`` by default). Specify the custom key as ``use_consolidated`` + (`.zmetadata` by default). Specify the custom key as `use_consolidated` to load consolidated metadata from a non-default key. Returns @@ -924,27 +924,27 @@ async def create( shape : int or tuple of ints Array shape. chunks : int or tuple of ints, optional - Chunk shape. If True, will be guessed from ``shape`` and ``dtype``. If - False, will be set to ``shape``, i.e., single chunk for the whole array. + Chunk shape. If True, will be guessed from `shape` and `dtype`. If + False, will be set to `shape`, i.e., single chunk for the whole array. If an int, the chunk size in each dimension will be given by the value - of ``chunks``. Default is True. + of `chunks`. Default is True. dtype : str or dtype, optional NumPy dtype. compressor : Codec, optional Primary compressor to compress chunk data. - Zarr format 2 only. Zarr format 3 arrays should use ``codecs`` instead. + Zarr format 2 only. Zarr format 3 arrays should use `codecs` instead. - If neither ``compressor`` nor ``filters`` are provided, the default compressor + If neither `compressor` nor `filters` are provided, the default compressor [`zarr.codecs.ZstdCodec`][] is used. - If ``compressor`` is set to ``None``, no compression is used. + If `compressor` is set to `None`, no compression is used. fill_value : Any, optional Fill value for the array. order : {'C', 'F'}, optional - Deprecated in favor of the ``config`` keyword argument. - Pass ``{'order': }`` to ``create`` instead of using this parameter. + Deprecated in favor of the `config` keyword argument. + Pass `{'order': }` to `create` instead of using this parameter. Memory layout to be used within each chunk. - If not specified, the ``array.order`` parameter in the global config will be used. + If not specified, the `array.order` parameter in the global config will be used. store : StoreLike or None, default=None StoreLike object to open. See the [storage documentation in the user guide][user-guide-store-like] @@ -952,12 +952,12 @@ async def create( synchronizer : object, optional Array synchronizer. overwrite : bool, optional - If True, delete all pre-existing data in ``store`` at ``path`` before + If True, delete all pre-existing data in `store` at `path` before creating the array. path : str, optional Path under which array is stored. chunk_store : StoreLike or None, default=None - Separate storage for chunks. If not provided, ``store`` will be used + Separate storage for chunks. If not provided, `store` will be used for storage of both chunks and metadata. filters : Iterable[Codec] | Literal["auto"], optional Iterable of filters to apply to each chunk of the array, in order, before serializing that @@ -970,14 +970,14 @@ async def create( For Zarr format 2, a "filter" can be any numcodecs codec; you should ensure that the order of your filters is consistent with the behavior of each filter. - The default value of ``"auto"`` instructs Zarr to use a default based on the data + The default value of `"auto"` instructs Zarr to use a default based on the data type of the array and the Zarr format specified. For all data types in Zarr V3, and most data types in Zarr V2, the default filters are empty. The only cases where default filters are not empty is when the Zarr format is 2, and the data type is a variable-length data type like [`zarr.dtype.VariableLengthUTF8`][] or [`zarr.dtype.VariableLengthUTF8`][]. In these cases, the default filters contains a single element which is a codec specific to that particular data type. - To create an array with no filters, provide an empty iterable or the value ``None``. + To create an array with no filters, provide an empty iterable or the value `None`. cache_metadata : bool, optional If True, array configuration metadata will be cached for the lifetime of the object. If False, array metadata will be reloaded @@ -993,17 +993,17 @@ async def create( A codec to encode object arrays, only needed if dtype=object. dimension_separator : {'.', '/'}, optional Separator placed between the dimensions of a chunk. - Zarr format 2 only. Zarr format 3 arrays should use ``chunk_key_encoding`` instead. + Zarr format 2 only. Zarr format 3 arrays should use `chunk_key_encoding` instead. write_empty_chunks : bool, optional - Deprecated in favor of the ``config`` keyword argument. - Pass ``{'write_empty_chunks': }`` to ``create`` instead of using this parameter. + Deprecated in favor of the `config` keyword argument. + Pass `{'write_empty_chunks': }` to `create` instead of using this parameter. If True, all chunks will be stored regardless of their contents. If False, each chunk is compared to the array's fill value prior to storing. If a chunk is uniformly equal to the fill value, then that chunk is not be stored, and the store entry for that chunk's key is deleted. zarr_format : {2, 3, None}, optional - The Zarr format to use when creating an array. The default is ``None``, + The Zarr format to use when creating an array. The default is `None`, which instructs Zarr to choose the default Zarr format value defined in the runtime configuration. meta_array : array-like, optional @@ -1016,15 +1016,15 @@ async def create( chunk_key_encoding : ChunkKeyEncoding, optional A specification of how the chunk keys are represented in storage. Zarr format 3 only. Zarr format 2 arrays should use `dimension_separator` instead. - Default is ``("default", "/")``. + Default is `("default", "/")`. codecs : Sequence of Codecs or dicts, optional An iterable of Codec or dict serializations of Codecs. Zarr V3 only. - The elements of ``codecs`` specify the transformation from array values to stored bytes. - Zarr format 3 only. Zarr format 2 arrays should use ``filters`` and ``compressor`` instead. + The elements of `codecs` specify the transformation from array values to stored bytes. + Zarr format 3 only. Zarr format 2 arrays should use `filters` and `compressor` instead. If no codecs are provided, default codecs will be used based on the data type of the array. - For most data types, the default codecs are the tuple ``(BytesCodec(), ZstdCodec())``; + For most data types, the default codecs are the tuple `(BytesCodec(), ZstdCodec())`; data types that require a special [`zarr.abc.codec.ArrayBytesCodec`][], like variable-length strings or bytes, will use the [`zarr.abc.codec.ArrayBytesCodec`][] required for the data type instead of [`zarr.codecs.BytesCodec`][]. dimension_names : Iterable[str | None] | None = None diff --git a/src/zarr/api/synchronous.py b/src/zarr/api/synchronous.py index dc12d5f7af..ebf42dca37 100644 --- a/src/zarr/api/synchronous.py +++ b/src/zarr/api/synchronous.py @@ -106,10 +106,10 @@ def consolidate_metadata( Returns ------- group: Group - The group, with the ``consolidated_metadata`` field set to include + The group, with the `consolidated_metadata` field set to include the metadata of each child node. If the Store doesn't support consolidated metadata, this function raises a `TypeError`. - See ``Store.supports_consolidated_metadata``. + See `Store.supports_consolidated_metadata`. """ return Group(sync(async_api.consolidate_metadata(store, path=path, zarr_format=zarr_format))) @@ -247,7 +247,7 @@ def open( def open_consolidated(*args: Any, use_consolidated: Literal[True] = True, **kwargs: Any) -> Group: """ - Alias for [`open_group`][zarr.api.synchronous.open_group] with ``use_consolidated=True``. + Alias for [`open_group`][zarr.api.synchronous.open_group] with `use_consolidated=True`. """ return Group( sync(async_api.open_consolidated(*args, use_consolidated=use_consolidated, **kwargs)) @@ -303,7 +303,7 @@ def save_array( arr : ndarray NumPy array with data to save. zarr_format : {2, 3, None}, optional - The zarr format to use when saving. The default is ``None``, which will + The zarr format to use when saving. The default is `None`, which will use the default Zarr format defined in the global configuration object. path : str or None, optional The path within the store where the array will be saved. @@ -530,17 +530,17 @@ def open_group( Whether to use consolidated metadata. By default, consolidated metadata is used if it's present in the - store (in the ``zarr.json`` for Zarr format 3 and in the ``.zmetadata`` file + store (in the `zarr.json` for Zarr format 3 and in the `.zmetadata` file for Zarr format 2). - To explicitly require consolidated metadata, set ``use_consolidated=True``, + To explicitly require consolidated metadata, set `use_consolidated=True`, which will raise an exception if consolidated metadata is not found. - To explicitly *not* use consolidated metadata, set ``use_consolidated=False``, + To explicitly *not* use consolidated metadata, set `use_consolidated=False`, which will fall back to using the regular, non consolidated metadata. Zarr format 2 allowed configuring the key storing the consolidated metadata - (``.zmetadata`` by default). Specify the custom key as ``use_consolidated`` + (`.zmetadata` by default). Specify the custom key as `use_consolidated` to load consolidated metadata from a non-default key. Returns @@ -587,12 +587,12 @@ def create_group( path : str, optional Group path within store. overwrite : bool, optional - If True, pre-existing data at ``path`` will be deleted before + If True, pre-existing data at `path` will be deleted before creating the group. zarr_format : {2, 3, None}, optional The zarr format to use when saving. - If no ``zarr_format`` is provided, the default format will be used. - This default can be changed by modifying the value of ``default_zarr_format`` + If no `zarr_format` is provided, the default format will be used. + This default can be changed by modifying the value of `default_zarr_format` in [`zarr.config`][zarr.config]. storage_options : dict If using an fsspec URL to create the store, these will be passed to @@ -662,27 +662,27 @@ def create( shape : int or tuple of ints Array shape. chunks : int or tuple of ints, optional - Chunk shape. If True, will be guessed from ``shape`` and ``dtype``. If - False, will be set to ``shape``, i.e., single chunk for the whole array. + Chunk shape. If True, will be guessed from `shape` and `dtype`. If + False, will be set to `shape`, i.e., single chunk for the whole array. If an int, the chunk size in each dimension will be given by the value - of ``chunks``. Default is True. + of `chunks`. Default is True. dtype : str or dtype, optional NumPy dtype. compressor : Codec, optional Primary compressor to compress chunk data. - Zarr format 2 only. Zarr format 3 arrays should use ``codecs`` instead. + Zarr format 2 only. Zarr format 3 arrays should use `codecs` instead. - If neither ``compressor`` nor ``filters`` are provided, the default compressor + If neither `compressor` nor `filters` are provided, the default compressor [`zarr.codecs.ZstdCodec`][] is used. - If ``compressor`` is set to ``None``, no compression is used. + If `compressor` is set to `None`, no compression is used. fill_value : Any, optional Fill value for the array. order : {'C', 'F'}, optional - Deprecated in favor of the ``config`` keyword argument. - Pass ``{'order': }`` to ``create`` instead of using this parameter. + Deprecated in favor of the `config` keyword argument. + Pass `{'order': }` to `create` instead of using this parameter. Memory layout to be used within each chunk. - If not specified, the ``array.order`` parameter in the global config will be used. + If not specified, the `array.order` parameter in the global config will be used. store : StoreLike or None, default=None StoreLike object to open. See the [storage documentation in the user guide][user-guide-store-like] @@ -690,12 +690,12 @@ def create( synchronizer : object, optional Array synchronizer. overwrite : bool, optional - If True, delete all pre-existing data in ``store`` at ``path`` before + If True, delete all pre-existing data in `store` at `path` before creating the array. path : str, optional Path under which array is stored. chunk_store : StoreLike or None, default=None - Separate storage for chunks. If not provided, ``store`` will be used + Separate storage for chunks. If not provided, `store` will be used for storage of both chunks and metadata. filters : Iterable[Codec] | Literal["auto"], optional Iterable of filters to apply to each chunk of the array, in order, before serializing that @@ -708,14 +708,14 @@ def create( For Zarr format 2, a "filter" can be any numcodecs codec; you should ensure that the order of your filters is consistent with the behavior of each filter. - The default value of ``"auto"`` instructs Zarr to use a default based on the data + The default value of `"auto"` instructs Zarr to use a default based on the data type of the array and the Zarr format specified. For all data types in Zarr V3, and most data types in Zarr V2, the default filters are empty. The only cases where default filters are not empty is when the Zarr format is 2, and the data type is a variable-length data type like [`zarr.dtype.VariableLengthUTF8`][] or [`zarr.dtype.VariableLengthUTF8`][]. In these cases, the default filters contains a single element which is a codec specific to that particular data type. - To create an array with no filters, provide an empty iterable or the value ``None``. + To create an array with no filters, provide an empty iterable or the value `None`. cache_metadata : bool, optional If True, array configuration metadata will be cached for the lifetime of the object. If False, array metadata will be reloaded @@ -731,17 +731,17 @@ def create( A codec to encode object arrays, only needed if dtype=object. dimension_separator : {'.', '/'}, optional Separator placed between the dimensions of a chunk. - Zarr format 2 only. Zarr format 3 arrays should use ``chunk_key_encoding`` instead. + Zarr format 2 only. Zarr format 3 arrays should use `chunk_key_encoding` instead. write_empty_chunks : bool, optional - Deprecated in favor of the ``config`` keyword argument. - Pass ``{'write_empty_chunks': }`` to ``create`` instead of using this parameter. + Deprecated in favor of the `config` keyword argument. + Pass `{'write_empty_chunks': }` to `create` instead of using this parameter. If True, all chunks will be stored regardless of their contents. If False, each chunk is compared to the array's fill value prior to storing. If a chunk is uniformly equal to the fill value, then that chunk is not be stored, and the store entry for that chunk's key is deleted. zarr_format : {2, 3, None}, optional - The Zarr format to use when creating an array. The default is ``None``, + The Zarr format to use when creating an array. The default is `None`, which instructs Zarr to choose the default Zarr format value defined in the runtime configuration. meta_array : array-like, optional @@ -754,15 +754,15 @@ def create( chunk_key_encoding : ChunkKeyEncoding, optional A specification of how the chunk keys are represented in storage. Zarr format 3 only. Zarr format 2 arrays should use `dimension_separator` instead. - Default is ``("default", "/")``. + Default is `("default", "/")`. codecs : Sequence of Codecs or dicts, optional An iterable of Codec or dict serializations of Codecs. Zarr V3 only. - The elements of ``codecs`` specify the transformation from array values to stored bytes. - Zarr format 3 only. Zarr format 2 arrays should use ``filters`` and ``compressor`` instead. + The elements of `codecs` specify the transformation from array values to stored bytes. + Zarr format 3 only. Zarr format 2 arrays should use `filters` and `compressor` instead. If no codecs are provided, default codecs will be used based on the data type of the array. - For most data types, the default codecs are the tuple ``(BytesCodec(), ZstdCodec())``; + For most data types, the default codecs are the tuple `(BytesCodec(), ZstdCodec())`; data types that require a special [`zarr.abc.codec.ArrayBytesCodec`][], like variable-length strings or bytes, will use the [`zarr.abc.codec.ArrayBytesCodec`][] required for the data type instead of [`zarr.codecs.BytesCodec`][]. dimension_names : Iterable[str | None] | None = None @@ -849,24 +849,24 @@ def create_array( [storage documentation in the user guide][user-guide-store-like] for a description of all valid StoreLike values. name : str or None, optional - The name of the array within the store. If ``name`` is ``None``, the array will be located + The name of the array within the store. If `name` is `None`, the array will be located at the root of the store. shape : ShapeLike, optional - Shape of the array. Must be ``None`` if ``data`` is provided. + Shape of the array. Must be `None` if `data` is provided. dtype : ZDTypeLike | None - Data type of the array. Must be ``None`` if ``data`` is provided. + Data type of the array. Must be `None` if `data` is provided. data : np.ndarray, optional Array-like data to use for initializing the array. If this parameter is provided, the - ``shape`` and ``dtype`` parameters must be ``None``. + `shape` and `dtype` parameters must be `None`. chunks : tuple[int, ...] | Sequence[Sequence[int]] | Literal["auto"], default="auto" Chunk shape of the array. If chunks is "auto", a chunk shape is guessed based on the shape of the array and the dtype. A nested list of per-dimension edge sizes creates a rectilinear grid. Rectilinear chunk grids are experimental and must be explicitly enabled - with ``zarr.config.set({'array.rectilinear_chunks': True})`` while the + with `zarr.config.set({'array.rectilinear_chunks': True})` while the feature is stabilizing. shards : tuple[int, ...], optional - Shard shape of the array. The default value of ``None`` results in no sharding at all. + Shard shape of the array. The default value of `None` results in no sharding at all. filters : Iterable[Codec] | Literal["auto"], optional Iterable of filters to apply to each chunk of the array, in order, before serializing that chunk to bytes. @@ -879,35 +879,35 @@ def create_array( For Zarr format 2, a "filter" can be any numcodecs codec; you should ensure that the order of your filters is consistent with the behavior of each filter. - The default value of ``"auto"`` instructs Zarr to use a default based on the data + The default value of `"auto"` instructs Zarr to use a default based on the data type of the array and the Zarr format specified. For all data types in Zarr V3, and most data types in Zarr V2, the default filters are empty. The only cases where default filters are not empty is when the Zarr format is 2, and the data type is a variable-length data type like [`zarr.dtype.VariableLengthUTF8`][] or [`zarr.dtype.VariableLengthUTF8`][]. In these cases, the default filters contains a single element which is a codec specific to that particular data type. - To create an array with no filters, provide an empty iterable or the value ``None``. + To create an array with no filters, provide an empty iterable or the value `None`. compressors : Iterable[Codec], optional List of compressors to apply to the array. Compressors are applied in order, and after any filters are applied (if any are specified) and the data is serialized into bytes. For Zarr format 3, a "compressor" is a codec that takes a bytestream, and returns another bytestream. Multiple compressors may be provided for Zarr format 3. - If no ``compressors`` are provided, a default set of compressors will be used. - These defaults can be changed by modifying the value of ``array.v3_default_compressors`` + If no `compressors` are provided, a default set of compressors will be used. + These defaults can be changed by modifying the value of `array.v3_default_compressors` in [`zarr.config`][zarr.config]. - Use ``None`` to omit default compressors. + Use `None` to omit default compressors. For Zarr format 2, a "compressor" can be any numcodecs codec. Only a single compressor may be provided for Zarr format 2. - If no ``compressor`` is provided, a default compressor will be used. + If no `compressor` is provided, a default compressor will be used. in [`zarr.config`][zarr.config]. - Use ``None`` to omit the default compressor. + Use `None` to omit the default compressor. serializer : dict[str, JSON] | ArrayBytesCodec, optional Array-to-bytes codec to use for encoding the array data. Zarr format 3 only. Zarr format 2 arrays use implicit array-to-bytes conversion. - If no ``serializer`` is provided, a default serializer will be used. - These defaults can be changed by modifying the value of ``array.v3_default_serializer`` + If no `serializer` is provided, a default serializer will be used. + These defaults can be changed by modifying the value of `array.v3_default_serializer` in [`zarr.config`][zarr.config]. fill_value : Any, optional Fill value for the array. @@ -916,17 +916,17 @@ def create_array( For Zarr format 2, this parameter sets the memory order of the array. For Zarr format 3, this parameter is deprecated, because memory order is a runtime parameter for Zarr format 3 arrays. The recommended way to specify the memory - order for Zarr format 3 arrays is via the ``config`` parameter, e.g. ``{'config': 'C'}``. - If no ``order`` is provided, a default order will be used. - This default can be changed by modifying the value of ``array.order`` in [`zarr.config`][zarr.config]. + order for Zarr format 3 arrays is via the `config` parameter, e.g. `{'config': 'C'}`. + If no `order` is provided, a default order will be used. + This default can be changed by modifying the value of `array.order` in [`zarr.config`][zarr.config]. zarr_format : {2, 3}, optional The zarr format to use when saving. attributes : dict, optional Attributes for the array. chunk_key_encoding : ChunkKeyEncodingLike, optional A specification of how the chunk keys are represented in storage. - For Zarr format 3, the default is ``{"name": "default", "separator": "/"}}``. - For Zarr format 2, the default is ``{"name": "v2", "separator": "."}}``. + For Zarr format 3, the default is `{"name": "default", "separator": "/"}}`. + For Zarr format 2, the default is `{"name": "v2", "separator": "."}}`. dimension_names : Iterable[str], optional The names of the dimensions (default is None). Zarr format 3 only. Zarr format 2 arrays should not use this parameter. @@ -935,13 +935,13 @@ def create_array( Ignored otherwise. overwrite : bool, default False Whether to overwrite an array with the same name in the store, if one exists. - If ``True``, all existing paths in the store will be deleted. + If `True`, all existing paths in the store will be deleted. config : ArrayConfigLike, optional Runtime configuration for the array. write_data : bool - If a pre-existing array-like object was provided to this function via the ``data`` parameter - then ``write_data`` determines whether the values in that array-like object should be - written to the Zarr array created by this function. If ``write_data`` is ``False``, then the + If a pre-existing array-like object was provided to this function via the `data` parameter + then `write_data` determines whether the values in that array-like object should be + written to the Zarr array created by this function. If `write_data` is `False`, then the array will be left empty. Returns @@ -1024,10 +1024,10 @@ def from_array( The array to copy. write_data : bool, default True Whether to copy the data from the input array to the new array. - If ``write_data`` is ``False``, the new array will be created with the same metadata as the + If `write_data` is `False`, the new array will be created with the same metadata as the input array, but without any data. name : str or None, optional - The name of the array within the store. If ``name`` is ``None``, the array will be located + The name of the array within the store. If `name` is `None`, the array will be located at the root of the store. chunks : tuple[int, ...] or Sequence[Sequence[int]] or "auto" or "keep", optional Chunk shape of the array. @@ -1038,7 +1038,7 @@ def from_array( - tuple[int, ...]: A tuple of integers representing the chunk shape (regular grid). - Sequence[Sequence[int]]: Per-dimension chunk edge lists (rectilinear grid). Rectilinear chunk grids are experimental and must be explicitly enabled - with ``zarr.config.set({'array.rectilinear_chunks': True})`` while the + with `zarr.config.set({'array.rectilinear_chunks': True})` while the feature is stabilizing. If not specified, defaults to "keep" if data is a zarr Array, otherwise "auto". @@ -1063,16 +1063,16 @@ def from_array( For Zarr format 2, a "filter" can be any numcodecs codec; you should ensure that the order of your filters is consistent with the behavior of each filter. - The default value of ``"keep"`` instructs Zarr to infer ``filters`` from ``data``. - If that inference is not possible, Zarr will fall back to the behavior specified by ``"auto"``, + The default value of `"keep"` instructs Zarr to infer `filters` from `data`. + If that inference is not possible, Zarr will fall back to the behavior specified by `"auto"`, which is to choose default filters based on the data type of the array and the Zarr format specified. - For all data types in Zarr V3, and most data types in Zarr V2, the default filters are the empty tuple ``()``. + For all data types in Zarr V3, and most data types in Zarr V2, the default filters are the empty tuple `()`. The only cases where default filters are not empty is when the Zarr format is 2, and the data type is a variable-length data type like [`zarr.dtype.VariableLengthUTF8`][] or [`zarr.dtype.VariableLengthUTF8`][]. In these cases, the default filters is a tuple with a single element which is a codec specific to that particular data type. - To create an array with no filters, provide an empty iterable or the value ``None``. + To create an array with no filters, provide an empty iterable or the value `None`. compressors : Iterable[Codec] or "auto" or "keep", optional List of compressors to apply to the array. Compressors are applied in order, and after any filters are applied (if any are specified) and the data is serialized into bytes. @@ -1089,17 +1089,17 @@ def from_array( - "auto": Automatically determine the compressors based on the array's dtype. - "keep": Retain the compressors of the input array if it is a zarr Array. - If no ``compressors`` are provided, defaults to "keep" if data is a zarr Array, otherwise "auto". + If no `compressors` are provided, defaults to "keep" if data is a zarr Array, otherwise "auto". serializer : dict[str, JSON] | ArrayBytesCodec or "auto" or "keep", optional Array-to-bytes codec to use for encoding the array data. Zarr format 3 only. Zarr format 2 arrays use implicit array-to-bytes conversion. Following values are supported: - - dict[str, JSON]: A dict representation of an ``ArrayBytesCodec``. - - ArrayBytesCodec: An instance of ``ArrayBytesCodec``. + - dict[str, JSON]: A dict representation of an `ArrayBytesCodec`. + - ArrayBytesCodec: An instance of `ArrayBytesCodec`. - "auto": a default serializer will be used. These defaults can be changed by modifying the value of - ``array.v3_default_serializer`` in [`zarr.config`][zarr.config]. + `array.v3_default_serializer` in [`zarr.config`][zarr.config]. - "keep": Retain the serializer of the input array if it is a zarr Array. fill_value : Any, optional @@ -1110,7 +1110,7 @@ def from_array( For Zarr format 2, this parameter sets the memory order of the array. For Zarr format 3, this parameter is deprecated, because memory order is a runtime parameter for Zarr format 3 arrays. The recommended way to specify the memory - order for Zarr format 3 arrays is via the ``config`` parameter, e.g. ``{'config': 'C'}``. + order for Zarr format 3 arrays is via the `config` parameter, e.g. `{'config': 'C'}`. If not specified, defaults to the memory order of the data array. zarr_format : {2, 3}, optional The zarr format to use when saving. @@ -1120,8 +1120,8 @@ def from_array( If not specified, defaults to the attributes of the data array. chunk_key_encoding : ChunkKeyEncoding, optional A specification of how the chunk keys are represented in storage. - For Zarr format 3, the default is ``{"name": "default", "separator": "/"}}``. - For Zarr format 2, the default is ``{"name": "v2", "separator": "."}}``. + For Zarr format 3, the default is `{"name": "default", "separator": "/"}}`. + For Zarr format 2, the default is `{"name": "v2", "separator": "."}}`. If not specified and the data array has the same zarr format as the target array, the chunk key encoding of the data array is used. dimension_names : Iterable[str | None] | None diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py index cd51dad50c..2b31eefcd4 100644 --- a/src/zarr/core/array.py +++ b/src/zarr/core/array.py @@ -160,7 +160,7 @@ from zarr.types import AnyArray, AnyAsyncArray, ArrayV2, ArrayV3, AsyncArrayV2, AsyncArrayV3 -# Array and AsyncArray are defined in the base ``zarr`` namespace +# Array and AsyncArray are defined in the base `zarr` namespace __all__ = [ "DEFAULT_FILL_VALUE", "create_codec_pipeline", @@ -336,8 +336,8 @@ async def _prepare_overwrite( """ Prepare a store path for writing a new node. - If ``overwrite`` is true and the store supports deletes, any existing node at - ``store_path`` is deleted. Otherwise, the absence of an existing node is enforced + If `overwrite` is true and the store supports deletes, any existing node at + `store_path` is deleted. Otherwise, the absence of an existing node is enforced (raising if one is present). """ if overwrite and store_path.store.supports_deletes: @@ -867,10 +867,10 @@ def read_chunk_sizes(self) -> tuple[tuple[int, ...], ...]: Boundary chunks that extend past the array shape are clipped, so the last size along a dimension may be smaller than the declared - chunk size. This matches the dask ``Array.chunks`` convention. + chunk size. This matches the dask `Array.chunks` convention. When sharding is used, returns the inner chunk sizes. - Otherwise, returns the outer chunk sizes (same as ``write_chunk_sizes``). + Otherwise, returns the outer chunk sizes (same as `write_chunk_sizes`). Returns ------- @@ -900,7 +900,7 @@ def write_chunk_sizes(self) -> tuple[tuple[int, ...], ...]: Always returns the outer chunk sizes, regardless of sharding. Boundary chunks that extend past the array shape are clipped, so the last size along a dimension may be smaller than the declared - chunk size. This matches the dask ``Array.chunks`` convention. + chunk size. This matches the dask `Array.chunks` convention. Returns ------- @@ -1439,7 +1439,7 @@ def nbytes(self) -> int: ----- This value is calculated by multiplying the number of elements in the array and the size of each element, the latter of which is determined by the dtype of the array. - For this reason, ``nbytes`` will likely be inaccurate for arrays with variable-length + For this reason, `nbytes` will likely be inaccurate for arrays with variable-length dtypes. It is not possible to determine the size of an array with variable-length elements from the shape and dtype alone. """ @@ -2041,10 +2041,10 @@ def read_chunk_sizes(self) -> tuple[tuple[int, ...], ...]: Boundary chunks that extend past the array shape are clipped, so the last size along a dimension may be smaller than the declared - chunk size. This matches the dask ``Array.chunks`` convention. + chunk size. This matches the dask `Array.chunks` convention. When sharding is used, returns the inner chunk sizes. - Otherwise, returns the outer chunk sizes (same as ``write_chunk_sizes``). + Otherwise, returns the outer chunk sizes (same as `write_chunk_sizes`). Returns ------- @@ -2068,7 +2068,7 @@ def write_chunk_sizes(self) -> tuple[tuple[int, ...], ...]: Always returns the outer chunk sizes, regardless of sharding. Boundary chunks that extend past the array shape are clipped, so the last size along a dimension may be smaller than the declared - chunk size. This matches the dask ``Array.chunks`` convention. + chunk size. This matches the dask `Array.chunks` convention. Returns ------- @@ -2286,7 +2286,7 @@ def nbytes(self) -> int: ----- This value is calculated by multiplying the number of elements in the array and the size of each element, the latter of which is determined by the dtype of the array. - For this reason, ``nbytes`` will likely be inaccurate for arrays with variable-length + For this reason, `nbytes` will likely be inaccurate for arrays with variable-length dtypes. It is not possible to determine the size of an array with variable-length elements from the shape and dtype alone. """ @@ -2300,7 +2300,7 @@ def nchunks_initialized(self) -> int: This value is calculated as the product of the number of initialized shards and the number of chunks per shard. For arrays that do not use sharding, the number of chunks per shard is effectively 1, and in that case the number of chunks initialized is the same as the number of stored objects associated with an - array. For a direct count of the number of initialized stored objects, see ``nshards_initialized``. + array. For a direct count of the number of initialized stored objects, see `nshards_initialized`. Returns ------- @@ -3980,7 +3980,7 @@ def info_complete(self) -> Any: """ Returns all the information about an array, including information from the Store. - In addition to the statically known information like ``name`` and ``zarr_format``, + In addition to the statically known information like `name` and `zarr_format`, this includes additional information like the size of the array in bytes and the number of chunks written. @@ -4098,10 +4098,10 @@ async def from_array( The array to copy. write_data : bool, default True Whether to copy the data from the input array to the new array. - If ``write_data`` is ``False``, the new array will be created with the same metadata as the + If `write_data` is `False`, the new array will be created with the same metadata as the input array, but without any data. name : str or None, optional - The name of the array within the store. If ``name`` is ``None``, the array will be located + The name of the array within the store. If `name` is `None`, the array will be located at the root of the store. chunks : tuple[int, ...] or Sequence[Sequence[int]] or "auto" or "keep", optional Chunk shape of the array. @@ -4112,7 +4112,7 @@ async def from_array( - tuple[int, ...]: A tuple of integers representing the chunk shape (regular grid). - Sequence[Sequence[int]]: Per-dimension chunk edge lists (rectilinear grid). Rectilinear chunk grids are experimental and must be explicitly enabled - with ``zarr.config.set({'array.rectilinear_chunks': True})`` while the + with `zarr.config.set({'array.rectilinear_chunks': True})` while the feature is stabilizing. If not specified, defaults to "keep" if data is a zarr Array, otherwise "auto". @@ -4137,16 +4137,16 @@ async def from_array( For Zarr format 2, a "filter" can be any numcodecs codec; you should ensure that the order of your filters is consistent with the behavior of each filter. - The default value of ``"keep"`` instructs Zarr to infer ``filters`` from ``data``. - If that inference is not possible, Zarr will fall back to the behavior specified by ``"auto"``, + The default value of `"keep"` instructs Zarr to infer `filters` from `data`. + If that inference is not possible, Zarr will fall back to the behavior specified by `"auto"`, which is to choose default filters based on the data type of the array and the Zarr format specified. - For all data types in Zarr V3, and most data types in Zarr V2, the default filters are the empty tuple ``()``. + For all data types in Zarr V3, and most data types in Zarr V2, the default filters are the empty tuple `()`. The only cases where default filters are not empty is when the Zarr format is 2, and the data type is a variable-length data type like [`zarr.dtype.VariableLengthUTF8`][] or [`zarr.dtype.VariableLengthUTF8`][]. In these cases, the default filters is a tuple with a single element which is a codec specific to that particular data type. - To create an array with no filters, provide an empty iterable or the value ``None``. + To create an array with no filters, provide an empty iterable or the value `None`. compressors : Iterable[Codec] or "auto" or "keep", optional List of compressors to apply to the array. Compressors are applied in order, and after any filters are applied (if any are specified) and the data is serialized into bytes. @@ -4163,17 +4163,17 @@ async def from_array( - "auto": Automatically determine the compressors based on the array's dtype. - "keep": Retain the compressors of the input array if it is a zarr Array. - If no ``compressors`` are provided, defaults to "keep" if data is a zarr Array, otherwise "auto". + If no `compressors` are provided, defaults to "keep" if data is a zarr Array, otherwise "auto". serializer : dict[str, JSON] | ArrayBytesCodec or "auto" or "keep", optional Array-to-bytes codec to use for encoding the array data. Zarr format 3 only. Zarr format 2 arrays use implicit array-to-bytes conversion. Following values are supported: - - dict[str, JSON]: A dict representation of an ``ArrayBytesCodec``. - - ArrayBytesCodec: An instance of ``ArrayBytesCodec``. + - dict[str, JSON]: A dict representation of an `ArrayBytesCodec`. + - ArrayBytesCodec: An instance of `ArrayBytesCodec`. - "auto": a default serializer will be used. These defaults can be changed by modifying the value of - ``array.v3_default_serializer`` in [`zarr.config`][zarr.config]. + `array.v3_default_serializer` in [`zarr.config`][zarr.config]. - "keep": Retain the serializer of the input array if it is a zarr Array. fill_value : Any, optional @@ -4184,7 +4184,7 @@ async def from_array( For Zarr format 2, this parameter sets the memory order of the array. For Zarr format 3, this parameter is deprecated, because memory order is a runtime parameter for Zarr format 3 arrays. The recommended way to specify the memory - order for Zarr format 3 arrays is via the ``config`` parameter, e.g. ``{'config': 'C'}``. + order for Zarr format 3 arrays is via the `config` parameter, e.g. `{'config': 'C'}`. If not specified, defaults to the memory order of the data array. zarr_format : {2, 3}, optional The zarr format to use when saving. @@ -4194,8 +4194,8 @@ async def from_array( If not specified, defaults to the attributes of the data array. chunk_key_encoding : ChunkKeyEncoding, optional A specification of how the chunk keys are represented in storage. - For Zarr format 3, the default is ``{"name": "default", "separator": "/"}}``. - For Zarr format 2, the default is ``{"name": "v2", "separator": "."}}``. + For Zarr format 3, the default is `{"name": "default", "separator": "/"}}`. + For Zarr format 2, the default is `{"name": "v2", "separator": "."}}`. If not specified and the data array has the same zarr format as the target array, the chunk key encoding of the data array is used. dimension_names : Iterable[str | None] | None @@ -4373,7 +4373,7 @@ async def init_array( Chunk shape of the array. If not specified, default are guessed based on the shape and dtype. shards : tuple[int, ...], optional - Shard shape of the array. The default value of ``None`` results in no sharding at all. + Shard shape of the array. The default value of `None` results in no sharding at all. filters : Iterable[Codec] | Literal["auto"], optional Iterable of filters to apply to each chunk of the array, in order, before serializing that chunk to bytes. @@ -4385,26 +4385,26 @@ async def init_array( For Zarr format 2, a "filter" can be any numcodecs codec; you should ensure that the order of your filters is consistent with the behavior of each filter. - The default value of ``"auto"`` instructs Zarr to use a default based on the data + The default value of `"auto"` instructs Zarr to use a default based on the data type of the array and the Zarr format specified. For all data types in Zarr V3, and most data types in Zarr V2, the default filters are empty. The only cases where default filters are not empty is when the Zarr format is 2, and the data type is a variable-length data type like [`zarr.dtype.VariableLengthUTF8`][] or [`zarr.dtype.VariableLengthUTF8`][]. In these cases, the default filters contains a single element which is a codec specific to that particular data type. - To create an array with no filters, provide an empty iterable or the value ``None``. + To create an array with no filters, provide an empty iterable or the value `None`. compressors : Iterable[Codec] | Literal["auto"], optional List of compressors to apply to the array. Compressors are applied in order, and after any filters are applied (if any are specified) and the data is serialized into bytes. - The default value of ``"auto"`` instructs Zarr to use a default of [`zarr.codecs.ZstdCodec`][]. + The default value of `"auto"` instructs Zarr to use a default of [`zarr.codecs.ZstdCodec`][]. - To create an array with no compressors, provide an empty iterable or the value ``None``. + To create an array with no compressors, provide an empty iterable or the value `None`. serializer : dict[str, JSON] | ArrayBytesCodec | Literal["auto"], optional Array-to-bytes codec to use for encoding the array data. Zarr format 3 only. Zarr format 2 arrays use implicit array-to-bytes conversion. - The default value of ``"auto"`` instructs Zarr to use a default codec based on the data type of the array. + The default value of `"auto"` instructs Zarr to use a default codec based on the data type of the array. For most data types this default codec is [`zarr.codecs.BytesCodec`][]. For [`zarr.dtype.VariableLengthUTF8`][], the default codec is [`zarr.codecs.VlenUTF8Codec`][]. For [`zarr.dtype.VariableLengthBytes`][], the default codec is [`zarr.codecs.VlenBytesCodec`][]. @@ -4415,17 +4415,17 @@ async def init_array( For Zarr format 2, this parameter sets the memory order of the array. For Zarr format 3, this parameter is deprecated, because memory order is a runtime parameter for Zarr format 3 arrays. The recommended way to specify the memory - order for Zarr format 3 arrays is via the ``config`` parameter, e.g. ``{'config': 'C'}``. - If no ``order`` is provided, a default order will be used. - This default can be changed by modifying the value of ``array.order`` in [`zarr.config`][zarr.config]. + order for Zarr format 3 arrays is via the `config` parameter, e.g. `{'config': 'C'}`. + If no `order` is provided, a default order will be used. + This default can be changed by modifying the value of `array.order` in [`zarr.config`][zarr.config]. zarr_format : {2, 3}, optional The zarr format to use when saving. attributes : dict, optional Attributes for the array. chunk_key_encoding : ChunkKeyEncodingLike, optional A specification of how the chunk keys are represented in storage. - For Zarr format 3, the default is ``{"name": "default", "separator": "/"}}``. - For Zarr format 2, the default is ``{"name": "v2", "separator": "."}}``. + For Zarr format 3, the default is `{"name": "default", "separator": "/"}}`. + For Zarr format 2, the default is `{"name": "v2", "separator": "."}}`. dimension_names : Iterable[str], optional The names of the dimensions (default is None). Zarr format 3 only. Zarr format 2 arrays should not use this parameter. @@ -4433,7 +4433,7 @@ async def init_array( Whether to overwrite an array with the same name in the store, if one exists. config : ArrayConfigLike or None, default=None Configuration for this array. - If ``None``, the default array runtime configuration will be used. This default + If `None`, the default array runtime configuration will be used. This default is stored in the global configuration object. Returns @@ -4595,24 +4595,24 @@ async def create_array( [storage documentation in the user guide][user-guide-store-like] for a description of all valid StoreLike values. name : str or None, optional - The name of the array within the store. If ``name`` is ``None``, the array will be located + The name of the array within the store. If `name` is `None`, the array will be located at the root of the store. shape : ShapeLike, optional - Shape of the array. Must be ``None`` if ``data`` is provided. + Shape of the array. Must be `None` if `data` is provided. dtype : ZDTypeLike | None - Data type of the array. Must be ``None`` if ``data`` is provided. + Data type of the array. Must be `None` if `data` is provided. data : np.ndarray, optional Array-like data to use for initializing the array. If this parameter is provided, the - ``shape`` and ``dtype`` parameters must be ``None``. + `shape` and `dtype` parameters must be `None`. chunks : tuple[int, ...] | Sequence[Sequence[int]] | Literal["auto"], default="auto" Chunk shape of the array. If chunks is "auto", a chunk shape is guessed based on the shape of the array and the dtype. A nested list of per-dimension edge sizes creates a rectilinear grid. Rectilinear chunk grids are experimental and must be explicitly enabled - with ``zarr.config.set({'array.rectilinear_chunks': True})`` while the + with `zarr.config.set({'array.rectilinear_chunks': True})` while the feature is stabilizing. shards : tuple[int, ...], optional - Shard shape of the array. The default value of ``None`` results in no sharding at all. + Shard shape of the array. The default value of `None` results in no sharding at all. filters : Iterable[Codec] | Literal["auto"], optional Iterable of filters to apply to each chunk of the array, in order, before serializing that chunk to bytes. @@ -4625,35 +4625,35 @@ async def create_array( For Zarr format 2, a "filter" can be any numcodecs codec; you should ensure that the order of your filters is consistent with the behavior of each filter. - The default value of ``"auto"`` instructs Zarr to use a default based on the data + The default value of `"auto"` instructs Zarr to use a default based on the data type of the array and the Zarr format specified. For all data types in Zarr V3, and most data types in Zarr V2, the default filters are empty. The only cases where default filters are not empty is when the Zarr format is 2, and the data type is a variable-length data type like [`zarr.dtype.VariableLengthUTF8`][] or [`zarr.dtype.VariableLengthUTF8`][]. In these cases, the default filters contains a single element which is a codec specific to that particular data type. - To create an array with no filters, provide an empty iterable or the value ``None``. + To create an array with no filters, provide an empty iterable or the value `None`. compressors : Iterable[Codec], optional List of compressors to apply to the array. Compressors are applied in order, and after any filters are applied (if any are specified) and the data is serialized into bytes. For Zarr format 3, a "compressor" is a codec that takes a bytestream, and returns another bytestream. Multiple compressors may be provided for Zarr format 3. - If no ``compressors`` are provided, a default set of compressors will be used. - These defaults can be changed by modifying the value of ``array.v3_default_compressors`` + If no `compressors` are provided, a default set of compressors will be used. + These defaults can be changed by modifying the value of `array.v3_default_compressors` in [`zarr.config`][zarr.config]. - Use ``None`` to omit default compressors. + Use `None` to omit default compressors. For Zarr format 2, a "compressor" can be any numcodecs codec. Only a single compressor may be provided for Zarr format 2. - If no ``compressor`` is provided, a default compressor will be used. + If no `compressor` is provided, a default compressor will be used. in [`zarr.config`][zarr.config]. - Use ``None`` to omit the default compressor. + Use `None` to omit the default compressor. serializer : dict[str, JSON] | ArrayBytesCodec, optional Array-to-bytes codec to use for encoding the array data. Zarr format 3 only. Zarr format 2 arrays use implicit array-to-bytes conversion. - If no ``serializer`` is provided, a default serializer will be used. - These defaults can be changed by modifying the value of ``array.v3_default_serializer`` + If no `serializer` is provided, a default serializer will be used. + These defaults can be changed by modifying the value of `array.v3_default_serializer` in [`zarr.config`][zarr.config]. fill_value : Any, optional Fill value for the array. @@ -4662,17 +4662,17 @@ async def create_array( For Zarr format 2, this parameter sets the memory order of the array. For Zarr format 3, this parameter is deprecated, because memory order is a runtime parameter for Zarr format 3 arrays. The recommended way to specify the memory - order for Zarr format 3 arrays is via the ``config`` parameter, e.g. ``{'config': 'C'}``. - If no ``order`` is provided, a default order will be used. - This default can be changed by modifying the value of ``array.order`` in [`zarr.config`][zarr.config]. + order for Zarr format 3 arrays is via the `config` parameter, e.g. `{'config': 'C'}`. + If no `order` is provided, a default order will be used. + This default can be changed by modifying the value of `array.order` in [`zarr.config`][zarr.config]. zarr_format : {2, 3}, optional The zarr format to use when saving. attributes : dict, optional Attributes for the array. chunk_key_encoding : ChunkKeyEncodingLike, optional A specification of how the chunk keys are represented in storage. - For Zarr format 3, the default is ``{"name": "default", "separator": "/"}}``. - For Zarr format 2, the default is ``{"name": "v2", "separator": "."}}``. + For Zarr format 3, the default is `{"name": "default", "separator": "/"}}`. + For Zarr format 2, the default is `{"name": "v2", "separator": "."}}`. dimension_names : Iterable[str], optional The names of the dimensions (default is None). Zarr format 3 only. Zarr format 2 arrays should not use this parameter. @@ -4681,13 +4681,13 @@ async def create_array( Ignored otherwise. overwrite : bool, default False Whether to overwrite an array with the same name in the store, if one exists. - If ``True``, all existing paths in the store will be deleted. + If `True`, all existing paths in the store will be deleted. config : ArrayConfigLike, optional Runtime configuration for the array. write_data : bool - If a pre-existing array-like object was provided to this function via the ``data`` parameter - then ``write_data`` determines whether the values in that array-like object should be - written to the Zarr array created by this function. If ``write_data`` is ``False``, then the + If a pre-existing array-like object was provided to this function via the `data` parameter + then `write_data` determines whether the values in that array-like object should be + written to the Zarr array created by this function. If `write_data` is `False`, then the array will be left empty. Returns @@ -4885,7 +4885,7 @@ def default_compressors_v3(dtype: ZDType[Any, Any]) -> tuple[BytesBytesCodec, .. """ Given a data type, return the default compressors for that data type. - This is just a tuple containing ``ZstdCodec`` + This is just a tuple containing `ZstdCodec` """ return (ZstdCodec(),) @@ -4894,12 +4894,12 @@ def default_serializer_v3(dtype: ZDType[Any, Any]) -> ArrayBytesCodec: """ Given a data type, return the default serializer for that data type. - The default serializer for most data types is the ``BytesCodec``, which may or may not be + The default serializer for most data types is the `BytesCodec`, which may or may not be parameterized with an endianness, depending on whether the data type has endianness. Variable - length strings and variable length bytes have hard-coded serializers -- ``VLenUTF8Codec`` and - ``VLenBytesCodec``, respectively. + length strings and variable length bytes have hard-coded serializers -- `VLenUTF8Codec` and + `VLenBytesCodec`, respectively. - Structured data types with multi-byte fields use ``BytesCodec`` with little-endian encoding. + Structured data types with multi-byte fields use `BytesCodec` with little-endian encoding. """ serializer: ArrayBytesCodec = BytesCodec(endian=None) @@ -4923,7 +4923,7 @@ def default_filters_v2(dtype: ZDType[Any, Any]) -> tuple[Numcodec] | None: Given a data type, return the default filters for that data type. For data types that require an object codec, namely variable length data types, - this is a tuple containing the object codec. Otherwise it's ``None``. + this is a tuple containing the object codec. Otherwise it's `None`. """ if isinstance(dtype, HasObjectCodec): if dtype.object_codec_id == "vlen-bytes": @@ -4944,7 +4944,7 @@ def default_compressor_v2(dtype: ZDType[Any, Any]) -> Numcodec: """ Given a data type, return the default compressors for that data type. - This is just the numcodecs ``Zstd`` codec. + This is just the numcodecs `Zstd` codec. """ from numcodecs import Zstd @@ -5101,7 +5101,7 @@ def _parse_data_params( dtype: ZDTypeLike | None, ) -> tuple[np.ndarray[Any, np.dtype[Any]] | None, ShapeLike, ZDTypeLike]: """ - Ensure an array-like ``data`` parameter is consistent with the ``dtype`` and ``shape`` + Ensure an array-like `data` parameter is consistent with the `dtype` and `shape` parameters. """ if data is None: diff --git a/src/zarr/registry.py b/src/zarr/registry.py index 48f60fabd7..c2c0eb2921 100644 --- a/src/zarr/registry.py +++ b/src/zarr/registry.py @@ -196,9 +196,9 @@ def _resolve_codec(data: dict[str, JSON]) -> Codec: def _parse_bytes_bytes_codec(data: dict[str, JSON] | Codec) -> BytesBytesCodec: """ - Normalize the input to a ``BytesBytesCodec`` instance. - If the input is already a ``BytesBytesCodec``, it is returned as is. If the input is a dict, it - is converted to a ``BytesBytesCodec`` instance via the ``_resolve_codec`` function. + Normalize the input to a `BytesBytesCodec` instance. + If the input is already a `BytesBytesCodec`, it is returned as is. If the input is a dict, it + is converted to a `BytesBytesCodec` instance via the `_resolve_codec` function. """ from zarr.abc.codec import BytesBytesCodec @@ -216,9 +216,9 @@ def _parse_bytes_bytes_codec(data: dict[str, JSON] | Codec) -> BytesBytesCodec: def _parse_array_bytes_codec(data: dict[str, JSON] | Codec) -> ArrayBytesCodec: """ - Normalize the input to a ``ArrayBytesCodec`` instance. - If the input is already a ``ArrayBytesCodec``, it is returned as is. If the input is a dict, it - is converted to a ``ArrayBytesCodec`` instance via the ``_resolve_codec`` function. + Normalize the input to a `ArrayBytesCodec` instance. + If the input is already a `ArrayBytesCodec`, it is returned as is. If the input is a dict, it + is converted to a `ArrayBytesCodec` instance via the `_resolve_codec` function. """ from zarr.abc.codec import ArrayBytesCodec @@ -236,9 +236,9 @@ def _parse_array_bytes_codec(data: dict[str, JSON] | Codec) -> ArrayBytesCodec: def _parse_array_array_codec(data: dict[str, JSON] | Codec) -> ArrayArrayCodec: """ - Normalize the input to a ``ArrayArrayCodec`` instance. - If the input is already a ``ArrayArrayCodec``, it is returned as is. If the input is a dict, it - is converted to a ``ArrayArrayCodec`` instance via the ``_resolve_codec`` function. + Normalize the input to a `ArrayArrayCodec` instance. + If the input is already a `ArrayArrayCodec`, it is returned as is. If the input is a dict, it + is converted to a `ArrayArrayCodec` instance via the `_resolve_codec` function. """ from zarr.abc.codec import ArrayArrayCodec diff --git a/src/zarr/storage/_common.py b/src/zarr/storage/_common.py index 7e9c035c69..ed554327cd 100644 --- a/src/zarr/storage/_common.py +++ b/src/zarr/storage/_common.py @@ -84,11 +84,11 @@ async def open(cls, store: Store, path: str, mode: AccessModeLiteral | None = No The accepted values are: - - ``'r'``: read only (must exist) - - ``'r+'``: read/write (must exist) - - ``'a'``: read/write (create if doesn't exist) - - ``'w'``: read/write (overwrite if exists) - - ``'w-'``: read/write (create if doesn't exist). + - `'r'`: read only (must exist) + - `'r+'`: read/write (must exist) + - `'a'`: read/write (create if doesn't exist) + - `'w'`: read/write (overwrite if exists) + - `'w-'`: read/write (create if doesn't exist). Raises ------ @@ -209,7 +209,7 @@ async def delete_dir(self) -> None: async def set_if_not_exists(self, default: Buffer) -> None: """ - Store a key to ``value`` if the key is not already present. + Store a key to `value` if the key is not already present. Parameters ---------- @@ -250,7 +250,7 @@ def get_sync( prototype: BufferPrototype | None = None, byte_range: ByteRequest | None = None, ) -> Buffer | None: - """Synchronous read — delegates to ``self.store.get_sync(self.path, ...)``.""" + """Synchronous read — delegates to `self.store.get_sync(self.path, ...)`.""" if not isinstance(self.store, SupportsGetSync): raise TypeError(f"Store {type(self.store).__name__} does not support synchronous get.") if prototype is None: @@ -258,13 +258,13 @@ def get_sync( return self.store.get_sync(self.path, prototype=prototype, byte_range=byte_range) def set_sync(self, value: Buffer) -> None: - """Synchronous write — delegates to ``self.store.set_sync(self.path, value)``.""" + """Synchronous write — delegates to `self.store.set_sync(self.path, value)`.""" if not isinstance(self.store, SupportsSetSync): raise TypeError(f"Store {type(self.store).__name__} does not support synchronous set.") self.store.set_sync(self.path, value) def delete_sync(self) -> None: - """Synchronous delete — delegates to ``self.store.delete_sync(self.path)``.""" + """Synchronous delete — delegates to `self.store.delete_sync(self.path)`.""" if not isinstance(self.store, SupportsDeleteSync): raise TypeError( f"Store {type(self.store).__name__} does not support synchronous delete." From 5a4767b9c4e6fcf5e4d60a7db0d08dc506935e45 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Wed, 5 Aug 2026 10:38:09 +0200 Subject: [PATCH 25/32] refactor(zarr-metadata)!: unify constant naming grammar with type names (#4232) A constant's name is now a purely syntactic transformation of the name of the `Literal` type it manifests, so the format version comes first and is spelled `ZARR_V2`/`ZARR_V3`, matching the `ZarrV2`/`ZarrV3` prefix already used by type names. This replaces the 0.4.0 split under which types put the version first and constants put it last; there is now one rule instead of two. Nine constants are renamed without aliases (pre-1.0). Digit runs stay glued to the token they follow, which keeps the spec vocabulary intact: `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. A strict letter/digit split would have renamed 18 of them for the worse. Store keys also move to the modules describing the documents they name. They are facts about the on-disk specs, so they belong beside the types they key: `ZARR_V2_ATTRIBUTES_STORE_KEY` now lives in `v2/attributes.py` next to `ZarrV2ZAttrsJSON`, rather than in the array model. This keeps the `v2`/`v3` packages as leaf spec-description modules that never import from `model`, and drops the `_group.py` -> `_array.py` import of a key that was never array-specific. `zarr_metadata.model` re-exports all of them, and they are now also exported from the top-level namespace alongside the rest of the spec vocabulary. `CONSOLIDATED_METADATA_KEY_V3` is renamed and moved likewise, but is not a store key: v3 consolidated metadata is embedded as an extension field in the group's own `zarr.json`, so it has no paired `Literal` alias and is not passed to the store-json helpers. Three tests pin what the refactor made implicit: constant names are derived from their types mechanically, the two v3 node store keys still name the same file now that they live in different modules, and the set of value-ambiguous constants the derivation check cannot see is counted so its coverage cannot shrink unnoticed. Assisted-by: ClaudeCode:claude-opus-4.8 --- .../zarr-metadata/changes/4232.removal.md | 63 +++++++ packages/zarr-metadata/docs/api/index.md | 5 +- .../src/zarr_metadata/__init__.py | 34 +++- .../src/zarr_metadata/model/__init__.py | 56 +++--- .../src/zarr_metadata/model/_array.py | 36 ++-- .../src/zarr_metadata/model/_group.py | 58 +++--- .../src/zarr_metadata/v2/array.py | 17 +- .../src/zarr_metadata/v2/attributes.py | 14 ++ .../src/zarr_metadata/v2/consolidated.py | 14 ++ .../src/zarr_metadata/v2/group.py | 11 +- .../src/zarr_metadata/v3/array.py | 15 +- .../src/zarr_metadata/v3/consolidated.py | 12 +- .../src/zarr_metadata/v3/group.py | 15 +- .../zarr-metadata/tests/model/test_array.py | 64 ++++++- .../zarr-metadata/tests/test_public_api.py | 165 +++++++++++++++++- 15 files changed, 480 insertions(+), 99 deletions(-) create mode 100644 packages/zarr-metadata/changes/4232.removal.md diff --git a/packages/zarr-metadata/changes/4232.removal.md b/packages/zarr-metadata/changes/4232.removal.md new file mode 100644 index 0000000000..73b2a18666 --- /dev/null +++ b/packages/zarr-metadata/changes/4232.removal.md @@ -0,0 +1,63 @@ +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. diff --git a/packages/zarr-metadata/docs/api/index.md b/packages/zarr-metadata/docs/api/index.md index 2aa39ab161..5e230c7aa2 100644 --- a/packages/zarr-metadata/docs/api/index.md +++ b/packages/zarr-metadata/docs/api/index.md @@ -17,9 +17,12 @@ The package is organized to mirror the structure of the Zarr specifications: [chunk key encodings](v3/chunk_key_encoding.md), [codecs](v3/codec.md), and [data types](v3/data_type.md) -Every public name is also re-exported at the top level, so +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 diff --git a/packages/zarr-metadata/src/zarr_metadata/__init__.py b/packages/zarr-metadata/src/zarr_metadata/__init__.py index b5e52e976d..1a6b39f04d 100644 --- a/packages/zarr-metadata/src/zarr_metadata/__init__.py +++ b/packages/zarr-metadata/src/zarr_metadata/__init__.py @@ -3,25 +3,38 @@ 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 ( - ARRAY_DIMENSION_SEPARATOR_V2, - ARRAY_ORDER_V2, + ZARR_V2_ARRAY_DIMENSION_SEPARATOR, + ZARR_V2_ARRAY_ORDER, ZarrV2ArrayDimensionSeparator, ZarrV2ArrayMetadataJSON, ZarrV2ArrayMetadataJSONPartial, @@ -217,8 +230,6 @@ __all__ = [ - "ARRAY_DIMENSION_SEPARATOR_V2", - "ARRAY_ORDER_V2", "BLOSC_CNAME", "BLOSC_CODEC_NAME", "BLOSC_SHUFFLE", @@ -260,6 +271,15 @@ "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", @@ -343,15 +363,19 @@ "ZarrV2ArrayMetadataJSON", "ZarrV2ArrayMetadataJSONPartial", "ZarrV2ArrayMetadataPartial", + "ZarrV2ArrayMetadataStoreKey", "ZarrV2ArrayOrder", + "ZarrV2AttributesStoreKey", "ZarrV2CodecMetadata", "ZarrV2ConsolidatedMetadata", "ZarrV2ConsolidatedMetadataJSON", + "ZarrV2ConsolidatedMetadataStoreKey", "ZarrV2DataTypeMetadata", "ZarrV2GroupMetadata", "ZarrV2GroupMetadataJSON", "ZarrV2GroupMetadataJSONPartial", "ZarrV2GroupMetadataPartial", + "ZarrV2GroupMetadataStoreKey", "ZarrV2ZArrayJSON", "ZarrV2ZAttrsJSON", "ZarrV2ZGroupJSON", @@ -359,6 +383,7 @@ "ZarrV3ArrayMetadataJSON", "ZarrV3ArrayMetadataJSONPartial", "ZarrV3ArrayMetadataPartial", + "ZarrV3ArrayMetadataStoreKey", "ZarrV3ConsolidatedMetadata", "ZarrV3ConsolidatedMetadataJSON", "ZarrV3ExtensionField", @@ -366,6 +391,7 @@ "ZarrV3GroupMetadataJSON", "ZarrV3GroupMetadataJSONPartial", "ZarrV3GroupMetadataPartial", + "ZarrV3GroupMetadataStoreKey", "ZarrV3MetadataField", "ZarrV3MetadataFieldJSON", "ZarrV3NamedConfig", diff --git a/packages/zarr-metadata/src/zarr_metadata/model/__init__.py b/packages/zarr-metadata/src/zarr_metadata/model/__init__.py index e726c54d3e..edf3561d1d 100644 --- a/packages/zarr-metadata/src/zarr_metadata/model/__init__.py +++ b/packages/zarr-metadata/src/zarr_metadata/model/__init__.py @@ -12,33 +12,20 @@ """ from zarr_metadata.model._array import ( - ARRAY_METADATA_STORE_KEY_V2, - ARRAY_METADATA_STORE_KEY_V3, - ATTRIBUTES_STORE_KEY_V2, ZarrV2ArrayMetadata, ZarrV2ArrayMetadataPartial, - ZarrV2ArrayMetadataStoreKey, - ZarrV2AttributesStoreKey, ZarrV3ArrayMetadata, ZarrV3ArrayMetadataPartial, - ZarrV3ArrayMetadataStoreKey, ZarrV3MetadataField, ZarrV3NamedConfig, ) from zarr_metadata.model._group import ( - CONSOLIDATED_METADATA_KEY_V3, - CONSOLIDATED_METADATA_STORE_KEY_V2, - GROUP_METADATA_STORE_KEY_V2, - GROUP_METADATA_STORE_KEY_V3, ZarrV2ConsolidatedMetadata, - ZarrV2ConsolidatedMetadataStoreKey, ZarrV2GroupMetadata, ZarrV2GroupMetadataPartial, - ZarrV2GroupMetadataStoreKey, ZarrV3ConsolidatedMetadata, ZarrV3GroupMetadata, ZarrV3GroupMetadataPartial, - ZarrV3GroupMetadataStoreKey, ) from zarr_metadata.model._sentinel import UNSET from zarr_metadata.model._validation import ( @@ -73,23 +60,52 @@ 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", - "ARRAY_METADATA_STORE_KEY_V2", - "ARRAY_METADATA_STORE_KEY_V3", - "ATTRIBUTES_STORE_KEY_V2", - "CONSOLIDATED_METADATA_KEY_V3", - "CONSOLIDATED_METADATA_STORE_KEY_V2", "GROUP_METADATA_OPTIONAL_KEYS_V3", "GROUP_METADATA_REQUIRED_KEYS_V2", "GROUP_METADATA_REQUIRED_KEYS_V3", "GROUP_METADATA_STANDARD_KEYS_V3", - "GROUP_METADATA_STORE_KEY_V2", - "GROUP_METADATA_STORE_KEY_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", diff --git a/packages/zarr-metadata/src/zarr_metadata/model/_array.py b/packages/zarr-metadata/src/zarr_metadata/model/_array.py index c4c967f891..0b562bc188 100644 --- a/packages/zarr-metadata/src/zarr_metadata/model/_array.py +++ b/packages/zarr-metadata/src/zarr_metadata/model/_array.py @@ -6,7 +6,7 @@ import dataclasses from collections.abc import Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Final, Literal, TypeAlias, cast +from typing import TYPE_CHECKING, Literal, TypeAlias, cast from typing_extensions import TypedDict, Unpack @@ -22,27 +22,27 @@ 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, ZarrV3ExtensionField - -ZarrV3ArrayMetadataStoreKey = Literal["zarr.json"] -ARRAY_METADATA_STORE_KEY_V3: Final[ZarrV3ArrayMetadataStoreKey] = "zarr.json" - -ZarrV2ArrayMetadataStoreKey = Literal[".zarray"] -ARRAY_METADATA_STORE_KEY_V2: Final[ZarrV2ArrayMetadataStoreKey] = ".zarray" - -ZarrV2AttributesStoreKey = Literal[".zattrs"] -ATTRIBUTES_STORE_KEY_V2: Final[ZarrV2AttributesStoreKey] = ".zattrs" + from zarr_metadata.v3.array import ( + ZarrV3ArrayMetadataJSON, + ZarrV3ArrayMetadataStoreKey, + ZarrV3ExtensionField, + ) @dataclass(frozen=True, slots=True, kw_only=True) @@ -319,12 +319,12 @@ def must_understand_fields(self) -> dict[str, ZarrV3ExtensionField]: @classmethod def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV3ArrayMetadata: - return cls.from_json(load_store_json(mapping, ARRAY_METADATA_STORE_KEY_V3)) + 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 {ARRAY_METADATA_STORE_KEY_V3: dump_store_json(self.to_json(), indent=indent)} + return {ZARR_V3_ARRAY_METADATA_STORE_KEY: dump_store_json(self.to_json(), indent=indent)} class ZarrV2ArrayMetadataPartial(TypedDict, total=False): @@ -464,7 +464,7 @@ def from_json(cls, data: object) -> ZarrV2ArrayMetadata: @classmethod def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2ArrayMetadata: - zarray_raw = cast("object", load_store_json(mapping, ARRAY_METADATA_STORE_KEY_V2)) + 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) @@ -478,8 +478,8 @@ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2ArrayMetadata: ) ] ) - if ATTRIBUTES_STORE_KEY_V2 in mapping: - zattrs = cast("object", load_store_json(mapping, ATTRIBUTES_STORE_KEY_V2)) + 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) @@ -491,8 +491,8 @@ def to_key_value( # 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] = { - ARRAY_METADATA_STORE_KEY_V2: dump_store_json(zarray, indent=indent) + ZARR_V2_ARRAY_METADATA_STORE_KEY: dump_store_json(zarray, indent=indent) } if self.attributes is not UNSET: - out[ATTRIBUTES_STORE_KEY_V2] = dump_store_json(self.attributes, indent=indent) + 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 index d576833c26..63dfe5611f 100644 --- a/packages/zarr-metadata/src/zarr_metadata/model/_group.py +++ b/packages/zarr-metadata/src/zarr_metadata/model/_group.py @@ -6,12 +6,11 @@ import dataclasses from collections.abc import Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Final, Literal, cast +from typing import TYPE_CHECKING, Literal, cast from typing_extensions import TypedDict, Unpack from zarr_metadata.model._array import ( - ATTRIBUTES_STORE_KEY_V2, ZarrV3ArrayMetadata, must_understand_subset, ) @@ -28,28 +27,20 @@ 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.model._array import ZarrV2AttributesStoreKey - from zarr_metadata.v2.group import ZarrV2GroupMetadataJSON + 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 = Literal["zarr.json"] -GROUP_METADATA_STORE_KEY_V3: Final[ZarrV3GroupMetadataStoreKey] = "zarr.json" - -ZarrV2GroupMetadataStoreKey = Literal[".zgroup"] -GROUP_METADATA_STORE_KEY_V2: Final[ZarrV2GroupMetadataStoreKey] = ".zgroup" - -ZarrV2ConsolidatedMetadataStoreKey = Literal[".zmetadata"] -CONSOLIDATED_METADATA_STORE_KEY_V2: Final[ZarrV2ConsolidatedMetadataStoreKey] = ".zmetadata" - -# The key under which consolidated metadata is embedded in a v3 group document. -# This is a reference-implementation convention (not a spec artifact), stored -# as an extension field on the group's `zarr.json`. -CONSOLIDATED_METADATA_KEY_V3: Final = "consolidated_metadata" + from zarr_metadata.v3.group import ZarrV3GroupMetadataJSON, ZarrV3GroupMetadataStoreKey class ZarrV3GroupMetadataPartial(TypedDict, total=False): @@ -88,7 +79,7 @@ class ZarrV3GroupMetadata: extra_fields: dict[str, ZarrV3ExtensionField] def __post_init__(self) -> None: - reserved = GROUP_METADATA_STANDARD_KEYS_V3 | {CONSOLIDATED_METADATA_KEY_V3} + reserved = GROUP_METADATA_STANDARD_KEYS_V3 | {ZARR_V3_CONSOLIDATED_METADATA_KEY} if set(self.extra_fields.keys()).intersection(reserved): raise MetadataValidationError( [ @@ -134,7 +125,7 @@ def to_json(self) -> ZarrV3GroupMetadataJSON: 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[CONSOLIDATED_METADATA_KEY_V3] = cast( + out[ZARR_V3_CONSOLIDATED_METADATA_KEY] = cast( "ZarrV3ExtensionField", self.consolidated_metadata.to_json() ) for key, value in self.extra_fields.items(): @@ -145,7 +136,7 @@ def to_json(self) -> ZarrV3GroupMetadataJSON: 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(CONSOLIDATED_METADATA_KEY_V3, UNSET)) + 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 @@ -162,7 +153,8 @@ def from_json(cls, data: object) -> ZarrV3GroupMetadata: { k: v for k, v in parsed.items() - if k not in GROUP_METADATA_STANDARD_KEYS_V3 and k != CONSOLIDATED_METADATA_KEY_V3 + if k not in GROUP_METADATA_STANDARD_KEYS_V3 + and k != ZARR_V3_CONSOLIDATED_METADATA_KEY }, ) return cls( @@ -185,12 +177,12 @@ def must_understand_fields(self) -> dict[str, ZarrV3ExtensionField]: @classmethod def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV3GroupMetadata: - return cls.from_json(load_store_json(mapping, GROUP_METADATA_STORE_KEY_V3)) + 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 {GROUP_METADATA_STORE_KEY_V3: dump_store_json(self.to_json(), indent=indent)} + return {ZARR_V3_GROUP_METADATA_STORE_KEY: dump_store_json(self.to_json(), indent=indent)} @dataclass(frozen=True, slots=True, kw_only=True) @@ -322,7 +314,7 @@ def from_json(cls, data: object) -> ZarrV2GroupMetadata: @classmethod def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2GroupMetadata: - zgroup_raw = cast("object", load_store_json(mapping, GROUP_METADATA_STORE_KEY_V2)) + 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) @@ -336,8 +328,8 @@ def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2GroupMetadata: ) ] ) - if ATTRIBUTES_STORE_KEY_V2 in mapping: - zattrs = cast("object", load_store_json(mapping, ATTRIBUTES_STORE_KEY_V2)) + 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) @@ -349,10 +341,10 @@ def to_key_value( # 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] = { - GROUP_METADATA_STORE_KEY_V2: dump_store_json(zgroup, indent=indent) + ZARR_V2_GROUP_METADATA_STORE_KEY: dump_store_json(zgroup, indent=indent) } if self.attributes is not UNSET: - out[ATTRIBUTES_STORE_KEY_V2] = dump_store_json(self.attributes, indent=indent) + out[ZARR_V2_ATTRIBUTES_STORE_KEY] = dump_store_json(self.attributes, indent=indent) return out @@ -434,9 +426,11 @@ def from_json(cls, data: object) -> ZarrV2ConsolidatedMetadata: @classmethod def from_key_value(cls, mapping: Mapping[str, bytes]) -> ZarrV2ConsolidatedMetadata: - return cls.from_json(load_store_json(mapping, CONSOLIDATED_METADATA_STORE_KEY_V2)) + 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 {CONSOLIDATED_METADATA_STORE_KEY_V2: dump_store_json(self.to_json(), indent=indent)} + return { + ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY: dump_store_json(self.to_json(), indent=indent) + } diff --git a/packages/zarr-metadata/src/zarr_metadata/v2/array.py b/packages/zarr-metadata/src/zarr_metadata/v2/array.py index 84b6446bcb..e026e5c655 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v2/array.py +++ b/packages/zarr-metadata/src/zarr_metadata/v2/array.py @@ -39,7 +39,7 @@ See https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html """ -ARRAY_ORDER_V2: Final = ("C", "F") +ZARR_V2_ARRAY_ORDER: Final = ("C", "F") """Tuple of permitted values for the `order` field of v2 array metadata.""" ZarrV2ArrayDimensionSeparator = Literal[".", "/"] @@ -51,7 +51,7 @@ See https://zarr-specs.readthedocs.io/en/latest/v2/v2.0.html """ -ARRAY_DIMENSION_SEPARATOR_V2: Final = (".", "/") +ZARR_V2_ARRAY_DIMENSION_SEPARATOR: Final = (".", "/") """Tuple of permitted values for the `dimension_separator` field of v2 array metadata.""" @@ -149,12 +149,21 @@ class ZarrV2ArrayMetadataJSONPartial(TypedDict, total=False): """ +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__ = [ - "ARRAY_DIMENSION_SEPARATOR_V2", - "ARRAY_ORDER_V2", + "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 index f7cc31babe..68785d1660 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v2/attributes.py +++ b/packages/zarr-metadata/src/zarr_metadata/v2/attributes.py @@ -4,6 +4,7 @@ """ from collections.abc import Mapping +from typing import Final, Literal from zarr_metadata._common import JSONValue @@ -17,6 +18,19 @@ """ +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/consolidated.py b/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py index 6b586bb92e..999c9131da 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py +++ b/packages/zarr-metadata/src/zarr_metadata/v2/consolidated.py @@ -7,6 +7,7 @@ """ from collections.abc import Mapping +from typing import Final, Literal from typing_extensions import TypedDict @@ -37,6 +38,19 @@ class ZarrV2ConsolidatedMetadataJSON(TypedDict): 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 index 50f2482e6f..34d72742c2 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v2/group.py +++ b/packages/zarr-metadata/src/zarr_metadata/v2/group.py @@ -4,7 +4,7 @@ """ from collections.abc import Mapping -from typing import Literal, NotRequired +from typing import Final, Literal, NotRequired from typing_extensions import TypedDict @@ -74,8 +74,17 @@ class ZarrV2GroupMetadataJSONPartial(TypedDict, total=False): 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/array.py b/packages/zarr-metadata/src/zarr_metadata/v3/array.py index 96341f73ca..31a5f6b755 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/array.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/array.py @@ -1,7 +1,7 @@ """Zarr v3 array metadata types.""" from collections.abc import Mapping -from typing import Literal, NotRequired, TypeAlias +from typing import Final, Literal, NotRequired, TypeAlias from typing_extensions import TypedDict @@ -75,8 +75,21 @@ class ZarrV3ArrayMetadataJSONPartial(TypedDict, total=False, extra_items=ZarrV3E 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/consolidated.py b/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py index bcbe675947..a9fe0c1f8f 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/consolidated.py @@ -12,7 +12,7 @@ """ from collections.abc import Mapping -from typing import Literal +from typing import Final, Literal from typing_extensions import TypedDict @@ -34,6 +34,16 @@ class ZarrV3ConsolidatedMetadataJSON(TypedDict): 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/group.py b/packages/zarr-metadata/src/zarr_metadata/v3/group.py index 033e91ff8c..37bfdd6934 100644 --- a/packages/zarr-metadata/src/zarr_metadata/v3/group.py +++ b/packages/zarr-metadata/src/zarr_metadata/v3/group.py @@ -4,7 +4,7 @@ """ from collections.abc import Mapping -from typing import Literal, NotRequired +from typing import Final, Literal, NotRequired from typing_extensions import TypedDict @@ -54,7 +54,20 @@ class ZarrV3GroupMetadataJSONPartial(TypedDict, total=False, extra_items=ZarrV3E attributes: NotRequired[Mapping[str, JSONValue]] +ZarrV3GroupMetadataStoreKey = Literal["zarr.json"] +"""Literal type of the store key holding a v3 group's metadata document.""" + +ZARR_V3_GROUP_METADATA_STORE_KEY: Final[ZarrV3GroupMetadataStoreKey] = "zarr.json" +"""The store key a v3 group's metadata document is persisted under. + +v3 uses one key for both node types; the document's `node_type` field +distinguishes a group from an array. +""" + + __all__ = [ + "ZARR_V3_GROUP_METADATA_STORE_KEY", "ZarrV3GroupMetadataJSON", "ZarrV3GroupMetadataJSONPartial", + "ZarrV3GroupMetadataStoreKey", ] diff --git a/packages/zarr-metadata/tests/model/test_array.py b/packages/zarr-metadata/tests/model/test_array.py index 467ef1e2ad..95dc7aea3a 100644 --- a/packages/zarr-metadata/tests/model/test_array.py +++ b/packages/zarr-metadata/tests/model/test_array.py @@ -64,25 +64,71 @@ def test_guards_exported_from_package() -> None: assert hasattr(zarr_metadata.model, name) +# `ZARR_V3_CONSOLIDATED_METADATA_KEY` is deliberately absent: it names a key +# *inside* a v3 group document, not a store key, so it has no paired `Literal` +# and no `to_key_value` signature to appear in. See `test_v3_consolidated_key_ +# is_not_a_store_key`, which pins that distinction. +STORE_KEY_PAIRS = [ + ("ZARR_V2_ARRAY_METADATA_STORE_KEY", "ZarrV2ArrayMetadataStoreKey", "zarr_metadata.v2.array"), + ("ZARR_V3_ARRAY_METADATA_STORE_KEY", "ZarrV3ArrayMetadataStoreKey", "zarr_metadata.v3.array"), + ("ZARR_V2_ATTRIBUTES_STORE_KEY", "ZarrV2AttributesStoreKey", "zarr_metadata.v2.attributes"), + ("ZARR_V2_GROUP_METADATA_STORE_KEY", "ZarrV2GroupMetadataStoreKey", "zarr_metadata.v2.group"), + ("ZARR_V3_GROUP_METADATA_STORE_KEY", "ZarrV3GroupMetadataStoreKey", "zarr_metadata.v3.group"), + ( + "ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY", + "ZarrV2ConsolidatedMetadataStoreKey", + "zarr_metadata.v2.consolidated", + ), +] + + def test_store_key_pairs_exported_from_package() -> None: """Each store-key constant is exported together with its Literal type alias, and the pair cannot drift apart.""" import zarr_metadata.model as m - pairs = [ - ("ARRAY_METADATA_STORE_KEY_V2", "ZarrV2ArrayMetadataStoreKey"), - ("ARRAY_METADATA_STORE_KEY_V3", "ZarrV3ArrayMetadataStoreKey"), - ("ATTRIBUTES_STORE_KEY_V2", "ZarrV2AttributesStoreKey"), - ("GROUP_METADATA_STORE_KEY_V2", "ZarrV2GroupMetadataStoreKey"), - ("GROUP_METADATA_STORE_KEY_V3", "ZarrV3GroupMetadataStoreKey"), - ("CONSOLIDATED_METADATA_STORE_KEY_V2", "ZarrV2ConsolidatedMetadataStoreKey"), - ] - for const_name, alias_name in pairs: + for const_name, alias_name, _ in STORE_KEY_PAIRS: assert const_name in m.__all__ assert alias_name in m.__all__ assert (getattr(m, const_name),) == get_args(getattr(m, alias_name)) +def test_store_keys_are_defined_in_their_spec_modules() -> None: + """Store keys are facts about the on-disk specs, so each is defined in the + `v2`/`v3` module describing that document — not in the model layer, which + only re-exports them.""" + import importlib + + for const_name, alias_name, module_name in STORE_KEY_PAIRS: + module = importlib.import_module(module_name) + for name in (const_name, alias_name): + assert name in module.__all__, f"{name} should be exported by {module_name}" + + +def test_v3_consolidated_key_is_not_a_store_key() -> None: + """v3 consolidated metadata is embedded as a field inside the group's own + `zarr.json`, not persisted under its own store key. It therefore has no + paired `Literal` alias, unlike every true store key — which is why it is + excluded from `STORE_KEY_PAIRS` rather than merely forgotten.""" + import zarr_metadata.model as m + + assert "ZARR_V3_CONSOLIDATED_METADATA_KEY" in m.__all__ + assert not hasattr(m, "ZarrV3ConsolidatedMetadataKey") + assert m.ZARR_V3_CONSOLIDATED_METADATA_KEY not in { + getattr(m, const_name) for const_name, _, _ in STORE_KEY_PAIRS + } + + +def test_v3_node_store_keys_agree() -> None: + """v3 keys both node types' metadata under one store key, distinguished by + the document's `node_type`. The array and group constants are separately + typed but must name the same file; adjacency used to make that obvious, and + they now live in different modules.""" + import zarr_metadata.model as m + + assert m.ZARR_V3_ARRAY_METADATA_STORE_KEY == m.ZARR_V3_GROUP_METADATA_STORE_KEY + + def test_validation_diagnostics_exported_from_package() -> None: """The validation-diagnostic types and validators are exported from the package.""" import zarr_metadata.model diff --git a/packages/zarr-metadata/tests/test_public_api.py b/packages/zarr-metadata/tests/test_public_api.py index e65c680fd1..6613aa394b 100644 --- a/packages/zarr-metadata/tests/test_public_api.py +++ b/packages/zarr-metadata/tests/test_public_api.py @@ -3,7 +3,7 @@ import importlib import pkgutil import re -from typing import get_args +from typing import Literal, get_args, get_origin import zarr_metadata as zm @@ -58,6 +58,23 @@ def _group_rank(s: str) -> int: "MetadataValidationError", "ProblemKind", "UNSET", + # Store keys — the names the documents are persisted under. Defined in the + # v2/v3 spec modules, re-exported through `zarr_metadata.model`. + "ZARR_V2_ARRAY_METADATA_STORE_KEY", + "ZarrV2ArrayMetadataStoreKey", + "ZARR_V2_GROUP_METADATA_STORE_KEY", + "ZarrV2GroupMetadataStoreKey", + "ZARR_V2_ATTRIBUTES_STORE_KEY", + "ZarrV2AttributesStoreKey", + "ZARR_V2_CONSOLIDATED_METADATA_STORE_KEY", + "ZarrV2ConsolidatedMetadataStoreKey", + "ZARR_V3_ARRAY_METADATA_STORE_KEY", + "ZarrV3ArrayMetadataStoreKey", + "ZARR_V3_GROUP_METADATA_STORE_KEY", + "ZarrV3GroupMetadataStoreKey", + # Not a store key: v3 consolidated metadata is embedded in the group's own + # `zarr.json`, so it has no paired Literal alias. + "ZARR_V3_CONSOLIDATED_METADATA_KEY", # v2 data-type encoding union "ZarrV2DataTypeMetadata", # Category B — codec canonical unions @@ -147,9 +164,9 @@ def _group_rank(s: str) -> int: "RawBytesDataTypeName", "RawBytesFillValue", # Category E — constant+Literal pairs - "ARRAY_ORDER_V2", + "ZARR_V2_ARRAY_ORDER", "ZarrV2ArrayOrder", - "ARRAY_DIMENSION_SEPARATOR_V2", + "ZARR_V2_ARRAY_DIMENSION_SEPARATOR", "ZarrV2ArrayDimensionSeparator", "ENDIANNESS", "Endianness", @@ -285,14 +302,19 @@ def test_all_is_grouped_and_unique() -> None: ) -def _public_type_names() -> set[tuple[str, str]]: - """Every (module, CamelCase name) pair exported via a public `__all__`.""" +def _iter_module_names() -> set[str]: + """Every public module in the package, including the top-level namespace.""" module_names = {"zarr_metadata"} for info in pkgutil.walk_packages(zm.__path__, prefix="zarr_metadata."): if not any(part.startswith("_") for part in info.name.split(".")[1:]): module_names.add(info.name) + return module_names + + +def _public_type_names() -> set[tuple[str, str]]: + """Every (module, CamelCase name) pair exported via a public `__all__`.""" out: set[tuple[str, str]] = set() - for module_name in module_names: + for module_name in _iter_module_names(): module = importlib.import_module(module_name) for name in getattr(module, "__all__", ()): if name.startswith("_") or name.isupper() or name.islower(): @@ -323,6 +345,8 @@ def test_standalone_vocab_is_not_stale() -> None: def test_promoted_pairs_drift() -> None: + """Each promoted runtime constant holds exactly the values of the `Literal` + type it manifests, so the two cannot drift apart.""" pairs = [ (zm.ENDIANNESS, zm.Endianness), (zm.BLOSC_CNAME, zm.BloscCName), @@ -331,7 +355,134 @@ def test_promoted_pairs_drift() -> None: (zm.NUMPY_TIME_UNIT, zm.NumpyTimeUnit), (zm.CAST_ROUNDING_MODE, zm.CastRoundingMode), (zm.CAST_OUT_OF_RANGE_MODE, zm.CastOutOfRangeMode), - (zm.ARRAY_ORDER_V2, zm.ZarrV2ArrayOrder), + (zm.ZARR_V2_ARRAY_ORDER, zm.ZarrV2ArrayOrder), + (zm.ZARR_V2_ARRAY_DIMENSION_SEPARATOR, zm.ZarrV2ArrayDimensionSeparator), + (zm.DEFAULT_CHUNK_KEY_ENCODING_SEPARATOR, zm.DefaultChunkKeyEncodingSeparator), + (zm.V2_CHUNK_KEY_ENCODING_SEPARATOR, zm.V2ChunkKeyEncodingSeparator), ] for const, lit in pairs: assert set(const) == set(get_args(lit)) + + +def constant_name_for(type_name: str) -> str: + """Derive a constant's name from the name of the type it manifests. + + The transformation is purely syntactic: split at each lowercase-to-uppercase + boundary and before an uppercase run that starts a new word, then uppercase. + Digit runs stay glued to the token they follow (`Uint8` -> `UINT8`, + `Crc32c` -> `CRC32C`), because a digit boundary in CamelCase does not mark a + word boundary in the spec vocabulary these names model. + + Consecutive capitals do not split, so acronym-adjacent names derive badly: + `ZarrV2ZArrayJSON` -> `ZARR_V2ZARRAY_JSON` and `...JSONPartial` -> + `...JSONPARTIAL`. Every such name in the package today is a `TypedDict` or + `TypeAliasType` that backs no constant, so none reaches this function — but + a future `Literal` spelled that way would silently be held to a bad name. + Splitting acronyms correctly needs a vocabulary, not a regex, so the rule + stays syntactic and this stays a known limit. + """ + return re.sub(r"(?<=[a-z0-9])(?=[A-Z][a-z])|(?<=[a-z])(?=[A-Z])", "_", type_name).upper() + + +def _literal_backed_constants() -> list[tuple[str, str, str]]: + """Every (module, constant, type) triple where a module-level SCREAMING_SNAKE + constant holds exactly the values of a `Literal` type in the same module. + + Pairing is by value, not by proximity: a constant manifests the type whose + members it enumerates. Constants with no such type (extension-field keys, + key sets, canonical bit patterns) are exempt from the naming rule and are + simply absent from the result. + """ + out: list[tuple[str, str, str]] = [] + for module_name in _iter_module_names(): + module = importlib.import_module(module_name) + literals = { + name: frozenset(get_args(obj)) + for name, obj in vars(module).items() + if not name.startswith("_") + and not name.isupper() + and get_origin(obj) is Literal + and get_args(obj) + } + if not literals: + continue + for const_name, value in vars(module).items(): + if const_name.startswith("_") or not const_name.isupper(): + continue + members = frozenset(value) if isinstance(value, tuple) else frozenset({value}) + if not all(isinstance(m, str) for m in members): + continue + matches = [t for t, args in literals.items() if args == members] + # A single unambiguous type means this constant manifests it. Ties + # (two Literals with identical members) carry no signal about which + # name the constant should take, so they are skipped. + if len(matches) == 1: + out.append((module_name, const_name, matches[0])) + return out + + +def _value_tied_constants() -> set[str]: + """Constants whose manifested type is ambiguous because two or more `Literal` + types in the same module share its exact members. + + These are invisible to the derivation check, so they are surfaced here and + counted, rather than silently dropped inside the pairing helper.""" + tied: set[str] = set() + for module_name in _iter_module_names(): + module = importlib.import_module(module_name) + literals = [ + frozenset(get_args(obj)) + for name, obj in vars(module).items() + if not name.startswith("_") + and not name.isupper() + and get_origin(obj) is Literal + and get_args(obj) + ] + for const_name, value in vars(module).items(): + if const_name.startswith("_") or not const_name.isupper(): + continue + members = frozenset(value) if isinstance(value, tuple) else frozenset({value}) + if not all(isinstance(m, str) for m in members): + continue + if sum(1 for args in literals if args == members) > 1: + tied.add(f"{module_name}.{const_name}") + return tied + + +# Constants whose `Literal` type cannot be identified by value because another +# `Literal` in the same module has identical members. Pairing is by value, so a +# tie carries no signal about which name the constant should take. These are +# checked by eye; the count below fails if the tied set grows silently. +KNOWN_VALUE_TIES = 9 + + +def test_constant_names_derive_from_their_type_names() -> None: + """Every `Literal`-backed constant whose type can be identified by value has + a name that is the mechanical transform of that type's name. + + Constants tied to more than one identically-valued `Literal` are exempt (see + `KNOWN_VALUE_TIES`), as are constants in private modules and those backing + no `Literal` at all — so this pins the rule for most of the package, not all + of it.""" + pairs = _literal_backed_constants() + assert pairs, "found no Literal-backed constants to check" + violations = [ + f"{module}: {const} should be {constant_name_for(type_name)} (manifests {type_name})" + for module, const, type_name in pairs + if const != constant_name_for(type_name) + ] + assert not violations, "constants whose names do not derive from their type:\n" + "\n".join( + violations + ) + + +def test_value_tied_constants_are_a_known_set() -> None: + """The derivation check cannot see constants whose type is ambiguous by + value. Pin how many there are, so the exempt set cannot grow unnoticed and + quietly shrink the rule's coverage.""" + tied = _value_tied_constants() + assert len(tied) == KNOWN_VALUE_TIES, ( + f"value-tied constants changed (expected {KNOWN_VALUE_TIES}, got {len(tied)}); " + f"these are unchecked by the derivation rule and must be named by hand:\n" + + "\n".join(sorted(tied)) + ) From 4e13cf577d5be18b8e5312fa254d1a3034a3d321 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Wed, 5 Aug 2026 16:34:26 +0200 Subject: [PATCH 26/32] fix: preserve non-JSON-serializable storage options in _make_async (#4239) * fix: preserve non-JSON-serializable storage options in _make_async Converting a sync instance of an async-capable filesystem to an async instance went through fs.to_json()/from_json(), which raises TypeError when storage options hold objects like azure.identity credentials. Reconstruct the filesystem from storage_args/storage_options instead. Closes #4220 Assisted-by: ClaudeCode:claude-fable-5 * Rename 4220.bugfix.md to 4239.bugfix.md --- changes/4239.bugfix.md | 1 + src/zarr/storage/_fsspec.py | 9 ++++----- tests/test_store/test_fsspec.py | 18 ++++++++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 changes/4239.bugfix.md diff --git a/changes/4239.bugfix.md b/changes/4239.bugfix.md new file mode 100644 index 0000000000..b5bc92f18b --- /dev/null +++ b/changes/4239.bugfix.md @@ -0,0 +1 @@ +`FsspecStore.from_mapper` and `FsspecStore.from_url` no longer fail when converting a synchronous instance of an async-capable filesystem whose storage options contain objects that cannot be serialized to JSON (e.g. an `azure.identity.DefaultAzureCredential`). The async instance is now constructed from the original filesystem arguments instead of a JSON round-trip. diff --git a/src/zarr/storage/_fsspec.py b/src/zarr/storage/_fsspec.py index 37d134dd95..b109f80935 100644 --- a/src/zarr/storage/_fsspec.py +++ b/src/zarr/storage/_fsspec.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json import warnings from contextlib import suppress from typing import TYPE_CHECKING, Any @@ -51,10 +50,10 @@ def _make_async(fs: AbstractFileSystem) -> AsyncFileSystem: # Already an async instance of an async filesystem, nothing to do return fs if fs.async_impl: - # Convert sync instance of an async fs to an async instance - fs_dict = json.loads(fs.to_json()) - fs_dict["asynchronous"] = True - return fsspec.AbstractFileSystem.from_json(json.dumps(fs_dict)) + # Convert sync instance of an async fs to an async instance. Reuse the original + # constructor arguments rather than round-tripping through JSON, since storage + # options may hold objects that are not JSON-serializable (e.g. credentials). + return type(fs)(*fs.storage_args, **{**fs.storage_options, "asynchronous": True}) if fsspec_version < parse_version("2024.12.0"): raise ImportError( diff --git a/tests/test_store/test_fsspec.py b/tests/test_store/test_fsspec.py index 515e1526b6..c367b908c5 100644 --- a/tests/test_store/test_fsspec.py +++ b/tests/test_store/test_fsspec.py @@ -584,6 +584,24 @@ def test_with_read_only_shares_filesystem(tmp_path: pathlib.Path) -> None: assert not source.read_only +def test_make_async_preserves_unserializable_storage_options() -> None: + """A sync instance of an async filesystem whose storage options hold objects that + cannot round-trip through JSON (e.g. an Azure credential) must still convert. + + See https://github.com/zarr-developers/zarr-python/issues/4220 + """ + pytest.importorskip("aiohttp") + credential = object() # stand-in for e.g. azure.identity.DefaultAzureCredential + sync_fs = fsspec.filesystem("http", client_kwargs={"auth": credential}) + assert sync_fs.async_impl + assert not sync_fs.asynchronous + + async_fs = _make_async(sync_fs) + + assert async_fs.asynchronous + assert async_fs.client_kwargs["auth"] is credential + + @pytest.mark.parametrize("asynchronous", [True, False]) def test_make_async(asynchronous: bool, endpoint_url: str) -> None: s3_filesystem = s3fs.S3FileSystem( From e382be8907f71729cd106d2ccf0b38eb016dc3c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:03:30 -0400 Subject: [PATCH 27/32] chore(deps): bump cryptography from 48.0.1 to 50.0.0 (#4240) Bumps [cryptography](https://github.com/pyca/cryptography) from 48.0.1 to 50.0.0. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/48.0.1...50.0.0) --- updated-dependencies: - dependency-name: cryptography dependency-version: 50.0.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 87 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/uv.lock b/uv.lock index 8eac71caa7..17441eee71 100644 --- a/uv.lock +++ b/uv.lock @@ -690,55 +690,52 @@ wheels = [ [[package]] name = "cryptography" -version = "48.0.1" +version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, - { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, - { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, - { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, - { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, - { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, - { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, - { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, - { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, - { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, - { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, - { url = "https://files.pythonhosted.org/packages/42/06/3e768b4c3bc78201583fa35a0e18f640dd782ff41afba88f8545481a8874/cryptography-48.0.1-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:f817adc181390bd54f2f700107a7419040fb7c1bdf2fc26f36551a06a68c3345", size = 7989830, upload-time = "2026-06-09T22:31:07.8Z" }, - { url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" }, - { url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" }, - { url = "https://files.pythonhosted.org/packages/24/dc/d8ca05ffea724eec6d232ea6f18e74c269eb6bdfdcc9bfba689790d1325f/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:e361afba8918070d376df76f408a4f67fec0ee9cff81a99e48fe9a233ef59e17", size = 5290385, upload-time = "2026-06-09T22:31:15.212Z" }, - { url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" }, - { url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/50d6b9e8aff12d8b67afaeb3569335e32dc83a5723e3bbded24fdac9f809/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:8ace4507d1e6533c125f4fac754f8bb8b6a74c08e92179dabd7e16571a3efbf3", size = 5245046, upload-time = "2026-06-09T22:31:25.774Z" }, - { url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" }, - { url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" }, - { url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" }, - { url = "https://files.pythonhosted.org/packages/a4/27/728c77876f12b000820b69ae490f3c4083775e79e07827e9e60be07ad209/cryptography-48.0.1-cp314-cp314t-win32.whl", hash = "sha256:0df56b056bc17c1b7d6821dfa65216e62bd232d8ab05eb3db44e71d235651471", size = 3278498, upload-time = "2026-06-09T22:31:29.154Z" }, - { url = "https://files.pythonhosted.org/packages/06/e3/79a612c6d7b1e6ee0edd43633d53035bec2cfb78c82b76f7864f39e36f34/cryptography-48.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:9de21387aa95e2a895823d0745b430bed4f33503ba9ab5e0b5311f33e37d66d2", size = 3798790, upload-time = "2026-06-09T22:31:56.697Z" }, - { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, - { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, - { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, - { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, - { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, - { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, - { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, - { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, - { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, ] [[package]] From b5e53a5e425c6fbb947e6e7cadb89a62a13fa3fb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:10:48 -0400 Subject: [PATCH 28/32] chore(deps): bump aiohttp from 3.14.1 to 3.14.3 (#4233) Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.14.1 to 3.14.3. - [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst) - [Commits](https://github.com/aio-libs/aiohttp/compare/v3.14.1...v3.14.3) --- updated-dependencies: - dependency-name: aiohttp dependency-version: 3.14.3 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Davis Bennett Co-authored-by: Max Jones <14077947+maxrjones@users.noreply.github.com> --- uv.lock | 170 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 85 insertions(+), 85 deletions(-) diff --git a/uv.lock b/uv.lock index 17441eee71..b7f8ae9b3c 100644 --- a/uv.lock +++ b/uv.lock @@ -35,7 +35,7 @@ wheels = [ [[package]] name = "aiohttp" -version = "3.14.1" +version = "3.14.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -47,90 +47,90 @@ dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, - { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, - { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, - { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, - { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, - { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, - { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, - { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, - { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, - { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, - { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, - { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, - { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, - { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, - { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, - { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, - { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, - { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, - { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, - { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, - { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, - { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, - { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, - { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, - { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, - { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, - { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, - { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, - { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, - { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, - { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, - { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, - { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, - { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, - { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, - { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, - { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, - { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, - { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, - { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, - { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, - { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, - { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, - { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, - { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, - { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, - { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, - { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, - { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +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]] From a1480823fb3819fba76a1a2ffcba76242f730180 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:11:33 -0400 Subject: [PATCH 29/32] chore(deps): bump pymdown-extensions from 10.21.3 to 11.0 (#4197) Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 10.21.3 to 11.0. - [Release notes](https://github.com/facelessuser/pymdown-extensions/releases) - [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.21.3...11.0) --- updated-dependencies: - dependency-name: pymdown-extensions dependency-version: '11.0' dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Davis Bennett Co-authored-by: Max Jones <14077947+maxrjones@users.noreply.github.com> --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index b7f8ae9b3c..048816cf02 100644 --- a/uv.lock +++ b/uv.lock @@ -2409,15 +2409,15 @@ wheels = [ [[package]] name = "pymdown-extensions" -version = "10.21.3" +version = "11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, + { url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" }, ] [[package]] From d28cceaa980cc24b6ed21a8c85728f9a1b97245e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:14:42 -0400 Subject: [PATCH 30/32] chore(deps): bump the actions group across 1 directory with 9 updates (#4241) Bumps the actions group with 9 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `7.0.0` | `7.0.1` | | [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `8.3.2` | `9.0.0` | | [CodSpeedHQ/action](https://github.com/codspeedhq/action) | `4.18.5` | `5.0.1` | | [actions/setup-python](https://github.com/actions/setup-python) | `6.3.0` | `7.0.0` | | [scientific-python/issue-from-pytest-log-action](https://github.com/scientific-python/issue-from-pytest-log-action) | `1.6.0` | `1.6.1` | | [j178/prek-action](https://github.com/j178/prek-action) | `2.0.5` | `3.0.0` | | [actions/attest](https://github.com/actions/attest) | `4.2.0` | `4.2.1` | | [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) | `1.14.0` | `1.14.2` | | [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) | `0.6.0` | `0.6.1` | Updates `actions/checkout` from 7.0.0 to 7.0.1 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0...3d3c42e5aac5ba805825da76410c181273ba90b1) Updates `astral-sh/setup-uv` from 8.3.2 to 9.0.0 - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/11f9893b081a58869d3b5fccaea48c9e9e46f990...c771a70e6277c0a99b617c7a806ffedaca235ff9) Updates `CodSpeedHQ/action` from 4.18.5 to 5.0.1 - [Release notes](https://github.com/codspeedhq/action/releases) - [Changelog](https://github.com/CodSpeedHQ/action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codspeedhq/action/compare/f99becdce5e5d51fd556489ebef684f4ecfd6286...88472375d0a4572cf70a9f1fe3a4e0ab8da1b924) Updates `actions/setup-python` from 6.3.0 to 7.0.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/ece7cb06caefa5fff74198d8649806c4678c61a1...5fda3b95a4ea91299a34e894583c3862153e4b97) Updates `scientific-python/issue-from-pytest-log-action` from 1.6.0 to 1.6.1 - [Release notes](https://github.com/scientific-python/issue-from-pytest-log-action/releases) - [Commits](https://github.com/scientific-python/issue-from-pytest-log-action/compare/87351a8f864e969567cda22a25a2f214cbe2340f...054799b34bd75a5fd6c86277a4a8a575224e60c6) Updates `j178/prek-action` from 2.0.5 to 3.0.0 - [Release notes](https://github.com/j178/prek-action/releases) - [Commits](https://github.com/j178/prek-action/compare/e98a699c41eb69ab013a45817a0406469a748f8d...4e14d07f9231acabce116ccfca13b13dd9755ece) Updates `actions/attest` from 4.2.0 to 4.2.1 - [Release notes](https://github.com/actions/attest/releases) - [Changelog](https://github.com/actions/attest/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest/compare/f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6...508db95dd578ae2727ebd6217d5ba78e4fbda05d) Updates `pypa/gh-action-pypi-publish` from 1.14.0 to 1.14.2 - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/cef221092ed1bacb1cc03d23a2d87d1d172e277b...dc37677b2e1c63e2034f94d8a5b11f265b73ba33) Updates `zizmorcore/zizmor-action` from 0.6.0 to 0.6.1 - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/6599ee8b7a49aef6a770f63d261d214911a7ce02...6fc4b006235f201fdab3722e17240ab420d580e5) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: astral-sh/setup-uv dependency-version: 9.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: CodSpeedHQ/action dependency-version: 5.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/setup-python dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: scientific-python/issue-from-pytest-log-action dependency-version: 1.6.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: j178/prek-action dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: actions/attest dependency-version: 4.2.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: pypa/gh-action-pypi-publish dependency-version: 1.14.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions - dependency-name: zizmorcore/zizmor-action dependency-version: 0.6.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Max Jones <14077947+maxrjones@users.noreply.github.com> --- .github/workflows/check_changelogs.yml | 4 ++-- .github/workflows/codspeed.yml | 4 ++-- .github/workflows/docs.yml | 4 ++-- .github/workflows/downstream.yml | 16 +++++++------- .github/workflows/gpu_test.yml | 6 +++--- .github/workflows/hypothesis.yaml | 8 +++---- .github/workflows/links.yml | 2 +- .github/workflows/lint.yml | 8 +++---- .github/workflows/nightly_wheels.yml | 4 ++-- .github/workflows/releases.yml | 8 +++---- .github/workflows/test.yml | 24 ++++++++++----------- .github/workflows/zarr-indexing-release.yml | 12 +++++------ .github/workflows/zarr-indexing.yml | 16 +++++++------- .github/workflows/zarr-metadata-release.yml | 12 +++++------ .github/workflows/zarr-metadata.yml | 16 +++++++------- .github/workflows/zizmor.yml | 4 ++-- 16 files changed, 74 insertions(+), 74 deletions(-) diff --git a/.github/workflows/check_changelogs.yml b/.github/workflows/check_changelogs.yml index d7a54fc2c4..b6c01e70fc 100644 --- a/.github/workflows/check_changelogs.yml +++ b/.github/workflows/check_changelogs.yml @@ -17,12 +17,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Check zarr-python changelog entries run: uv run --no-sync python ci/check_changelog_entries.py diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 427262d598..17e9de89ba 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -23,7 +23,7 @@ jobs: github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'benchmark')) steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false @@ -32,7 +32,7 @@ jobs: with: version: '1.16.5' - name: Run the benchmarks - uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5 + uses: CodSpeedHQ/action@88472375d0a4572cf70a9f1fe3a4e0ab8da1b924 # v5.0.1 env: ZARR_BENCHMARK_CLEAR_CACHE: '1' with: diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index baf9233fc7..792ee431ab 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,10 +19,10 @@ jobs: name: Check docs runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - run: uv sync --group docs # Fast source-level guards that need no built site, so they run before the (slower) # build for a quick failure: every public export is in the API reference, and no diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml index f65f8d47e3..98cd0fee3f 100644 --- a/.github/workflows/downstream.yml +++ b/.github/workflows/downstream.yml @@ -21,13 +21,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out zarr-python - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Check out xarray - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: pydata/xarray path: xarray @@ -40,12 +40,12 @@ jobs: # `meson-python: error: Unknown option "pixi-conda-environment"`, breaking # the job before any test runs. Tests that need a backend we don't install # are skipped via xarray's `requires_*` markers, not failed. - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.13' - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install xarray and test dependencies working-directory: xarray @@ -83,13 +83,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out zarr-python - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Check out numcodecs - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: zarr-developers/numcodecs fetch-depth: 0 @@ -97,12 +97,12 @@ jobs: submodules: recursive persist-credentials: false - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.13' - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install numcodecs with test-zarr-main group working-directory: numcodecs diff --git a/.github/workflows/gpu_test.yml b/.github/workflows/gpu_test.yml index bbbb3e5133..bf8700400e 100644 --- a/.github/workflows/gpu_test.yml +++ b/.github/workflows/gpu_test.yml @@ -34,7 +34,7 @@ jobs: python-version: ['3.12'] steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # grab all branches and tags persist-credentials: false @@ -57,12 +57,12 @@ jobs: echo $LD_LIBRARY_PATH nvcc -V - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install Hatch uses: pypa/hatch@257e27e51a6a5616ed08a39a408a21c35c9931bc with: diff --git a/.github/workflows/hypothesis.yaml b/.github/workflows/hypothesis.yaml index e836f30a5b..cfe4477e52 100644 --- a/.github/workflows/hypothesis.yaml +++ b/.github/workflows/hypothesis.yaml @@ -39,7 +39,7 @@ jobs: dependency-set: ["optional"] steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set HYPOTHESIS_PROFILE based on trigger @@ -52,12 +52,12 @@ jobs: echo "HYPOTHESIS_PROFILE=ci" >> $GITHUB_ENV fi - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install Hatch uses: pypa/hatch@257e27e51a6a5616ed08a39a408a21c35c9931bc with: @@ -109,7 +109,7 @@ jobs: && steps.status.outcome == 'failure' && github.event_name == 'schedule' && github.repository_owner == 'zarr-developers' - uses: scientific-python/issue-from-pytest-log-action@87351a8f864e969567cda22a25a2f214cbe2340f # v1.6.0 + uses: scientific-python/issue-from-pytest-log-action@054799b34bd75a5fd6c86277a4a8a575224e60c6 # v1.6.1 with: log-path: output-${{ matrix.python-version }}-log.jsonl issue-title: "Nightly Hypothesis tests failed" diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 0af76deece..d52639a708 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -13,7 +13,7 @@ jobs: permissions: issues: write # required for peter-evans/create-issue-from-file steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dacba6648f..83cc0a1b3e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,15 +19,15 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true - - uses: j178/prek-action@e98a699c41eb69ab013a45817a0406469a748f8d # v2.0.5 + - uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0 diff --git a/.github/workflows/nightly_wheels.yml b/.github/workflows/nightly_wheels.yml index 0a0cafd425..5b99c523a1 100644 --- a/.github/workflows/nightly_wheels.yml +++ b/.github/workflows/nightly_wheels.yml @@ -22,13 +22,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: true fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 name: Install Python with: python-version: '3.14' diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index fe0d09f300..759c443dd5 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -26,13 +26,13 @@ jobs: fail-fast: false steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: true fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 name: Install Python with: python-version: '3.12' @@ -81,8 +81,8 @@ jobs: name: releases path: dist - name: Generate artifact attestation - uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 with: subject-path: dist/* - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce6b7e3eba..50bb85ff5c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,17 +56,17 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # grab all branches and tags persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install Hatch run: python -m pip install hatch==1.16.5 - name: Set Up Hatch Env @@ -105,17 +105,17 @@ jobs: - python-version: "3.12" dependency-set: upstream steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install Hatch run: python -m pip install hatch==1.16.5 - name: Set Up Hatch Env @@ -140,17 +140,17 @@ jobs: name: doctests runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # required for hatch version discovery, which is needed for numcodecs.zarr3 persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.13' cache: 'pip' - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install Hatch run: python -m pip install hatch==1.16.5 - name: Set Up Hatch Env @@ -164,17 +164,17 @@ jobs: name: Benchmark smoke test runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.13' cache: 'pip' - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install Hatch run: python -m pip install hatch==1.16.5 - name: Run Benchmarks diff --git a/.github/workflows/zarr-indexing-release.yml b/.github/workflows/zarr-indexing-release.yml index 7cfd571eae..de57594d2b 100644 --- a/.github/workflows/zarr-indexing-release.yml +++ b/.github/workflows/zarr-indexing-release.yml @@ -22,7 +22,7 @@ jobs: shell: bash working-directory: packages/zarr-indexing steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 # hatch-vcs needs full history + tags @@ -51,7 +51,7 @@ jobs: path: dist - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: false @@ -82,12 +82,12 @@ jobs: path: dist - name: Generate artifact attestation - uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 with: subject-path: dist/* - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 upload_testpypi: name: Upload to TestPyPI @@ -107,11 +107,11 @@ jobs: path: dist - name: Generate artifact attestation - uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 with: subject-path: dist/* - name: Publish package to TestPyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/zarr-indexing.yml b/.github/workflows/zarr-indexing.yml index 2106b10916..afaa9e6db7 100644 --- a/.github/workflows/zarr-indexing.yml +++ b/.github/workflows/zarr-indexing.yml @@ -31,11 +31,11 @@ jobs: matrix: python-version: ['3.12', '3.13', '3.14'] steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true - name: Set up Python ${{ matrix.python-version }} @@ -57,11 +57,11 @@ jobs: shell: bash working-directory: packages/zarr-indexing steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Run ruff run: uvx ruff check . @@ -73,11 +73,11 @@ jobs: shell: bash working-directory: packages/zarr-indexing steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true - name: Set up Python @@ -95,11 +95,11 @@ jobs: shell: bash working-directory: packages/zarr-indexing steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true - name: Install just diff --git a/.github/workflows/zarr-metadata-release.yml b/.github/workflows/zarr-metadata-release.yml index bc9ecf9871..f9516ead71 100644 --- a/.github/workflows/zarr-metadata-release.yml +++ b/.github/workflows/zarr-metadata-release.yml @@ -22,7 +22,7 @@ jobs: shell: bash working-directory: packages/zarr-metadata steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false fetch-depth: 0 # hatch-vcs needs full history + tags @@ -51,7 +51,7 @@ jobs: path: dist - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: false @@ -82,12 +82,12 @@ jobs: path: dist - name: Generate artifact attestation - uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 with: subject-path: dist/* - name: Publish package to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 upload_testpypi: name: Upload to TestPyPI @@ -107,11 +107,11 @@ jobs: path: dist - name: Generate artifact attestation - uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 + uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4.2.1 with: subject-path: dist/* - name: Publish package to TestPyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/zarr-metadata.yml b/.github/workflows/zarr-metadata.yml index b5f56dd508..5b3b83b0e0 100644 --- a/.github/workflows/zarr-metadata.yml +++ b/.github/workflows/zarr-metadata.yml @@ -35,11 +35,11 @@ jobs: matrix: python-version: ['3.11', '3.12', '3.13', '3.14'] steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true - name: Install just @@ -59,11 +59,11 @@ jobs: shell: bash working-directory: packages/zarr-metadata steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - name: Install just uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4 - name: Run ruff @@ -77,11 +77,11 @@ jobs: shell: bash working-directory: packages/zarr-metadata steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true - name: Install just @@ -98,11 +98,11 @@ jobs: shell: bash working-directory: packages/zarr-metadata steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: enable-cache: true - name: Install just diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 1567bea713..9022c56455 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -27,9 +27,9 @@ jobs: security-events: write # Required by zizmor-action to upload SARIF files steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 # v0.6.0 + uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1 From 69bb812c9a9a385a4064b69d57db60daa24fc163 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Mon, 10 Aug 2026 21:21:47 +0200 Subject: [PATCH 31/32] docs: update roadmap per feedback from review. it's simpler --- docs/roadmap.md | 195 +++++++++--------------------------------------- 1 file changed, 36 insertions(+), 159 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 10bea05768..39667925f8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -8,15 +8,15 @@ on the *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/d-v-b/zarr-python-planning) +[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: the work ships when it is ready, - and individual items may change shape as the proposals are discussed and - refined. + 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 @@ -25,7 +25,8 @@ was a total redesign of the library's internals, with three goals: full support for the Zarr V2 and V3 storage formats, storage APIs ergonomic for high-latency (cloud) storage, and backwards compatibility with Zarr-Python 2.x where possible. Those goals were largely achieved, and more than a year on, the -2.x → 3.x transition is effectively resolved. +2.x → 3.x transition is effectively resolved, in the sense that it the transition +is no longer the prevailing source of issues and pull requests. The 3.x redesign was carried out under hard backwards-compatibility constraints, and it inherited many structural patterns from the 2.x @@ -42,7 +43,12 @@ 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: -- Give Zarr-Python users excellent performance, out of the box. +!!! note + Many of the features described below 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. + +- Deliver excellent performance, out of the box, by whatever means necessary (e.g., Rust bindings). - 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. @@ -52,13 +58,8 @@ directions: 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 have converged on the same architectural patterns — -sync-first codec APIs, per-codec concurrency budgets, adaptive sharded-read -strategies, request deduplication, conditional reads. We treat them as -complementary rather than competitive: Zarr-Python aims to be the best -pure-Python Zarr implementation *and* the best wrapper around the -compiled-language implementations, so that users who need native throughput can -get it without leaving the Zarr-Python API surface. +Zarr implementations that use architectural patterns we want to learn from. +We see them a complementary rather than competitive. ## The Zarr stack @@ -84,10 +85,10 @@ 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: -- **A focused package per level** — `zarr-metadata`, `zarr-store`, - `zarr-codec`, `zarr-dtype`, with `zarr` as the facade that composes them. - The first of these, - [`zarr-metadata`](https://pypi.org/project/zarr-metadata/), is already +- **A focused package per level** — `zarr-metadata`, `zarr-storage`, + `zarr-codec`, `zarr-dtype`, 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. The first two 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. - **A documented interface per level** — capability protocols for stores, a small stateless codec API, pure-data dtypes. @@ -102,73 +103,32 @@ on, conform to, or replace, without buying every other level: Each theme below is backed by a detailed technical proposal; the summaries here describe the intended end state. -### Foundation: a functional core - -Refactor the internals around a *functional core* — pure data structures and -pure functions for the algebra of Zarr (metadata, chunk layouts, slice -planning, codec walking) — with the side-effecting protocols (stores, codecs) -at the edges. This is an internal change that makes the per-level package split -implementable and provides a clean substrate for engine pluggability. - -### Foundation: a formal hierarchy layer - -Name and specify the layer that sits between the store API (key-agnostic -bytes) and the user-facing `Array` / `Group` facade, as a small set of typed -verbs (`read_array_metadata`, `write_chunk`, `list_children`, -`read_selection`, ...). Alternative engines implement the verbs end-to-end; -hierarchy-aware caching wraps them; chunk-introspection APIs expose them. - -### Codecs - -The current codec API wraps every codec in an unnecessary async layer (a -profiling hotspot), bakes batching into every signature, and forces output -allocation even when the caller has a buffer ready. Rewrite the codec API as a -small, stateless capability bundle — sync-first encode/decode, single-element -signatures, optional `decode_into`, capability flags — decoupled from the rest -of the library, with a compatibility shim for existing codecs and clear paths -for migrating Zarr V2 codecs that still have no V3 equivalent. - -### Stores - -The store abstraction conflates lifecycle, path handling, sync/async, -capability advertisement, and read-only semantics into one inheritance -hierarchy, and the resulting friction has produced a recurring stream of -regressions. Redesign stores as composable capability protocols (`Get`, `Put`, -`List`, ...) with composable wrappers (caching, range coalescing, retries), -transactional semantics, and a shared conformance suite that backends and -wrappers parameterize. - -### Performance - -A cross-cutting theme that ties the codec, store, and functional-core work -into one performance story: typed, library-owned concurrency resources with -dask-safe defaults; synchronous codec encode/decode on the default read path; -range coalescing; pre-allocated decode buffers; in-flight request -deduplication; ETag-style conditional reads; a unified caching substrate with -sensible defaults; an adaptive whole-shard-vs-coalesced read strategy; and -pluggable high-performance backends (zarrs, TensorStore) selectable with a -keyword argument, so the same `Array` and `Group` — and the same Xarray, Dask, -and napari integrations — work at native throughput. A benchmark suite for the -target access patterns lands first, so every performance lever ships with -before/after numbers. +### Foundation: swappable backends + +Refactor the internals around a *swappable engine* — a single protocol that defines the core routines a +Zarr implementation must support. Zarr-Python becomes one user-facing API that can be driven by multiple +backends, including a Python-heavy backend, but also a Rust-based backend, via bindings to the `zarrs` crate. + +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, where async scheduling is pure friction. The asynchronous backend will use `async` and 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 -syntax. `Array.__getitem__` performs IO eagerly and returns a NumPy arrays. +array indexing syntax. `Array.__getitem__` performs IO eagerly and returns a NumPy arrays. That was helpful to the dominant use-case at the time of its creation, but it means deferred I/O and computation currently require an external library -such as Dask. It means there is no build-in support for representing multi +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. -To solve this limitation, Add an opt-in `array.lazy[...]` accessor backed by a -stable coordinate-mapping algebra (the `IndexTransform` work in -[#3906](https://github.com/zarr-developers/zarr-python/pull/3906)), plus a -small query planner that turns chained selections into a single IO plan before -any chunks are fetched. No new array type is introduced. Whether the *default* -of bare `array[...]` ever flips from eager to lazy is an explicit, separate -decision — see [decision points](#decision-points) below. +We can fix this by introducing an API for lazy indexing. Under this model, an 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 is an excellent role model for Zarr-Python here, and we can deliver + this functionality without breaking ordinary indexing behavior. See this + [classic discussion](https://github.com/zarr-developers/zarr-python/discussions/1603) for more + background. ### Data types @@ -190,24 +150,13 @@ 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. -### Observability - -Two pillars: **performance metrics and tracing** (a small library-owned -`Metrics` object plus OpenTelemetry auto-instrumentation across stores, codecs, -caches, and the engine boundary) and **stored-state introspection** (public -APIs for asking about chunk-level structure, materialization, byte ranges, and -storage footprint without reading the chunks — the surface projects like -VirtualiZarr and Kerchunk have been asking for). - ### 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. This substrate is -where the performance-lever defaults (concurrency, caching, engine selection) -will live, so it lands early. +deliberately, and named profiles replacing global mutators. ### Coordinated and distributed writes @@ -221,78 +170,6 @@ appenders) are enabled through the seam a transactional engine such as [Icechunk](https://icechunk.io/) builds on, rather than implemented in Zarr-Python itself. -### Missing APIs - -User-facing conveniences that users have been asking for, in some cases for -years: hierarchy navigation helpers, chunk introspection, explicit constructors -replacing `mode=`, a typed exception hierarchy, rich reprs, context-manager -support, data copying, and an in-library rechunking primitive. - -## How the work will be released - -**"v4" names this whole body of work, delivered across many releases — it is -not a single "4.0" feature release.** The work is organized into three streams -that run in parallel: - -| Stream | Release vehicle | Scope | -|---|---|---| -| **Additive value** | 3.x minor releases, shipping continuously | The overwhelming majority of the plan, including the entire foundation. No migration required. | -| **Deprecation accumulation** | Warnings across the 3.x line | Each surface is deprecated only *after* its additive replacement has shipped, so users always have a migration target before they see a warning. | -| **Breaking removals** | One minimal, late major release (4.0.0) | Removal of the deprecated surfaces, and *only* those, after deprecation windows have elapsed and downstream libraries have had release windows to adapt. | - -The additive stream is itself roughly ordered: - -1. **Ship-now wins** — dependency-free improvements that land first: the - benchmark suite, store-layer range coalescing, in-flight request - deduplication, the sync codec path on default reads, ML dtype support, - constructor and display UX. -2. **Foundation** — the functional-core refactor, the per-level package split, - the new stores API, the hierarchy verbs, the typed configuration substrate, - the full concurrency and caching rework, and the codec API rewrite. Mostly - invisible to users, all additive. -3. **User-facing surface** — opt-in lazy indexing and the query planner, - device-agnostic IO, observability, chunk introspection, and the zarrs and - TensorStore engine wrappers, built on the foundation. - -The eventual 4.0.0 release contains only removals whose replacements shipped -earlier: the legacy `Store` ABC and the `Buffer`/`prototype` read contract, the -`mode=` constructors, the internal `sync()` bridge, and — conditionally — the -eager `array[...]` path. Nothing new is delivered there; it is the only release -downstream maintainers must treat as breaking, and it arrives after the value -has already been delivered additively. - -### Backwards-compatibility commitments - -The v4 work changes the public API: methods will be renamed, signatures will -change, deprecated patterns will be removed, and the codec and store APIs will -be rewritten. We believe the changes are worth the cost, and we commit to the -following: - -- **Conformance with community standards.** Where a relevant cross-language - standard exists, we conform to it: the Python Array API at the array surface, - the Zarr V3 spec and its extensions at the storage layer, OpenTelemetry for - tracing, and standard buffer-protocol and device-interop conventions for - device-agnostic IO. -- **Functional coverage.** Anything you can do in Zarr-Python 3.x you will - still be able to do once the v4 work has landed — sometimes through a renamed - API, but the capability is preserved. We will not remove the ability to read - or write any Zarr-format data that 3.x supports. -- **A deprecation window for every change.** Renames and removals land through - deprecation cycles, and downstream libraries (Xarray, Dask, napari) get - release windows to absorb each change before the next one lands. -- **Generous legacy support** If necessary, we can keep old code around in a `legacy` module. Pydantic used a similar strategy to manage their 2.0 release: see https://pydantic.dev/docs/validation/dev/get-started/migration/#using-pydantic-v1-features-in-a-v1v2-environment. - -### Decision points - -Flipping the default of bare `array[...]` from eager to lazy is the single -highest-migration-cost item in the plan, so it is handled as an explicit -decision, not bundled into the additive work. The opt-in `array.lazy[...]` -accessor ships first, with no default change. Whether the default ever flips -hinges on whether Array API conformance at the bare-`__getitem__` surface turns -out to be a hard requirement; if it does, the flip happens as a long-window -deprecation with an explicit eager escape hatch and downstream coordination — -never as a reason to adopt a major version. - ## How to get involved - **Discuss the plans.** Comments and counter-proposals on any of the themes From c2ce491e0de29b8975b4616ddfc475046d51a5f6 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Mon, 10 Aug 2026 21:26:55 +0200 Subject: [PATCH 32/32] docs: lint --- docs/roadmap.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index 39667925f8..4776dd23fb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -111,7 +111,6 @@ backends, including a Python-heavy backend, but also a Rust-based backend, via b 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, where async scheduling is pure friction. The asynchronous backend will use `async` and 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 @@ -158,7 +157,6 @@ 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 Give the two patterns that actually produce large Zarr archives — parallel @@ -182,4 +180,4 @@ Zarr-Python itself. - **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. \ No newline at end of file + workloads or patterns that don't fit.