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/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/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..6a0d94052e 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': @@ -93,8 +95,11 @@ 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: + - blog/index.md hooks: - mkdocs_hooks.py @@ -153,6 +158,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: 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 0000000000..e77977ccf4 Binary files /dev/null and b/packages/zarr-indexing/docs/_static/favicon-96x96.png differ diff --git a/packages/zarr-indexing/docs/_static/logo_bw.png b/packages/zarr-indexing/docs/_static/logo_bw.png new file mode 100644 index 0000000000..df1979d3cc Binary files /dev/null and b/packages/zarr-indexing/docs/_static/logo_bw.png differ diff --git a/packages/zarr-indexing/docs/api/chunk_resolution.md b/packages/zarr-indexing/docs/api/chunk_resolution.md new file mode 100644 index 0000000000..0d81ec3829 --- /dev/null +++ b/packages/zarr-indexing/docs/api/chunk_resolution.md @@ -0,0 +1,5 @@ +--- +title: chunk_resolution +--- + +::: zarr_indexing.chunk_resolution diff --git a/packages/zarr-indexing/docs/api/composition.md b/packages/zarr-indexing/docs/api/composition.md new file mode 100644 index 0000000000..59affe016c --- /dev/null +++ b/packages/zarr-indexing/docs/api/composition.md @@ -0,0 +1,5 @@ +--- +title: composition +--- + +::: zarr_indexing.composition diff --git a/packages/zarr-indexing/docs/api/domain.md b/packages/zarr-indexing/docs/api/domain.md new file mode 100644 index 0000000000..b039d3a7f4 --- /dev/null +++ b/packages/zarr-indexing/docs/api/domain.md @@ -0,0 +1,5 @@ +--- +title: domain +--- + +::: zarr_indexing.domain diff --git a/packages/zarr-indexing/docs/api/errors.md b/packages/zarr-indexing/docs/api/errors.md new file mode 100644 index 0000000000..994a74248a --- /dev/null +++ b/packages/zarr-indexing/docs/api/errors.md @@ -0,0 +1,5 @@ +--- +title: errors +--- + +::: zarr_indexing.errors diff --git a/packages/zarr-indexing/docs/api/grid.md b/packages/zarr-indexing/docs/api/grid.md new file mode 100644 index 0000000000..c4c9cadb4f --- /dev/null +++ b/packages/zarr-indexing/docs/api/grid.md @@ -0,0 +1,5 @@ +--- +title: grid +--- + +::: zarr_indexing.grid diff --git a/packages/zarr-indexing/docs/api/index.md b/packages/zarr-indexing/docs/api/index.md new file mode 100644 index 0000000000..b9a58b70fa --- /dev/null +++ b/packages/zarr-indexing/docs/api/index.md @@ -0,0 +1,47 @@ +--- +title: API reference +--- + +# API reference + +The modules are layered: the transform algebra at the bottom, chunk resolution +and the wire format built on top of it. + +**The transform algebra** + +- [`zarr_indexing.domain`](domain.md) — `IndexDomain`, a rectangular region of + integer coordinates with an explicit (possibly non-zero) origin +- [`zarr_indexing.output_map`](output_map.md) — `ConstantMap`, `DimensionMap`, + and `ArrayMap`: three representations of a set of integer coordinates, one + per storage dimension +- [`zarr_indexing.transform`](transform.md) — `IndexTransform`, which pairs a + domain with output maps, plus the indexing (`[...]`, `.oindex`, `.vindex`), + `intersect`, and `translate` operations, and `selection_to_transform` +- [`zarr_indexing.composition`](composition.md) — `compose`, which chains two + transforms into one + +**Chunk resolution** + +- [`zarr_indexing.chunk_resolution`](chunk_resolution.md) — + `iter_chunk_transforms` (transform + chunk grid → per-chunk transforms) and + `sub_transform_to_selections` (the bridge back to the selection tuples the + current codec pipeline expects) +- [`zarr_indexing.grid`](grid.md) — `DimensionGridLike`, the Protocol + describing the narrow chunk-grid surface chunk resolution consumes, so that + nothing here imports `zarr` + +**The ndsel wire format** (see [the guide](../ndsel.md)) + +- [`zarr_indexing.messages`](messages.md) — `parse_ndsel` / `normalize_ndsel`, + the pure JSON→JSON message layer, and `NdselError` +- [`zarr_indexing.json`](json.md) — lowering between canonical ndsel bodies and + in-memory transforms + +**Errors** + +- [`zarr_indexing.errors`](errors.md) — the canonical index-error types, which + `zarr.errors` re-exports by identity + +Every name listed in `zarr_indexing.__all__` is re-exported at the top level, +so `from zarr_indexing import IndexTransform` and +`from zarr_indexing.transform import IndexTransform` are equivalent. diff --git a/packages/zarr-indexing/docs/api/json.md b/packages/zarr-indexing/docs/api/json.md new file mode 100644 index 0000000000..0183ab30e7 --- /dev/null +++ b/packages/zarr-indexing/docs/api/json.md @@ -0,0 +1,5 @@ +--- +title: json +--- + +::: zarr_indexing.json diff --git a/packages/zarr-indexing/docs/api/messages.md b/packages/zarr-indexing/docs/api/messages.md new file mode 100644 index 0000000000..6c2a434540 --- /dev/null +++ b/packages/zarr-indexing/docs/api/messages.md @@ -0,0 +1,5 @@ +--- +title: messages +--- + +::: zarr_indexing.messages diff --git a/packages/zarr-indexing/docs/api/output_map.md b/packages/zarr-indexing/docs/api/output_map.md new file mode 100644 index 0000000000..55114a6997 --- /dev/null +++ b/packages/zarr-indexing/docs/api/output_map.md @@ -0,0 +1,5 @@ +--- +title: output_map +--- + +::: zarr_indexing.output_map diff --git a/packages/zarr-indexing/docs/api/transform.md b/packages/zarr-indexing/docs/api/transform.md new file mode 100644 index 0000000000..bd754f5ec5 --- /dev/null +++ b/packages/zarr-indexing/docs/api/transform.md @@ -0,0 +1,5 @@ +--- +title: transform +--- + +::: zarr_indexing.transform diff --git a/packages/zarr-indexing/docs/index.md b/packages/zarr-indexing/docs/index.md new file mode 100644 index 0000000000..d123cbd32b --- /dev/null +++ b/packages/zarr-indexing/docs/index.md @@ -0,0 +1,150 @@ +# zarr-indexing + +Composable, lazy coordinate transforms for Zarr array indexing. + +`zarr-indexing` is developed in the +[zarr-python repository](https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-indexing) +and released independently of `zarr` itself. Install it with: + +``` +pip install zarr-indexing +``` + +## What this is + +An indexing operation — a slice, an integer, a fancy index array — is a +*mapping* from the coordinates a user asks for to the coordinates that live in +storage. This library makes that mapping a first-class value: an +[`IndexTransform`](api/transform.md). Transforms compose, so a view of a view +of an array is still a single transform, and nothing is read until someone +asks for data. + +Three pieces do the work: + +- **The transform algebra** ([`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)): an `IndexTransform` + pairs an input [`IndexDomain`](api/domain.md) — a rectangular region of + integer coordinates, which unlike NumPy may have a non-zero origin — with + one output map per storage dimension. `ConstantMap`, `DimensionMap`, and + `ArrayMap` are three representations of the same thing, a set of integer + coordinates, traded off against each other for efficiency. +- **Chunk resolution** ([`zarr_indexing.chunk_resolution`](api/chunk_resolution.md)): + given a transform and a chunk grid, which chunks does this selection touch, + which coordinates does it touch *inside* each chunk, and where do the values + land in the output buffer? The resolver is dependency-aware: correlated + (`vindex`) array maps are enumerated jointly rather than as a cartesian + product, and orthogonal (`oindex`) array maps contribute only the chunks + their index arrays actually land in, so resolution scales with the number of + selected coordinates instead of with the size of the grid. +- **A wire format** ([`zarr_indexing.messages`](api/messages.md), + [`zarr_indexing.json`](api/json.md)): selections serialize to and from + [ndsel](https://github.com/zarr-developers/ndsel), a JSON representation of + NumPy-style n-dimensional selections. See [the ndsel wire format](ndsel.md). + +The package depends only on NumPy and the standard library. In particular it +does not import `zarr`: the chunk-grid surface chunk resolution needs is +described by the [`DimensionGridLike`](api/grid.md) Protocol, which zarr's +per-dimension grids satisfy structurally. + +## Relationship to TensorStore + +The model is [TensorStore's](https://google.github.io/tensorstore/index_space.html) +index transform, reimplemented in Python against NumPy: index domains with +explicit origins, output index maps of constant / single-input-dimension / +index-array flavour, and composition as the single operation that stacks +views. Names and semantics follow TensorStore where they overlap — notably, +negative indices are literal coordinates, not Python-style offsets from the +end, and it is the caller's job to normalize them. + +The differences are the ones NumPy compatibility forces. `ArrayMap` records +the input dimension an *orthogonal* (`oindex`) index array varies over, which +TensorStore's format has no field for; the +[serializer collapses or reconstructs that field](api/json.md) so the wire +format stays TensorStore-loadable. Chunk resolution and the `oindex`/`vindex` +helpers exist to serve NumPy-shaped selection semantics, which TensorStore +does not have to model. + +## Quickstart + +Indexing a transform produces a new transform. No I/O happens, and no +coordinates are materialized: + +```python +from zarr_indexing import IndexTransform + +transform = IndexTransform.from_shape((100, 100)) + +view = transform[10:50, 5] +view.domain # IndexDomain(inclusive_min=(10,), exclusive_max=(50,)) +view.selection_repr # '{ [10, 50), 5 }' +``` + +The domain describes what the *user* sees (here a single dimension, 40 long, +with origin 10); the output maps describe what *storage* sees (a stride-1 +`DimensionMap` and the `ConstantMap` for the dropped dimension). + +Fancy indexing works the same way, in both flavours, and still materializes +nothing but the index arrays themselves: + +```python +import numpy as np + +transform.oindex[np.array([3, 1, 90]), 0:4] # '{ {3, 1, 90}, [0, 4) }', shape (3, 4) +transform.vindex[np.array([0, 40, 99]), np.array([1, 2, 3])] # shape (3,) +``` + +Transforms built independently stack with +[`compose`](api/composition.md), which is what `transform[...]` uses +internally when you index an already-indexed view: + +```python +from zarr_indexing import compose + +inner = IndexTransform.from_shape((100,))[::2] # storage 0, 2, 4, ... over domain [0, 50) +outer = IndexTransform.from_shape((50,))[10:20] + +compose(outer, inner).selection_repr # '{ [20, 40) step 2 }' +``` + +Resolution against a chunk grid is where a transform finally meets storage. +Chunk resolution asks the grid only for the per-dimension index-to-chunk +mapping described by [`DimensionGridLike`](api/grid.md), so any object with +those four methods will do: + +```python +from dataclasses import dataclass + +from zarr_indexing import iter_chunk_transforms + + +@dataclass(frozen=True) +class RegularDimensionGrid: + chunk: int + + def index_to_chunk(self, idx): return idx // self.chunk + def chunk_offset(self, chunk_ix): return chunk_ix * self.chunk + def chunk_size(self, chunk_ix): return self.chunk + def indices_to_chunks(self, indices): return indices // self.chunk + + +grids = [RegularDimensionGrid(32), RegularDimensionGrid(32)] + +for chunk_coords, sub_transform, out_indices in iter_chunk_transforms(view, grids): + print(chunk_coords, sub_transform.selection_repr) +# (0, 0) { [10, 32), 5 } +# (1, 0) { [0, 18), 5 } +``` + +Each yielded `sub_transform` is the original transform restricted to one chunk +and translated into chunk-local coordinates — exactly what a codec pipeline +needs to decode that chunk and scatter the result. `out_indices` carries the +output scatter indices for array selections, and is `None` for basic indexing. + +## Reference + +- [The ndsel wire format](ndsel.md) +- [API reference](api/index.md) +- [Changelog](https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-indexing/CHANGELOG.md) +- [License (MIT)](https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-indexing/LICENSE.txt) diff --git a/packages/zarr-indexing/docs/ndsel.md b/packages/zarr-indexing/docs/ndsel.md new file mode 100644 index 0000000000..74f394b758 --- /dev/null +++ b/packages/zarr-indexing/docs/ndsel.md @@ -0,0 +1,156 @@ +--- +title: The ndsel wire format +--- + +# The ndsel wire format + +[ndsel](https://github.com/zarr-developers/ndsel) is a draft JSON +representation of NumPy-style n-dimensional selections, adapted from +TensorStore's `IndexTransform` model. `zarr-indexing` implements it in two +layers, and the split between them is the thing worth understanding: + +| Layer | Module | Depends on | Job | +| --- | --- | --- | --- | +| Message | [`zarr_indexing.messages`](api/messages.md) | stdlib only | JSON in, canonical JSON out. Validates and desugars. Never rounds, clamps, or drops information. | +| Engine | [`zarr_indexing.json`](api/json.md) | NumPy | Lowers a *canonical* body into an in-memory [`IndexTransform`](api/transform.md), and back. | + +Constraints that only make sense for a real array — finite bounds, index +arrays as `ndarray`s — live in the engine layer and nowhere else. That is why +`messages` can happily normalize a message with `"-inf"` bounds that +`json.transform_from_canonical` will refuse to lower. + +## Two entry points + +[`parse_ndsel`](api/messages.md#zarr_indexing.messages.parse_ndsel) +structurally validates a message of any kind and returns it **unchanged** — +use it when you want to keep a message in its compact shorthand form but +confirm it is well formed. + +[`normalize_ndsel`](api/messages.md#zarr_indexing.messages.normalize_ndsel) +desugars a message into the single deterministic **canonical transform body** +of the spec (section 4.3): a bare `IndexTransform` body without the `kind` +discriminator. + +```python +from zarr_indexing import normalize_ndsel + +normalize_ndsel({"kind": "box", "inclusive_min": [10, 5], "shape": [40, 1]}) +# {'input_rank': 2, +# 'input_inclusive_min': [10, 5], +# 'input_exclusive_max': [50, 6], +# 'input_labels': ['', ''], +# 'output': [{'offset': 0, 'stride': 1, 'input_dimension': 0}, +# {'offset': 0, 'stride': 1, 'input_dimension': 1}]} +``` + +Normalization is idempotent: re-tag the output with `kind: "transform"` and +normalizing it again returns the same body. Because the canonical body is +field-for-field a TensorStore `IndexTransform` minus `kind`, a normalized +message loads directly into `tensorstore.IndexTransform(json=...)`. + +Both entry points raise +[`NdselError`](api/messages.md#zarr_indexing.messages.NdselError), which +carries the spec `reason` code (`unknown_kind`, `rank_mismatch`, `step_zero`, +`output_map_conflict`, …) alongside a human-readable detail, so callers can +branch on the code rather than on message text. + +## The five message kinds + +Four are shorthands; the fifth is the canonical form itself. + +| `kind` | Fields | Selects | +| --- | --- | --- | +| `point` | `coords` | A single element. Normalizes to rank 0 with one `constant` output map per dimension. | +| `box` | `inclusive_min`, one of `exclusive_max` / `inclusive_max` / `shape`, `labels` | A rectangular region. Exactly one upper-bound spelling may appear. | +| `slice` | `start`, `stop`, `step`, `labels` | A strided region, one Python-style slice per dimension. | +| `points` | `coords` (a list of coordinate rows) | An explicit list of points — the `vindex` case. Normalizes to one `index_array` output map per dimension over a shared rank-1 input domain. | +| `transform` | `input_rank`, `input_inclusive_min`, one of the three `input_*` upper bounds, `input_labels`, `output` | The full canonical form. | + +Value rules the message layer enforces throughout: every integer is a 64-bit +signed value; JSON booleans are **not** integers (Python's +`isinstance(True, int)` is guarded against explicitly); the `"-inf"` / `"+inf"` +sentinels are legal only in bound positions; and an implicit bound is the +one-element `[n]`-bracket form, whose implicit/explicit flag survives +normalization intact. + +## Lowering to a transform + +The engine layer converts between canonical bodies and `IndexTransform`s: + +```python +from zarr_indexing import transform_from_canonical, transform_to_canonical + +t = transform_from_canonical(canonical) +transform_to_canonical(t) == canonical +``` + +`index_transform_to_json` / `index_transform_from_json` (and the +`index_domain_*` variants) are these same converters under their historical +names. + +Two engine constraints apply here and only here. A canonical body carrying a +`"-inf"` or `"+inf"` bound cannot be lowered — an `IndexDomain` addresses a +finite array — so `transform_from_canonical` raises. And implicit bounds lower +*by value*: the `[n]`-bracket flag is a message-layer concern, and the engine +keeps only the integer. + +### The `index_array` round trip + +ndsel and TensorStore both **reject** an output map that carries both +`input_dimension` and `index_array`. The in-memory +[`ArrayMap`](api/output_map.md#zarr_indexing.output_map.ArrayMap), though, +records an `input_dimension` to pin the axis an orthogonal (`oindex`) array +varies over. The serializer bridges that gap in both directions: + +- **On serialize**, a non-degenerate `index_array` map is emitted *without* + `input_dimension`. +- **On load**, the in-memory `input_dimension` is reconstructed from the + full-rank array's dependency axes — its non-singleton axes. An array that + solely owns a single non-singleton axis is orthogonal; arrays that share + non-singleton axes, or vary over several, are correlated (`vindex`), and get + `input_dimension = None`. A single 1-D array over a rank-1 domain is + inherently ambiguous between the two flavours and reconstructs as + orthogonal, which is behaviorally identical in that case. + +There is one deliberate exception, worth calling out because it is the one +place a round trip changes representation rather than preserving it. An +all-singleton `index_array` — size 1 — selects the same coordinate no matter +what the input is, so it is **collapsed to a `constant` map** on serialize: + +```python +from zarr_indexing import IndexTransform, transform_to_canonical + +transform_to_canonical(IndexTransform.from_shape((100, 100)).oindex[[5], 0:2]) +# {'input_rank': 2, +# 'input_inclusive_min': [0, 0], +# 'input_exclusive_max': [1, 2], +# 'input_labels': ['', ''], +# 'output': [{'offset': 5}, +# {'offset': 0, 'stride': 1, 'input_dimension': 1}]} +``` + +The size-1 input dimension stays in the domain, unconsumed by any output map — +still a valid transform, and still the right output shape. A length-1 `oindex` +selection therefore round-trips *behaviorally* (an `ArrayMap` comes back as a +`ConstantMap`) rather than by object identity. + +## Conformance + +The package is checked against the language-agnostic ndsel conformance corpus, +vendored unmodified under +[`tests/conformance/`](https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-indexing/tests/conformance) +— one JSON file per message kind plus `errors.json`, with the source commit +recorded in `PROVENANCE.md`. Each fixture is either a *success* case +(`input` + expected `normalized` body) or an *error* case (`input` + expected +reason code), and an implementation is conformant iff `normalize` reproduces +every one. `tests/test_conformance.py` runs the whole corpus as one +parametrized test per fixture, so a corpus update reports failures fixture by +fixture rather than as a single opaque assertion. + +Do not edit the vendored files; to pick up spec changes, re-vendor from a newer +ndsel commit and update the recorded SHA. + +A second, optional test (`tests/test_ndsel_tensorstore.py`, skipped unless +`tensorstore` is installed) closes the loop against a real TensorStore by +loading canonical bodies into `tensorstore.IndexTransform` and re-loading +TensorStore's own `to_json()` output back through the engine layer. diff --git a/packages/zarr-indexing/justfile b/packages/zarr-indexing/justfile new file mode 100644 index 0000000000..20e1b2b837 --- /dev/null +++ b/packages/zarr-indexing/justfile @@ -0,0 +1,58 @@ +# Development verbs for the zarr-indexing package. Recipes run with this +# directory as the working directory regardless of where `just` is invoked. + +# List available recipes +default: + @just --list + +# The chunk-resolution tests exercise this package against zarr's ChunkGrid, so +# they need an environment that has both `zarr` and this package installed. +# `zarr` is deliberately not a dependency of this package, and the repo is not +# a uv workspace, so run against the repo-root environment (which provides +# `zarr`) with this package layered in as an editable overlay — the same +# invocation CI uses. +# Run the test suite; extra args are passed to pytest +test *args: + uv run --project ../.. --group test --with-editable . python -m pytest tests {{ args }} + +# Lint with the same invocation CI uses +lint: + uvx ruff check . + +# Type-check the package sources +typecheck: + uv run --group test --with pyright 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-indexing/mkdocs.yml b/packages/zarr-indexing/mkdocs.yml new file mode 100644 index 0000000000..d7261f32e1 --- /dev/null +++ b/packages/zarr-indexing/mkdocs.yml @@ -0,0 +1,110 @@ +site_name: zarr-indexing +# The package lives in the zarr-python monorepo; point the header source +# widget at the package directory rather than the repository root. +repo_name: zarr-python/packages/zarr-indexing +repo_url: https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-indexing +# Absolute because mkdocs would otherwise append this to repo_url's subpath. +edit_uri: https://github.com/zarr-developers/zarr-python/edit/main/packages/zarr-indexing/docs/ +site_description: Composable, lazy coordinate transforms for Zarr array indexing. +site_author: Davis Bennett +site_url: !ENV [READTHEDOCS_CANONICAL_URL, 'https://zarr-indexing.readthedocs.io/'] +docs_dir: docs +use_directory_urls: true + +nav: + - index.md + - ndsel.md + - API Reference: + - api/index.md + - ' zarr_indexing.transform': api/transform.md + - ' zarr_indexing.domain': api/domain.md + - ' zarr_indexing.output_map': api/output_map.md + - ' zarr_indexing.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) 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/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/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_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/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..597f338c42 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( @@ -282,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] @@ -344,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 := ( @@ -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) @@ -1174,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 @@ -1237,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/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/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/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/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_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") 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_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_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 fd86936853..5c712fa97a 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 @@ -15,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 @@ -22,11 +24,16 @@ 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, NDBuffer + from zarr.core.buffer import Buffer, BufferPrototype, NDBuffer @pytest.mark.parametrize( @@ -439,6 +446,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. @@ -966,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_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") 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") 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" }, ]