From ddb7b2724d1c24f37acae579b49b789338681944 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Sat, 28 Feb 2026 10:42:15 -0500 Subject: [PATCH 01/29] implement store server and node server --- pyproject.toml | 5 + src/zarr/core/keys.py | 181 +++++++++++++++++++++++ src/zarr/experimental/serve.py | 205 ++++++++++++++++++++++++++ tests/test_experimental/test_serve.py | 189 ++++++++++++++++++++++++ 4 files changed, 580 insertions(+) create mode 100644 src/zarr/core/keys.py create mode 100644 src/zarr/experimental/serve.py create mode 100644 tests/test_experimental/test_serve.py diff --git a/pyproject.toml b/pyproject.toml index 068caa1f0d..8a0cd004e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,6 +70,11 @@ gpu = [ "cupy-cuda12x", ] cli = ["typer"] +server = [ + "starlette", + "httpx", + "uvicorn", +] # Testing extras test = [ "coverage>=7.10", diff --git a/src/zarr/core/keys.py b/src/zarr/core/keys.py new file mode 100644 index 0000000000..0d18887598 --- /dev/null +++ b/src/zarr/core/keys.py @@ -0,0 +1,181 @@ +"""Utilities for determining the set of valid store keys for zarr nodes. + +A zarr node (array or group) implicitly defines a subset of keys in the +underlying store. For an **array** the valid keys are: + +* metadata documents (``zarr.json`` for v3, ``.zarray`` / ``.zattrs`` for v2) +* chunk (or shard) keys whose decoded coordinates fall within the storage grid + +For a **group** the valid keys are: + +* its own metadata documents +* any path ``/`` where ```` is a direct member and + ```` is recursively valid for that child +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from zarr.core.common import ZARR_JSON, ZARRAY_JSON, ZATTRS_JSON, ZGROUP_JSON, ZMETADATA_V2_JSON + +if TYPE_CHECKING: + from zarr.core.array import Array + from zarr.core.group import Group + +_METADATA_KEYS_V3 = frozenset({ZARR_JSON}) +_METADATA_KEYS_V2 = frozenset({ZARRAY_JSON, ZATTRS_JSON, ZGROUP_JSON, ZMETADATA_V2_JSON}) + + +def metadata_keys(zarr_format: int) -> frozenset[str]: + """Return the set of metadata key basenames for a given zarr format version. + + Parameters + ---------- + zarr_format : int + The zarr format version (2 or 3). + + Returns + ------- + frozenset of str + """ + if zarr_format == 3: + return _METADATA_KEYS_V3 + return _METADATA_KEYS_V2 + + +def decode_chunk_key(array: Array, key: str) -> tuple[int, ...] | None: + """Try to decode *key* into chunk coordinates for *array*. + + Parameters + ---------- + array : Array + The array whose chunk key encoding should be used. + key : str + The candidate chunk key string. + + Returns + ------- + tuple of int, or None + The decoded coordinates, or ``None`` if *key* is not a valid chunk key. + """ + from zarr.core.chunk_key_encodings import DefaultChunkKeyEncoding, V2ChunkKeyEncoding + from zarr.core.metadata.v2 import ArrayV2Metadata + + try: + if isinstance(array.metadata, ArrayV2Metadata): + parts = key.split(array.metadata.dimension_separator) + return tuple(int(p) for p in parts) + + encoding = array.metadata.chunk_key_encoding + if isinstance(encoding, DefaultChunkKeyEncoding): + # Default v3 keys have the form "c012". + prefix = "c" + encoding.separator + if key == "c": + return () + if not key.startswith(prefix): + return None + return tuple(int(p) for p in key[len(prefix) :].split(encoding.separator)) + if isinstance(encoding, V2ChunkKeyEncoding): + return tuple(int(p) for p in key.split(encoding.separator)) + + # Unknown encoding — fall back to the encoding's own decode. + return encoding.decode_chunk_key(key) + except (ValueError, TypeError, NotImplementedError): + return None + + +def is_valid_chunk_key(array: Array, key: str) -> bool: + """Check whether *key* is a valid chunk key for *array*. + + Tries to decode the key and checks that the resulting coordinates fall + within the storage grid (shard grid if sharding is used, chunk grid + otherwise). + + Parameters + ---------- + array : Array + The array to validate against. + key : str + The candidate chunk key string. + + Returns + ------- + bool + """ + coords = decode_chunk_key(array, key) + if coords is None: + return False + grid = array._shard_grid_shape + if len(coords) != len(grid): + return False + return all(0 <= c < g for c, g in zip(coords, grid, strict=True)) + + +def is_valid_array_key(array: Array, key: str) -> bool: + """Check whether *key* is a valid store key for *array*. + + Valid keys are metadata documents and chunk keys. + + Parameters + ---------- + array : Array + The array to validate against. + key : str + The candidate key, relative to the array's root. + + Returns + ------- + bool + """ + if key in metadata_keys(array.metadata.zarr_format): + return True + return is_valid_chunk_key(array, key) + + +def is_valid_node_key(node: Array | Group, key: str) -> bool: + """Check whether *key* is a valid store key relative to *node*. + + For an ``Array``, valid keys are metadata documents and chunk keys. + + For a ``Group``, valid keys are the group's own metadata documents, or + a path of the form ``/`` where ```` is a direct + member and ```` is recursively valid for that child. + + Parameters + ---------- + node : Array or Group + The zarr node to validate against. + key : str + The candidate key, relative to the node's root. + + Returns + ------- + bool + """ + from zarr.core.array import Array + from zarr.core.group import Group + + if isinstance(node, Array): + return is_valid_array_key(node, key) + + # Group + if key in metadata_keys(node.metadata.zarr_format): + return True + + # Try to match the first path component against a child member. + if "/" in key: + child_name, remainder = key.split("/", 1) + else: + # A bare name with no slash can't be a valid group-level key — + # groups contain children (which have subkeys), not bare keys. + return False + + try: + child = node[child_name] + except KeyError: + return False + + if isinstance(child, (Array, Group)): + return is_valid_node_key(child, remainder) + return False diff --git a/src/zarr/experimental/serve.py b/src/zarr/experimental/serve.py new file mode 100644 index 0000000000..ec9c0aca14 --- /dev/null +++ b/src/zarr/experimental/serve.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, TypedDict + +from zarr.abc.store import OffsetByteRequest, RangeByteRequest, SuffixByteRequest +from zarr.core.buffer import cpu +from zarr.core.keys import is_valid_node_key + +if TYPE_CHECKING: + from starlette.requests import Request + from starlette.responses import Response + + from zarr.abc.store import ByteRequest, Store + from zarr.core.array import Array + from zarr.core.group import Group + + +class CorsOptions(TypedDict): + allow_origins: list[str] + allow_methods: list[str] + + +HTTPMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"] + + +def _parse_range_header(range_header: str) -> ByteRequest | None: + """Parse an HTTP Range header into a ByteRequest. + + Parameters + ---------- + range_header : str + The value of the Range header, e.g. ``"bytes=0-99"`` or ``"bytes=-100"``. + + Returns + ------- + ByteRequest or None + A ``RangeByteRequest``, ``OffsetByteRequest``, or ``SuffixByteRequest``, + or ``None`` if the header cannot be parsed. + """ + if not range_header.startswith("bytes="): + return None + range_spec = range_header[len("bytes=") :] + if range_spec.startswith("-"): + # suffix request: bytes=-N + suffix = int(range_spec[1:]) + return SuffixByteRequest(suffix=suffix) + parts = range_spec.split("-", 1) + if len(parts) != 2: + return None + start_str, end_str = parts + start = int(start_str) + if end_str == "": + # offset request: bytes=N- + return OffsetByteRequest(offset=start) + # range request: bytes=N-M (HTTP end is inclusive, ByteRequest end is exclusive) + end = int(end_str) + 1 + return RangeByteRequest(start=start, end=end) + + +async def _get_response(store: Store, path: str, byte_range: ByteRequest | None = None) -> Response: + """Fetch a key from the store and return an HTTP response.""" + from starlette.responses import Response + + proto = cpu.buffer_prototype + content_type = "application/json" if path.endswith("zarr.json") else "application/octet-stream" + + buf = await store.get(path, proto, byte_range=byte_range) + if buf is None: + return Response(status_code=404) + + status_code = 206 if byte_range is not None else 200 + return Response(content=buf.to_bytes(), status_code=status_code, media_type=content_type) + + +async def _handle_request(request: Request) -> Response: + """Handle a request, optionally filtering by node validity.""" + from starlette.responses import Response + + store: Store = request.app.state.store + node: Array | Group | None = request.app.state.node + prefix: str = request.app.state.prefix + path = request.path_params.get("path", "") + + # If serving a node, validate the key before touching the store. + if node is not None and not is_valid_node_key(node, path): + return Response(status_code=404) + + # Resolve the full store key by prepending the node's prefix. + store_key = f"{prefix}/{path}" if prefix else path + + if request.method == "PUT": + body = await request.body() + buf = cpu.buffer_prototype.buffer.from_bytes(body) + await store.set(store_key, buf) + return Response(status_code=204) + + range_header = request.headers.get("range") + byte_range: ByteRequest | None = None + if range_header is not None: + byte_range = _parse_range_header(range_header) + if byte_range is None: + return Response(status_code=416) + + return await _get_response(store, store_key, byte_range) + + +def _make_starlette_app( + *, + methods: set[HTTPMethod] | None = None, + cors_options: CorsOptions | None = None, +) -> Any: + """Create a Starlette app with the request handler.""" + try: + from starlette.applications import Starlette + from starlette.middleware.cors import CORSMiddleware + from starlette.routing import Route + except ImportError as e: + raise ImportError( + "The zarr server requires the 'starlette' package. " + "Install it with: pip install zarr[server]" + ) from e + + if methods is None: + methods = {"GET"} + + app = Starlette( + routes=[Route("/{path:path}", _handle_request, methods=list(methods))], + ) + + if cors_options is not None: + app.add_middleware( + CORSMiddleware, + allow_origins=cors_options["allow_origins"], + allow_methods=cors_options["allow_methods"], + ) + return app + + +def serve_store( + store: Store, + *, + methods: set[HTTPMethod] | None = None, + cors_options: CorsOptions | None = None, +) -> Any: + """Create a Starlette ASGI app that serves every key in a zarr ``Store``. + + Parameters + ---------- + store : Store + The zarr store to serve. + methods : set of HTTPMethod, optional + The HTTP methods to accept. Defaults to ``{"GET"}``. + cors_options : CorsOptions, optional + If provided, CORS middleware will be added with the given options. + + Returns + ------- + Starlette + An ASGI application. + """ + app = _make_starlette_app(methods=methods, cors_options=cors_options) + app.state.store = store + app.state.node = None + app.state.prefix = "" + return app + + +def serve_node( + node: Array | Group, + *, + methods: set[HTTPMethod] | None = None, + cors_options: CorsOptions | None = None, +) -> Any: + """Create a Starlette ASGI app that serves only the keys belonging to a + zarr ``Array`` or ``Group``. + + For an ``Array``, the served keys are the metadata document(s) and all + chunk (or shard) keys whose coordinates fall within the array's grid. + + For a ``Group``, the served keys are the group's own metadata plus any + path that resolves through the group's members to a valid array metadata + document or chunk key. + + Requests for keys outside this set receive a 404 response, even if the + underlying store contains data at that path. + + Parameters + ---------- + node : Array or Group + The zarr array or group to serve. + methods : set of HTTPMethod, optional + The HTTP methods to accept. Defaults to ``{"GET"}``. + cors_options : CorsOptions, optional + If provided, CORS middleware will be added with the given options. + + Returns + ------- + Starlette + An ASGI application. + """ + app = _make_starlette_app(methods=methods, cors_options=cors_options) + app.state.store = node.store_path.store + app.state.node = node + app.state.prefix = node.store_path.path + return app diff --git a/tests/test_experimental/test_serve.py b/tests/test_experimental/test_serve.py new file mode 100644 index 0000000000..2ea11530c4 --- /dev/null +++ b/tests/test_experimental/test_serve.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import numpy as np +import pytest + +import zarr +from zarr.core.buffer import cpu +from zarr.core.sync import sync +from zarr.storage import MemoryStore + +pytest.importorskip("starlette") +pytest.importorskip("httpx") + +from starlette.testclient import TestClient + +from zarr.experimental.serve import serve_node, serve_store + + +@pytest.fixture +def memory_store() -> MemoryStore: + return MemoryStore() + + +@pytest.fixture +def group_with_arrays(memory_store: MemoryStore) -> zarr.Group: + """Create a group containing a regular array and a sharded array.""" + root = zarr.open_group(memory_store, mode="w") + zarr.create_array(root.store_path / "regular", shape=(4, 4), chunks=(2, 2), dtype="f8") + zarr.create_array( + root.store_path / "sharded", + shape=(8, 8), + chunks=(2, 2), + shards=(4, 4), + dtype="i4", + ) + return root + + +class TestServeNodeDoesNotExposeNonZarrKeys: + """serve_node must never expose keys that are not part of the zarr hierarchy.""" + + def test_non_zarr_key_returns_404( + self, memory_store: MemoryStore, group_with_arrays: zarr.Group + ) -> None: + # Plant a non-zarr file in the store. + non_zarr_buf = cpu.buffer_prototype.buffer.from_bytes(b"secret data") + sync(memory_store.set("secret.txt", non_zarr_buf)) + + app = serve_node(group_with_arrays) + client = TestClient(app) + + # The non-zarr key must not be accessible. + response = client.get("/secret.txt") + assert response.status_code == 404 + + def test_non_zarr_key_nested_returns_404( + self, memory_store: MemoryStore, group_with_arrays: zarr.Group + ) -> None: + # Plant a non-zarr file under a path that shares a prefix with a + # real array, but is not a valid chunk or metadata key. + non_zarr_buf = cpu.buffer_prototype.buffer.from_bytes(b"not a chunk") + sync(memory_store.set("regular/notes.txt", non_zarr_buf)) + + app = serve_node(group_with_arrays) + client = TestClient(app) + + response = client.get("/regular/notes.txt") + assert response.status_code == 404 + + def test_valid_metadata_is_accessible(self, group_with_arrays: zarr.Group) -> None: + app = serve_node(group_with_arrays) + client = TestClient(app) + + # Root group metadata + response = client.get("/zarr.json") + assert response.status_code == 200 + + # Array metadata + response = client.get("/regular/zarr.json") + assert response.status_code == 200 + + def test_valid_chunk_is_accessible(self, group_with_arrays: zarr.Group) -> None: + # Write some data so the chunk actually exists in the store. + arr = group_with_arrays["regular"] + arr[:] = np.ones((4, 4)) + + app = serve_node(group_with_arrays) + client = TestClient(app) + + # c/0/0 is a valid chunk key for a (4,4) array with (2,2) chunks. + response = client.get("/regular/c/0/0") + assert response.status_code == 200 + + +class TestShardedArrayByteRangeReads: + """Byte-range reads against a sharded array served via serve_node.""" + + def test_range_read_returns_206(self, group_with_arrays: zarr.Group) -> None: + arr = group_with_arrays["sharded"] + arr[:] = np.arange(64, dtype="i4").reshape((8, 8)) + + app = serve_node(group_with_arrays) + client = TestClient(app) + + # c/0/0 is the first shard key for an (8,8) array with (4,4) shards. + full_response = client.get("/sharded/c/0/0") + assert full_response.status_code == 200 + full_body = full_response.content + + # Request the first 8 bytes. + range_response = client.get("/sharded/c/0/0", headers={"Range": "bytes=0-7"}) + assert range_response.status_code == 206 + assert range_response.content == full_body[:8] + + def test_suffix_range_read(self, group_with_arrays: zarr.Group) -> None: + arr = group_with_arrays["sharded"] + arr[:] = np.arange(64, dtype="i4").reshape((8, 8)) + + app = serve_node(group_with_arrays) + client = TestClient(app) + + full_response = client.get("/sharded/c/0/0") + full_body = full_response.content + + # Request the last 4 bytes. + range_response = client.get("/sharded/c/0/0", headers={"Range": "bytes=-4"}) + assert range_response.status_code == 206 + assert range_response.content == full_body[-4:] + + def test_offset_range_read(self, group_with_arrays: zarr.Group) -> None: + arr = group_with_arrays["sharded"] + arr[:] = np.arange(64, dtype="i4").reshape((8, 8)) + + app = serve_node(group_with_arrays) + client = TestClient(app) + + full_response = client.get("/sharded/c/0/0") + full_body = full_response.content + + # Request everything from byte 4 onward. + range_response = client.get("/sharded/c/0/0", headers={"Range": "bytes=4-"}) + assert range_response.status_code == 206 + assert range_response.content == full_body[4:] + + +class TestWriteViaPut: + """serve_store and serve_node can be configured to accept PUT writes.""" + + def test_put_writes_to_store(self, memory_store: MemoryStore) -> None: + app = serve_store(memory_store, methods={"GET", "PUT"}) + client = TestClient(app) + + payload = b"hello zarr" + response = client.put("/some/key", content=payload) + assert response.status_code == 204 + + # Verify the data landed in the store. + buf = sync(memory_store.get("some/key", cpu.buffer_prototype)) + assert buf is not None + assert buf.to_bytes() == payload + + def test_put_then_get_roundtrip(self, memory_store: MemoryStore) -> None: + app = serve_store(memory_store, methods={"GET", "PUT"}) + client = TestClient(app) + + payload = b"\x00\x01\x02\x03" + client.put("/data/blob", content=payload) + + response = client.get("/data/blob") + assert response.status_code == 200 + assert response.content == payload + + def test_put_rejected_when_not_configured(self, memory_store: MemoryStore) -> None: + # Default methods is {"GET"} only. + app = serve_store(memory_store) + client = TestClient(app) + + response = client.put("/some/key", content=b"data") + assert response.status_code == 405 + + def test_put_on_node_validates_key( + self, memory_store: MemoryStore, group_with_arrays: zarr.Group + ) -> None: + app = serve_node(group_with_arrays, methods={"GET", "PUT"}) + client = TestClient(app) + + # Writing to a non-zarr key should be rejected. + response = client.put("/not_a_zarr_key.bin", content=b"data") + assert response.status_code == 404 From 8bec48d9e7d848f860af3be420f8a4b994fc019b Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Sun, 1 Mar 2026 14:59:19 -0500 Subject: [PATCH 02/29] update tests and add v2 -> v3 example --- examples/serve_v2_v3/README.md | 27 ++ examples/serve_v2_v3/serve_v2_v3.py | 337 +++++++++++++++++++++++ src/zarr/core/chunk_key_encodings.py | 6 +- src/zarr/core/keys.py | 15 +- src/zarr/experimental/serve.py | 35 +-- tests/test_examples.py | 59 ++-- tests/test_experimental/test_serve.py | 370 +++++++++++++++++++++++--- 7 files changed, 753 insertions(+), 96 deletions(-) create mode 100644 examples/serve_v2_v3/README.md create mode 100644 examples/serve_v2_v3/serve_v2_v3.py diff --git a/examples/serve_v2_v3/README.md b/examples/serve_v2_v3/README.md new file mode 100644 index 0000000000..c4790f18ac --- /dev/null +++ b/examples/serve_v2_v3/README.md @@ -0,0 +1,27 @@ +# Serve a Zarr v2 Array as v3 over HTTP + +This example demonstrates how to build a custom read-only `Store` that +translates Zarr v2 data into v3 format on the fly, and serve it over HTTP +using `zarr.experimental.serve.serve_store`. + +The example shows how to: + +- Implement a custom `Store` subclass (`V2AsV3Store`) that wraps an + existing v2 store +- Translate v2 metadata (`.zarray` + `.zattrs`) to v3 `zarr.json` using + the same `_convert_array_metadata` helper that powers `zarr migrate v3` +- Map v3 default chunk keys (`c/0/0`) to their v2 equivalents (`0.0`) +- Serve the translated store over HTTP so that any v3-compatible client + can read v2 data without knowing the original format + +## Running the Example + +```bash +python examples/serve_v2_v3/serve_v2_v3.py +``` + +Or run with uv: + +```bash +uv run examples/serve_v2_v3/serve_v2_v3.py +``` diff --git a/examples/serve_v2_v3/serve_v2_v3.py b/examples/serve_v2_v3/serve_v2_v3.py new file mode 100644 index 0000000000..558ca2acaf --- /dev/null +++ b/examples/serve_v2_v3/serve_v2_v3.py @@ -0,0 +1,337 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "zarr[server]", +# "httpx", +# ] +# /// +# +""" +Serve a Zarr v2 array over HTTP as a Zarr v3 array. + +This example demonstrates how to build a read-only ``Store`` that translates +between Zarr formats on the fly. A v2 array lives in a ``MemoryStore``; the +custom ``V2AsV3Store`` intercepts reads and translates metadata: + +* Requests for ``zarr.json`` are answered by reading the v2 ``.zarray`` / + ``.zattrs`` metadata and converting it to a v3 ``zarr.json`` document + using the same ``_convert_array_metadata`` helper that powers the + ``zarr migrate v3`` CLI command. + +* Chunk keys are passed through unchanged because ``_convert_array_metadata`` + preserves the v2 chunk key encoding (``V2ChunkKeyEncoding``). A v3 + client reads the encoding from ``zarr.json`` and naturally produces the + same keys (e.g. ``0.0``) that the v2 store already contains. + +* The v2 metadata files (``.zarray``, ``.zattrs``) are hidden so only + v3 keys are visible. + +The translated store is then served over HTTP with ``serve_store``. A test +at the bottom opens the served data *as a v3 array* and verifies it can +read the values back. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import numpy as np + +import zarr +from zarr.abc.store import ByteRequest, Store +from zarr.core.buffer import Buffer, cpu +from zarr.core.buffer.core import default_buffer_prototype +from zarr.core.common import ZARR_JSON, ZARRAY_JSON, ZATTRS_JSON +from zarr.core.metadata.v2 import ArrayV2Metadata +from zarr.core.sync import sync +from zarr.metadata.migrate_v3 import _convert_array_metadata +from zarr.storage import MemoryStore + +if TYPE_CHECKING: + from collections.abc import AsyncIterator, Iterable + + from zarr.core.buffer import BufferPrototype + + +# --------------------------------------------------------------------------- +# Custom store that presents v2 data as v3 +# --------------------------------------------------------------------------- + +# v2 metadata keys that should be hidden from v3 clients. +_HIDDEN_V2_KEYS = frozenset({ZARRAY_JSON, ZATTRS_JSON}) + + +class V2AsV3Store(Store): + """A read-only store that wraps an existing v2 store and presents it as v3. + + Metadata translation + -------------------- + ``zarr.json`` ← ``.zarray`` + ``.zattrs`` (converted via + ``_convert_array_metadata`` from the CLI migration module) + + Chunk keys + ---------- + Chunk keys are **not** translated. The v3 metadata produced by + ``_convert_array_metadata`` uses ``V2ChunkKeyEncoding`` with the + same separator as the original v2 array, so chunk keys like ``0.0`` + are valid in both formats. + + Visibility + ---------- + The v2 metadata files (``.zarray``, ``.zattrs``) are hidden from + listing and ``get`` so that clients only see v3 keys. + """ + + supports_writes: bool = False + supports_deletes: bool = False + supports_listing: bool = True + + def __init__(self, v2_store: Store) -> None: + super().__init__(read_only=True) + self._v2 = v2_store + + def __eq__(self, other: object) -> bool: + return isinstance(other, V2AsV3Store) and self._v2 == other._v2 + + # -- metadata conversion ----------------------------------------------- + + async def _build_zarr_json(self, prototype: BufferPrototype) -> Buffer | None: + """Read v2 metadata from the wrapped store and return a v3 + ``zarr.json`` buffer.""" + zarray_buf = await self._v2.get(ZARRAY_JSON, prototype) + if zarray_buf is None: + return None + + zarray_dict = json.loads(zarray_buf.to_bytes()) + v2_meta = ArrayV2Metadata.from_dict(zarray_dict) + + # Merge in .zattrs if present. + zattrs_buf = await self._v2.get(ZATTRS_JSON, prototype) + if zattrs_buf is not None: + attrs = json.loads(zattrs_buf.to_bytes()) + if attrs: + v2_meta = v2_meta.update_attributes(attrs) + + # Reuse the same conversion the CLI uses. + v3_meta = _convert_array_metadata(v2_meta) + v3_json = json.dumps(v3_meta.to_dict()).encode() + return prototype.buffer.from_bytes(v3_json) + + # -- Store ABC implementation ------------------------------------------ + + async def get( + self, + key: str, + prototype: BufferPrototype | None = None, + byte_range: ByteRequest | None = None, + ) -> Buffer | None: + if prototype is None: + prototype = default_buffer_prototype() + await self._ensure_open() + + # Synthesise zarr.json from v2 metadata. + if key == ZARR_JSON: + buf = await self._build_zarr_json(prototype) + if buf is None or byte_range is None: + return buf + from zarr.storage._utils import _normalize_byte_range_index + + start, stop = _normalize_byte_range_index(buf, byte_range) + return prototype.buffer.from_buffer(buf[start:stop]) + + # Hide v2 metadata files. + if key in _HIDDEN_V2_KEYS: + return None + + # All other keys (chunk keys) pass through unchanged. + return await self._v2.get(key, prototype, byte_range=byte_range) + + async def get_partial_values( + self, + prototype: BufferPrototype, + key_ranges: Iterable[tuple[str, ByteRequest | None]], + ) -> list[Buffer | None]: + return [await self.get(k, prototype, br) for k, br in key_ranges] + + async def exists(self, key: str) -> bool: + if key == ZARR_JSON: + return await self._v2.exists(ZARRAY_JSON) + if key in _HIDDEN_V2_KEYS: + return False + return await self._v2.exists(key) + + async def set(self, key: str, value: Buffer) -> None: + raise NotImplementedError("V2AsV3Store is read-only") + + async def delete(self, key: str) -> None: + raise NotImplementedError("V2AsV3Store is read-only") + + async def list(self) -> AsyncIterator[str]: + async for key in self._v2.list(): + if key == ZARRAY_JSON: + yield ZARR_JSON + elif key in _HIDDEN_V2_KEYS: + continue + else: + yield key + + async def list_prefix(self, prefix: str) -> AsyncIterator[str]: + async for key in self.list(): + if key.startswith(prefix): + yield key + + async def list_dir(self, prefix: str) -> AsyncIterator[str]: + async for key in self.list(): + if not key.startswith(prefix): + continue + remainder = key[len(prefix) :] + if "/" not in remainder: + yield key + + +# --------------------------------------------------------------------------- +# Demo / tests +# --------------------------------------------------------------------------- + + +def create_v2_array() -> tuple[MemoryStore, np.ndarray]: + """Create a v2 array with some data and return the store + data.""" + store = MemoryStore() + data = np.arange(16, dtype="float64").reshape(4, 4) + arr = zarr.create_array(store, shape=data.shape, chunks=(2, 2), dtype=data.dtype, zarr_format=2) + arr[:] = data + return store, data + + +def test_metadata_translation() -> None: + """The translated zarr.json should be valid v3 metadata.""" + v2_store, _ = create_v2_array() + v3_store = V2AsV3Store(v2_store) + + buf = sync(v3_store.get(ZARR_JSON, cpu.buffer_prototype)) + assert buf is not None + meta = json.loads(buf.to_bytes()) + + assert meta["zarr_format"] == 3 + assert meta["node_type"] == "array" + assert meta["shape"] == [4, 4] + assert meta["chunk_grid"]["configuration"]["chunk_shape"] == [2, 2] + # The v2 chunk key encoding is preserved. + assert meta["chunk_key_encoding"]["name"] == "v2" + assert any(c["name"] in ("bytes", "zstd", "blosc") for c in meta["codecs"]) + print(" metadata translation: OK") + print(f" zarr.json:\n{json.dumps(meta, indent=2)}") + + +def test_chunk_passthrough() -> None: + """Chunk keys should pass through unchanged (v2 encoding preserved).""" + v2_store, _ = create_v2_array() + v3_store = V2AsV3Store(v2_store) + + # The v2 store has chunk key "0.0"; the v3 store should serve the + # same key since the metadata says V2ChunkKeyEncoding. + v2_buf = sync(v2_store.get("0.0", cpu.buffer_prototype)) + v3_buf = sync(v3_store.get("0.0", cpu.buffer_prototype)) + assert v2_buf is not None + assert v3_buf is not None + assert v2_buf.to_bytes() == v3_buf.to_bytes() + print(" chunk passthrough: OK") + + +def test_v2_metadata_hidden() -> None: + """v2 metadata files should not be visible.""" + v2_store, _ = create_v2_array() + v3_store = V2AsV3Store(v2_store) + + assert sync(v3_store.get(ZARRAY_JSON, cpu.buffer_prototype)) is None + assert sync(v3_store.get(ZATTRS_JSON, cpu.buffer_prototype)) is None + assert not sync(v3_store.exists(ZARRAY_JSON)) + assert not sync(v3_store.exists(ZATTRS_JSON)) + print(" v2 metadata hidden: OK") + + +def test_listing() -> None: + """Store listing should show v3 keys only.""" + v2_store, _ = create_v2_array() + v3_store = V2AsV3Store(v2_store) + + async def _list() -> list[str]: + return [k async for k in v3_store.list()] + + keys = sync(_list()) + assert ZARR_JSON in keys + assert ZARRAY_JSON not in keys + assert ZATTRS_JSON not in keys + # Chunk keys use v2 encoding (unchanged). + assert "0.0" in keys + print(f" listing keys: {sorted(keys)}") + + +def test_serve_roundtrip() -> None: + """Serve the translated store over HTTP and read it back as v3.""" + from starlette.testclient import TestClient + + from zarr.experimental.serve import serve_store + + v2_store, _data = create_v2_array() + v3_store = V2AsV3Store(v2_store) + + app = serve_store(v3_store) + client = TestClient(app) + + # Metadata should be valid v3 JSON. + resp = client.get("/zarr.json") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "application/json" + meta = resp.json() + assert meta["zarr_format"] == 3 + + # Chunks should be accessible via v2-style keys (as the metadata declares). + resp = client.get("/0.0") + assert resp.status_code == 200 + assert len(resp.content) > 0 + + # v2 metadata files should NOT be accessible. + resp = client.get("/.zarray") + assert resp.status_code == 404 + resp = client.get("/.zattrs") + assert resp.status_code == 404 + + print(" HTTP round-trip: OK") + + +def test_open_as_v3_array() -> None: + """Open the translated store as a v3 array and verify the data.""" + v2_store, data = create_v2_array() + v3_store = V2AsV3Store(v2_store) + + arr = zarr.open_array(v3_store) + assert arr.metadata.zarr_format == 3 + np.testing.assert_array_equal(arr[:], data) + print(" open as v3 array: OK") + print(f" data:\n{arr[:]}") + + +if __name__ == "__main__": + print("Creating v2 array and wrapping it with V2AsV3Store...\n") + + print("1. Metadata translation") + test_metadata_translation() + + print("\n2. Chunk key passthrough") + test_chunk_passthrough() + + print("\n3. v2 metadata hidden") + test_v2_metadata_hidden() + + print("\n4. Store listing") + test_listing() + + print("\n5. HTTP round-trip via serve_store") + test_serve_roundtrip() + + print("\n6. Open as v3 array") + test_open_as_v3_array() + + print("\nAll checks passed.") diff --git a/src/zarr/core/chunk_key_encodings.py b/src/zarr/core/chunk_key_encodings.py index 5c9f77118a..ca6154d1ba 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/keys.py b/src/zarr/core/keys.py index 0d18887598..4cc13874ab 100644 --- a/src/zarr/core/keys.py +++ b/src/zarr/core/keys.py @@ -15,7 +15,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from zarr.core.common import ZARR_JSON, ZARRAY_JSON, ZATTRS_JSON, ZGROUP_JSON, ZMETADATA_V2_JSON @@ -44,7 +44,7 @@ def metadata_keys(zarr_format: int) -> frozenset[str]: return _METADATA_KEYS_V2 -def decode_chunk_key(array: Array, key: str) -> tuple[int, ...] | None: +def decode_chunk_key(array: Array[Any], key: str) -> tuple[int, ...] | None: """Try to decode *key* into chunk coordinates for *array*. Parameters @@ -85,7 +85,7 @@ def decode_chunk_key(array: Array, key: str) -> tuple[int, ...] | None: return None -def is_valid_chunk_key(array: Array, key: str) -> bool: +def is_valid_chunk_key(array: Array[Any], key: str) -> bool: """Check whether *key* is a valid chunk key for *array*. Tries to decode the key and checks that the resulting coordinates fall @@ -112,7 +112,7 @@ def is_valid_chunk_key(array: Array, key: str) -> bool: return all(0 <= c < g for c, g in zip(coords, grid, strict=True)) -def is_valid_array_key(array: Array, key: str) -> bool: +def is_valid_array_key(array: Array[Any], key: str) -> bool: """Check whether *key* is a valid store key for *array*. Valid keys are metadata documents and chunk keys. @@ -133,7 +133,7 @@ def is_valid_array_key(array: Array, key: str) -> bool: return is_valid_chunk_key(array, key) -def is_valid_node_key(node: Array | Group, key: str) -> bool: +def is_valid_node_key(node: Array[Any] | Group, key: str) -> bool: """Check whether *key* is a valid store key relative to *node*. For an ``Array``, valid keys are metadata documents and chunk keys. @@ -154,7 +154,6 @@ def is_valid_node_key(node: Array | Group, key: str) -> bool: bool """ from zarr.core.array import Array - from zarr.core.group import Group if isinstance(node, Array): return is_valid_array_key(node, key) @@ -176,6 +175,4 @@ def is_valid_node_key(node: Array | Group, key: str) -> bool: except KeyError: return False - if isinstance(child, (Array, Group)): - return is_valid_node_key(child, remainder) - return False + return is_valid_node_key(child, remainder) diff --git a/src/zarr/experimental/serve.py b/src/zarr/experimental/serve.py index ec9c0aca14..1b6d4367a6 100644 --- a/src/zarr/experimental/serve.py +++ b/src/zarr/experimental/serve.py @@ -40,21 +40,24 @@ def _parse_range_header(range_header: str) -> ByteRequest | None: if not range_header.startswith("bytes="): return None range_spec = range_header[len("bytes=") :] - if range_spec.startswith("-"): - # suffix request: bytes=-N - suffix = int(range_spec[1:]) - return SuffixByteRequest(suffix=suffix) - parts = range_spec.split("-", 1) - if len(parts) != 2: + try: + if range_spec.startswith("-"): + # suffix request: bytes=-N + suffix = int(range_spec[1:]) + return SuffixByteRequest(suffix=suffix) + parts = range_spec.split("-", 1) + if len(parts) != 2: + return None + start_str, end_str = parts + start = int(start_str) + if end_str == "": + # offset request: bytes=N- + return OffsetByteRequest(offset=start) + # range request: bytes=N-M (HTTP end is inclusive, ByteRequest end is exclusive) + end = int(end_str) + 1 + return RangeByteRequest(start=start, end=end) + except ValueError: return None - start_str, end_str = parts - start = int(start_str) - if end_str == "": - # offset request: bytes=N- - return OffsetByteRequest(offset=start) - # range request: bytes=N-M (HTTP end is inclusive, ByteRequest end is exclusive) - end = int(end_str) + 1 - return RangeByteRequest(start=start, end=end) async def _get_response(store: Store, path: str, byte_range: ByteRequest | None = None) -> Response: @@ -77,7 +80,7 @@ async def _handle_request(request: Request) -> Response: from starlette.responses import Response store: Store = request.app.state.store - node: Array | Group | None = request.app.state.node + node: Array[Any] | Group | None = request.app.state.node prefix: str = request.app.state.prefix path = request.path_params.get("path", "") @@ -166,7 +169,7 @@ def serve_store( def serve_node( - node: Array | Group, + node: Array[Any] | Group, *, methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, diff --git a/tests/test_examples.py b/tests/test_examples.py index 152b0a1a88..4c358f8de9 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -19,47 +19,27 @@ ZARR_PROJECT_PATH = Path(".").absolute() -def set_dep(script: str, dependency: str) -> str: - """ - Set a dependency in a PEP-723 script header. - If the package is already in the list, it will be replaced. - If the package is not already in the list, it will be added. +def _get_zarr_extras(script_path: Path) -> str: + """Extract extras from the zarr dependency in a script's PEP 723 header. - Source code modified from - https://packaging.python.org/en/latest/specifications/inline-script-metadata/#reference-implementation + For example, if the script declares ``zarr[server]``, this returns ``[server]``. + If the script declares ``zarr`` with no extras, this returns ``""``. """ - match = re.search(PEP_723_REGEX, script) - + source_text = script_path.read_text() + match = re.search(PEP_723_REGEX, source_text) if match is None: - raise ValueError(f"PEP-723 header not found in {script}") + return "" content = "".join( line[2:] if line.startswith("# ") else line[1:] for line in match.group("content").splitlines(keepends=True) ) - config = tomlkit.parse(content) - for idx, dep in enumerate(tuple(config["dependencies"])): - if Requirement(dep).name == Requirement(dependency).name: - config["dependencies"][idx] = dependency - - new_content = "".join( - f"# {line}" if line.strip() else f"#{line}" - for line in tomlkit.dumps(config).splitlines(keepends=True) - ) - - start, end = match.span("content") - return script[:start] + new_content + script[end:] - - -def resave_script(source_path: Path, dest_path: Path) -> None: - """ - Read a script from source_path and save it to dest_path after inserting the absolute path to the - local Zarr project directory in the PEP-723 header. - """ - source_text = source_path.read_text() - dest_text = set_dep(source_text, f"zarr @ file:///{ZARR_PROJECT_PATH}") - dest_path.write_text(dest_text) + for dep in config.get("dependencies", []): + req = Requirement(dep) + if req.name == "zarr" and req.extras: + return "[" + ",".join(sorted(req.extras)) + "]" + return "" def test_script_paths() -> None: @@ -73,14 +53,15 @@ def test_script_paths() -> None: sys.platform in ("win32",), reason="This test fails due for unknown reasons on Windows in CI." ) @pytest.mark.parametrize("script_path", script_paths) -def test_scripts_can_run(script_path: Path, tmp_path: Path) -> None: - dest_path = tmp_path / script_path.name - # We resave the script after inserting the absolute path to the local Zarr project directory, - # and then test its behavior. - # This allows the example to be useful to users who don't have Zarr installed, but also testable. - resave_script(script_path, dest_path) +def test_scripts_can_run(script_path: Path) -> None: + # Override the zarr dependency with the local project, preserving any extras + # declared in the script's PEP 723 header (e.g. zarr[server]). + extras = _get_zarr_extras(script_path) + zarr_dep = f"zarr{extras} @ file:///{ZARR_PROJECT_PATH}" result = subprocess.run( - ["uv", "run", "--refresh", str(dest_path)], capture_output=True, text=True + ["uv", "run", "--with", zarr_dep, "--refresh", str(script_path)], + capture_output=True, + text=True, ) assert result.returncode == 0, ( f"Script at {script_path} failed to run. Output: {result.stdout} Error: {result.stderr}" diff --git a/tests/test_experimental/test_serve.py b/tests/test_experimental/test_serve.py index 2ea11530c4..e8ecfff28a 100644 --- a/tests/test_experimental/test_serve.py +++ b/tests/test_experimental/test_serve.py @@ -1,30 +1,29 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import numpy as np import pytest import zarr from zarr.core.buffer import cpu from zarr.core.sync import sync -from zarr.storage import MemoryStore + +if TYPE_CHECKING: + from zarr.abc.store import Store pytest.importorskip("starlette") pytest.importorskip("httpx") from starlette.testclient import TestClient -from zarr.experimental.serve import serve_node, serve_store +from zarr.experimental.serve import CorsOptions, _parse_range_header, serve_node, serve_store @pytest.fixture -def memory_store() -> MemoryStore: - return MemoryStore() - - -@pytest.fixture -def group_with_arrays(memory_store: MemoryStore) -> zarr.Group: +def group_with_arrays(store: Store) -> zarr.Group: """Create a group containing a regular array and a sharded array.""" - root = zarr.open_group(memory_store, mode="w") + root = zarr.open_group(store, mode="w") zarr.create_array(root.store_path / "regular", shape=(4, 4), chunks=(2, 2), dtype="f8") zarr.create_array( root.store_path / "sharded", @@ -36,15 +35,15 @@ def group_with_arrays(memory_store: MemoryStore) -> zarr.Group: return root +@pytest.mark.parametrize("store", ["memory"], indirect=True) class TestServeNodeDoesNotExposeNonZarrKeys: """serve_node must never expose keys that are not part of the zarr hierarchy.""" - def test_non_zarr_key_returns_404( - self, memory_store: MemoryStore, group_with_arrays: zarr.Group - ) -> None: - # Plant a non-zarr file in the store. + def test_non_zarr_key_returns_404(self, store: Store, group_with_arrays: zarr.Group) -> None: + """A key that is not valid zarr metadata or a valid chunk key should return 404, + even if the underlying store contains data at that path.""" non_zarr_buf = cpu.buffer_prototype.buffer.from_bytes(b"secret data") - sync(memory_store.set("secret.txt", non_zarr_buf)) + sync(store.set("secret.txt", non_zarr_buf)) app = serve_node(group_with_arrays) client = TestClient(app) @@ -54,12 +53,12 @@ def test_non_zarr_key_returns_404( assert response.status_code == 404 def test_non_zarr_key_nested_returns_404( - self, memory_store: MemoryStore, group_with_arrays: zarr.Group + self, store: Store, group_with_arrays: zarr.Group ) -> None: - # Plant a non-zarr file under a path that shares a prefix with a - # real array, but is not a valid chunk or metadata key. + """A non-zarr key nested under a real array's path should return 404, + even though the path prefix matches a valid zarr node.""" non_zarr_buf = cpu.buffer_prototype.buffer.from_bytes(b"not a chunk") - sync(memory_store.set("regular/notes.txt", non_zarr_buf)) + sync(store.set("regular/notes.txt", non_zarr_buf)) app = serve_node(group_with_arrays) client = TestClient(app) @@ -68,6 +67,8 @@ def test_non_zarr_key_nested_returns_404( assert response.status_code == 404 def test_valid_metadata_is_accessible(self, group_with_arrays: zarr.Group) -> None: + """Zarr metadata keys (zarr.json) for both the root group and child arrays + should be served with a 200 status.""" app = serve_node(group_with_arrays) client = TestClient(app) @@ -80,8 +81,10 @@ def test_valid_metadata_is_accessible(self, group_with_arrays: zarr.Group) -> No assert response.status_code == 200 def test_valid_chunk_is_accessible(self, group_with_arrays: zarr.Group) -> None: - # Write some data so the chunk actually exists in the store. + """A valid, in-bounds chunk key for an array with written data should + be served with a 200 status.""" arr = group_with_arrays["regular"] + assert isinstance(arr, zarr.Array) arr[:] = np.ones((4, 4)) app = serve_node(group_with_arrays) @@ -91,12 +94,40 @@ def test_valid_chunk_is_accessible(self, group_with_arrays: zarr.Group) -> None: response = client.get("/regular/c/0/0") assert response.status_code == 200 + def test_out_of_bounds_chunk_key_returns_404(self, group_with_arrays: zarr.Group) -> None: + """A chunk key that is syntactically valid but references indices beyond + the array's chunk grid should return 404.""" + arr = group_with_arrays["regular"] + assert isinstance(arr, zarr.Array) + arr[:] = np.ones((4, 4)) + + app = serve_node(group_with_arrays) + client = TestClient(app) + + # (4,4) array with (2,2) chunks has grid shape (2,2), so c/99/99 is + # syntactically valid but out of bounds. + response = client.get("/regular/c/99/99") + assert response.status_code == 404 + + def test_empty_path_returns_404(self, group_with_arrays: zarr.Group) -> None: + """A request to the root path '/' should return 404 because an empty + string is not a valid zarr key.""" + app = serve_node(group_with_arrays) + client = TestClient(app) + + response = client.get("/") + assert response.status_code == 404 + +@pytest.mark.parametrize("store", ["memory"], indirect=True) class TestShardedArrayByteRangeReads: """Byte-range reads against a sharded array served via serve_node.""" def test_range_read_returns_206(self, group_with_arrays: zarr.Group) -> None: + """A Range header requesting a specific byte range (e.g. bytes=0-7) should + return 206 Partial Content with exactly those bytes.""" arr = group_with_arrays["sharded"] + assert isinstance(arr, zarr.Array) arr[:] = np.arange(64, dtype="i4").reshape((8, 8)) app = serve_node(group_with_arrays) @@ -113,7 +144,10 @@ def test_range_read_returns_206(self, group_with_arrays: zarr.Group) -> None: assert range_response.content == full_body[:8] def test_suffix_range_read(self, group_with_arrays: zarr.Group) -> None: + """A suffix byte range (e.g. bytes=-4) should return the last N bytes + of the resource with a 206 status.""" arr = group_with_arrays["sharded"] + assert isinstance(arr, zarr.Array) arr[:] = np.arange(64, dtype="i4").reshape((8, 8)) app = serve_node(group_with_arrays) @@ -128,7 +162,10 @@ def test_suffix_range_read(self, group_with_arrays: zarr.Group) -> None: assert range_response.content == full_body[-4:] def test_offset_range_read(self, group_with_arrays: zarr.Group) -> None: + """An offset byte range (e.g. bytes=4-) should return all bytes from + the given offset to the end, with a 206 status.""" arr = group_with_arrays["sharded"] + assert isinstance(arr, zarr.Array) arr[:] = np.arange(64, dtype="i4").reshape((8, 8)) app = serve_node(group_with_arrays) @@ -143,11 +180,100 @@ def test_offset_range_read(self, group_with_arrays: zarr.Group) -> None: assert range_response.content == full_body[4:] +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestMalformedRangeHeaders: + """Malformed Range headers must return 416, never crash the server.""" + + @pytest.mark.parametrize( + "header", + [ + "bytes=abc-def", + "bytes=-abc", + "bytes=abc-", + "bytes=0-7,10-20", + ], + ) + def test_malformed_range_returns_416(self, store: Store, header: str) -> None: + """Range headers with non-numeric values, multiple ranges, or other + malformed syntax should return 416 Range Not Satisfiable instead of + crashing the server.""" + buf = cpu.buffer_prototype.buffer.from_bytes(b"some data here") + sync(store.set("key", buf)) + + app = serve_store(store) + client = TestClient(app, raise_server_exceptions=False) + + response = client.get("/key", headers={"Range": header}) + assert response.status_code == 416 + + def test_non_bytes_unit_returns_416(self, store: Store) -> None: + """A Range header using a unit other than 'bytes' (e.g. 'chars=0-7') + should return 416 because only byte ranges are supported.""" + buf = cpu.buffer_prototype.buffer.from_bytes(b"some data") + sync(store.set("key", buf)) + + app = serve_store(store) + client = TestClient(app) + + response = client.get("/key", headers={"Range": "chars=0-7"}) + assert response.status_code == 416 + + +class TestParseRangeHeader: + """Unit tests for _parse_range_header.""" + + def test_valid_range(self) -> None: + """'bytes=0-99' should parse into a RangeByteRequest with start=0 and + end=100 (end is exclusive, so the inclusive HTTP end is incremented).""" + from zarr.abc.store import RangeByteRequest + + result = _parse_range_header("bytes=0-99") + assert result == RangeByteRequest(start=0, end=100) + + def test_valid_suffix(self) -> None: + """'bytes=-50' should parse into a SuffixByteRequest requesting the + last 50 bytes of the resource.""" + from zarr.abc.store import SuffixByteRequest + + result = _parse_range_header("bytes=-50") + assert result == SuffixByteRequest(suffix=50) + + def test_valid_offset(self) -> None: + """'bytes=10-' should parse into an OffsetByteRequest starting at + byte 10 and reading to the end of the resource.""" + from zarr.abc.store import OffsetByteRequest + + result = _parse_range_header("bytes=10-") + assert result == OffsetByteRequest(offset=10) + + def test_non_bytes_unit(self) -> None: + """A Range header with a non-'bytes' unit should return None.""" + assert _parse_range_header("chars=0-7") is None + + def test_garbage_values(self) -> None: + """Non-numeric values in the byte range should return None instead + of raising a ValueError.""" + assert _parse_range_header("bytes=abc-def") is None + + def test_multi_range(self) -> None: + """Multi-range requests (e.g. bytes=0-7,10-20) are not supported and + should return None.""" + assert _parse_range_header("bytes=0-7,10-20") is None + + def test_empty_spec(self) -> None: + """A Range header with no range specifier after 'bytes=' should + return None.""" + assert _parse_range_header("bytes=") is None + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) class TestWriteViaPut: """serve_store and serve_node can be configured to accept PUT writes.""" - def test_put_writes_to_store(self, memory_store: MemoryStore) -> None: - app = serve_store(memory_store, methods={"GET", "PUT"}) + def test_put_writes_to_store(self, store: Store) -> None: + """A PUT request to serve_store with PUT enabled should write the + request body into the store at the given key.""" + app = serve_store(store, methods={"GET", "PUT"}) client = TestClient(app) payload = b"hello zarr" @@ -155,12 +281,14 @@ def test_put_writes_to_store(self, memory_store: MemoryStore) -> None: assert response.status_code == 204 # Verify the data landed in the store. - buf = sync(memory_store.get("some/key", cpu.buffer_prototype)) + buf = sync(store.get("some/key", cpu.buffer_prototype)) assert buf is not None assert buf.to_bytes() == payload - def test_put_then_get_roundtrip(self, memory_store: MemoryStore) -> None: - app = serve_store(memory_store, methods={"GET", "PUT"}) + def test_put_then_get_roundtrip(self, store: Store) -> None: + """Data written via PUT should be retrievable via a subsequent GET + at the same key.""" + app = serve_store(store, methods={"GET", "PUT"}) client = TestClient(app) payload = b"\x00\x01\x02\x03" @@ -170,20 +298,200 @@ def test_put_then_get_roundtrip(self, memory_store: MemoryStore) -> None: assert response.status_code == 200 assert response.content == payload - def test_put_rejected_when_not_configured(self, memory_store: MemoryStore) -> None: - # Default methods is {"GET"} only. - app = serve_store(memory_store) + def test_put_rejected_when_not_configured(self, store: Store) -> None: + """PUT requests should return 405 Method Not Allowed when the server + is created with the default methods (GET only).""" + app = serve_store(store) client = TestClient(app) response = client.put("/some/key", content=b"data") assert response.status_code == 405 - def test_put_on_node_validates_key( - self, memory_store: MemoryStore, group_with_arrays: zarr.Group - ) -> None: + def test_put_on_node_validates_key(self, store: Store, group_with_arrays: zarr.Group) -> None: + """PUT requests via serve_node should be rejected with 404 when the + target key is not a valid zarr key (metadata or chunk).""" app = serve_node(group_with_arrays, methods={"GET", "PUT"}) client = TestClient(app) - # Writing to a non-zarr key should be rejected. response = client.put("/not_a_zarr_key.bin", content=b"data") assert response.status_code == 404 + + def test_put_to_valid_chunk_key_succeeds(self, group_with_arrays: zarr.Group) -> None: + """PUT requests via serve_node to a valid chunk key should succeed + with 204, and the written data should be retrievable via GET.""" + app = serve_node(group_with_arrays, methods={"GET", "PUT"}) + client = TestClient(app) + + payload = b"\x00" * 32 + response = client.put("/regular/c/0/0", content=payload) + assert response.status_code == 204 + + # Confirm it round-trips. + get_response = client.get("/regular/c/0/0") + assert get_response.status_code == 200 + assert get_response.content == payload + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestServeStoreEdgeCases: + """Edge cases for serve_store.""" + + def test_get_nonexistent_key_returns_404(self, store: Store) -> None: + """GET for a key that does not exist in the store should return 404.""" + app = serve_store(store) + client = TestClient(app) + + response = client.get("/no/such/key") + assert response.status_code == 404 + + def test_empty_path_returns_404(self, store: Store) -> None: + """GET to the root path '/' (empty key) should return 404 because + an empty string is not a valid store key.""" + app = serve_store(store) + client = TestClient(app) + + response = client.get("/") + assert response.status_code == 404 + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestServeNodeDirectArray: + """Serve a single array directly (not through a group).""" + + def test_serve_nested_array_directly(self, store: Store) -> None: + """When serve_node is given a nested array (not a group), requests + should use keys relative to that array's path. Metadata and in-bounds + chunks should return 200, and out-of-bounds chunks should return 404.""" + root = zarr.open_group(store, mode="w") + arr = zarr.create_array( + root.store_path / "sub/nested", + shape=(4,), + chunks=(2,), + dtype="f8", + ) + arr[:] = np.arange(4, dtype="f8") + + # Serve the array directly — its prefix is "sub/nested". + app = serve_node(arr) + client = TestClient(app) + + # Metadata should be accessible at the array root. + response = client.get("/zarr.json") + assert response.status_code == 200 + + # Chunk keys are relative to the array. + response = client.get("/c/0") + assert response.status_code == 200 + + response = client.get("/c/1") + assert response.status_code == 200 + + # Out of bounds. + response = client.get("/c/99") + assert response.status_code == 404 + + def test_serve_root_array(self, store: Store) -> None: + """When serve_node is given an array stored at the root of a store + (empty prefix), metadata and chunk keys should be accessible at + their natural paths.""" + arr = zarr.create_array( + store, + shape=(6,), + chunks=(3,), + dtype="i4", + ) + arr[:] = np.arange(6, dtype="i4") + + # Root-level array has prefix = "". + app = serve_node(arr) + client = TestClient(app) + + response = client.get("/zarr.json") + assert response.status_code == 200 + + response = client.get("/c/0") + assert response.status_code == 200 + + response = client.get("/c/1") + assert response.status_code == 200 + + response = client.get("/c/2") + assert response.status_code == 404 + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestContentType: + """Responses should have the correct Content-Type.""" + + def test_metadata_has_json_content_type(self, group_with_arrays: zarr.Group) -> None: + """Zarr metadata files (zarr.json) should be served with + Content-Type: application/json.""" + app = serve_node(group_with_arrays) + client = TestClient(app) + + response = client.get("/zarr.json") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + + def test_chunk_has_octet_stream_content_type(self, group_with_arrays: zarr.Group) -> None: + """Chunk data should be served with Content-Type: application/octet-stream + since it is binary data.""" + arr = group_with_arrays["regular"] + assert isinstance(arr, zarr.Array) + arr[:] = np.ones((4, 4)) + + app = serve_node(group_with_arrays) + client = TestClient(app) + + response = client.get("/regular/c/0/0") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/octet-stream" + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestCorsMiddleware: + """CORS middleware should add the expected headers.""" + + def test_cors_headers_present(self, store: Store) -> None: + """When cors_options are provided, responses should include the + Access-Control-Allow-Origin header matching the request origin.""" + buf = cpu.buffer_prototype.buffer.from_bytes(b"data") + sync(store.set("key", buf)) + + cors = CorsOptions(allow_origins=["https://example.com"], allow_methods=["GET"]) + app = serve_store(store, cors_options=cors) + client = TestClient(app) + + response = client.get("/key", headers={"Origin": "https://example.com"}) + assert response.status_code == 200 + assert response.headers["access-control-allow-origin"] == "https://example.com" + + def test_cors_preflight(self, store: Store) -> None: + """CORS preflight OPTIONS requests should return 200 with the + Access-Control-Allow-Origin header when CORS is configured.""" + cors = CorsOptions(allow_origins=["*"], allow_methods=["GET", "PUT"]) + app = serve_store(store, methods={"GET", "PUT"}, cors_options=cors) + client = TestClient(app) + + response = client.options( + "/any/path", + headers={ + "Origin": "https://example.com", + "Access-Control-Request-Method": "PUT", + }, + ) + assert response.status_code == 200 + assert "access-control-allow-origin" in response.headers + + def test_no_cors_headers_without_option(self, store: Store) -> None: + """When no cors_options are provided, responses should not include + any CORS headers, even if the request includes an Origin header.""" + buf = cpu.buffer_prototype.buffer.from_bytes(b"data") + sync(store.set("key", buf)) + + app = serve_store(store) + client = TestClient(app) + + response = client.get("/key", headers={"Origin": "https://example.com"}) + assert response.status_code == 200 + assert "access-control-allow-origin" not in response.headers From a3d0358245f5607842b576e9697048f733fa8aca Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Sun, 1 Mar 2026 15:09:54 -0500 Subject: [PATCH 03/29] add __all__ and clean up tests --- src/zarr/experimental/serve.py | 9 ++- tests/test_experimental/test_serve.py | 83 +++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/zarr/experimental/serve.py b/src/zarr/experimental/serve.py index 1b6d4367a6..26978aa02f 100644 --- a/src/zarr/experimental/serve.py +++ b/src/zarr/experimental/serve.py @@ -7,6 +7,7 @@ from zarr.core.keys import is_valid_node_key if TYPE_CHECKING: + from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import Response @@ -14,6 +15,8 @@ from zarr.core.array import Array from zarr.core.group import Group +__all__ = ["CorsOptions", "HTTPMethod", "serve_node", "serve_store"] + class CorsOptions(TypedDict): allow_origins: list[str] @@ -111,7 +114,7 @@ def _make_starlette_app( *, methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, -) -> Any: +) -> Starlette: """Create a Starlette app with the request handler.""" try: from starlette.applications import Starlette @@ -144,7 +147,7 @@ def serve_store( *, methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, -) -> Any: +) -> Starlette: """Create a Starlette ASGI app that serves every key in a zarr ``Store``. Parameters @@ -173,7 +176,7 @@ def serve_node( *, methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, -) -> Any: +) -> Starlette: """Create a Starlette ASGI app that serves only the keys belonging to a zarr ``Array`` or ``Group``. diff --git a/tests/test_experimental/test_serve.py b/tests/test_experimental/test_serve.py index e8ecfff28a..5ec28b9a81 100644 --- a/tests/test_experimental/test_serve.py +++ b/tests/test_experimental/test_serve.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from zarr.abc.store import Store + from zarr.core.common import ZarrFormat pytest.importorskip("starlette") pytest.importorskip("httpx") @@ -495,3 +496,85 @@ def test_no_cors_headers_without_option(self, store: Store) -> None: response = client.get("/key", headers={"Origin": "https://example.com"}) assert response.status_code == 200 assert "access-control-allow-origin" not in response.headers + + +def _metadata_key(zarr_format: ZarrFormat) -> str: + """Return the metadata key for the given zarr format.""" + return "zarr.json" if zarr_format == 3 else ".zarray" + + +def _chunk_key(zarr_format: ZarrFormat, coords: str) -> str: + """Return a chunk key for the given format. + + *coords* is a dot-separated string like ``"0.0"``. For v3 this becomes + ``"c/0/0"``; for v2 it is returned unchanged. + """ + if zarr_format == 3: + return "c/" + coords.replace(".", "/") + return coords + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestServeNodeV2AndV3: + """Test serve_node with both v2 and v3 arrays side by side.""" + + def test_metadata_accessible(self, store: Store, zarr_format: ZarrFormat) -> None: + """The format-appropriate metadata key should be served with 200.""" + arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format) + app = serve_node(arr) + client = TestClient(app) + + response = client.get(f"/{_metadata_key(zarr_format)}") + assert response.status_code == 200 + + def test_chunk_accessible(self, store: Store, zarr_format: ZarrFormat) -> None: + """An in-bounds chunk key should be served with 200 for both formats.""" + arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format) + arr[:] = np.ones(4) + + app = serve_node(arr) + client = TestClient(app) + + response = client.get(f"/{_chunk_key(zarr_format, '0')}") + assert response.status_code == 200 + + def test_out_of_bounds_chunk_returns_404(self, store: Store, zarr_format: ZarrFormat) -> None: + """An out-of-bounds chunk key should return 404 for both formats.""" + arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format) + arr[:] = np.ones(4) + + app = serve_node(arr) + client = TestClient(app) + + response = client.get(f"/{_chunk_key(zarr_format, '99')}") + assert response.status_code == 404 + + def test_non_zarr_key_returns_404(self, store: Store, zarr_format: ZarrFormat) -> None: + """A non-zarr key should return 404 regardless of format.""" + arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format) + non_zarr_buf = cpu.buffer_prototype.buffer.from_bytes(b"secret") + sync(store.set("secret.txt", non_zarr_buf)) + + app = serve_node(arr) + client = TestClient(app) + + response = client.get("/secret.txt") + assert response.status_code == 404 + + def test_data_roundtrip(self, store: Store, zarr_format: ZarrFormat) -> None: + """Data written to an array should be readable via serve_store for + both formats.""" + arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format) + arr[:] = np.arange(4, dtype="f8") + + app = serve_store(store) + client = TestClient(app) + + # Metadata should be accessible. + response = client.get(f"/{_metadata_key(zarr_format)}") + assert response.status_code == 200 + + # First chunk should be accessible. + response = client.get(f"/{_chunk_key(zarr_format, '0')}") + assert response.status_code == 200 + assert len(response.content) > 0 From 71e303f5d944e230389232f27cf0721ed376e1e6 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Sun, 1 Mar 2026 15:26:40 -0500 Subject: [PATCH 04/29] add docs and changelog --- changes/3732.feature.md | 3 + docs/api/zarr/experimental.md | 6 +- docs/user-guide/examples/serve_v2_v3.md | 7 ++ docs/user-guide/experimental.md | 133 ++++++++++++++++++++++++ examples/serve_v2_v3/README.md | 3 +- 5 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 changes/3732.feature.md create mode 100644 docs/user-guide/examples/serve_v2_v3.md diff --git a/changes/3732.feature.md b/changes/3732.feature.md new file mode 100644 index 0000000000..8ec3ddac9c --- /dev/null +++ b/changes/3732.feature.md @@ -0,0 +1,3 @@ +Adds an experimental HTTP server that can expose `Store`, `Array`, or `Group` instances over HTTP. +See the [user guide](https://zarr.readthedocs.io/en/latest/user-guide/experimental.html#http-server) +and the [Serve v2 as v3 example](https://zarr.readthedocs.io/en/latest/user-guide/examples/serve_v2_v3.html). \ No newline at end of file diff --git a/docs/api/zarr/experimental.md b/docs/api/zarr/experimental.md index 60f1f987b5..159300952d 100644 --- a/docs/api/zarr/experimental.md +++ b/docs/api/zarr/experimental.md @@ -4,6 +4,10 @@ title: experimental Experimental functionality is not stable and may change or be removed at any point. -## Classes +## Cache Store ::: zarr.experimental.cache_store + +## HTTP Server + +::: zarr.experimental.serve diff --git a/docs/user-guide/examples/serve_v2_v3.md b/docs/user-guide/examples/serve_v2_v3.md new file mode 100644 index 0000000000..1e39bc59e6 --- /dev/null +++ b/docs/user-guide/examples/serve_v2_v3.md @@ -0,0 +1,7 @@ +--8<-- "examples/serve_v2_v3/README.md" + +## Source Code + +```python +--8<-- "examples/serve_v2_v3/serve_v2_v3.py" +``` diff --git a/docs/user-guide/experimental.md b/docs/user-guide/experimental.md index eaa53a4622..94e2ddc61b 100644 --- a/docs/user-guide/experimental.md +++ b/docs/user-guide/experimental.md @@ -273,3 +273,136 @@ print(f"Cache contains {info['cached_keys']} keys with {info['current_size']} by This example shows how the CacheStore can significantly reduce access times for repeated data reads, particularly important when working with remote data sources. The dual-store architecture allows for flexible cache persistence and management. + +## HTTP Server + +Zarr Python provides an experimental HTTP server that exposes a Zarr `Store`, `Array`, +or `Group` over HTTP as an [ASGI](https://asgi.readthedocs.io/) application. +This makes it possible to serve zarr data to any HTTP-capable client (including +another Zarr Python process backed by an `HTTPStore`). + +The server is built on [Starlette](https://www.starlette.io/) and can be run with +any ASGI server such as [Uvicorn](https://www.uvicorn.org/). + +Install the server dependencies with: + +```bash +pip install zarr[server] +``` + +### Serving a Store + +[`zarr.experimental.serve.serve_store`][] creates an ASGI app that exposes every key +in a store: + +```python +import zarr +from zarr.experimental.serve import serve_store + +store = zarr.storage.MemoryStore() +zarr.create_array(store, shape=(100, 100), chunks=(10, 10), dtype="float64") + +app = serve_store(store) + +# Run with Uvicorn: +# uvicorn my_module:app --host 0.0.0.0 --port 8000 +``` + +### Serving a Node + +[`zarr.experimental.serve.serve_node`][] creates an ASGI app that only serves keys +belonging to a specific `Array` or `Group`. Requests for keys outside the node +receive a 404, even if those keys exist in the underlying store: + +```python +import zarr +from zarr.experimental.serve import serve_node + +store = zarr.storage.MemoryStore() +root = zarr.open_group(store) +root.create_array("a", shape=(10,), dtype="int32") +root.create_array("b", shape=(20,), dtype="float64") + +# Only serve the array at "a" — requests for "b" will return 404. +arr = root["a"] +app = serve_node(arr) +``` + +### CORS Support + +Both `serve_store` and `serve_node` accept a [`CorsOptions`][zarr.experimental.serve.CorsOptions] +parameter to enable [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) +middleware for browser-based clients: + +```python +from zarr.experimental.serve import CorsOptions, serve_store + +app = serve_store( + store, + cors_options=CorsOptions( + allow_origins=["*"], + allow_methods=["GET"], + ), +) +``` + +### HTTP Range Requests + +The server supports the standard `Range` header for partial reads. The three +forms defined by [RFC 7233](https://httpwg.org/specs/rfc7233.html) are supported: + +| Header | Meaning | +| -------------------- | ------------------------------ | +| `bytes=0-99` | First 100 bytes | +| `bytes=100-` | Everything from byte 100 | +| `bytes=-50` | Last 50 bytes | + +A successful range request returns HTTP 206 (Partial Content). + +### Write Support + +By default only `GET` requests are accepted. To enable writes, pass +`methods={"GET", "PUT"}`: + +```python +app = serve_store(store, methods={"GET", "PUT"}) +``` + +A `PUT` request stores the request body at the given path and returns 204 (No Content). + +### Running the Server in a Background Thread + +Because `serve_store` and `serve_node` return a standard ASGI app, you can run the +server in a daemon thread and interact with it from the same process. This is +useful for notebooks, scripts, and interactive exploration: + +```python +import threading + +import numpy as np +import uvicorn + +import zarr +from zarr.experimental.serve import serve_node +from zarr.storage import MemoryStore + +# Create an array with some data. +store = MemoryStore() +arr = zarr.create_array(store, shape=(100,), chunks=(10,), dtype="float64") +arr[:] = np.arange(100, dtype="float64") + +# Build the ASGI app and launch Uvicorn in a daemon thread. +app = serve_node(arr) +config = uvicorn.Config(app, host="127.0.0.1", port=8000) +server = uvicorn.Server(config) +thread = threading.Thread(target=server.run, daemon=True) +thread.start() + +# Now open the served array from another zarr client. +remote = zarr.open_array("http://127.0.0.1:8000", mode="r") +np.testing.assert_array_equal(remote[:], arr[:]) + +# Shut down when finished. +server.should_exit = True +thread.join() +``` diff --git a/examples/serve_v2_v3/README.md b/examples/serve_v2_v3/README.md index c4790f18ac..1bdf92be2d 100644 --- a/examples/serve_v2_v3/README.md +++ b/examples/serve_v2_v3/README.md @@ -10,7 +10,8 @@ The example shows how to: existing v2 store - Translate v2 metadata (`.zarray` + `.zattrs`) to v3 `zarr.json` using the same `_convert_array_metadata` helper that powers `zarr migrate v3` -- Map v3 default chunk keys (`c/0/0`) to their v2 equivalents (`0.0`) +- Pass chunk keys through unchanged (the converted metadata preserves + `V2ChunkKeyEncoding`, so keys like `0.0` work in both formats) - Serve the translated store over HTTP so that any v3-compatible client can read v2 data without knowing the original format From 64de16fa73261913161e24ae7a1686bd9ec40e82 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Sun, 1 Mar 2026 19:42:13 -0500 Subject: [PATCH 05/29] add proper server --- changes/3732.feature.md | 2 + docs/user-guide/experimental.md | 105 +++++++-------- examples/serve_v2_v3/README.md | 2 +- examples/serve_v2_v3/serve_v2_v3.py | 8 +- mkdocs.yml | 1 + src/zarr/experimental/serve.py | 186 +++++++++++++++++++++++++- tests/test_experimental/test_serve.py | 150 ++++++++++++++------- 7 files changed, 346 insertions(+), 108 deletions(-) diff --git a/changes/3732.feature.md b/changes/3732.feature.md index 8ec3ddac9c..5706e99552 100644 --- a/changes/3732.feature.md +++ b/changes/3732.feature.md @@ -1,3 +1,5 @@ Adds an experimental HTTP server that can expose `Store`, `Array`, or `Group` instances over HTTP. +`store_app` and `node_app` build ASGI applications; `serve_store` and `serve_node` additionally +start a Uvicorn server (blocking by default, or in a background thread with `background=True`). See the [user guide](https://zarr.readthedocs.io/en/latest/user-guide/experimental.html#http-server) and the [Serve v2 as v3 example](https://zarr.readthedocs.io/en/latest/user-guide/examples/serve_v2_v3.html). \ No newline at end of file diff --git a/docs/user-guide/experimental.md b/docs/user-guide/experimental.md index 94e2ddc61b..8644506d7f 100644 --- a/docs/user-guide/experimental.md +++ b/docs/user-guide/experimental.md @@ -290,33 +290,31 @@ Install the server dependencies with: pip install zarr[server] ``` -### Serving a Store +### Building an ASGI App -[`zarr.experimental.serve.serve_store`][] creates an ASGI app that exposes every key +[`zarr.experimental.serve.store_app`][] creates an ASGI app that exposes every key in a store: ```python import zarr -from zarr.experimental.serve import serve_store +from zarr.experimental.serve import store_app store = zarr.storage.MemoryStore() zarr.create_array(store, shape=(100, 100), chunks=(10, 10), dtype="float64") -app = serve_store(store) +app = store_app(store) -# Run with Uvicorn: +# Run with any ASGI server, e.g. Uvicorn: # uvicorn my_module:app --host 0.0.0.0 --port 8000 ``` -### Serving a Node - -[`zarr.experimental.serve.serve_node`][] creates an ASGI app that only serves keys +[`zarr.experimental.serve.node_app`][] creates an ASGI app that only serves keys belonging to a specific `Array` or `Group`. Requests for keys outside the node receive a 404, even if those keys exist in the underlying store: ```python import zarr -from zarr.experimental.serve import serve_node +from zarr.experimental.serve import node_app store = zarr.storage.MemoryStore() root = zarr.open_group(store) @@ -325,19 +323,57 @@ root.create_array("b", shape=(20,), dtype="float64") # Only serve the array at "a" — requests for "b" will return 404. arr = root["a"] -app = serve_node(arr) +app = node_app(arr) +``` + +### Running the Server + +[`zarr.experimental.serve.serve_store`][] and [`zarr.experimental.serve.serve_node`][] +build an ASGI app *and* start a [Uvicorn](https://www.uvicorn.org/) server. +By default they block until the server is shut down: + +```python +from zarr.experimental.serve import serve_store + +serve_store(store, host="127.0.0.1", port=8000) +``` + +Pass `background=True` to start the server in a daemon thread and return +immediately. The returned `uvicorn.Server` object can be used to shut down +the server: + +```python +import numpy as np + +import zarr +from zarr.experimental.serve import serve_node +from zarr.storage import MemoryStore + +store = MemoryStore() +arr = zarr.create_array(store, shape=(100,), chunks=(10,), dtype="float64") +arr[:] = np.arange(100, dtype="float64") + +server = serve_node(arr, host="127.0.0.1", port=8000, background=True) + +# Now open the served array from another zarr client. +remote = zarr.open_array("http://127.0.0.1:8000", mode="r") +np.testing.assert_array_equal(remote[:], arr[:]) + +# Shut down when finished. +server.should_exit = True ``` ### CORS Support -Both `serve_store` and `serve_node` accept a [`CorsOptions`][zarr.experimental.serve.CorsOptions] -parameter to enable [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) -middleware for browser-based clients: +Both `store_app` and `node_app` (and their `serve_*` counterparts) accept a +[`CorsOptions`][zarr.experimental.serve.CorsOptions] parameter to enable +[CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) middleware for +browser-based clients: ```python -from zarr.experimental.serve import CorsOptions, serve_store +from zarr.experimental.serve import CorsOptions, store_app -app = serve_store( +app = store_app( store, cors_options=CorsOptions( allow_origins=["*"], @@ -365,44 +401,7 @@ By default only `GET` requests are accepted. To enable writes, pass `methods={"GET", "PUT"}`: ```python -app = serve_store(store, methods={"GET", "PUT"}) +app = store_app(store, methods={"GET", "PUT"}) ``` A `PUT` request stores the request body at the given path and returns 204 (No Content). - -### Running the Server in a Background Thread - -Because `serve_store` and `serve_node` return a standard ASGI app, you can run the -server in a daemon thread and interact with it from the same process. This is -useful for notebooks, scripts, and interactive exploration: - -```python -import threading - -import numpy as np -import uvicorn - -import zarr -from zarr.experimental.serve import serve_node -from zarr.storage import MemoryStore - -# Create an array with some data. -store = MemoryStore() -arr = zarr.create_array(store, shape=(100,), chunks=(10,), dtype="float64") -arr[:] = np.arange(100, dtype="float64") - -# Build the ASGI app and launch Uvicorn in a daemon thread. -app = serve_node(arr) -config = uvicorn.Config(app, host="127.0.0.1", port=8000) -server = uvicorn.Server(config) -thread = threading.Thread(target=server.run, daemon=True) -thread.start() - -# Now open the served array from another zarr client. -remote = zarr.open_array("http://127.0.0.1:8000", mode="r") -np.testing.assert_array_equal(remote[:], arr[:]) - -# Shut down when finished. -server.should_exit = True -thread.join() -``` diff --git a/examples/serve_v2_v3/README.md b/examples/serve_v2_v3/README.md index 1bdf92be2d..8bec69d4d1 100644 --- a/examples/serve_v2_v3/README.md +++ b/examples/serve_v2_v3/README.md @@ -2,7 +2,7 @@ This example demonstrates how to build a custom read-only `Store` that translates Zarr v2 data into v3 format on the fly, and serve it over HTTP -using `zarr.experimental.serve.serve_store`. +using `zarr.experimental.serve.store_app`. The example shows how to: diff --git a/examples/serve_v2_v3/serve_v2_v3.py b/examples/serve_v2_v3/serve_v2_v3.py index 558ca2acaf..c600fb6c3c 100644 --- a/examples/serve_v2_v3/serve_v2_v3.py +++ b/examples/serve_v2_v3/serve_v2_v3.py @@ -26,7 +26,7 @@ * The v2 metadata files (``.zarray``, ``.zattrs``) are hidden so only v3 keys are visible. -The translated store is then served over HTTP with ``serve_store``. A test +The translated store is then served over HTTP with ``store_app``. A test at the bottom opens the served data *as a v3 array* and verifies it can read the values back. """ @@ -272,12 +272,12 @@ def test_serve_roundtrip() -> None: """Serve the translated store over HTTP and read it back as v3.""" from starlette.testclient import TestClient - from zarr.experimental.serve import serve_store + from zarr.experimental.serve import store_app v2_store, _data = create_v2_array() v3_store = V2AsV3Store(v2_store) - app = serve_store(v3_store) + app = store_app(v3_store) client = TestClient(app) # Metadata should be valid v3 JSON. @@ -328,7 +328,7 @@ def test_open_as_v3_array() -> None: print("\n4. Store listing") test_listing() - print("\n5. HTTP round-trip via serve_store") + print("\n5. HTTP round-trip via store_app") test_serve_roundtrip() print("\n6. Open as v3 array") diff --git a/mkdocs.yml b/mkdocs.yml index 61872b6234..bfdc19586b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,6 +29,7 @@ nav: - user-guide/experimental.md - Examples: - user-guide/examples/custom_dtype.md + - user-guide/examples/serve_v2_v3.md - API Reference: - api/zarr/index.md - api/zarr/array.md diff --git a/src/zarr/experimental/serve.py b/src/zarr/experimental/serve.py index 26978aa02f..d9fd04b2ca 100644 --- a/src/zarr/experimental/serve.py +++ b/src/zarr/experimental/serve.py @@ -1,12 +1,15 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal, TypedDict +import threading +import time +from typing import TYPE_CHECKING, Any, Literal, TypedDict, overload from zarr.abc.store import OffsetByteRequest, RangeByteRequest, SuffixByteRequest from zarr.core.buffer import cpu from zarr.core.keys import is_valid_node_key if TYPE_CHECKING: + import uvicorn from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import Response @@ -15,7 +18,7 @@ from zarr.core.array import Array from zarr.core.group import Group -__all__ = ["CorsOptions", "HTTPMethod", "serve_node", "serve_store"] +__all__ = ["CorsOptions", "HTTPMethod", "node_app", "serve_node", "serve_store", "store_app"] class CorsOptions(TypedDict): @@ -142,7 +145,42 @@ def _make_starlette_app( return app -def serve_store( +def _start_server( + app: Starlette, + *, + host: str, + port: int, + background: bool, +) -> uvicorn.Server | None: + """Create a uvicorn server for *app* and either block or run in a daemon thread.""" + try: + import uvicorn + except ImportError as e: + raise ImportError( + "The zarr server requires the 'uvicorn' package. " + "Install it with: pip install zarr[server]" + ) from e + + config = uvicorn.Config(app, host=host, port=port) + server = uvicorn.Server(config) + + if not background: + server.run() + return None + + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + + deadline = time.monotonic() + 5.0 + while not server.started: + if time.monotonic() > deadline: + raise RuntimeError("Server failed to start within 5 seconds") + time.sleep(0.01) + + return server + + +def store_app( store: Store, *, methods: set[HTTPMethod] | None = None, @@ -171,7 +209,7 @@ def serve_store( return app -def serve_node( +def node_app( node: Array[Any] | Group, *, methods: set[HTTPMethod] | None = None, @@ -209,3 +247,143 @@ def serve_node( app.state.node = node app.state.prefix = node.store_path.path return app + + +@overload +def serve_store( + store: Store, + *, + host: str = ..., + port: int = ..., + methods: set[HTTPMethod] | None = ..., + cors_options: CorsOptions | None = ..., + background: Literal[False] = ..., +) -> None: ... + + +@overload +def serve_store( + store: Store, + *, + host: str = ..., + port: int = ..., + methods: set[HTTPMethod] | None = ..., + cors_options: CorsOptions | None = ..., + background: Literal[True], +) -> uvicorn.Server: ... + + +def serve_store( + store: Store, + *, + host: str = "127.0.0.1", + port: int = 8000, + methods: set[HTTPMethod] | None = None, + cors_options: CorsOptions | None = None, + background: bool = False, +) -> uvicorn.Server | None: + """Serve every key in a zarr ``Store`` over HTTP. + + Builds a Starlette ASGI app (see :func:`store_app`) and starts a + `Uvicorn `_ server. + + Parameters + ---------- + store : Store + The zarr store to serve. + host : str, optional + The host to bind to. Defaults to ``"127.0.0.1"``. + port : int, optional + The port to bind to. Defaults to ``8000``. + methods : set of HTTPMethod, optional + The HTTP methods to accept. Defaults to ``{"GET"}``. + cors_options : CorsOptions, optional + If provided, CORS middleware will be added with the given options. + background : bool, optional + If ``False`` (the default), the server blocks until shut down. + If ``True``, the server runs in a daemon thread and this function + returns immediately. + + Returns + ------- + uvicorn.Server or None + The running server when ``background=True``, or ``None`` when + the server has been shut down after blocking. + """ + app = store_app(store, methods=methods, cors_options=cors_options) + return _start_server(app, host=host, port=port, background=background) + + +@overload +def serve_node( + node: Array[Any] | Group, + *, + host: str = ..., + port: int = ..., + methods: set[HTTPMethod] | None = ..., + cors_options: CorsOptions | None = ..., + background: Literal[False] = ..., +) -> None: ... + + +@overload +def serve_node( + node: Array[Any] | Group, + *, + host: str = ..., + port: int = ..., + methods: set[HTTPMethod] | None = ..., + cors_options: CorsOptions | None = ..., + background: Literal[True], +) -> uvicorn.Server: ... + + +def serve_node( + node: Array[Any] | Group, + *, + host: str = "127.0.0.1", + port: int = 8000, + methods: set[HTTPMethod] | None = None, + cors_options: CorsOptions | None = None, + background: bool = False, +) -> uvicorn.Server | None: + """Serve only the keys belonging to a zarr ``Array`` or ``Group`` over HTTP. + + Builds a Starlette ASGI app (see :func:`node_app`) and starts a + `Uvicorn `_ server. + + For an ``Array``, the served keys are the metadata document(s) and all + chunk (or shard) keys whose coordinates fall within the array's grid. + + For a ``Group``, the served keys are the group's own metadata plus any + path that resolves through the group's members to a valid array metadata + document or chunk key. + + Requests for keys outside this set receive a 404 response, even if the + underlying store contains data at that path. + + Parameters + ---------- + node : Array or Group + The zarr array or group to serve. + host : str, optional + The host to bind to. Defaults to ``"127.0.0.1"``. + port : int, optional + The port to bind to. Defaults to ``8000``. + methods : set of HTTPMethod, optional + The HTTP methods to accept. Defaults to ``{"GET"}``. + cors_options : CorsOptions, optional + If provided, CORS middleware will be added with the given options. + background : bool, optional + If ``False`` (the default), the server blocks until shut down. + If ``True``, the server runs in a daemon thread and this function + returns immediately. + + Returns + ------- + uvicorn.Server or None + The running server when ``background=True``, or ``None`` when + the server has been shut down after blocking. + """ + app = node_app(node, methods=methods, cors_options=cors_options) + return _start_server(app, host=host, port=port, background=background) diff --git a/tests/test_experimental/test_serve.py b/tests/test_experimental/test_serve.py index 5ec28b9a81..58e7da3aef 100644 --- a/tests/test_experimental/test_serve.py +++ b/tests/test_experimental/test_serve.py @@ -18,7 +18,7 @@ from starlette.testclient import TestClient -from zarr.experimental.serve import CorsOptions, _parse_range_header, serve_node, serve_store +from zarr.experimental.serve import CorsOptions, _parse_range_header, node_app, store_app @pytest.fixture @@ -37,8 +37,8 @@ def group_with_arrays(store: Store) -> zarr.Group: @pytest.mark.parametrize("store", ["memory"], indirect=True) -class TestServeNodeDoesNotExposeNonZarrKeys: - """serve_node must never expose keys that are not part of the zarr hierarchy.""" +class TestNodeAppDoesNotExposeNonZarrKeys: + """node_app must never expose keys that are not part of the zarr hierarchy.""" def test_non_zarr_key_returns_404(self, store: Store, group_with_arrays: zarr.Group) -> None: """A key that is not valid zarr metadata or a valid chunk key should return 404, @@ -46,7 +46,7 @@ def test_non_zarr_key_returns_404(self, store: Store, group_with_arrays: zarr.Gr non_zarr_buf = cpu.buffer_prototype.buffer.from_bytes(b"secret data") sync(store.set("secret.txt", non_zarr_buf)) - app = serve_node(group_with_arrays) + app = node_app(group_with_arrays) client = TestClient(app) # The non-zarr key must not be accessible. @@ -61,7 +61,7 @@ def test_non_zarr_key_nested_returns_404( non_zarr_buf = cpu.buffer_prototype.buffer.from_bytes(b"not a chunk") sync(store.set("regular/notes.txt", non_zarr_buf)) - app = serve_node(group_with_arrays) + app = node_app(group_with_arrays) client = TestClient(app) response = client.get("/regular/notes.txt") @@ -70,7 +70,7 @@ def test_non_zarr_key_nested_returns_404( def test_valid_metadata_is_accessible(self, group_with_arrays: zarr.Group) -> None: """Zarr metadata keys (zarr.json) for both the root group and child arrays should be served with a 200 status.""" - app = serve_node(group_with_arrays) + app = node_app(group_with_arrays) client = TestClient(app) # Root group metadata @@ -88,7 +88,7 @@ def test_valid_chunk_is_accessible(self, group_with_arrays: zarr.Group) -> None: assert isinstance(arr, zarr.Array) arr[:] = np.ones((4, 4)) - app = serve_node(group_with_arrays) + app = node_app(group_with_arrays) client = TestClient(app) # c/0/0 is a valid chunk key for a (4,4) array with (2,2) chunks. @@ -102,7 +102,7 @@ def test_out_of_bounds_chunk_key_returns_404(self, group_with_arrays: zarr.Group assert isinstance(arr, zarr.Array) arr[:] = np.ones((4, 4)) - app = serve_node(group_with_arrays) + app = node_app(group_with_arrays) client = TestClient(app) # (4,4) array with (2,2) chunks has grid shape (2,2), so c/99/99 is @@ -113,7 +113,7 @@ def test_out_of_bounds_chunk_key_returns_404(self, group_with_arrays: zarr.Group def test_empty_path_returns_404(self, group_with_arrays: zarr.Group) -> None: """A request to the root path '/' should return 404 because an empty string is not a valid zarr key.""" - app = serve_node(group_with_arrays) + app = node_app(group_with_arrays) client = TestClient(app) response = client.get("/") @@ -122,7 +122,7 @@ def test_empty_path_returns_404(self, group_with_arrays: zarr.Group) -> None: @pytest.mark.parametrize("store", ["memory"], indirect=True) class TestShardedArrayByteRangeReads: - """Byte-range reads against a sharded array served via serve_node.""" + """Byte-range reads against a sharded array served via node_app.""" def test_range_read_returns_206(self, group_with_arrays: zarr.Group) -> None: """A Range header requesting a specific byte range (e.g. bytes=0-7) should @@ -131,7 +131,7 @@ def test_range_read_returns_206(self, group_with_arrays: zarr.Group) -> None: assert isinstance(arr, zarr.Array) arr[:] = np.arange(64, dtype="i4").reshape((8, 8)) - app = serve_node(group_with_arrays) + app = node_app(group_with_arrays) client = TestClient(app) # c/0/0 is the first shard key for an (8,8) array with (4,4) shards. @@ -151,7 +151,7 @@ def test_suffix_range_read(self, group_with_arrays: zarr.Group) -> None: assert isinstance(arr, zarr.Array) arr[:] = np.arange(64, dtype="i4").reshape((8, 8)) - app = serve_node(group_with_arrays) + app = node_app(group_with_arrays) client = TestClient(app) full_response = client.get("/sharded/c/0/0") @@ -169,7 +169,7 @@ def test_offset_range_read(self, group_with_arrays: zarr.Group) -> None: assert isinstance(arr, zarr.Array) arr[:] = np.arange(64, dtype="i4").reshape((8, 8)) - app = serve_node(group_with_arrays) + app = node_app(group_with_arrays) client = TestClient(app) full_response = client.get("/sharded/c/0/0") @@ -201,7 +201,7 @@ def test_malformed_range_returns_416(self, store: Store, header: str) -> None: buf = cpu.buffer_prototype.buffer.from_bytes(b"some data here") sync(store.set("key", buf)) - app = serve_store(store) + app = store_app(store) client = TestClient(app, raise_server_exceptions=False) response = client.get("/key", headers={"Range": header}) @@ -213,7 +213,7 @@ def test_non_bytes_unit_returns_416(self, store: Store) -> None: buf = cpu.buffer_prototype.buffer.from_bytes(b"some data") sync(store.set("key", buf)) - app = serve_store(store) + app = store_app(store) client = TestClient(app) response = client.get("/key", headers={"Range": "chars=0-7"}) @@ -269,12 +269,12 @@ def test_empty_spec(self) -> None: @pytest.mark.parametrize("store", ["memory"], indirect=True) class TestWriteViaPut: - """serve_store and serve_node can be configured to accept PUT writes.""" + """store_app and node_app can be configured to accept PUT writes.""" def test_put_writes_to_store(self, store: Store) -> None: - """A PUT request to serve_store with PUT enabled should write the + """A PUT request to store_app with PUT enabled should write the request body into the store at the given key.""" - app = serve_store(store, methods={"GET", "PUT"}) + app = store_app(store, methods={"GET", "PUT"}) client = TestClient(app) payload = b"hello zarr" @@ -289,7 +289,7 @@ def test_put_writes_to_store(self, store: Store) -> None: def test_put_then_get_roundtrip(self, store: Store) -> None: """Data written via PUT should be retrievable via a subsequent GET at the same key.""" - app = serve_store(store, methods={"GET", "PUT"}) + app = store_app(store, methods={"GET", "PUT"}) client = TestClient(app) payload = b"\x00\x01\x02\x03" @@ -302,25 +302,25 @@ def test_put_then_get_roundtrip(self, store: Store) -> None: def test_put_rejected_when_not_configured(self, store: Store) -> None: """PUT requests should return 405 Method Not Allowed when the server is created with the default methods (GET only).""" - app = serve_store(store) + app = store_app(store) client = TestClient(app) response = client.put("/some/key", content=b"data") assert response.status_code == 405 def test_put_on_node_validates_key(self, store: Store, group_with_arrays: zarr.Group) -> None: - """PUT requests via serve_node should be rejected with 404 when the + """PUT requests via node_app should be rejected with 404 when the target key is not a valid zarr key (metadata or chunk).""" - app = serve_node(group_with_arrays, methods={"GET", "PUT"}) + app = node_app(group_with_arrays, methods={"GET", "PUT"}) client = TestClient(app) response = client.put("/not_a_zarr_key.bin", content=b"data") assert response.status_code == 404 def test_put_to_valid_chunk_key_succeeds(self, group_with_arrays: zarr.Group) -> None: - """PUT requests via serve_node to a valid chunk key should succeed + """PUT requests via node_app to a valid chunk key should succeed with 204, and the written data should be retrievable via GET.""" - app = serve_node(group_with_arrays, methods={"GET", "PUT"}) + app = node_app(group_with_arrays, methods={"GET", "PUT"}) client = TestClient(app) payload = b"\x00" * 32 @@ -334,12 +334,12 @@ def test_put_to_valid_chunk_key_succeeds(self, group_with_arrays: zarr.Group) -> @pytest.mark.parametrize("store", ["memory"], indirect=True) -class TestServeStoreEdgeCases: - """Edge cases for serve_store.""" +class TestStoreAppEdgeCases: + """Edge cases for store_app.""" def test_get_nonexistent_key_returns_404(self, store: Store) -> None: """GET for a key that does not exist in the store should return 404.""" - app = serve_store(store) + app = store_app(store) client = TestClient(app) response = client.get("/no/such/key") @@ -348,7 +348,7 @@ def test_get_nonexistent_key_returns_404(self, store: Store) -> None: def test_empty_path_returns_404(self, store: Store) -> None: """GET to the root path '/' (empty key) should return 404 because an empty string is not a valid store key.""" - app = serve_store(store) + app = store_app(store) client = TestClient(app) response = client.get("/") @@ -356,11 +356,11 @@ def test_empty_path_returns_404(self, store: Store) -> None: @pytest.mark.parametrize("store", ["memory"], indirect=True) -class TestServeNodeDirectArray: +class TestNodeAppDirectArray: """Serve a single array directly (not through a group).""" def test_serve_nested_array_directly(self, store: Store) -> None: - """When serve_node is given a nested array (not a group), requests + """When node_app is given a nested array (not a group), requests should use keys relative to that array's path. Metadata and in-bounds chunks should return 200, and out-of-bounds chunks should return 404.""" root = zarr.open_group(store, mode="w") @@ -373,7 +373,7 @@ def test_serve_nested_array_directly(self, store: Store) -> None: arr[:] = np.arange(4, dtype="f8") # Serve the array directly — its prefix is "sub/nested". - app = serve_node(arr) + app = node_app(arr) client = TestClient(app) # Metadata should be accessible at the array root. @@ -392,7 +392,7 @@ def test_serve_nested_array_directly(self, store: Store) -> None: assert response.status_code == 404 def test_serve_root_array(self, store: Store) -> None: - """When serve_node is given an array stored at the root of a store + """When node_app is given an array stored at the root of a store (empty prefix), metadata and chunk keys should be accessible at their natural paths.""" arr = zarr.create_array( @@ -404,7 +404,7 @@ def test_serve_root_array(self, store: Store) -> None: arr[:] = np.arange(6, dtype="i4") # Root-level array has prefix = "". - app = serve_node(arr) + app = node_app(arr) client = TestClient(app) response = client.get("/zarr.json") @@ -427,7 +427,7 @@ class TestContentType: def test_metadata_has_json_content_type(self, group_with_arrays: zarr.Group) -> None: """Zarr metadata files (zarr.json) should be served with Content-Type: application/json.""" - app = serve_node(group_with_arrays) + app = node_app(group_with_arrays) client = TestClient(app) response = client.get("/zarr.json") @@ -441,7 +441,7 @@ def test_chunk_has_octet_stream_content_type(self, group_with_arrays: zarr.Group assert isinstance(arr, zarr.Array) arr[:] = np.ones((4, 4)) - app = serve_node(group_with_arrays) + app = node_app(group_with_arrays) client = TestClient(app) response = client.get("/regular/c/0/0") @@ -460,7 +460,7 @@ def test_cors_headers_present(self, store: Store) -> None: sync(store.set("key", buf)) cors = CorsOptions(allow_origins=["https://example.com"], allow_methods=["GET"]) - app = serve_store(store, cors_options=cors) + app = store_app(store, cors_options=cors) client = TestClient(app) response = client.get("/key", headers={"Origin": "https://example.com"}) @@ -471,7 +471,7 @@ def test_cors_preflight(self, store: Store) -> None: """CORS preflight OPTIONS requests should return 200 with the Access-Control-Allow-Origin header when CORS is configured.""" cors = CorsOptions(allow_origins=["*"], allow_methods=["GET", "PUT"]) - app = serve_store(store, methods={"GET", "PUT"}, cors_options=cors) + app = store_app(store, methods={"GET", "PUT"}, cors_options=cors) client = TestClient(app) response = client.options( @@ -490,7 +490,7 @@ def test_no_cors_headers_without_option(self, store: Store) -> None: buf = cpu.buffer_prototype.buffer.from_bytes(b"data") sync(store.set("key", buf)) - app = serve_store(store) + app = store_app(store) client = TestClient(app) response = client.get("/key", headers={"Origin": "https://example.com"}) @@ -515,13 +515,13 @@ def _chunk_key(zarr_format: ZarrFormat, coords: str) -> str: @pytest.mark.parametrize("store", ["memory"], indirect=True) -class TestServeNodeV2AndV3: - """Test serve_node with both v2 and v3 arrays side by side.""" +class TestNodeAppV2AndV3: + """Test node_app with both v2 and v3 arrays side by side.""" def test_metadata_accessible(self, store: Store, zarr_format: ZarrFormat) -> None: """The format-appropriate metadata key should be served with 200.""" arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format) - app = serve_node(arr) + app = node_app(arr) client = TestClient(app) response = client.get(f"/{_metadata_key(zarr_format)}") @@ -532,7 +532,7 @@ def test_chunk_accessible(self, store: Store, zarr_format: ZarrFormat) -> None: arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format) arr[:] = np.ones(4) - app = serve_node(arr) + app = node_app(arr) client = TestClient(app) response = client.get(f"/{_chunk_key(zarr_format, '0')}") @@ -543,7 +543,7 @@ def test_out_of_bounds_chunk_returns_404(self, store: Store, zarr_format: ZarrFo arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format) arr[:] = np.ones(4) - app = serve_node(arr) + app = node_app(arr) client = TestClient(app) response = client.get(f"/{_chunk_key(zarr_format, '99')}") @@ -555,19 +555,19 @@ def test_non_zarr_key_returns_404(self, store: Store, zarr_format: ZarrFormat) - non_zarr_buf = cpu.buffer_prototype.buffer.from_bytes(b"secret") sync(store.set("secret.txt", non_zarr_buf)) - app = serve_node(arr) + app = node_app(arr) client = TestClient(app) response = client.get("/secret.txt") assert response.status_code == 404 def test_data_roundtrip(self, store: Store, zarr_format: ZarrFormat) -> None: - """Data written to an array should be readable via serve_store for + """Data written to an array should be readable via store_app for both formats.""" arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format) arr[:] = np.arange(4, dtype="f8") - app = serve_store(store) + app = store_app(store) client = TestClient(app) # Metadata should be accessible. @@ -578,3 +578,61 @@ def test_data_roundtrip(self, store: Store, zarr_format: ZarrFormat) -> None: response = client.get(f"/{_chunk_key(zarr_format, '0')}") assert response.status_code == 200 assert len(response.content) > 0 + + +def _get_free_port() -> int: + """Return an unused TCP port on localhost.""" + import socket + + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + port: int = s.getsockname()[1] + return port + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestServeBackground: + """Test serve_store and serve_node with background=True.""" + + def test_serve_store_background(self, store: Store) -> None: + """serve_store(background=True) should start a server in a daemon + thread and return a uvicorn.Server that responds to HTTP requests.""" + import httpx + + from zarr.experimental.serve import serve_store + + buf = cpu.buffer_prototype.buffer.from_bytes(b"hello") + sync(store.set("key", buf)) + + port = _get_free_port() + server = serve_store(store, host="127.0.0.1", port=port, background=True) + try: + assert server is not None + assert server.started + + response = httpx.get(f"http://127.0.0.1:{port}/key") + assert response.status_code == 200 + assert response.content == b"hello" + finally: + server.should_exit = True + + def test_serve_node_background(self, store: Store) -> None: + """serve_node(background=True) should start a server in a daemon + thread and return a uvicorn.Server that responds to HTTP requests.""" + import httpx + + from zarr.experimental.serve import serve_node + + arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8") + arr[:] = np.arange(4, dtype="f8") + + port = _get_free_port() + server = serve_node(arr, host="127.0.0.1", port=port, background=True) + try: + assert server is not None + assert server.started + + response = httpx.get(f"http://127.0.0.1:{port}/zarr.json") + assert response.status_code == 200 + finally: + server.should_exit = True From e40b20b364f2127b07a9acc8ec9b0cb29c765234 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Sun, 1 Mar 2026 20:33:27 -0500 Subject: [PATCH 06/29] rework examples (simplify) and make server a context manager --- changes/3732.feature.md | 2 +- docs/user-guide/examples/serve.md | 7 + docs/user-guide/examples/serve_v2_v3.md | 7 - docs/user-guide/experimental.md | 17 +- examples/serve/README.md | 17 ++ examples/serve/serve.py | 43 +++ examples/serve_v2_v3/README.md | 28 -- examples/serve_v2_v3/serve_v2_v3.py | 337 ------------------------ mkdocs.yml | 2 +- src/zarr/experimental/serve.py | 75 +++++- tests/test_experimental/test_serve.py | 30 +-- 11 files changed, 150 insertions(+), 415 deletions(-) create mode 100644 docs/user-guide/examples/serve.md delete mode 100644 docs/user-guide/examples/serve_v2_v3.md create mode 100644 examples/serve/README.md create mode 100644 examples/serve/serve.py delete mode 100644 examples/serve_v2_v3/README.md delete mode 100644 examples/serve_v2_v3/serve_v2_v3.py diff --git a/changes/3732.feature.md b/changes/3732.feature.md index 5706e99552..a83e5c0264 100644 --- a/changes/3732.feature.md +++ b/changes/3732.feature.md @@ -2,4 +2,4 @@ Adds an experimental HTTP server that can expose `Store`, `Array`, or `Group` in `store_app` and `node_app` build ASGI applications; `serve_store` and `serve_node` additionally start a Uvicorn server (blocking by default, or in a background thread with `background=True`). See the [user guide](https://zarr.readthedocs.io/en/latest/user-guide/experimental.html#http-server) -and the [Serve v2 as v3 example](https://zarr.readthedocs.io/en/latest/user-guide/examples/serve_v2_v3.html). \ No newline at end of file +and the [example](https://zarr.readthedocs.io/en/latest/user-guide/examples/serve.html). \ No newline at end of file diff --git a/docs/user-guide/examples/serve.md b/docs/user-guide/examples/serve.md new file mode 100644 index 0000000000..d02a67e4a9 --- /dev/null +++ b/docs/user-guide/examples/serve.md @@ -0,0 +1,7 @@ +--8<-- "examples/serve/README.md" + +## Source Code + +```python +--8<-- "examples/serve/serve.py" +``` diff --git a/docs/user-guide/examples/serve_v2_v3.md b/docs/user-guide/examples/serve_v2_v3.md deleted file mode 100644 index 1e39bc59e6..0000000000 --- a/docs/user-guide/examples/serve_v2_v3.md +++ /dev/null @@ -1,7 +0,0 @@ ---8<-- "examples/serve_v2_v3/README.md" - -## Source Code - -```python ---8<-- "examples/serve_v2_v3/serve_v2_v3.py" -``` diff --git a/docs/user-guide/experimental.md b/docs/user-guide/experimental.md index 8644506d7f..eded9661fc 100644 --- a/docs/user-guide/experimental.md +++ b/docs/user-guide/experimental.md @@ -339,8 +339,8 @@ serve_store(store, host="127.0.0.1", port=8000) ``` Pass `background=True` to start the server in a daemon thread and return -immediately. The returned `uvicorn.Server` object can be used to shut down -the server: +immediately. The returned [`BackgroundServer`][zarr.experimental.serve.BackgroundServer] +can be used as a context manager for automatic shutdown: ```python import numpy as np @@ -353,14 +353,11 @@ store = MemoryStore() arr = zarr.create_array(store, shape=(100,), chunks=(10,), dtype="float64") arr[:] = np.arange(100, dtype="float64") -server = serve_node(arr, host="127.0.0.1", port=8000, background=True) - -# Now open the served array from another zarr client. -remote = zarr.open_array("http://127.0.0.1:8000", mode="r") -np.testing.assert_array_equal(remote[:], arr[:]) - -# Shut down when finished. -server.should_exit = True +with serve_node(arr, host="127.0.0.1", port=8000, background=True) as server: + # Now open the served array from another zarr client. + remote = zarr.open_array(server.url, mode="r") + np.testing.assert_array_equal(remote[:], arr[:]) +# Server is shut down automatically when the block exits. ``` ### CORS Support diff --git a/examples/serve/README.md b/examples/serve/README.md new file mode 100644 index 0000000000..7b37968248 --- /dev/null +++ b/examples/serve/README.md @@ -0,0 +1,17 @@ +# Serve a Zarr Array over HTTP + +This example creates an in-memory Zarr array, serves it over HTTP with +`zarr.experimental.serve.serve_node`, and fetches the `zarr.json` metadata +document and a raw chunk using `httpx`. + +## Running the Example + +```bash +python examples/serve/serve.py +``` + +Or run with uv: + +```bash +uv run examples/serve/serve.py +``` diff --git a/examples/serve/serve.py b/examples/serve/serve.py new file mode 100644 index 0000000000..19550cc0ec --- /dev/null +++ b/examples/serve/serve.py @@ -0,0 +1,43 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "zarr[server] @ git+https://github.com/zarr-developers/zarr-python.git@main", +# "httpx", +# ] +# /// +""" +Serve a Zarr array over HTTP and fetch its metadata and chunks. + +This example creates an in-memory array, serves it in a background thread, +then uses ``httpx`` to request the ``zarr.json`` metadata document and a raw +chunk. +""" + +import json + +import httpx +import numpy as np + +import zarr +from zarr.experimental.serve import serve_node +from zarr.storage import MemoryStore + +# -- create an array -------------------------------------------------------- +store = MemoryStore() +data = np.arange(1000, dtype="uint8").reshape(10, 10, 10) +# no compression +arr = zarr.create_array(store, data=data, chunks=(5, 5, 5), write_data=True, compressors=None) + +# -- serve it in the background --------------------------------------------- +with serve_node(arr, host="127.0.0.1", port=8000, background=True) as server: + # -- fetch metadata ------------------------------------------------------ + resp = httpx.get(f"{server.url}/zarr.json") + assert resp.status_code == 200 + meta = resp.json() + print("zarr.json:") + print(json.dumps(meta, indent=2)) + + # -- fetch a raw chunk --------------------------------------------------- + resp = httpx.get(f"{server.url}/c/0/0/0") + assert resp.status_code == 200 + print(f"\nchunk c/0/0/0: {len(resp.content)} bytes") diff --git a/examples/serve_v2_v3/README.md b/examples/serve_v2_v3/README.md deleted file mode 100644 index 8bec69d4d1..0000000000 --- a/examples/serve_v2_v3/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Serve a Zarr v2 Array as v3 over HTTP - -This example demonstrates how to build a custom read-only `Store` that -translates Zarr v2 data into v3 format on the fly, and serve it over HTTP -using `zarr.experimental.serve.store_app`. - -The example shows how to: - -- Implement a custom `Store` subclass (`V2AsV3Store`) that wraps an - existing v2 store -- Translate v2 metadata (`.zarray` + `.zattrs`) to v3 `zarr.json` using - the same `_convert_array_metadata` helper that powers `zarr migrate v3` -- Pass chunk keys through unchanged (the converted metadata preserves - `V2ChunkKeyEncoding`, so keys like `0.0` work in both formats) -- Serve the translated store over HTTP so that any v3-compatible client - can read v2 data without knowing the original format - -## Running the Example - -```bash -python examples/serve_v2_v3/serve_v2_v3.py -``` - -Or run with uv: - -```bash -uv run examples/serve_v2_v3/serve_v2_v3.py -``` diff --git a/examples/serve_v2_v3/serve_v2_v3.py b/examples/serve_v2_v3/serve_v2_v3.py deleted file mode 100644 index c600fb6c3c..0000000000 --- a/examples/serve_v2_v3/serve_v2_v3.py +++ /dev/null @@ -1,337 +0,0 @@ -# /// script -# requires-python = ">=3.11" -# dependencies = [ -# "zarr[server]", -# "httpx", -# ] -# /// -# -""" -Serve a Zarr v2 array over HTTP as a Zarr v3 array. - -This example demonstrates how to build a read-only ``Store`` that translates -between Zarr formats on the fly. A v2 array lives in a ``MemoryStore``; the -custom ``V2AsV3Store`` intercepts reads and translates metadata: - -* Requests for ``zarr.json`` are answered by reading the v2 ``.zarray`` / - ``.zattrs`` metadata and converting it to a v3 ``zarr.json`` document - using the same ``_convert_array_metadata`` helper that powers the - ``zarr migrate v3`` CLI command. - -* Chunk keys are passed through unchanged because ``_convert_array_metadata`` - preserves the v2 chunk key encoding (``V2ChunkKeyEncoding``). A v3 - client reads the encoding from ``zarr.json`` and naturally produces the - same keys (e.g. ``0.0``) that the v2 store already contains. - -* The v2 metadata files (``.zarray``, ``.zattrs``) are hidden so only - v3 keys are visible. - -The translated store is then served over HTTP with ``store_app``. A test -at the bottom opens the served data *as a v3 array* and verifies it can -read the values back. -""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING - -import numpy as np - -import zarr -from zarr.abc.store import ByteRequest, Store -from zarr.core.buffer import Buffer, cpu -from zarr.core.buffer.core import default_buffer_prototype -from zarr.core.common import ZARR_JSON, ZARRAY_JSON, ZATTRS_JSON -from zarr.core.metadata.v2 import ArrayV2Metadata -from zarr.core.sync import sync -from zarr.metadata.migrate_v3 import _convert_array_metadata -from zarr.storage import MemoryStore - -if TYPE_CHECKING: - from collections.abc import AsyncIterator, Iterable - - from zarr.core.buffer import BufferPrototype - - -# --------------------------------------------------------------------------- -# Custom store that presents v2 data as v3 -# --------------------------------------------------------------------------- - -# v2 metadata keys that should be hidden from v3 clients. -_HIDDEN_V2_KEYS = frozenset({ZARRAY_JSON, ZATTRS_JSON}) - - -class V2AsV3Store(Store): - """A read-only store that wraps an existing v2 store and presents it as v3. - - Metadata translation - -------------------- - ``zarr.json`` ← ``.zarray`` + ``.zattrs`` (converted via - ``_convert_array_metadata`` from the CLI migration module) - - Chunk keys - ---------- - Chunk keys are **not** translated. The v3 metadata produced by - ``_convert_array_metadata`` uses ``V2ChunkKeyEncoding`` with the - same separator as the original v2 array, so chunk keys like ``0.0`` - are valid in both formats. - - Visibility - ---------- - The v2 metadata files (``.zarray``, ``.zattrs``) are hidden from - listing and ``get`` so that clients only see v3 keys. - """ - - supports_writes: bool = False - supports_deletes: bool = False - supports_listing: bool = True - - def __init__(self, v2_store: Store) -> None: - super().__init__(read_only=True) - self._v2 = v2_store - - def __eq__(self, other: object) -> bool: - return isinstance(other, V2AsV3Store) and self._v2 == other._v2 - - # -- metadata conversion ----------------------------------------------- - - async def _build_zarr_json(self, prototype: BufferPrototype) -> Buffer | None: - """Read v2 metadata from the wrapped store and return a v3 - ``zarr.json`` buffer.""" - zarray_buf = await self._v2.get(ZARRAY_JSON, prototype) - if zarray_buf is None: - return None - - zarray_dict = json.loads(zarray_buf.to_bytes()) - v2_meta = ArrayV2Metadata.from_dict(zarray_dict) - - # Merge in .zattrs if present. - zattrs_buf = await self._v2.get(ZATTRS_JSON, prototype) - if zattrs_buf is not None: - attrs = json.loads(zattrs_buf.to_bytes()) - if attrs: - v2_meta = v2_meta.update_attributes(attrs) - - # Reuse the same conversion the CLI uses. - v3_meta = _convert_array_metadata(v2_meta) - v3_json = json.dumps(v3_meta.to_dict()).encode() - return prototype.buffer.from_bytes(v3_json) - - # -- Store ABC implementation ------------------------------------------ - - async def get( - self, - key: str, - prototype: BufferPrototype | None = None, - byte_range: ByteRequest | None = None, - ) -> Buffer | None: - if prototype is None: - prototype = default_buffer_prototype() - await self._ensure_open() - - # Synthesise zarr.json from v2 metadata. - if key == ZARR_JSON: - buf = await self._build_zarr_json(prototype) - if buf is None or byte_range is None: - return buf - from zarr.storage._utils import _normalize_byte_range_index - - start, stop = _normalize_byte_range_index(buf, byte_range) - return prototype.buffer.from_buffer(buf[start:stop]) - - # Hide v2 metadata files. - if key in _HIDDEN_V2_KEYS: - return None - - # All other keys (chunk keys) pass through unchanged. - return await self._v2.get(key, prototype, byte_range=byte_range) - - async def get_partial_values( - self, - prototype: BufferPrototype, - key_ranges: Iterable[tuple[str, ByteRequest | None]], - ) -> list[Buffer | None]: - return [await self.get(k, prototype, br) for k, br in key_ranges] - - async def exists(self, key: str) -> bool: - if key == ZARR_JSON: - return await self._v2.exists(ZARRAY_JSON) - if key in _HIDDEN_V2_KEYS: - return False - return await self._v2.exists(key) - - async def set(self, key: str, value: Buffer) -> None: - raise NotImplementedError("V2AsV3Store is read-only") - - async def delete(self, key: str) -> None: - raise NotImplementedError("V2AsV3Store is read-only") - - async def list(self) -> AsyncIterator[str]: - async for key in self._v2.list(): - if key == ZARRAY_JSON: - yield ZARR_JSON - elif key in _HIDDEN_V2_KEYS: - continue - else: - yield key - - async def list_prefix(self, prefix: str) -> AsyncIterator[str]: - async for key in self.list(): - if key.startswith(prefix): - yield key - - async def list_dir(self, prefix: str) -> AsyncIterator[str]: - async for key in self.list(): - if not key.startswith(prefix): - continue - remainder = key[len(prefix) :] - if "/" not in remainder: - yield key - - -# --------------------------------------------------------------------------- -# Demo / tests -# --------------------------------------------------------------------------- - - -def create_v2_array() -> tuple[MemoryStore, np.ndarray]: - """Create a v2 array with some data and return the store + data.""" - store = MemoryStore() - data = np.arange(16, dtype="float64").reshape(4, 4) - arr = zarr.create_array(store, shape=data.shape, chunks=(2, 2), dtype=data.dtype, zarr_format=2) - arr[:] = data - return store, data - - -def test_metadata_translation() -> None: - """The translated zarr.json should be valid v3 metadata.""" - v2_store, _ = create_v2_array() - v3_store = V2AsV3Store(v2_store) - - buf = sync(v3_store.get(ZARR_JSON, cpu.buffer_prototype)) - assert buf is not None - meta = json.loads(buf.to_bytes()) - - assert meta["zarr_format"] == 3 - assert meta["node_type"] == "array" - assert meta["shape"] == [4, 4] - assert meta["chunk_grid"]["configuration"]["chunk_shape"] == [2, 2] - # The v2 chunk key encoding is preserved. - assert meta["chunk_key_encoding"]["name"] == "v2" - assert any(c["name"] in ("bytes", "zstd", "blosc") for c in meta["codecs"]) - print(" metadata translation: OK") - print(f" zarr.json:\n{json.dumps(meta, indent=2)}") - - -def test_chunk_passthrough() -> None: - """Chunk keys should pass through unchanged (v2 encoding preserved).""" - v2_store, _ = create_v2_array() - v3_store = V2AsV3Store(v2_store) - - # The v2 store has chunk key "0.0"; the v3 store should serve the - # same key since the metadata says V2ChunkKeyEncoding. - v2_buf = sync(v2_store.get("0.0", cpu.buffer_prototype)) - v3_buf = sync(v3_store.get("0.0", cpu.buffer_prototype)) - assert v2_buf is not None - assert v3_buf is not None - assert v2_buf.to_bytes() == v3_buf.to_bytes() - print(" chunk passthrough: OK") - - -def test_v2_metadata_hidden() -> None: - """v2 metadata files should not be visible.""" - v2_store, _ = create_v2_array() - v3_store = V2AsV3Store(v2_store) - - assert sync(v3_store.get(ZARRAY_JSON, cpu.buffer_prototype)) is None - assert sync(v3_store.get(ZATTRS_JSON, cpu.buffer_prototype)) is None - assert not sync(v3_store.exists(ZARRAY_JSON)) - assert not sync(v3_store.exists(ZATTRS_JSON)) - print(" v2 metadata hidden: OK") - - -def test_listing() -> None: - """Store listing should show v3 keys only.""" - v2_store, _ = create_v2_array() - v3_store = V2AsV3Store(v2_store) - - async def _list() -> list[str]: - return [k async for k in v3_store.list()] - - keys = sync(_list()) - assert ZARR_JSON in keys - assert ZARRAY_JSON not in keys - assert ZATTRS_JSON not in keys - # Chunk keys use v2 encoding (unchanged). - assert "0.0" in keys - print(f" listing keys: {sorted(keys)}") - - -def test_serve_roundtrip() -> None: - """Serve the translated store over HTTP and read it back as v3.""" - from starlette.testclient import TestClient - - from zarr.experimental.serve import store_app - - v2_store, _data = create_v2_array() - v3_store = V2AsV3Store(v2_store) - - app = store_app(v3_store) - client = TestClient(app) - - # Metadata should be valid v3 JSON. - resp = client.get("/zarr.json") - assert resp.status_code == 200 - assert resp.headers["content-type"] == "application/json" - meta = resp.json() - assert meta["zarr_format"] == 3 - - # Chunks should be accessible via v2-style keys (as the metadata declares). - resp = client.get("/0.0") - assert resp.status_code == 200 - assert len(resp.content) > 0 - - # v2 metadata files should NOT be accessible. - resp = client.get("/.zarray") - assert resp.status_code == 404 - resp = client.get("/.zattrs") - assert resp.status_code == 404 - - print(" HTTP round-trip: OK") - - -def test_open_as_v3_array() -> None: - """Open the translated store as a v3 array and verify the data.""" - v2_store, data = create_v2_array() - v3_store = V2AsV3Store(v2_store) - - arr = zarr.open_array(v3_store) - assert arr.metadata.zarr_format == 3 - np.testing.assert_array_equal(arr[:], data) - print(" open as v3 array: OK") - print(f" data:\n{arr[:]}") - - -if __name__ == "__main__": - print("Creating v2 array and wrapping it with V2AsV3Store...\n") - - print("1. Metadata translation") - test_metadata_translation() - - print("\n2. Chunk key passthrough") - test_chunk_passthrough() - - print("\n3. v2 metadata hidden") - test_v2_metadata_hidden() - - print("\n4. Store listing") - test_listing() - - print("\n5. HTTP round-trip via store_app") - test_serve_roundtrip() - - print("\n6. Open as v3 array") - test_open_as_v3_array() - - print("\nAll checks passed.") diff --git a/mkdocs.yml b/mkdocs.yml index bfdc19586b..1b1f70c8fb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -29,7 +29,7 @@ nav: - user-guide/experimental.md - Examples: - user-guide/examples/custom_dtype.md - - user-guide/examples/serve_v2_v3.md + - user-guide/examples/serve.md - API Reference: - api/zarr/index.md - api/zarr/array.md diff --git a/src/zarr/experimental/serve.py b/src/zarr/experimental/serve.py index d9fd04b2ca..c83c06a9b8 100644 --- a/src/zarr/experimental/serve.py +++ b/src/zarr/experimental/serve.py @@ -2,7 +2,7 @@ import threading import time -from typing import TYPE_CHECKING, Any, Literal, TypedDict, overload +from typing import TYPE_CHECKING, Any, Literal, Self, TypedDict, overload from zarr.abc.store import OffsetByteRequest, RangeByteRequest, SuffixByteRequest from zarr.core.buffer import cpu @@ -18,7 +18,15 @@ from zarr.core.array import Array from zarr.core.group import Group -__all__ = ["CorsOptions", "HTTPMethod", "node_app", "serve_node", "serve_store", "store_app"] +__all__ = [ + "BackgroundServer", + "CorsOptions", + "HTTPMethod", + "node_app", + "serve_node", + "serve_store", + "store_app", +] class CorsOptions(TypedDict): @@ -29,6 +37,45 @@ class CorsOptions(TypedDict): HTTPMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"] +class BackgroundServer: + """A running background HTTP server that can be used as a context manager. + + Wraps a ``uvicorn.Server`` running in a daemon thread. When used as a + context manager the server is shut down automatically on exit. + + Parameters + ---------- + server : uvicorn.Server + The running uvicorn server instance. + + Examples + -------- + >>> with serve_node(arr, background=True) as server: # doctest: +SKIP + ... print(f"Listening on {server.host}:{server.port}") + ... # server is shut down when the block exits + """ + + def __init__(self, server: uvicorn.Server, *, host: str, port: int) -> None: + self._server = server + self.host = host + self.port = port + + @property + def url(self) -> str: + """The base URL of the running server.""" + return f"http://{self.host}:{self.port}" + + def shutdown(self) -> None: + """Signal the server to shut down.""" + self._server.should_exit = True + + def __enter__(self) -> Self: + return self + + def __exit__(self, *args: object) -> None: + self.shutdown() + + def _parse_range_header(range_header: str) -> ByteRequest | None: """Parse an HTTP Range header into a ByteRequest. @@ -151,7 +198,7 @@ def _start_server( host: str, port: int, background: bool, -) -> uvicorn.Server | None: +) -> BackgroundServer | None: """Create a uvicorn server for *app* and either block or run in a daemon thread.""" try: import uvicorn @@ -177,7 +224,7 @@ def _start_server( raise RuntimeError("Server failed to start within 5 seconds") time.sleep(0.01) - return server + return BackgroundServer(server, host=host, port=port) def store_app( @@ -270,7 +317,7 @@ def serve_store( methods: set[HTTPMethod] | None = ..., cors_options: CorsOptions | None = ..., background: Literal[True], -) -> uvicorn.Server: ... +) -> BackgroundServer: ... def serve_store( @@ -281,7 +328,7 @@ def serve_store( methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, background: bool = False, -) -> uvicorn.Server | None: +) -> BackgroundServer | None: """Serve every key in a zarr ``Store`` over HTTP. Builds a Starlette ASGI app (see :func:`store_app`) and starts a @@ -306,9 +353,11 @@ def serve_store( Returns ------- - uvicorn.Server or None + BackgroundServer or None The running server when ``background=True``, or ``None`` when - the server has been shut down after blocking. + the server has been shut down after blocking. The + ``BackgroundServer`` can be used as a context manager for + automatic shutdown. """ app = store_app(store, methods=methods, cors_options=cors_options) return _start_server(app, host=host, port=port, background=background) @@ -335,7 +384,7 @@ def serve_node( methods: set[HTTPMethod] | None = ..., cors_options: CorsOptions | None = ..., background: Literal[True], -) -> uvicorn.Server: ... +) -> BackgroundServer: ... def serve_node( @@ -346,7 +395,7 @@ def serve_node( methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, background: bool = False, -) -> uvicorn.Server | None: +) -> BackgroundServer | None: """Serve only the keys belonging to a zarr ``Array`` or ``Group`` over HTTP. Builds a Starlette ASGI app (see :func:`node_app`) and starts a @@ -381,9 +430,11 @@ def serve_node( Returns ------- - uvicorn.Server or None + BackgroundServer or None The running server when ``background=True``, or ``None`` when - the server has been shut down after blocking. + the server has been shut down after blocking. The + ``BackgroundServer`` can be used as a context manager for + automatic shutdown. """ app = node_app(node, methods=methods, cors_options=cors_options) return _start_server(app, host=host, port=port, background=background) diff --git a/tests/test_experimental/test_serve.py b/tests/test_experimental/test_serve.py index 58e7da3aef..41b323b26b 100644 --- a/tests/test_experimental/test_serve.py +++ b/tests/test_experimental/test_serve.py @@ -595,8 +595,8 @@ class TestServeBackground: """Test serve_store and serve_node with background=True.""" def test_serve_store_background(self, store: Store) -> None: - """serve_store(background=True) should start a server in a daemon - thread and return a uvicorn.Server that responds to HTTP requests.""" + """serve_store(background=True) should return a BackgroundServer + that responds to HTTP requests and can be used as a context manager.""" import httpx from zarr.experimental.serve import serve_store @@ -605,20 +605,18 @@ def test_serve_store_background(self, store: Store) -> None: sync(store.set("key", buf)) port = _get_free_port() - server = serve_store(store, host="127.0.0.1", port=port, background=True) - try: - assert server is not None - assert server.started + with serve_store(store, host="127.0.0.1", port=port, background=True) as server: + assert server.host == "127.0.0.1" + assert server.port == port + assert server.url == f"http://127.0.0.1:{port}" - response = httpx.get(f"http://127.0.0.1:{port}/key") + response = httpx.get(f"{server.url}/key") assert response.status_code == 200 assert response.content == b"hello" - finally: - server.should_exit = True def test_serve_node_background(self, store: Store) -> None: - """serve_node(background=True) should start a server in a daemon - thread and return a uvicorn.Server that responds to HTTP requests.""" + """serve_node(background=True) should return a BackgroundServer + that responds to HTTP requests and can be used as a context manager.""" import httpx from zarr.experimental.serve import serve_node @@ -627,12 +625,6 @@ def test_serve_node_background(self, store: Store) -> None: arr[:] = np.arange(4, dtype="f8") port = _get_free_port() - server = serve_node(arr, host="127.0.0.1", port=port, background=True) - try: - assert server is not None - assert server.started - - response = httpx.get(f"http://127.0.0.1:{port}/zarr.json") + with serve_node(arr, host="127.0.0.1", port=port, background=True) as server: + response = httpx.get(f"{server.url}/zarr.json") assert response.status_code == 200 - finally: - server.should_exit = True From e167ad34b319a01cc791fad0d476cd86195370e0 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Sun, 1 Mar 2026 20:36:51 -0500 Subject: [PATCH 07/29] minor tweaks to server --- src/zarr/experimental/serve.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/zarr/experimental/serve.py b/src/zarr/experimental/serve.py index c83c06a9b8..96f37bc290 100644 --- a/src/zarr/experimental/serve.py +++ b/src/zarr/experimental/serve.py @@ -55,8 +55,11 @@ class BackgroundServer: ... # server is shut down when the block exits """ - def __init__(self, server: uvicorn.Server, *, host: str, port: int) -> None: + def __init__( + self, server: uvicorn.Server, thread: threading.Thread, *, host: str, port: int + ) -> None: self._server = server + self._thread = thread self.host = host self.port = port @@ -66,8 +69,9 @@ def url(self) -> str: return f"http://{self.host}:{self.port}" def shutdown(self) -> None: - """Signal the server to shut down.""" + """Signal the server to shut down and wait for it to stop.""" self._server.should_exit = True + self._thread.join() def __enter__(self) -> Self: return self @@ -215,6 +219,11 @@ def _start_server( server.run() return None + # Signal handlers can only be installed on the main thread, so + # disable them when running in a background thread. + # See https://github.com/encode/uvicorn/issues/742 + server.install_signal_handlers = lambda: None + thread = threading.Thread(target=server.run, daemon=True) thread.start() @@ -224,7 +233,7 @@ def _start_server( raise RuntimeError("Server failed to start within 5 seconds") time.sleep(0.01) - return BackgroundServer(server, host=host, port=port) + return BackgroundServer(server, thread, host=host, port=port) def store_app( From 9bfe855a6cfd6985073fd5e4e5b5be027628eca8 Mon Sep 17 00:00:00 2001 From: Davis Bennett Date: Tue, 14 Jul 2026 13:25:16 +0200 Subject: [PATCH 08/29] fix: byte-order handling for structured dtypes in the bytes codec (#220) * fix: byte-order handling for structured dtypes in the bytes codec The bytes codec neither byte-swapped structured-dtype fields to its configured endian on encode (numpy reports byteorder '|' for void dtypes, so the top-level byteorder comparison never detected a mismatch) nor honored its endian when decoding, silently corrupting any structured data whose field byte order differed from the stored one (e.g. virtual references to external big-endian data). Encode now detects byte-order mismatches by comparing full dtypes via newbyteorder, and decode reinterprets raw bytes in the stored byte order before converting to the data type's declared byte order, so the stored layout (codec state) and the in-memory layout (array data type) are independent. Closes #4141 Assisted-by: ClaudeCode:claude-fable-5 * test: fold structured byte-order cases into existing bytes codec tests Extend test_endian's parametrization with structured dtypes and test_bytes_codec_sync_roundtrip with endian/dtype parametrization plus stored-layout and decoded-dtype assertions, instead of adding parallel test functions for the same properties. Assisted-by: ClaudeCode:claude-fable-5 * refactor: rename stored_dtype to view_dtype in BytesCodec decode The variable is the dtype used to view the raw chunk bytes (byte order from the codec's endian configuration), not a property of the stored data or of the returned buffer, which always carries the array's declared dtype. Assisted-by: ClaudeCode:claude-fable-5 * docs: note that the decode-side byte-order conversion copies the chunk Assisted-by: ClaudeCode:claude-fable-5 --- changes/4141.bugfix.md | 1 + src/zarr/codecs/bytes.py | 33 +++++++++++----- tests/test_codecs/test_bytes.py | 69 ++++++++++++++++++++++++++------- 3 files changed, 80 insertions(+), 23 deletions(-) create mode 100644 changes/4141.bugfix.md diff --git a/changes/4141.bugfix.md b/changes/4141.bugfix.md new file mode 100644 index 0000000000..6a132da3f5 --- /dev/null +++ b/changes/4141.bugfix.md @@ -0,0 +1 @@ +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. diff --git a/src/zarr/codecs/bytes.py b/src/zarr/codecs/bytes.py index 240c077627..fae762fd08 100644 --- a/src/zarr/codecs/bytes.py +++ b/src/zarr/codecs/bytes.py @@ -100,14 +100,28 @@ def _decode_sync( chunk_spec: ArraySpec, ) -> NDBuffer: endian_str = self.endian + dtype = chunk_spec.dtype.to_native_dtype() + # The byte order of the stored data is set by this codec's `endian` + # configuration; the byte order of the decoded array is set by the array's + # data type. The two are independent: the raw bytes are viewed with a dtype + # in the stored byte order, then converted to the declared dtype if needed. if isinstance(chunk_spec.dtype, HasEndianness): - dtype = replace(chunk_spec.dtype, endianness=endian_str).to_native_dtype() # type: ignore[call-arg] + view_dtype = replace(chunk_spec.dtype, endianness=endian_str).to_native_dtype() # type: ignore[call-arg] + elif isinstance(chunk_spec.dtype, Struct) and endian_str is not None: + # Per the struct data type spec, all multi-byte fields are stored in the + # byte order configured on this codec. + view_dtype = dtype.newbyteorder(endian_str) else: - dtype = chunk_spec.dtype.to_native_dtype() + view_dtype = dtype as_array_like = chunk_bytes.as_array_like() chunk_array = chunk_spec.prototype.nd_buffer.from_ndarray_like( - as_array_like.view(dtype=dtype) # type: ignore[attr-defined] + as_array_like.view(dtype=view_dtype) # type: ignore[attr-defined] ) + if view_dtype != dtype: + # This byte-swapping conversion copies the chunk. The dtype inequality + # guard keeps the common case, where the stored and declared byte orders + # already match, on the zero-copy view path above. + chunk_array = chunk_array.astype(dtype) # ensure correct chunk shape if chunk_array.shape != chunk_spec.shape: @@ -129,13 +143,14 @@ def _encode_sync( chunk_spec: ArraySpec, ) -> Buffer | None: assert isinstance(chunk_array, NDBuffer) - if ( - chunk_array.dtype.itemsize > 1 - and self.endian is not None - and self.endian != chunk_array.byteorder - ): + if chunk_array.dtype.itemsize > 1 and self.endian is not None: + # Compare full dtypes rather than the top-level byteorder: numpy reports + # byteorder '|' for structured dtypes even when their fields are + # byte-order-sensitive, so newbyteorder is the only reliable way to + # detect (and normalize) a byte-order mismatch. new_dtype = chunk_array.dtype.newbyteorder(self.endian) - chunk_array = chunk_array.astype(new_dtype) + if new_dtype != chunk_array.dtype: + chunk_array = chunk_array.astype(new_dtype) nd_array = chunk_array.as_ndarray_like() # Flatten the nd-array (only copy if needed) and reinterpret as bytes diff --git a/tests/test_codecs/test_bytes.py b/tests/test_codecs/test_bytes.py index 03dd0b40c6..ead778f526 100644 --- a/tests/test_codecs/test_bytes.py +++ b/tests/test_codecs/test_bytes.py @@ -33,29 +33,50 @@ @pytest.mark.parametrize("store", ["local", "memory"], indirect=["store"]) -@pytest.mark.parametrize("input_dtype", [">u2", "u2", + "f4"), ("mask", ">i4")], + [("flux", "u2", " None: """ The `bytes` codec stores multi-byte data in the byte order configured on the codec, regardless of the input array's byte order, and reads it back to the - original values. The input-dtype/store-endian cross-product exercises the - encode-side byteswap (input byte order != store byte order) and the no-op - case alike. Compression is disabled so the stored chunk is the codec's raw - output and its byte layout can be asserted directly. + original values. For structured dtypes this applies to every multi-byte + field, per the `struct` data type spec; the struct cases guard against the + endianness bugs from + https://github.com/zarr-developers/zarr-python/issues/4141, where the + encode path never byte-swapped struct fields (numpy reports byteorder '|' + for void dtypes) and the decode path ignored the codec's endian entirely. + The input-dtype/store-endian cross-product exercises the encode-side + byteswap (input byte order != store byte order) and the no-op case alike. + Compression is disabled so the stored chunk is the codec's raw output and + its byte layout can be asserted directly. """ - data = np.arange(0, 256, dtype=input_dtype).reshape((16, 16)) + dtype = np.dtype(input_dtype) + if dtype.fields is None: + data = np.arange(0, 256, dtype=dtype).reshape((16, 16)) + else: + data = np.zeros((16, 16), dtype=dtype) + data["flux"] = np.arange(0, 256).reshape((16, 16)) + data["mask"] = np.arange(256, 512).reshape((16, 16)) path = "endian" spath = StorePath(store, path) a = await zarr.api.asynchronous.create_array( spath, shape=data.shape, chunks=(16, 16), - dtype="uint16", + dtype=dtype, fill_value=0, compressors=None, serializer=BytesCodec(endian=store_endian), @@ -66,8 +87,7 @@ async def test_endian( # The stored chunk is laid out in the byte order configured on the codec. stored = await store.get(f"{path}/c/0/0", prototype=default_buffer_prototype()) assert stored is not None - expected_dtype = ">u2" if store_endian == "big" else " None: assert isinstance(BytesCodec(), SupportsSyncCodec) -def test_bytes_codec_sync_roundtrip() -> None: - codec = BytesCodec() - arr = np.arange(100, dtype="float64") +@pytest.mark.parametrize("endian", ENDIAN) +@pytest.mark.parametrize( + "native_dtype", + [np.dtype("float64"), np.dtype(">u2"), np.dtype([("a", ">f4"), ("b", " None: + """ + The synchronous encode/decode path round-trips data, and the two byte + orders involved are independent: the codec's `endian` configuration governs + only the stored byte layout (every multi-byte value, including struct + fields, is laid out in the codec's byte order regardless of the input + array's byte order), while the decoded buffer's byte order is governed by + the array's data type regardless of the codec's. The mixed-endian struct + case pins that per-field byte order of the in-memory dtype survives a + roundtrip through a single stored byte order. + """ + if native_dtype.fields is None: + arr = np.arange(100, dtype=native_dtype) + else: + arr = np.array([(1.5, 2), (3.5, 4), (5.5, 6), (7.5, 8)], dtype=native_dtype) zdtype = get_data_type_from_native_dtype(arr.dtype) spec = ArraySpec( shape=arr.shape, @@ -91,11 +129,14 @@ def test_bytes_codec_sync_roundtrip() -> None: ) nd_buf: NDBuffer = default_buffer_prototype().nd_buffer.from_numpy_array(arr) - codec = codec.evolve_from_array_spec(spec) + codec = BytesCodec(endian=endian).evolve_from_array_spec(spec) encoded = codec._encode_sync(nd_buf, spec) assert encoded is not None + assert encoded.to_bytes() == arr.astype(native_dtype.newbyteorder(endian)).tobytes() + decoded = codec._decode_sync(encoded, spec) + assert decoded.dtype == zdtype.to_native_dtype() np.testing.assert_array_equal(arr, decoded.as_numpy_array()) From 97aebb07c7bb506f14cfa7e6773168451a5d9dbf Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 14:09:40 +0200 Subject: [PATCH 09/29] fix: adapt server branch to current starlette/uvicorn starlette 1.3 deprecated using httpx with its TestClient, which the warnings-as-errors filter turns into a collection error; add httpx2 to the test dependency group. uvicorn 0.51 removed Server.install_signal_handlers and now skips signal-handler setup off the main thread natively, so drop the monkey-patch workaround. Assisted-by: ClaudeCode:claude-fable-5 --- pyproject.toml | 1 + src/zarr/experimental/serve.py | 7 +- uv.lock | 136 ++++++++++++++++++++++++++++++++- 3 files changed, 135 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 80806bfd85..d7e24beb97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,7 @@ test = [ "pytest-codspeed==5.0.3", "tomlkit==0.15.0", "uv==0.11.26", + "httpx2==2.9.1", ] remote-tests = [ {include-group = "test"}, diff --git a/src/zarr/experimental/serve.py b/src/zarr/experimental/serve.py index 96f37bc290..2be3829b39 100644 --- a/src/zarr/experimental/serve.py +++ b/src/zarr/experimental/serve.py @@ -219,11 +219,8 @@ def _start_server( server.run() return None - # Signal handlers can only be installed on the main thread, so - # disable them when running in a background thread. - # See https://github.com/encode/uvicorn/issues/742 - server.install_signal_handlers = lambda: None - + # uvicorn skips signal-handler installation off the main thread + # (Server.capture_signals), so no workaround is needed here. thread = threading.Thread(target=server.run, daemon=True) thread.start() diff --git a/uv.lock b/uv.lock index 6035acc616..8cad5e1fcf 100644 --- a/uv.lock +++ b/uv.lock @@ -191,6 +191,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/03/a851e84fcbb85214dc637b6378121ef9a0dd61b4c65264675d8a5c9b1ae7/antlr4_python3_runtime-4.13.2-py3-none-any.whl", hash = "sha256:fe3835eb8d33daece0e799090eda89719dbccee7aa39ef94eed3818cafa5a7e8", size = 144462, upload-time = "2024-08-03T19:00:11.134Z" }, ] +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + [[package]] name = "ast-serialize" version = "0.3.0" @@ -1027,6 +1040,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, +] + [[package]] name = "hypothesis" version = "6.155.7" @@ -1041,11 +1120,11 @@ wheels = [ [[package]] name = "idna" -version = "3.15" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -3021,6 +3100,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -3067,6 +3159,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/06/8ba22ec32c74ac1be3baa26116e3c28bc0e76a5387476921d20b6fdade11/towncrier-25.8.0-py3-none-any.whl", hash = "sha256:b953d133d98f9aeae9084b56a3563fd2519dfc6ec33f61c9cd2c61ff243fb513", size = 65101, upload-time = "2025-08-30T11:41:53.644Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typer" version = "0.26.8" @@ -3151,6 +3252,19 @@ wheels = [ { 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" }, ] +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + [[package]] name = "verspec" version = "0.1.0" @@ -3411,6 +3525,11 @@ remote = [ { name = "fsspec" }, { name = "obstore" }, ] +server = [ + { name = "httpx" }, + { name = "starlette" }, + { name = "uvicorn" }, +] [package.dev-dependencies] dev = [ @@ -3419,6 +3538,7 @@ dev = [ { name = "coverage" }, { name = "fsspec" }, { name = "griffe-inherited-docstrings" }, + { name = "httpx2" }, { name = "hypothesis" }, { name = "markdown-exec", extra = ["ansi"] }, { name = "mike" }, @@ -3470,6 +3590,7 @@ remote-tests = [ { name = "botocore" }, { name = "coverage" }, { name = "fsspec" }, + { name = "httpx2" }, { name = "hypothesis" }, { name = "moto", extra = ["s3", "server"] }, { name = "numpydoc" }, @@ -3488,6 +3609,7 @@ remote-tests = [ ] test = [ { name = "coverage" }, + { name = "httpx2" }, { name = "hypothesis" }, { name = "numpydoc" }, { name = "pytest" }, @@ -3508,15 +3630,18 @@ requires-dist = [ { name = "donfig", specifier = ">=0.8" }, { name = "fsspec", marker = "extra == 'remote'", specifier = ">=2023.10.0" }, { name = "google-crc32c", specifier = ">=1.5" }, + { name = "httpx", marker = "extra == 'server'" }, { name = "numcodecs", specifier = ">=0.14" }, { name = "numpy", specifier = ">=2" }, { name = "obstore", marker = "extra == 'remote'", specifier = ">=0.5.1" }, { name = "packaging", specifier = ">=22.0" }, + { name = "starlette", marker = "extra == 'server'" }, { name = "typer", marker = "extra == 'cli'" }, { name = "typing-extensions", specifier = ">=4.14" }, { name = "universal-pathlib", marker = "extra == 'optional'" }, + { name = "uvicorn", marker = "extra == 'server'" }, ] -provides-extras = ["cast-value-rs", "cli", "gpu", "optional", "remote"] +provides-extras = ["cast-value-rs", "cli", "gpu", "optional", "remote", "server"] [package.metadata.requires-dev] dev = [ @@ -3525,6 +3650,7 @@ dev = [ { name = "coverage", specifier = "==7.14.3" }, { name = "fsspec", specifier = ">=2023.10.0" }, { name = "griffe-inherited-docstrings", specifier = "==1.1.3" }, + { name = "httpx2", specifier = "==2.9.1" }, { name = "hypothesis", specifier = "==6.155.7" }, { name = "markdown-exec", extras = ["ansi"], specifier = "==1.12.1" }, { name = "mike", specifier = "==2.2.0" }, @@ -3574,6 +3700,7 @@ remote-tests = [ { name = "botocore" }, { name = "coverage", specifier = "==7.14.3" }, { name = "fsspec", specifier = ">=2023.10.0" }, + { name = "httpx2", specifier = "==2.9.1" }, { name = "hypothesis", specifier = "==6.155.7" }, { name = "moto", extras = ["s3", "server"], specifier = "==5.2.2" }, { name = "numpydoc", specifier = "==1.10.0" }, @@ -3592,6 +3719,7 @@ remote-tests = [ ] test = [ { name = "coverage", specifier = "==7.14.3" }, + { name = "httpx2", specifier = "==2.9.1" }, { name = "hypothesis", specifier = "==6.155.7" }, { name = "numpydoc", specifier = "==1.10.0" }, { name = "pytest", specifier = "==9.1.1" }, From d72b4727acfeb3486cdfa09634a6cc4cd7c24f20 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 14:44:36 +0200 Subject: [PATCH 10/29] feat(zarr-server): scaffold packages/zarr-server Assisted-by: ClaudeCode:claude-fable-5 --- packages/zarr-server/CHANGELOG.md | 3 + packages/zarr-server/LICENSE.txt | 21 + packages/zarr-server/README.md | 3 + packages/zarr-server/changes/3732.feature.md | 3 + packages/zarr-server/changes/README.md | 25 + packages/zarr-server/pyproject.toml | 100 ++++ .../zarr-server/src/zarr_server/__init__.py | 7 + packages/zarr-server/src/zarr_server/py.typed | 0 packages/zarr-server/uv.lock | 528 ++++++++++++++++++ 9 files changed, 690 insertions(+) create mode 100644 packages/zarr-server/CHANGELOG.md create mode 100644 packages/zarr-server/LICENSE.txt create mode 100644 packages/zarr-server/README.md create mode 100644 packages/zarr-server/changes/3732.feature.md create mode 100644 packages/zarr-server/changes/README.md create mode 100644 packages/zarr-server/pyproject.toml create mode 100644 packages/zarr-server/src/zarr_server/__init__.py create mode 100644 packages/zarr-server/src/zarr_server/py.typed create mode 100644 packages/zarr-server/uv.lock diff --git a/packages/zarr-server/CHANGELOG.md b/packages/zarr-server/CHANGELOG.md new file mode 100644 index 0000000000..7c4bc92cad --- /dev/null +++ b/packages/zarr-server/CHANGELOG.md @@ -0,0 +1,3 @@ +# Release notes + + diff --git a/packages/zarr-server/LICENSE.txt b/packages/zarr-server/LICENSE.txt new file mode 100644 index 0000000000..1e8da4d242 --- /dev/null +++ b/packages/zarr-server/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015-2025 Zarr Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/zarr-server/README.md b/packages/zarr-server/README.md new file mode 100644 index 0000000000..39a0e0d5ee --- /dev/null +++ b/packages/zarr-server/README.md @@ -0,0 +1,3 @@ +# zarr-server + +HTTP server for Zarr stores, arrays, and groups. diff --git a/packages/zarr-server/changes/3732.feature.md b/packages/zarr-server/changes/3732.feature.md new file mode 100644 index 0000000000..52178f54a2 --- /dev/null +++ b/packages/zarr-server/changes/3732.feature.md @@ -0,0 +1,3 @@ +Initial release of `zarr-server`: an HTTP server exposing Zarr stores, +arrays, and groups over an ASGI app, extracted from the +`zarr.experimental.serve` prototype in zarr-python. diff --git a/packages/zarr-server/changes/README.md b/packages/zarr-server/changes/README.md new file mode 100644 index 0000000000..2a9084f761 --- /dev/null +++ b/packages/zarr-server/changes/README.md @@ -0,0 +1,25 @@ +Writing a changelog entry for `zarr-server` +--------------------------------------------- + +Fragments in **this** directory are released notes for the `zarr-server` +package only — kept separate from the parent zarr-python `changes/` +directory so a PR touching only `packages/zarr-server/` produces a +release note for this package only. + +Please put a new file in this directory named `xxxx..md`, where + +- `xxxx` is the pull request number associated with this entry +- `` is one of: + - feature + - bugfix + - doc + - removal + - misc + +Inside the file, please write a short description of what you have +changed, and how it impacts users of `zarr-server`. + +A `zarr-server` release runs `towncrier build` in `packages/zarr-server/`, +which consumes the fragments here and updates `CHANGELOG.md`. Fragments +that describe parent zarr-python changes (not the server package) +belong in the top-level `changes/` directory, not here. diff --git a/packages/zarr-server/pyproject.toml b/packages/zarr-server/pyproject.toml new file mode 100644 index 0000000000..84215cfa47 --- /dev/null +++ b/packages/zarr-server/pyproject.toml @@ -0,0 +1,100 @@ +[build-system] +requires = ["hatchling>=1.29.0", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "zarr-server" +dynamic = ["version"] +description = "HTTP server for Zarr stores, arrays, and groups." +readme = "README.md" +requires-python = ">=3.12" +license = "MIT" +license-files = ["LICENSE.txt"] +authors = [ + { name = "Davis Bennett", email = "davis.v.bennett@gmail.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Scientific/Engineering", + "Topic :: Internet :: WWW/HTTP :: HTTP Servers", + "Typing :: Typed", +] +keywords = ["zarr", "http", "server", "asgi"] +dependencies = [ + "zarr>=3.1", + "starlette>=1.0", + "uvicorn>=0.29", +] + +[project.urls] +Homepage = "https://github.com/zarr-developers/zarr-python" +Source = "https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-server" +Issues = "https://github.com/zarr-developers/zarr-python/issues" +Changelog = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-server/CHANGELOG.md" +Documentation = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-server/README.md" + +[dependency-groups] +test = ["pytest", "httpx", "httpx2"] + +# Dev-only: resolve zarr from the repo root so package tests run against +# in-repo zarr. Affects uv resolution only, not published metadata. +[tool.uv.sources] +zarr = { path = "../..", editable = true } + +[tool.hatch.version] +source = "vcs" +tag-pattern = '^zarr_server-v(?P.+)$' +# `git_describe_command` ensures we get the zarr_server tags instead of latest. +# `local_scheme` strips the git commit info so the appending info is just a counter from latest tag. +# test-pypi doesn't accept git commit info in tags, and the count should be enough to distinguish unique runs. +raw-options = { root = "../..", git_describe_command = "git describe --dirty --tags --long --match zarr_server-v*", local_scheme = "no-local-version" } + +[tool.hatch.build.targets.wheel] +packages = ["src/zarr_server"] + +[tool.ruff] +extend = "../../pyproject.toml" +target-version = "py312" + +[tool.pytest.ini_options] +minversion = "7" +testpaths = ["tests"] +xfail_strict = true +addopts = ["-ra", "--strict-config", "--strict-markers"] +filterwarnings = [ + "error", +] + +[tool.mypy] +files = ["src"] +python_version = "3.12" +ignore_missing_imports = true +namespace_packages = false +pretty = true +show_error_code_links = true +show_error_context = true +strict = true +warn_unreachable = true +enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool", "truthy-iterable"] + +[tool.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-server/` produces a release note for this package only. +directory = "changes" +filename = "CHANGELOG.md" +package = "zarr_server" +underlines = ["", "", ""] +title_format = "## {version} ({project_date})" +issue_format = "[#{issue}](https://github.com/zarr-developers/zarr-python/issues/{issue})" +start_string = "\n" diff --git a/packages/zarr-server/src/zarr_server/__init__.py b/packages/zarr-server/src/zarr_server/__init__.py new file mode 100644 index 0000000000..7ddb49ad0a --- /dev/null +++ b/packages/zarr-server/src/zarr_server/__init__.py @@ -0,0 +1,7 @@ +"""Zarr-server: HTTP server for Zarr stores, arrays, and groups.""" + +from importlib.metadata import version + +__version__ = version("zarr-server") + +__all__ = ["__version__"] diff --git a/packages/zarr-server/src/zarr_server/py.typed b/packages/zarr-server/src/zarr_server/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/zarr-server/uv.lock b/packages/zarr-server/uv.lock new file mode 100644 index 0000000000..8136500210 --- /dev/null +++ b/packages/zarr-server/uv.lock @@ -0,0 +1,528 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "donfig" +version = "0.8.1.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/71/80cc718ff6d7abfbabacb1f57aaa42e9c1552bfdd01e64ddd704e4a03638/donfig-0.8.1.post1.tar.gz", hash = "sha256:3bef3413a4c1c601b585e8d297256d0c1470ea012afa6e8461dc28bfb7c23f52", size = 19506, upload-time = "2024-05-23T14:14:31.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592, upload-time = "2024-05-23T14:13:55.283Z" }, +] + +[[package]] +name = "google-crc32c" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, + { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" }, + { url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpcore2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx2" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpcore2" }, + { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "numcodecs" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/8a391e7c356366224734efd24da929cc4796fff468bfb179fe1af6548535/numcodecs-0.16.5.tar.gz", hash = "sha256:0d0fb60852f84c0bd9543cc4d2ab9eefd37fc8efcc410acd4777e62a1d300318", size = 6276387, upload-time = "2025-11-21T02:49:48.986Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/cc/55420f3641a67f78392dc0bc5d02cb9eb0a9dcebf2848d1ac77253ca61fa/numcodecs-0.16.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:24e675dc8d1550cd976a99479b87d872cb142632c75cc402fea04c08c4898523", size = 1656287, upload-time = "2025-11-21T02:49:25.755Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6c/86644987505dcb90ba6d627d6989c27bafb0699f9fd00187e06d05ea8594/numcodecs-0.16.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:94ddfa4341d1a3ab99989d13b01b5134abb687d3dab2ead54b450aefe4ad5bd6", size = 1148899, upload-time = "2025-11-21T02:49:26.87Z" }, + { url = "https://files.pythonhosted.org/packages/97/1e/98aaddf272552d9fef1f0296a9939d1487914a239e98678f6b20f8b0a5c8/numcodecs-0.16.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b554ab9ecf69de7ca2b6b5e8bc696bd9747559cb4dd5127bd08d7a28bec59c3a", size = 8534814, upload-time = "2025-11-21T02:49:28.547Z" }, + { url = "https://files.pythonhosted.org/packages/fb/53/78c98ef5c8b2b784453487f3e4d6c017b20747c58b470393e230c78d18e8/numcodecs-0.16.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad1a379a45bd3491deab8ae6548313946744f868c21d5340116977ea3be5b1d6", size = 9173471, upload-time = "2025-11-21T02:49:30.444Z" }, + { url = "https://files.pythonhosted.org/packages/1c/20/2fdec87fc7f8cec950d2b0bea603c12dc9f05b4966dc5924ba5a36a61bf6/numcodecs-0.16.5-cp312-cp312-win_amd64.whl", hash = "sha256:845a9857886ffe4a3172ba1c537ae5bcc01e65068c31cf1fce1a844bd1da050f", size = 801412, upload-time = "2025-11-21T02:49:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/38/38/071ced5a5fd1c85ba0e14ba721b66b053823e5176298c2f707e50bed11d9/numcodecs-0.16.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25be3a516ab677dad890760d357cfe081a371d9c0a2e9a204562318ac5969de3", size = 1654359, upload-time = "2025-11-21T02:49:33.673Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/5f84ba7525577c1b9909fc2d06ef11314825fc4ad4378f61d0e4c9883b4a/numcodecs-0.16.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0107e839ef75b854e969cb577e140b1aadb9847893937636582d23a2a4c6ce50", size = 1144237, upload-time = "2025-11-21T02:49:35.294Z" }, + { url = "https://files.pythonhosted.org/packages/0b/00/787ea5f237b8ea7bc67140c99155f9c00b5baf11c49afc5f3bfefa298f95/numcodecs-0.16.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:015a7c859ecc2a06e2a548f64008c0ec3aaecabc26456c2c62f4278d8fc20597", size = 8483064, upload-time = "2025-11-21T02:49:36.454Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e6/d359fdd37498e74d26a167f7a51e54542e642ea47181eb4e643a69a066c3/numcodecs-0.16.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84230b4b9dad2392f2a84242bd6e3e659ac137b5a1ce3571d6965fca673e0903", size = 9126063, upload-time = "2025-11-21T02:49:38.018Z" }, + { url = "https://files.pythonhosted.org/packages/27/72/6663cc0382ddbb866136c255c837bcb96cc7ce5e83562efec55e1b995941/numcodecs-0.16.5-cp313-cp313-win_amd64.whl", hash = "sha256:5088145502ad1ebf677ec47d00eb6f0fd600658217db3e0c070c321c85d6cf3d", size = 799275, upload-time = "2025-11-21T02:49:39.558Z" }, + { url = "https://files.pythonhosted.org/packages/3c/9e/38e7ca8184c958b51f45d56a4aeceb1134ecde2d8bd157efadc98502cc42/numcodecs-0.16.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b05647b8b769e6bc8016e9fd4843c823ce5c9f2337c089fb5c9c4da05e5275de", size = 1654721, upload-time = "2025-11-21T02:49:40.602Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/260fa42e7b2b08e6e00ad632f8dd620961a60a459426c26cea390f8c68d0/numcodecs-0.16.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3832bd1b5af8bb3e413076b7d93318c8e7d7b68935006b9fa36ca057d1725a8f", size = 1146887, upload-time = "2025-11-21T02:49:41.721Z" }, + { url = "https://files.pythonhosted.org/packages/4e/15/e2e1151b5a8b14a15dfd4bb4abccce7fff7580f39bc34092780088835f3a/numcodecs-0.16.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49f7b7d24f103187f53135bed28bb9f0ed6b2e14c604664726487bb6d7c882e1", size = 8476987, upload-time = "2025-11-21T02:49:43.363Z" }, + { url = "https://files.pythonhosted.org/packages/6d/30/16a57fc4d9fb0ba06c600408bd6634f2f1753c54a7a351c99c5e09b51ee2/numcodecs-0.16.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aec9736d81b70f337d89c4070ee3ffeff113f386fd789492fa152d26a15043e4", size = 9102377, upload-time = "2025-11-21T02:49:45.508Z" }, + { url = "https://files.pythonhosted.org/packages/31/a5/a0425af36c20d55a3ea884db4b4efca25a43bea9214ba69ca7932dd997b4/numcodecs-0.16.5-cp314-cp314-win_amd64.whl", hash = "sha256:b16a14303800e9fb88abc39463ab4706c037647ac17e49e297faa5f7d7dbbf1d", size = 819022, upload-time = "2025-11-21T02:49:47.39Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + +[[package]] +name = "zarr" +source = { editable = "../../" } +dependencies = [ + { name = "donfig" }, + { name = "google-crc32c" }, + { name = "numcodecs" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] + +[package.metadata] +requires-dist = [ + { name = "cast-value-rs", marker = "extra == 'cast-value-rs'" }, + { name = "cupy-cuda12x", marker = "sys_platform != 'darwin' and extra == 'gpu'" }, + { name = "donfig", specifier = ">=0.8" }, + { name = "fsspec", marker = "extra == 'remote'", specifier = ">=2023.10.0" }, + { name = "google-crc32c", specifier = ">=1.5" }, + { name = "httpx", marker = "extra == 'server'" }, + { name = "numcodecs", specifier = ">=0.14" }, + { name = "numpy", specifier = ">=2" }, + { name = "obstore", marker = "extra == 'remote'", specifier = ">=0.5.1" }, + { name = "packaging", specifier = ">=22.0" }, + { name = "starlette", marker = "extra == 'server'" }, + { name = "typer", marker = "extra == 'cli'" }, + { name = "typing-extensions", specifier = ">=4.14" }, + { name = "universal-pathlib", marker = "extra == 'optional'" }, + { name = "uvicorn", marker = "extra == 'server'" }, +] +provides-extras = ["cast-value-rs", "cli", "gpu", "optional", "remote", "server"] + +[package.metadata.requires-dev] +dev = [ + { name = "astroid", specifier = "==4.1.2" }, + { name = "botocore" }, + { name = "coverage", specifier = "==7.14.3" }, + { name = "fsspec", specifier = ">=2023.10.0" }, + { name = "griffe-inherited-docstrings", specifier = "==1.1.3" }, + { name = "httpx2", specifier = "==2.9.1" }, + { name = "hypothesis", specifier = "==6.155.7" }, + { name = "markdown-exec", extras = ["ansi"], specifier = "==1.12.1" }, + { name = "mike", specifier = "==2.2.0" }, + { name = "mkdocs", specifier = "==1.6.1" }, + { name = "mkdocs-material", extras = ["imaging"], specifier = "==9.7.6" }, + { name = "mkdocs-redirects", specifier = "==1.2.3" }, + { name = "mkdocstrings", specifier = "==1.0.4" }, + { name = "mkdocstrings-python", specifier = "==2.0.5" }, + { name = "moto", extras = ["s3", "server"], specifier = "==5.2.2" }, + { name = "mypy", specifier = "==2.1.0" }, + { name = "numcodecs", extras = ["msgpack"] }, + { name = "numpydoc", specifier = "==1.10.0" }, + { name = "obstore", specifier = ">=0.5.1" }, + { name = "pytest", specifier = "==9.1.1" }, + { name = "pytest-accept", specifier = "==0.3.0" }, + { name = "pytest-asyncio", specifier = "==1.4.0" }, + { name = "pytest-benchmark", specifier = "==5.2.3" }, + { name = "pytest-codspeed", specifier = "==5.0.3" }, + { name = "pytest-cov", specifier = "==7.1.0" }, + { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "requests", specifier = "==2.34.2" }, + { name = "ruff", specifier = "==0.15.20" }, + { name = "s3fs", specifier = ">=2023.10.0" }, + { name = "tomlkit", specifier = "==0.15.0" }, + { name = "towncrier", specifier = "==25.8.0" }, + { name = "universal-pathlib" }, + { name = "uv", specifier = "==0.11.26" }, +] +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 = "mike", specifier = "==2.2.0" }, + { name = "mkdocs", specifier = "==1.6.1" }, + { name = "mkdocs-material", extras = ["imaging"], specifier = "==9.7.6" }, + { name = "mkdocs-redirects", specifier = "==1.2.3" }, + { name = "mkdocstrings", specifier = "==1.0.4" }, + { name = "mkdocstrings-python", specifier = "==2.0.5" }, + { name = "numcodecs", extras = ["msgpack"] }, + { name = "pytest", specifier = "==9.1.1" }, + { name = "ruff", specifier = "==0.15.20" }, + { 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 = "fsspec", specifier = ">=2023.10.0" }, + { name = "httpx2", specifier = "==2.9.1" }, + { name = "hypothesis", specifier = "==6.155.7" }, + { name = "moto", extras = ["s3", "server"], specifier = "==5.2.2" }, + { name = "numpydoc", specifier = "==1.10.0" }, + { name = "obstore", specifier = ">=0.5.1" }, + { name = "pytest", specifier = "==9.1.1" }, + { name = "pytest-accept", specifier = "==0.3.0" }, + { name = "pytest-asyncio", specifier = "==1.4.0" }, + { name = "pytest-benchmark", specifier = "==5.2.3" }, + { name = "pytest-codspeed", specifier = "==5.0.3" }, + { name = "pytest-cov", specifier = "==7.1.0" }, + { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "requests", specifier = "==2.34.2" }, + { name = "s3fs", specifier = ">=2023.10.0" }, + { name = "tomlkit", specifier = "==0.15.0" }, + { name = "uv", specifier = "==0.11.26" }, +] +test = [ + { name = "coverage", specifier = "==7.14.3" }, + { name = "httpx2", specifier = "==2.9.1" }, + { name = "hypothesis", specifier = "==6.155.7" }, + { name = "numpydoc", specifier = "==1.10.0" }, + { name = "pytest", specifier = "==9.1.1" }, + { name = "pytest-accept", specifier = "==0.3.0" }, + { name = "pytest-asyncio", specifier = "==1.4.0" }, + { name = "pytest-benchmark", specifier = "==5.2.3" }, + { name = "pytest-codspeed", specifier = "==5.0.3" }, + { name = "pytest-cov", specifier = "==7.1.0" }, + { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "tomlkit", specifier = "==0.15.0" }, + { name = "uv", specifier = "==0.11.26" }, +] + +[[package]] +name = "zarr-server" +source = { editable = "." } +dependencies = [ + { name = "starlette" }, + { name = "uvicorn" }, + { name = "zarr" }, +] + +[package.dev-dependencies] +test = [ + { name = "httpx" }, + { name = "httpx2" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "starlette", specifier = ">=1.0" }, + { name = "uvicorn", specifier = ">=0.29" }, + { name = "zarr", editable = "../../" }, +] + +[package.metadata.requires-dev] +test = [ + { name = "httpx" }, + { name = "httpx2" }, + { name = "pytest" }, +] From 800e6bd0e446dc1818134e15f852c513e1076878 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 15:00:14 +0200 Subject: [PATCH 11/29] feat(zarr-server): move server code, tests, and example into the package The moved code now uses only public zarr API: zarr.buffer.cpu replaces zarr.core.buffer.cpu, spec-defined key names are inlined, chunk-key encodings are duck-typed on their spec names, and the shard grid shape is computed locally from public Array attributes. Also mirrors the root repo's [tool.numpydoc_validation] override in the package's pyproject.toml, since numpydoc-validation resolves config from the nearest pyproject.toml and would otherwise apply its stricter default checks to the moved docstrings. Assisted-by: ClaudeCode:claude-fable-5 --- examples/serve/README.md | 17 ------ .../zarr-server/examples}/serve.py | 8 +-- packages/zarr-server/pyproject.toml | 13 ++++ .../zarr-server/src/zarr_server/__init__.py | 21 ++++++- .../zarr-server/src/zarr_server/_keys.py | 35 ++++++----- .../zarr-server/src/zarr_server/_serve.py | 29 +++------ packages/zarr-server/tests/conftest.py | 30 ++++++++++ .../zarr-server/tests}/test_serve.py | 25 ++++---- tests/test_examples.py | 59 ++++++++++++------- 9 files changed, 149 insertions(+), 88 deletions(-) delete mode 100644 examples/serve/README.md rename {examples/serve => packages/zarr-server/examples}/serve.py (87%) rename src/zarr/core/keys.py => packages/zarr-server/src/zarr_server/_keys.py (85%) rename src/zarr/experimental/serve.py => packages/zarr-server/src/zarr_server/_serve.py (94%) create mode 100644 packages/zarr-server/tests/conftest.py rename {tests/test_experimental => packages/zarr-server/tests}/test_serve.py (98%) diff --git a/examples/serve/README.md b/examples/serve/README.md deleted file mode 100644 index 7b37968248..0000000000 --- a/examples/serve/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Serve a Zarr Array over HTTP - -This example creates an in-memory Zarr array, serves it over HTTP with -`zarr.experimental.serve.serve_node`, and fetches the `zarr.json` metadata -document and a raw chunk using `httpx`. - -## Running the Example - -```bash -python examples/serve/serve.py -``` - -Or run with uv: - -```bash -uv run examples/serve/serve.py -``` diff --git a/examples/serve/serve.py b/packages/zarr-server/examples/serve.py similarity index 87% rename from examples/serve/serve.py rename to packages/zarr-server/examples/serve.py index 19550cc0ec..216cc1e07a 100644 --- a/examples/serve/serve.py +++ b/packages/zarr-server/examples/serve.py @@ -1,7 +1,7 @@ # /// script -# requires-python = ">=3.11" +# requires-python = ">=3.12" # dependencies = [ -# "zarr[server] @ git+https://github.com/zarr-developers/zarr-python.git@main", +# "zarr-server @ git+https://github.com/zarr-developers/zarr-python.git@main#subdirectory=packages/zarr-server", # "httpx", # ] # /// @@ -17,11 +17,11 @@ import httpx import numpy as np - import zarr -from zarr.experimental.serve import serve_node from zarr.storage import MemoryStore +from zarr_server import serve_node + # -- create an array -------------------------------------------------------- store = MemoryStore() data = np.arange(1000, dtype="uint8").reshape(10, 10, 10) diff --git a/packages/zarr-server/pyproject.toml b/packages/zarr-server/pyproject.toml index 84215cfa47..63c8758830 100644 --- a/packages/zarr-server/pyproject.toml +++ b/packages/zarr-server/pyproject.toml @@ -87,6 +87,19 @@ strict = true warn_unreachable = true enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool", "truthy-iterable"] +[tool.numpydoc_validation] +# Mirrors the root zarr-python config so moved docstrings (written for that +# config) don't trip stricter defaults just because this package has its own +# pyproject.toml. See https://numpydoc.readthedocs.io/en/latest/validation.html#built-in-validation-checks +checks = [ + "GL10", + "SS04", + "PR02", + "PR03", + "PR05", + "PR06", +] + [tool.towncrier] # Fragments for this package live alongside the package source, separate # from the parent zarr-python `changes/` directory, so a PR touching only diff --git a/packages/zarr-server/src/zarr_server/__init__.py b/packages/zarr-server/src/zarr_server/__init__.py index 7ddb49ad0a..79c8473961 100644 --- a/packages/zarr-server/src/zarr_server/__init__.py +++ b/packages/zarr-server/src/zarr_server/__init__.py @@ -2,6 +2,25 @@ from importlib.metadata import version +from zarr_server._serve import ( + BackgroundServer, + CorsOptions, + HTTPMethod, + node_app, + serve_node, + serve_store, + store_app, +) + __version__ = version("zarr-server") -__all__ = ["__version__"] +__all__ = [ + "BackgroundServer", + "CorsOptions", + "HTTPMethod", + "__version__", + "node_app", + "serve_node", + "serve_store", + "store_app", +] diff --git a/src/zarr/core/keys.py b/packages/zarr-server/src/zarr_server/_keys.py similarity index 85% rename from src/zarr/core/keys.py rename to packages/zarr-server/src/zarr_server/_keys.py index 4cc13874ab..cb20246639 100644 --- a/src/zarr/core/keys.py +++ b/packages/zarr-server/src/zarr_server/_keys.py @@ -17,11 +17,14 @@ from typing import TYPE_CHECKING, Any -from zarr.core.common import ZARR_JSON, ZARRAY_JSON, ZATTRS_JSON, ZGROUP_JSON, ZMETADATA_V2_JSON +ZARR_JSON = "zarr.json" +ZARRAY_JSON = ".zarray" +ZGROUP_JSON = ".zgroup" +ZATTRS_JSON = ".zattrs" +ZMETADATA_V2_JSON = ".zmetadata" if TYPE_CHECKING: - from zarr.core.array import Array - from zarr.core.group import Group + from zarr import Array, Group _METADATA_KEYS_V3 = frozenset({ZARR_JSON}) _METADATA_KEYS_V2 = frozenset({ZARRAY_JSON, ZATTRS_JSON, ZGROUP_JSON, ZMETADATA_V2_JSON}) @@ -59,25 +62,23 @@ def decode_chunk_key(array: Array[Any], key: str) -> tuple[int, ...] | None: tuple of int, or None The decoded coordinates, or ``None`` if *key* is not a valid chunk key. """ - from zarr.core.chunk_key_encodings import DefaultChunkKeyEncoding, V2ChunkKeyEncoding - from zarr.core.metadata.v2 import ArrayV2Metadata - try: - if isinstance(array.metadata, ArrayV2Metadata): + if array.metadata.zarr_format == 2: parts = key.split(array.metadata.dimension_separator) return tuple(int(p) for p in parts) encoding = array.metadata.chunk_key_encoding - if isinstance(encoding, DefaultChunkKeyEncoding): + separator = getattr(encoding, "separator", "/") + if encoding.name == "default": # Default v3 keys have the form "c012". - prefix = "c" + encoding.separator + prefix = "c" + separator if key == "c": return () if not key.startswith(prefix): return None - return tuple(int(p) for p in key[len(prefix) :].split(encoding.separator)) - if isinstance(encoding, V2ChunkKeyEncoding): - return tuple(int(p) for p in key.split(encoding.separator)) + return tuple(int(p) for p in key[len(prefix) :].split(separator)) + if encoding.name == "v2": + return tuple(int(p) for p in key.split(separator)) # Unknown encoding — fall back to the encoding's own decode. return encoding.decode_chunk_key(key) @@ -85,6 +86,12 @@ def decode_chunk_key(array: Array[Any], key: str) -> tuple[int, ...] | None: return None +def _shard_grid_shape(array: Array[Any]) -> tuple[int, ...]: + """Shape of the shard grid, falling back to the chunk grid when unsharded.""" + shard_shape = array.shards if array.shards is not None else array.chunks + return tuple(-(-s // c) for s, c in zip(array.shape, shard_shape, strict=True)) + + def is_valid_chunk_key(array: Array[Any], key: str) -> bool: """Check whether *key* is a valid chunk key for *array*. @@ -106,7 +113,7 @@ def is_valid_chunk_key(array: Array[Any], key: str) -> bool: coords = decode_chunk_key(array, key) if coords is None: return False - grid = array._shard_grid_shape + grid = _shard_grid_shape(array) if len(coords) != len(grid): return False return all(0 <= c < g for c, g in zip(coords, grid, strict=True)) @@ -153,7 +160,7 @@ def is_valid_node_key(node: Array[Any] | Group, key: str) -> bool: ------- bool """ - from zarr.core.array import Array + from zarr import Array if isinstance(node, Array): return is_valid_array_key(node, key) diff --git a/src/zarr/experimental/serve.py b/packages/zarr-server/src/zarr_server/_serve.py similarity index 94% rename from src/zarr/experimental/serve.py rename to packages/zarr-server/src/zarr_server/_serve.py index 2be3829b39..7d383d1c57 100644 --- a/src/zarr/experimental/serve.py +++ b/packages/zarr-server/src/zarr_server/_serve.py @@ -5,18 +5,17 @@ from typing import TYPE_CHECKING, Any, Literal, Self, TypedDict, overload from zarr.abc.store import OffsetByteRequest, RangeByteRequest, SuffixByteRequest -from zarr.core.buffer import cpu -from zarr.core.keys import is_valid_node_key +from zarr.buffer import cpu + +from zarr_server._keys import is_valid_node_key if TYPE_CHECKING: import uvicorn from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import Response - + from zarr import Array, Group from zarr.abc.store import ByteRequest, Store - from zarr.core.array import Array - from zarr.core.group import Group __all__ = [ "BackgroundServer", @@ -170,15 +169,9 @@ def _make_starlette_app( cors_options: CorsOptions | None = None, ) -> Starlette: """Create a Starlette app with the request handler.""" - try: - from starlette.applications import Starlette - from starlette.middleware.cors import CORSMiddleware - from starlette.routing import Route - except ImportError as e: - raise ImportError( - "The zarr server requires the 'starlette' package. " - "Install it with: pip install zarr[server]" - ) from e + from starlette.applications import Starlette + from starlette.middleware.cors import CORSMiddleware + from starlette.routing import Route if methods is None: methods = {"GET"} @@ -204,13 +197,7 @@ def _start_server( background: bool, ) -> BackgroundServer | None: """Create a uvicorn server for *app* and either block or run in a daemon thread.""" - try: - import uvicorn - except ImportError as e: - raise ImportError( - "The zarr server requires the 'uvicorn' package. " - "Install it with: pip install zarr[server]" - ) from e + import uvicorn config = uvicorn.Config(app, host=host, port=port) server = uvicorn.Server(config) diff --git a/packages/zarr-server/tests/conftest.py b/packages/zarr-server/tests/conftest.py new file mode 100644 index 0000000000..84694b42ba --- /dev/null +++ b/packages/zarr-server/tests/conftest.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal + +import pytest +from zarr.storage import MemoryStore + +if TYPE_CHECKING: + from zarr.abc.store import Store + +ZarrFormat = Literal[2, 3] + + +@pytest.fixture +def store(request: pytest.FixtureRequest) -> Store: + """Store fixture resolved via indirect parametrization.""" + if request.param != "memory": + raise ValueError(f"unsupported store param: {request.param!r}") + return MemoryStore() + + +@pytest.fixture(params=(2, 3), ids=["zarr2", "zarr3"]) +def zarr_format(request: pytest.FixtureRequest) -> ZarrFormat: + """Zarr format version fixture, parametrized over v2 and v3.""" + if request.param == 2: + return 2 + elif request.param == 3: + return 3 + msg = f"Invalid zarr format requested. Got {request.param}, expected one of (2, 3)." + raise ValueError(msg) diff --git a/tests/test_experimental/test_serve.py b/packages/zarr-server/tests/test_serve.py similarity index 98% rename from tests/test_experimental/test_serve.py rename to packages/zarr-server/tests/test_serve.py index 41b323b26b..ee2e72eae6 100644 --- a/tests/test_experimental/test_serve.py +++ b/packages/zarr-server/tests/test_serve.py @@ -1,24 +1,27 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import asyncio +from typing import TYPE_CHECKING, Any, Literal import numpy as np import pytest - import zarr -from zarr.core.buffer import cpu -from zarr.core.sync import sync +from starlette.testclient import TestClient +from zarr.buffer import cpu + +from zarr_server._serve import CorsOptions, _parse_range_header, node_app, store_app if TYPE_CHECKING: + from collections.abc import Coroutine + from zarr.abc.store import Store - from zarr.core.common import ZarrFormat -pytest.importorskip("starlette") -pytest.importorskip("httpx") +ZarrFormat = Literal[2, 3] -from starlette.testclient import TestClient -from zarr.experimental.serve import CorsOptions, _parse_range_header, node_app, store_app +def sync[T](coro: Coroutine[Any, Any, T]) -> T: + """Run a store coroutine to completion (tests use MemoryStore only).""" + return asyncio.run(coro) @pytest.fixture @@ -599,7 +602,7 @@ def test_serve_store_background(self, store: Store) -> None: that responds to HTTP requests and can be used as a context manager.""" import httpx - from zarr.experimental.serve import serve_store + from zarr_server import serve_store buf = cpu.buffer_prototype.buffer.from_bytes(b"hello") sync(store.set("key", buf)) @@ -619,7 +622,7 @@ def test_serve_node_background(self, store: Store) -> None: that responds to HTTP requests and can be used as a context manager.""" import httpx - from zarr.experimental.serve import serve_node + from zarr_server import serve_node arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8") arr[:] = np.arange(4, dtype="f8") diff --git a/tests/test_examples.py b/tests/test_examples.py index f79a4e24a3..9f8085e8c2 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -19,27 +19,47 @@ ZARR_PROJECT_PATH = Path(".").absolute() -def _get_zarr_extras(script_path: Path) -> str: - """Extract extras from the zarr dependency in a script's PEP 723 header. +def set_dep(script: str, dependency: str) -> str: + """ + Set a dependency in a PEP-723 script header. + If the package is already in the list, it will be replaced. + If the package is not already in the list, it will be added. - For example, if the script declares ``zarr[server]``, this returns ``[server]``. - If the script declares ``zarr`` with no extras, this returns ``""``. + Source code modified from + https://packaging.python.org/en/latest/specifications/inline-script-metadata/#reference-implementation """ - source_text = script_path.read_text() - match = re.search(PEP_723_REGEX, source_text) + match = re.search(PEP_723_REGEX, script) + if match is None: - return "" + raise ValueError(f"PEP-723 header not found in {script}") content = "".join( line[2:] if line.startswith("# ") else line[1:] for line in match.group("content").splitlines(keepends=True) ) + config = tomlkit.parse(content) - for dep in config.get("dependencies", []): - req = Requirement(dep) - if req.name == "zarr" and req.extras: - return "[" + ",".join(sorted(req.extras)) + "]" - return "" + for idx, dep in enumerate(tuple(config["dependencies"])): + if Requirement(dep).name == Requirement(dependency).name: + config["dependencies"][idx] = dependency + + new_content = "".join( + f"# {line}" if line.strip() else f"#{line}" + for line in tomlkit.dumps(config).splitlines(keepends=True) + ) + + start, end = match.span("content") + return script[:start] + new_content + script[end:] + + +def resave_script(source_path: Path, dest_path: Path) -> None: + """ + Read a script from source_path and save it to dest_path after inserting the absolute path to the + local Zarr project directory in the PEP-723 header. + """ + source_text = source_path.read_text() + dest_text = set_dep(source_text, f"zarr @ file:///{ZARR_PROJECT_PATH}") + dest_path.write_text(dest_text) def test_script_paths() -> None: @@ -53,15 +73,14 @@ def test_script_paths() -> None: sys.platform == "win32", reason="This test fails for unknown reasons on Windows in CI." ) @pytest.mark.parametrize("script_path", script_paths) -def test_scripts_can_run(script_path: Path) -> None: - # Override the zarr dependency with the local project, preserving any extras - # declared in the script's PEP 723 header (e.g. zarr[server]). - extras = _get_zarr_extras(script_path) - zarr_dep = f"zarr{extras} @ file:///{ZARR_PROJECT_PATH}" +def test_scripts_can_run(script_path: Path, tmp_path: Path) -> None: + dest_path = tmp_path / script_path.name + # We resave the script after inserting the absolute path to the local Zarr project directory, + # and then test its behavior. + # This allows the example to be useful to users who don't have Zarr installed, but also testable. + resave_script(script_path, dest_path) result = subprocess.run( - ["uv", "run", "--with", zarr_dep, "--refresh", str(script_path)], - capture_output=True, - text=True, + ["uv", "run", "--refresh", str(dest_path)], capture_output=True, text=True ) assert result.returncode == 0, ( f"Script at {script_path} failed to run. Output: {result.stdout} Error: {result.stderr}" From 88c98844a6d713d7cdbb318e80c1ba4cb20fbd1e Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 15:10:04 +0200 Subject: [PATCH 12/29] docs(zarr-server): package README; retarget root changelog fragment at the core fix Assisted-by: ClaudeCode:claude-fable-5 --- changes/3732.bugfix.md | 3 + changes/3732.feature.md | 5 -- packages/zarr-server/README.md | 142 +++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 changes/3732.bugfix.md delete mode 100644 changes/3732.feature.md diff --git a/changes/3732.bugfix.md b/changes/3732.bugfix.md new file mode 100644 index 0000000000..728e8a7e3a --- /dev/null +++ b/changes/3732.bugfix.md @@ -0,0 +1,3 @@ +`DefaultChunkKeyEncoding.decode_chunk_key` now validates that a chunk key +starts with the configured `c` prefix and raises `ValueError` for +malformed keys, instead of silently decoding them incorrectly. diff --git a/changes/3732.feature.md b/changes/3732.feature.md deleted file mode 100644 index a83e5c0264..0000000000 --- a/changes/3732.feature.md +++ /dev/null @@ -1,5 +0,0 @@ -Adds an experimental HTTP server that can expose `Store`, `Array`, or `Group` instances over HTTP. -`store_app` and `node_app` build ASGI applications; `serve_store` and `serve_node` additionally -start a Uvicorn server (blocking by default, or in a background thread with `background=True`). -See the [user guide](https://zarr.readthedocs.io/en/latest/user-guide/experimental.html#http-server) -and the [example](https://zarr.readthedocs.io/en/latest/user-guide/examples/serve.html). \ No newline at end of file diff --git a/packages/zarr-server/README.md b/packages/zarr-server/README.md index 39a0e0d5ee..155f471f8f 100644 --- a/packages/zarr-server/README.md +++ b/packages/zarr-server/README.md @@ -1,3 +1,145 @@ # zarr-server HTTP server for Zarr stores, arrays, and groups. + +`zarr-server` exposes a Zarr `Store`, `Array`, or `Group` over HTTP via an +ASGI app, so any HTTP-capable client (including zarr-python itself, via +`FsspecStore` or `ObjectStore`) can read the data. The app is built on +[Starlette](https://www.starlette.io/) and can be run with any ASGI server; +`serve_store`/`serve_node` conveniences run it with +[Uvicorn](https://www.uvicorn.org/). + +## Installation + +```bash +pip install zarr-server +``` + +### Building an ASGI App + +`store_app` creates an ASGI app that exposes every key +in a store: + +```python +import zarr +from zarr_server import store_app + +store = zarr.storage.MemoryStore() +zarr.create_array(store, shape=(100, 100), chunks=(10, 10), dtype="float64") + +app = store_app(store) + +# Run with any ASGI server, e.g. Uvicorn: +# uvicorn my_module:app --host 0.0.0.0 --port 8000 +``` + +`node_app` creates an ASGI app that only serves keys +belonging to a specific `Array` or `Group`. Requests for keys outside the node +receive a 404, even if those keys exist in the underlying store: + +```python +import zarr +from zarr_server import node_app + +store = zarr.storage.MemoryStore() +root = zarr.open_group(store) +root.create_array("a", shape=(10,), dtype="int32") +root.create_array("b", shape=(20,), dtype="float64") + +# Only serve the array at "a" — requests for "b" will return 404. +arr = root["a"] +app = node_app(arr) +``` + +### Running the Server + +`serve_store` and `serve_node` +build an ASGI app *and* start a [Uvicorn](https://www.uvicorn.org/) server. +By default they block until the server is shut down: + +```python +from zarr_server import serve_store + +serve_store(store, host="127.0.0.1", port=8000) +``` + +Pass `background=True` to start the server in a daemon thread and return +immediately. The returned `BackgroundServer` +can be used as a context manager for automatic shutdown: + +```python +import numpy as np + +import zarr +from zarr_server import serve_node +from zarr.storage import MemoryStore + +store = MemoryStore() +arr = zarr.create_array(store, shape=(100,), chunks=(10,), dtype="float64") +arr[:] = np.arange(100, dtype="float64") + +with serve_node(arr, host="127.0.0.1", port=8000, background=True) as server: + # Now open the served array from another zarr client. + remote = zarr.open_array(server.url, mode="r") + np.testing.assert_array_equal(remote[:], arr[:]) +# Server is shut down automatically when the block exits. +``` + +### CORS Support + +Both `store_app` and `node_app` (and their `serve_*` counterparts) accept a +`CorsOptions` parameter to enable +[CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) middleware for +browser-based clients: + +```python +from zarr_server import CorsOptions, store_app + +app = store_app( + store, + cors_options=CorsOptions( + allow_origins=["*"], + allow_methods=["GET"], + ), +) +``` + +### HTTP Range Requests + +The server supports the standard `Range` header for partial reads. The three +forms defined by [RFC 7233](https://httpwg.org/specs/rfc7233.html) are supported: + +| Header | Meaning | +| -------------------- | ------------------------------ | +| `bytes=0-99` | First 100 bytes | +| `bytes=100-` | Everything from byte 100 | +| `bytes=-50` | Last 50 bytes | + +A successful range request returns HTTP 206 (Partial Content). + +### Write Support + +By default only `GET` requests are accepted. To enable writes, pass +`methods={"GET", "PUT"}`: + +```python +app = store_app(store, methods={"GET", "PUT"}) +``` + +A `PUT` request stores the request body at the given path and returns 204 (No Content). + +## Example + +`examples/serve.py` creates an in-memory Zarr array, serves it over HTTP with +`serve_node`, and fetches the `zarr.json` metadata document and a raw chunk +using `httpx`. + +```bash +python examples/serve.py +``` + +Or run with uv: + +```bash +uv run examples/serve.py +``` From 50bb7b1cdb81a034e4c2bf4e817704e22035b09e Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 15:23:15 +0200 Subject: [PATCH 13/29] refactor!: remove the HTTP server from zarr core The server now lives in packages/zarr-server (published as zarr-server). The feature never shipped in a zarr release, so there is no deprecation shim. The decode_chunk_key strictness fix stays in zarr core. Assisted-by: ClaudeCode:claude-fable-5 --- docs/api/zarr/experimental.md | 4 - docs/user-guide/examples/serve.md | 7 -- docs/user-guide/experimental.md | 129 ----------------------------- mkdocs.yml | 1 - pyproject.toml | 6 -- uv.lock | 130 +----------------------------- 6 files changed, 1 insertion(+), 276 deletions(-) delete mode 100644 docs/user-guide/examples/serve.md diff --git a/docs/api/zarr/experimental.md b/docs/api/zarr/experimental.md index 159300952d..0961db08c4 100644 --- a/docs/api/zarr/experimental.md +++ b/docs/api/zarr/experimental.md @@ -7,7 +7,3 @@ Experimental functionality is not stable and may change or be removed at any poi ## Cache Store ::: zarr.experimental.cache_store - -## HTTP Server - -::: zarr.experimental.serve diff --git a/docs/user-guide/examples/serve.md b/docs/user-guide/examples/serve.md deleted file mode 100644 index d02a67e4a9..0000000000 --- a/docs/user-guide/examples/serve.md +++ /dev/null @@ -1,7 +0,0 @@ ---8<-- "examples/serve/README.md" - -## Source Code - -```python ---8<-- "examples/serve/serve.py" -``` diff --git a/docs/user-guide/experimental.md b/docs/user-guide/experimental.md index 389fa183bd..e14146610c 100644 --- a/docs/user-guide/experimental.md +++ b/docs/user-guide/experimental.md @@ -358,132 +358,3 @@ print(f"Cache contains {info['cached_keys']} keys with {info['current_size']} by This example shows how the CacheStore can significantly reduce access times for repeated data reads, particularly important when working with remote data sources. The dual-store architecture allows for flexible cache persistence and management. - -## HTTP Server - -Zarr Python provides an experimental HTTP server that exposes a Zarr `Store`, `Array`, -or `Group` over HTTP as an [ASGI](https://asgi.readthedocs.io/) application. -This makes it possible to serve zarr data to any HTTP-capable client (including -another Zarr Python process backed by an `HTTPStore`). - -The server is built on [Starlette](https://www.starlette.io/) and can be run with -any ASGI server such as [Uvicorn](https://www.uvicorn.org/). - -Install the server dependencies with: - -```bash -pip install zarr[server] -``` - -### Building an ASGI App - -[`zarr.experimental.serve.store_app`][] creates an ASGI app that exposes every key -in a store: - -```python -import zarr -from zarr.experimental.serve import store_app - -store = zarr.storage.MemoryStore() -zarr.create_array(store, shape=(100, 100), chunks=(10, 10), dtype="float64") - -app = store_app(store) - -# Run with any ASGI server, e.g. Uvicorn: -# uvicorn my_module:app --host 0.0.0.0 --port 8000 -``` - -[`zarr.experimental.serve.node_app`][] creates an ASGI app that only serves keys -belonging to a specific `Array` or `Group`. Requests for keys outside the node -receive a 404, even if those keys exist in the underlying store: - -```python -import zarr -from zarr.experimental.serve import node_app - -store = zarr.storage.MemoryStore() -root = zarr.open_group(store) -root.create_array("a", shape=(10,), dtype="int32") -root.create_array("b", shape=(20,), dtype="float64") - -# Only serve the array at "a" — requests for "b" will return 404. -arr = root["a"] -app = node_app(arr) -``` - -### Running the Server - -[`zarr.experimental.serve.serve_store`][] and [`zarr.experimental.serve.serve_node`][] -build an ASGI app *and* start a [Uvicorn](https://www.uvicorn.org/) server. -By default they block until the server is shut down: - -```python -from zarr.experimental.serve import serve_store - -serve_store(store, host="127.0.0.1", port=8000) -``` - -Pass `background=True` to start the server in a daemon thread and return -immediately. The returned [`BackgroundServer`][zarr.experimental.serve.BackgroundServer] -can be used as a context manager for automatic shutdown: - -```python -import numpy as np - -import zarr -from zarr.experimental.serve import serve_node -from zarr.storage import MemoryStore - -store = MemoryStore() -arr = zarr.create_array(store, shape=(100,), chunks=(10,), dtype="float64") -arr[:] = np.arange(100, dtype="float64") - -with serve_node(arr, host="127.0.0.1", port=8000, background=True) as server: - # Now open the served array from another zarr client. - remote = zarr.open_array(server.url, mode="r") - np.testing.assert_array_equal(remote[:], arr[:]) -# Server is shut down automatically when the block exits. -``` - -### CORS Support - -Both `store_app` and `node_app` (and their `serve_*` counterparts) accept a -[`CorsOptions`][zarr.experimental.serve.CorsOptions] parameter to enable -[CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) middleware for -browser-based clients: - -```python -from zarr.experimental.serve import CorsOptions, store_app - -app = store_app( - store, - cors_options=CorsOptions( - allow_origins=["*"], - allow_methods=["GET"], - ), -) -``` - -### HTTP Range Requests - -The server supports the standard `Range` header for partial reads. The three -forms defined by [RFC 7233](https://httpwg.org/specs/rfc7233.html) are supported: - -| Header | Meaning | -| -------------------- | ------------------------------ | -| `bytes=0-99` | First 100 bytes | -| `bytes=100-` | Everything from byte 100 | -| `bytes=-50` | Last 50 bytes | - -A successful range request returns HTTP 206 (Partial Content). - -### Write Support - -By default only `GET` requests are accepted. To enable writes, pass -`methods={"GET", "PUT"}`: - -```python -app = store_app(store, methods={"GET", "PUT"}) -``` - -A `PUT` request stores the request body at the given path and returns 204 (No Content). diff --git a/mkdocs.yml b/mkdocs.yml index d34b888e68..46bfc1764c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,7 +31,6 @@ nav: - Examples: - user-guide/examples/custom_dtype.md - user-guide/examples/rectilinear_chunks.md - - user-guide/examples/serve.md - API Reference: - api/zarr/index.md - ' zarr.abc': diff --git a/pyproject.toml b/pyproject.toml index d7e24beb97..727071b5a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,11 +70,6 @@ gpu = [ ] cast-value-rs = ["cast-value-rs"] cli = ["typer"] -server = [ - "starlette", - "httpx", - "uvicorn", -] optional = ["universal-pathlib"] [project.scripts] @@ -111,7 +106,6 @@ test = [ "pytest-codspeed==5.0.3", "tomlkit==0.15.0", "uv==0.11.26", - "httpx2==2.9.1", ] remote-tests = [ {include-group = "test"}, diff --git a/uv.lock b/uv.lock index 8cad5e1fcf..495433584a 100644 --- a/uv.lock +++ b/uv.lock @@ -191,19 +191,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/03/a851e84fcbb85214dc637b6378121ef9a0dd61b4c65264675d8a5c9b1ae7/antlr4_python3_runtime-4.13.2-py3-none-any.whl", hash = "sha256:fe3835eb8d33daece0e799090eda89719dbccee7aa39ef94eed3818cafa5a7e8", size = 144462, upload-time = "2024-08-03T19:00:11.134Z" }, ] -[[package]] -name = "anyio" -version = "4.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, -] - [[package]] name = "ast-serialize" version = "0.3.0" @@ -1040,72 +1027,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httpcore2" -version = "2.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "h11" }, - { name = "truststore" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "httpx2" -version = "2.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpcore2" }, - { name = "idna" }, - { name = "truststore" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, -] - [[package]] name = "hypothesis" version = "6.155.7" @@ -3100,19 +3021,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, ] -[[package]] -name = "starlette" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, -] - [[package]] name = "sympy" version = "1.14.0" @@ -3159,15 +3067,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/06/8ba22ec32c74ac1be3baa26116e3c28bc0e76a5387476921d20b6fdade11/towncrier-25.8.0-py3-none-any.whl", hash = "sha256:b953d133d98f9aeae9084b56a3563fd2519dfc6ec33f61c9cd2c61ff243fb513", size = 65101, upload-time = "2025-08-30T11:41:53.644Z" }, ] -[[package]] -name = "truststore" -version = "0.10.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, -] - [[package]] name = "typer" version = "0.26.8" @@ -3252,19 +3151,6 @@ wheels = [ { 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" }, ] -[[package]] -name = "uvicorn" -version = "0.51.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, -] - [[package]] name = "verspec" version = "0.1.0" @@ -3525,11 +3411,6 @@ remote = [ { name = "fsspec" }, { name = "obstore" }, ] -server = [ - { name = "httpx" }, - { name = "starlette" }, - { name = "uvicorn" }, -] [package.dev-dependencies] dev = [ @@ -3538,7 +3419,6 @@ dev = [ { name = "coverage" }, { name = "fsspec" }, { name = "griffe-inherited-docstrings" }, - { name = "httpx2" }, { name = "hypothesis" }, { name = "markdown-exec", extra = ["ansi"] }, { name = "mike" }, @@ -3590,7 +3470,6 @@ remote-tests = [ { name = "botocore" }, { name = "coverage" }, { name = "fsspec" }, - { name = "httpx2" }, { name = "hypothesis" }, { name = "moto", extra = ["s3", "server"] }, { name = "numpydoc" }, @@ -3609,7 +3488,6 @@ remote-tests = [ ] test = [ { name = "coverage" }, - { name = "httpx2" }, { name = "hypothesis" }, { name = "numpydoc" }, { name = "pytest" }, @@ -3630,18 +3508,15 @@ requires-dist = [ { name = "donfig", specifier = ">=0.8" }, { name = "fsspec", marker = "extra == 'remote'", specifier = ">=2023.10.0" }, { name = "google-crc32c", specifier = ">=1.5" }, - { name = "httpx", marker = "extra == 'server'" }, { name = "numcodecs", specifier = ">=0.14" }, { name = "numpy", specifier = ">=2" }, { name = "obstore", marker = "extra == 'remote'", specifier = ">=0.5.1" }, { name = "packaging", specifier = ">=22.0" }, - { name = "starlette", marker = "extra == 'server'" }, { name = "typer", marker = "extra == 'cli'" }, { name = "typing-extensions", specifier = ">=4.14" }, { name = "universal-pathlib", marker = "extra == 'optional'" }, - { name = "uvicorn", marker = "extra == 'server'" }, ] -provides-extras = ["cast-value-rs", "cli", "gpu", "optional", "remote", "server"] +provides-extras = ["cast-value-rs", "cli", "gpu", "optional", "remote"] [package.metadata.requires-dev] dev = [ @@ -3650,7 +3525,6 @@ dev = [ { name = "coverage", specifier = "==7.14.3" }, { name = "fsspec", specifier = ">=2023.10.0" }, { name = "griffe-inherited-docstrings", specifier = "==1.1.3" }, - { name = "httpx2", specifier = "==2.9.1" }, { name = "hypothesis", specifier = "==6.155.7" }, { name = "markdown-exec", extras = ["ansi"], specifier = "==1.12.1" }, { name = "mike", specifier = "==2.2.0" }, @@ -3700,7 +3574,6 @@ remote-tests = [ { name = "botocore" }, { name = "coverage", specifier = "==7.14.3" }, { name = "fsspec", specifier = ">=2023.10.0" }, - { name = "httpx2", specifier = "==2.9.1" }, { name = "hypothesis", specifier = "==6.155.7" }, { name = "moto", extras = ["s3", "server"], specifier = "==5.2.2" }, { name = "numpydoc", specifier = "==1.10.0" }, @@ -3719,7 +3592,6 @@ remote-tests = [ ] test = [ { name = "coverage", specifier = "==7.14.3" }, - { name = "httpx2", specifier = "==2.9.1" }, { name = "hypothesis", specifier = "==6.155.7" }, { name = "numpydoc", specifier = "==1.10.0" }, { name = "pytest", specifier = "==9.1.1" }, From aa3350898795c5a749db25314701c00481f64b77 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 15:27:28 +0200 Subject: [PATCH 14/29] ci(zarr-server): add package test and release workflows Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/zarr-server-release.yml | 117 ++++++++++++++++++++++ .github/workflows/zarr-server.yml | 99 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 .github/workflows/zarr-server-release.yml create mode 100644 .github/workflows/zarr-server.yml diff --git a/.github/workflows/zarr-server-release.yml b/.github/workflows/zarr-server-release.yml new file mode 100644 index 0000000000..baadb0b110 --- /dev/null +++ b/.github/workflows/zarr-server-release.yml @@ -0,0 +1,117 @@ +name: zarr-server release + +on: + workflow_dispatch: + push: + tags: + - 'zarr_server-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-server + 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-server-dist + path: packages/zarr-server/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-server-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_server; print('zarr_server', zarr_server.__version__)" + + upload_pypi: + name: Upload to PyPI + needs: [build, test_artifacts] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/zarr_server-v') + runs-on: ubuntu-latest + environment: + name: zarr-server-releases + url: https://pypi.org/p/zarr-server + 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-server-dist + path: dist + + - name: Generate artifact attestation + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + 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-server-releases-test + url: https://test.pypi.org/p/zarr-server + permissions: + id-token: write + attestations: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: zarr-server-dist + path: dist + + - name: Generate artifact attestation + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + 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-server.yml b/.github/workflows/zarr-server.yml new file mode 100644 index 0000000000..450dc0fcaf --- /dev/null +++ b/.github/workflows/zarr-server.yml @@ -0,0 +1,99 @@ +name: zarr-server + +on: + push: + branches: [main] + paths: + - 'packages/zarr-server/**' + - '.github/workflows/zarr-server.yml' + pull_request: + paths: + - 'packages/zarr-server/**' + - '.github/workflows/zarr-server.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 + working-directory: packages/zarr-server + 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 }} + - name: Sync test dependency group + run: uv sync --group test --python ${{ matrix.python-version }} + - name: Run pytest + run: uv run --group test pytest tests + + ruff: + name: ruff + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: packages/zarr-server + 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 . + + mypy: + name: mypy + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: packages/zarr-server + 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 mypy + run: uv run --group test --with mypy mypy src + + zarr-server-complete: + name: zarr-server complete + needs: [test, ruff, mypy] + 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! From 3c19122d531ef0fdbdcfe33f69cfb4f8a1208cf4 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 15:36:36 +0200 Subject: [PATCH 15/29] chore(zarr-server): refresh package lockfile after root server-extra removal The package lock embeds the workspace-root zarr project's metadata; Task 4 removed the root server extra and httpx2 test dep after this lock was first generated. Assisted-by: ClaudeCode:claude-fable-5 --- packages/zarr-server/uv.lock | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/zarr-server/uv.lock b/packages/zarr-server/uv.lock index 8136500210..0abfd55a8a 100644 --- a/packages/zarr-server/uv.lock +++ b/packages/zarr-server/uv.lock @@ -394,18 +394,15 @@ requires-dist = [ { name = "donfig", specifier = ">=0.8" }, { name = "fsspec", marker = "extra == 'remote'", specifier = ">=2023.10.0" }, { name = "google-crc32c", specifier = ">=1.5" }, - { name = "httpx", marker = "extra == 'server'" }, { name = "numcodecs", specifier = ">=0.14" }, { name = "numpy", specifier = ">=2" }, { name = "obstore", marker = "extra == 'remote'", specifier = ">=0.5.1" }, { name = "packaging", specifier = ">=22.0" }, - { name = "starlette", marker = "extra == 'server'" }, { name = "typer", marker = "extra == 'cli'" }, { name = "typing-extensions", specifier = ">=4.14" }, { name = "universal-pathlib", marker = "extra == 'optional'" }, - { name = "uvicorn", marker = "extra == 'server'" }, ] -provides-extras = ["cast-value-rs", "cli", "gpu", "optional", "remote", "server"] +provides-extras = ["cast-value-rs", "cli", "gpu", "optional", "remote"] [package.metadata.requires-dev] dev = [ @@ -414,7 +411,6 @@ dev = [ { name = "coverage", specifier = "==7.14.3" }, { name = "fsspec", specifier = ">=2023.10.0" }, { name = "griffe-inherited-docstrings", specifier = "==1.1.3" }, - { name = "httpx2", specifier = "==2.9.1" }, { name = "hypothesis", specifier = "==6.155.7" }, { name = "markdown-exec", extras = ["ansi"], specifier = "==1.12.1" }, { name = "mike", specifier = "==2.2.0" }, @@ -464,7 +460,6 @@ remote-tests = [ { name = "botocore" }, { name = "coverage", specifier = "==7.14.3" }, { name = "fsspec", specifier = ">=2023.10.0" }, - { name = "httpx2", specifier = "==2.9.1" }, { name = "hypothesis", specifier = "==6.155.7" }, { name = "moto", extras = ["s3", "server"], specifier = "==5.2.2" }, { name = "numpydoc", specifier = "==1.10.0" }, @@ -483,7 +478,6 @@ remote-tests = [ ] test = [ { name = "coverage", specifier = "==7.14.3" }, - { name = "httpx2", specifier = "==2.9.1" }, { name = "hypothesis", specifier = "==6.155.7" }, { name = "numpydoc", specifier = "==1.10.0" }, { name = "pytest", specifier = "==9.1.1" }, From 2e589b57a9ea3d6f14262b3c3d195f2a1b1e8281 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 15:52:41 +0200 Subject: [PATCH 16/29] fix: reject path-traversal segments in zarr-server request handling store_app (and thus serve_store) passed the request path straight to the store without validation, since the is_valid_node_key gate only ran for node_app. Starlette percent-decodes path params, so a request like GET /..%2fsecret.txt arrived as literal ".." and LocalStore resolved it outside the store root, allowing arbitrary file read via GET and arbitrary file write via PUT. Closes this path-traversal vulnerability by rejecting any "." or ".." path segment in _handle_request before the store is touched, for both store_app and node_app. Assisted-by: ClaudeCode:claude-fable-5 --- packages/zarr-server/README.md | 4 +- .../zarr-server/src/zarr_server/_serve.py | 8 +++ packages/zarr-server/tests/test_serve.py | 65 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/zarr-server/README.md b/packages/zarr-server/README.md index 155f471f8f..b54dc68c0d 100644 --- a/packages/zarr-server/README.md +++ b/packages/zarr-server/README.md @@ -18,7 +18,9 @@ pip install zarr-server ### Building an ASGI App `store_app` creates an ASGI app that exposes every key -in a store: +in a store. Only point it at a store whose full contents are safe to serve +publicly — it grants read (and, if `PUT` is enabled, write) access to +everything the store contains, with no per-key filtering: ```python import zarr diff --git a/packages/zarr-server/src/zarr_server/_serve.py b/packages/zarr-server/src/zarr_server/_serve.py index 7d383d1c57..145f6aadd7 100644 --- a/packages/zarr-server/src/zarr_server/_serve.py +++ b/packages/zarr-server/src/zarr_server/_serve.py @@ -140,6 +140,14 @@ async def _handle_request(request: Request) -> Response: prefix: str = request.app.state.prefix path = request.path_params.get("path", "") + # Reject path-traversal attempts before touching the store. Starlette + # percent-decodes path params, so "..%2f" arrives here as a literal ".." + # segment; without this guard a filesystem-backed store (e.g. LocalStore) + # would resolve it outside the store root. Applied unconditionally (not + # just when node is None) as defense in depth. + if any(segment in (".", "..") for segment in path.split("/")): + return Response(status_code=404) + # If serving a node, validate the key before touching the store. if node is not None and not is_valid_node_key(node, path): return Response(status_code=404) diff --git a/packages/zarr-server/tests/test_serve.py b/packages/zarr-server/tests/test_serve.py index ee2e72eae6..48f6272bb2 100644 --- a/packages/zarr-server/tests/test_serve.py +++ b/packages/zarr-server/tests/test_serve.py @@ -583,6 +583,71 @@ def test_data_roundtrip(self, store: Store, zarr_format: ZarrFormat) -> None: assert len(response.content) > 0 +class TestPathTraversalProtection: + """store_app and node_app must reject path-traversal attempts before + touching the store, regardless of URL-encoding tricks.""" + + def test_get_traversal_outside_store_root_returns_404(self, tmp_path: Any) -> None: + """A GET for a percent-encoded '../secret.txt' must not escape the + store root and read a file outside it.""" + from zarr.storage import LocalStore + + root = tmp_path / "store_root" + root.mkdir() + secret = tmp_path / "secret.txt" + secret.write_text("top secret contents") + + store = LocalStore(root) + app = store_app(store, methods={"GET", "PUT"}) + client = TestClient(app) + + response = client.get("/..%2fsecret.txt") + assert response.status_code == 404 + assert b"top secret" not in response.content + + def test_put_traversal_outside_store_root_returns_404(self, tmp_path: Any) -> None: + """A PUT to a percent-encoded '../pwned.txt' must not escape the + store root and write a file outside it.""" + from zarr.storage import LocalStore + + root = tmp_path / "store_root" + root.mkdir() + pwned = tmp_path / "pwned.txt" + + store = LocalStore(root) + app = store_app(store, methods={"GET", "PUT"}) + client = TestClient(app) + + response = client.put("/..%2fpwned.txt", content=b"pwned") + assert response.status_code == 404 + assert not pwned.exists() + + @pytest.mark.parametrize( + "encoded_path", + [ + "/..%2fsecret.txt", + "/%2e%2e/secret.txt", + "/..%2f..%2fsecret.txt", + ], + ) + def test_encoded_traversal_variants_return_404(self, tmp_path: Any, encoded_path: str) -> None: + """Various percent-encoded traversal spellings must all be rejected.""" + from zarr.storage import LocalStore + + root = tmp_path / "store_root" + root.mkdir() + secret = tmp_path / "secret.txt" + secret.write_text("top secret contents") + + store = LocalStore(root) + app = store_app(store, methods={"GET", "PUT"}) + client = TestClient(app) + + response = client.get(encoded_path) + assert response.status_code == 404 + assert b"top secret" not in response.content + + def _get_free_port() -> int: """Return an unused TCP port on localhost.""" import socket From 35dd0e137e3c033fd4e86b39259d260080d61a3d Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 15:58:53 +0200 Subject: [PATCH 17/29] fix(zarr-server): close absolute-key path-traversal bypass in request guard The path-traversal guard in _handle_request only rejected "." and ".." segments. A percent-encoded leading slash (e.g. "/%2fetc%2fhostname") decodes to an absolute path param ("/etc/hostname"), whose split() produces an empty leading segment with no "." or ".." segment, so the guard let it through. LocalStore resolves an absolute key by discarding its configured root, allowing arbitrary filesystem read/write outside the store. Reject empty segments too, closing the bypass. Assisted-by: ClaudeCode:claude-fable-5 --- .../zarr-server/src/zarr_server/_serve.py | 12 ++--- packages/zarr-server/tests/test_serve.py | 46 +++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/zarr-server/src/zarr_server/_serve.py b/packages/zarr-server/src/zarr_server/_serve.py index 145f6aadd7..7ee829d11d 100644 --- a/packages/zarr-server/src/zarr_server/_serve.py +++ b/packages/zarr-server/src/zarr_server/_serve.py @@ -140,12 +140,12 @@ async def _handle_request(request: Request) -> Response: prefix: str = request.app.state.prefix path = request.path_params.get("path", "") - # Reject path-traversal attempts before touching the store. Starlette - # percent-decodes path params, so "..%2f" arrives here as a literal ".." - # segment; without this guard a filesystem-backed store (e.g. LocalStore) - # would resolve it outside the store root. Applied unconditionally (not - # just when node is None) as defense in depth. - if any(segment in (".", "..") for segment in path.split("/")): + # Reject non-canonical / traversal-prone keys before touching the store. + # Starlette percent-decodes path params, so "..%2f" arrives as a literal + # ".." segment and "%2f"-encoded leading slashes arrive as an empty leading + # segment (making the key absolute, which escapes a filesystem store root). + # Legitimate zarr keys never contain empty, ".", or ".." segments. + if any(segment in ("", ".", "..") for segment in path.split("/")): return Response(status_code=404) # If serving a node, validate the key before touching the store. diff --git a/packages/zarr-server/tests/test_serve.py b/packages/zarr-server/tests/test_serve.py index 48f6272bb2..3abf7f0442 100644 --- a/packages/zarr-server/tests/test_serve.py +++ b/packages/zarr-server/tests/test_serve.py @@ -647,6 +647,52 @@ def test_encoded_traversal_variants_return_404(self, tmp_path: Any, encoded_path assert response.status_code == 404 assert b"top secret" not in response.content + def test_get_absolute_key_bypass_returns_404(self, tmp_path: Any) -> None: + """A percent-encoded leading slash decodes to an ABSOLUTE path param + (e.g. request '/%2fetc%2fhostname' -> path param '/etc/hostname'). + '/etc/hostname'.split('/') -> ['', 'etc', 'hostname'] has no '.' or + '..' segment, so the two-element guard misses it, but LocalStore + resolves an absolute key by discarding its root entirely -- an + arbitrary-file read. The empty leading segment must be rejected.""" + from zarr.storage import LocalStore + + root = tmp_path / "store_root" + root.mkdir() + secret = tmp_path / "secret_abs.txt" + secret.write_text("top secret absolute contents") + + store = LocalStore(root) + app = store_app(store, methods={"GET", "PUT"}) + client = TestClient(app) + + # Mirror the exploit: percent-encode every "/" (including the + # leading one) in the absolute secret path as "%2f". + encoded_path = "/" + str(secret).replace("/", "%2f") + + response = client.get(encoded_path) + assert response.status_code == 404 + assert b"top secret absolute" not in response.content + assert secret.read_text() == "top secret absolute contents" + + def test_put_absolute_key_bypass_returns_404(self, tmp_path: Any) -> None: + """Same absolute-key vector as above, but for PUT: a percent-encoded + leading slash must not allow writing a file outside the store root.""" + from zarr.storage import LocalStore + + root = tmp_path / "store_root" + root.mkdir() + pwned = tmp_path / "pwned_abs.txt" + + store = LocalStore(root) + app = store_app(store, methods={"GET", "PUT"}) + client = TestClient(app) + + encoded_path = "/" + str(pwned).replace("/", "%2f") + + response = client.put(encoded_path, content=b"pwned") + assert response.status_code == 404 + assert not pwned.exists() + def _get_free_port() -> int: """Return an unused TCP port on localhost.""" From 1074284fa861ac766f8ba1ed4d435f374c31cc4b Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 16:25:20 +0200 Subject: [PATCH 18/29] ci(zarr-server): bump actions/attest to v4.2.0 to match sibling workflow Keeps the zarr-server release workflow's pinned action SHAs in sync with the dependabot bump that landed in zarr-metadata-release.yml via the merge. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/zarr-server-release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/zarr-server-release.yml b/.github/workflows/zarr-server-release.yml index baadb0b110..6c215a4f2d 100644 --- a/.github/workflows/zarr-server-release.yml +++ b/.github/workflows/zarr-server-release.yml @@ -82,7 +82,7 @@ jobs: path: dist - name: Generate artifact attestation - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 with: subject-path: dist/* @@ -107,7 +107,7 @@ jobs: path: dist - name: Generate artifact attestation - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 with: subject-path: dist/* From a31521e145b3dfb6fdb8ea1fba62980ed502c8e8 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 17:35:05 +0200 Subject: [PATCH 19/29] fix(zarr-server): fold backslash separators and reject drive-qualified keys The traversal guard in _handle_request split only on "/", so backslash- separated segments like "..\\..\\win.ini" and drive-qualified or UNC-rooted keys like "C:/Windows/win.ini" or "\\host\share\x" passed through untouched. On Windows, LocalStore joins keys onto its root via pathlib, which discards the root entirely for a drive-qualified or rooted key -- turning percent-encoded backslash paths into arbitrary file read/write. Fold backslashes to "/" before the segment check (mirroring zarr's own normalize_path) and add an ntpath.splitdrive check to catch drive letters and UNC prefixes that don't produce empty/"."/".." segments. Added TDD coverage that fails against the old guard (via a store double that raises if get/set is ever called) and passes against the fix. Assisted-by: ClaudeCode:claude-fable-5 --- .../zarr-server/src/zarr_server/_serve.py | 10 +++- packages/zarr-server/tests/test_serve.py | 46 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/zarr-server/src/zarr_server/_serve.py b/packages/zarr-server/src/zarr_server/_serve.py index 7ee829d11d..30a9aa8733 100644 --- a/packages/zarr-server/src/zarr_server/_serve.py +++ b/packages/zarr-server/src/zarr_server/_serve.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ntpath import threading import time from typing import TYPE_CHECKING, Any, Literal, Self, TypedDict, overload @@ -144,8 +145,13 @@ async def _handle_request(request: Request) -> Response: # Starlette percent-decodes path params, so "..%2f" arrives as a literal # ".." segment and "%2f"-encoded leading slashes arrive as an empty leading # segment (making the key absolute, which escapes a filesystem store root). - # Legitimate zarr keys never contain empty, ".", or ".." segments. - if any(segment in ("", ".", "..") for segment in path.split("/")): + # Backslashes are separators on Windows and drive-qualified or + # root-relative keys discard a filesystem store's root entirely, so fold + # separators before checking segments -- mirroring zarr's normalize_path + # (src/zarr/storage/_utils.py) plus a drive check it doesn't need. Legitimate + # zarr keys never contain empty, ".", or ".." segments, or a drive letter. + segments = path.replace("\\", "/").split("/") + if any(segment in ("", ".", "..") for segment in segments) or ntpath.splitdrive(path)[0]: return Response(status_code=404) # If serving a node, validate the key before touching the store. diff --git a/packages/zarr-server/tests/test_serve.py b/packages/zarr-server/tests/test_serve.py index 3abf7f0442..3fe2975a29 100644 --- a/packages/zarr-server/tests/test_serve.py +++ b/packages/zarr-server/tests/test_serve.py @@ -693,6 +693,52 @@ def test_put_absolute_key_bypass_returns_404(self, tmp_path: Any) -> None: assert response.status_code == 404 assert not pwned.exists() + @pytest.mark.parametrize( + "encoded_path", + [ + "/..%5C..%5Cwin.ini", + "/%5CWindows%5Cwin.ini", + "/C:/Windows/win.ini", + "/C:%5CWindows", + "/%5C%5Chost%5Cshare%5Cx", + ], + ) + def test_backslash_and_drive_traversal_variants_return_404(self, encoded_path: str) -> None: + """Backslash is a path separator on Windows, and a drive-qualified or + root-relative key discards a filesystem store's root entirely on + Windows, even though POSIX only ever treats '/' as a separator. The + guard must reject these purely from the string, before the store is + ever touched -- verified here by making the store raise if called.""" + from unittest.mock import AsyncMock + + from zarr.storage import MemoryStore + + store = MemoryStore() + store.get = AsyncMock(side_effect=AssertionError("store.get should not be called")) # type: ignore[method-assign] + store.set = AsyncMock(side_effect=AssertionError("store.set should not be called")) # type: ignore[method-assign] + + app = store_app(store, methods={"GET", "PUT"}) + client = TestClient(app) + + response = client.get(encoded_path) + assert response.status_code == 404 + + def test_put_backslash_traversal_returns_404(self) -> None: + """A PUT to a backslash-encoded '..\\..\\pwned.txt' must be rejected + before the store is touched, mirroring the GET case above.""" + from unittest.mock import AsyncMock + + from zarr.storage import MemoryStore + + store = MemoryStore() + store.set = AsyncMock(side_effect=AssertionError("store.set should not be called")) # type: ignore[method-assign] + + app = store_app(store, methods={"GET", "PUT"}) + client = TestClient(app) + + response = client.put("/..%5C..%5Cpwned.txt", content=b"pwned") + assert response.status_code == 404 + def _get_free_port() -> int: """Return an unused TCP port on localhost.""" From 8ee276b4810b48dc92c50ad3a1e9a659445e3119 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 17:37:32 +0200 Subject: [PATCH 20/29] fix(zarr-server): fix vacuous two-level traversal test The `/..%2f..%2fsecret.txt` case in test_encoded_traversal_variants_ return_404 climbed two levels from tmp_path/store_root to tmp_path/.., but the secret was written at tmp_path/secret.txt (one level up) -- so the case passed for the wrong reason (no such file, not "guard blocked it") even with the traversal guard deleted entirely. Nest the store root exactly `climb_depth` directories below tmp_path per case, so every case's ".." segments resolve to tmp_path/secret.txt. Verified by mutation: copied _serve.py to a scratch dir (never the repo), deleted the guard body, and ran TestPathTraversalProtection against it via PYTHONPATH. Before this fix, 12/13 traversal tests failed against the mutant (1 false pass -- this vacuous case). After this fix, 13/13 fail against the mutant, and all still pass against the real guard. Assisted-by: ClaudeCode:claude-fable-5 --- packages/zarr-server/tests/test_serve.py | 27 +++++++++++++++++------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/zarr-server/tests/test_serve.py b/packages/zarr-server/tests/test_serve.py index 3fe2975a29..a4fe480b40 100644 --- a/packages/zarr-server/tests/test_serve.py +++ b/packages/zarr-server/tests/test_serve.py @@ -623,19 +623,30 @@ def test_put_traversal_outside_store_root_returns_404(self, tmp_path: Any) -> No assert not pwned.exists() @pytest.mark.parametrize( - "encoded_path", + ("encoded_path", "climb_depth"), [ - "/..%2fsecret.txt", - "/%2e%2e/secret.txt", - "/..%2f..%2fsecret.txt", + ("/..%2fsecret.txt", 1), + ("/%2e%2e/secret.txt", 1), + ("/..%2f..%2fsecret.txt", 2), ], ) - def test_encoded_traversal_variants_return_404(self, tmp_path: Any, encoded_path: str) -> None: - """Various percent-encoded traversal spellings must all be rejected.""" + def test_encoded_traversal_variants_return_404( + self, tmp_path: Any, encoded_path: str, climb_depth: int + ) -> None: + """Various percent-encoded traversal spellings must all be rejected. + + The store root is nested exactly ``climb_depth`` directories below + ``tmp_path`` and the secret lives at ``tmp_path/secret.txt`` -- the + exact location each traversal's ".." segments resolve to -- so a + case with a missing or deleted guard would actually reach the + secret instead of just returning 404 for an unrelated reason (e.g. + a two-level climb landing on a directory that happens to be empty). + """ from zarr.storage import LocalStore - root = tmp_path / "store_root" - root.mkdir() + parts = [f"level{i}" for i in range(climb_depth - 1)] + ["store_root"] + root = tmp_path.joinpath(*parts) + root.mkdir(parents=True) secret = tmp_path / "secret.txt" secret.write_text("top secret contents") From 90d15d7cacff7e5d4a3c33e5d8fb3e9512a17f66 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 20:39:27 +0200 Subject: [PATCH 21/29] fix(zarr-server): bound BackgroundServer shutdown instead of blocking forever shutdown() joined the server thread with no timeout, and uvicorn's graceful wait is itself unbounded: force_exit is only set by a signal handler uvicorn deliberately skips off the main thread. A client mid request could wedge __exit__ unrecoverably. Bound uvicorn's graceful wait with timeout_graceful_shutdown and fall back to force_exit if the thread outlives it, both driven by a new shutdown_timeout parameter on serve_store/serve_node. Assisted-by: ClaudeCode:claude-fable-5 --- .../zarr-server/src/zarr_server/_serve.py | 65 ++++++++++++++++--- packages/zarr-server/tests/test_serve.py | 56 ++++++++++++++++ 2 files changed, 113 insertions(+), 8 deletions(-) diff --git a/packages/zarr-server/src/zarr_server/_serve.py b/packages/zarr-server/src/zarr_server/_serve.py index 30a9aa8733..b64ae30b66 100644 --- a/packages/zarr-server/src/zarr_server/_serve.py +++ b/packages/zarr-server/src/zarr_server/_serve.py @@ -47,6 +47,9 @@ class BackgroundServer: ---------- server : uvicorn.Server The running uvicorn server instance. + shutdown_timeout : int, optional + Seconds to wait for in-flight requests to finish gracefully during + :meth:`shutdown` before forcing the server closed. Defaults to ``5``. Examples -------- @@ -56,12 +59,19 @@ class BackgroundServer: """ def __init__( - self, server: uvicorn.Server, thread: threading.Thread, *, host: str, port: int + self, + server: uvicorn.Server, + thread: threading.Thread, + *, + host: str, + port: int, + shutdown_timeout: int = 5, ) -> None: self._server = server self._thread = thread self.host = host self.port = port + self._shutdown_timeout = shutdown_timeout @property def url(self) -> str: @@ -69,9 +79,21 @@ def url(self) -> str: return f"http://{self.host}:{self.port}" def shutdown(self) -> None: - """Signal the server to shut down and wait for it to stop.""" + """Signal the server to shut down and wait for it to stop. + + Waits up to ``shutdown_timeout`` seconds (set when the server was + started) for the server thread to exit on its own -- uvicorn's own + graceful-shutdown wait for in-flight requests is bounded by the same + value via ``timeout_graceful_shutdown``. If the thread is still + alive after that, ``force_exit`` is set on the underlying uvicorn + server to tear it down immediately rather than block forever on a + stuck or slow in-flight request. + """ self._server.should_exit = True - self._thread.join() + self._thread.join(timeout=self._shutdown_timeout) + if self._thread.is_alive(): + self._server.force_exit = True + self._thread.join() def __enter__(self) -> Self: return self @@ -209,11 +231,18 @@ def _start_server( host: str, port: int, background: bool, + shutdown_timeout: int = 5, ) -> BackgroundServer | None: - """Create a uvicorn server for *app* and either block or run in a daemon thread.""" + """Create a uvicorn server for *app* and either block or run in a daemon thread. + + ``shutdown_timeout`` bounds uvicorn's own graceful-shutdown wait for + in-flight requests (``timeout_graceful_shutdown``), and, for a + background server, is also the bound :meth:`BackgroundServer.shutdown` + uses before forcing the server thread closed. + """ import uvicorn - config = uvicorn.Config(app, host=host, port=port) + config = uvicorn.Config(app, host=host, port=port, timeout_graceful_shutdown=shutdown_timeout) server = uvicorn.Server(config) if not background: @@ -231,7 +260,7 @@ def _start_server( raise RuntimeError("Server failed to start within 5 seconds") time.sleep(0.01) - return BackgroundServer(server, thread, host=host, port=port) + return BackgroundServer(server, thread, host=host, port=port, shutdown_timeout=shutdown_timeout) def store_app( @@ -312,6 +341,7 @@ def serve_store( methods: set[HTTPMethod] | None = ..., cors_options: CorsOptions | None = ..., background: Literal[False] = ..., + shutdown_timeout: int = ..., ) -> None: ... @@ -324,6 +354,7 @@ def serve_store( methods: set[HTTPMethod] | None = ..., cors_options: CorsOptions | None = ..., background: Literal[True], + shutdown_timeout: int = ..., ) -> BackgroundServer: ... @@ -335,6 +366,7 @@ def serve_store( methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, background: bool = False, + shutdown_timeout: int = 5, ) -> BackgroundServer | None: """Serve every key in a zarr ``Store`` over HTTP. @@ -357,6 +389,11 @@ def serve_store( If ``False`` (the default), the server blocks until shut down. If ``True``, the server runs in a daemon thread and this function returns immediately. + shutdown_timeout : int, optional + Seconds to wait for in-flight requests to finish gracefully before + the server is forced closed, whether on `BackgroundServer.shutdown` + or (for a blocking server) on receiving a shutdown signal. Defaults + to ``5``. Returns ------- @@ -367,7 +404,9 @@ def serve_store( automatic shutdown. """ app = store_app(store, methods=methods, cors_options=cors_options) - return _start_server(app, host=host, port=port, background=background) + return _start_server( + app, host=host, port=port, background=background, shutdown_timeout=shutdown_timeout + ) @overload @@ -379,6 +418,7 @@ def serve_node( methods: set[HTTPMethod] | None = ..., cors_options: CorsOptions | None = ..., background: Literal[False] = ..., + shutdown_timeout: int = ..., ) -> None: ... @@ -391,6 +431,7 @@ def serve_node( methods: set[HTTPMethod] | None = ..., cors_options: CorsOptions | None = ..., background: Literal[True], + shutdown_timeout: int = ..., ) -> BackgroundServer: ... @@ -402,6 +443,7 @@ def serve_node( methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, background: bool = False, + shutdown_timeout: int = 5, ) -> BackgroundServer | None: """Serve only the keys belonging to a zarr ``Array`` or ``Group`` over HTTP. @@ -434,6 +476,11 @@ def serve_node( If ``False`` (the default), the server blocks until shut down. If ``True``, the server runs in a daemon thread and this function returns immediately. + shutdown_timeout : int, optional + Seconds to wait for in-flight requests to finish gracefully before + the server is forced closed, whether on `BackgroundServer.shutdown` + or (for a blocking server) on receiving a shutdown signal. Defaults + to ``5``. Returns ------- @@ -444,4 +491,6 @@ def serve_node( automatic shutdown. """ app = node_app(node, methods=methods, cors_options=cors_options) - return _start_server(app, host=host, port=port, background=background) + return _start_server( + app, host=host, port=port, background=background, shutdown_timeout=shutdown_timeout + ) diff --git a/packages/zarr-server/tests/test_serve.py b/packages/zarr-server/tests/test_serve.py index a4fe480b40..00e7591604 100644 --- a/packages/zarr-server/tests/test_serve.py +++ b/packages/zarr-server/tests/test_serve.py @@ -799,3 +799,59 @@ def test_serve_node_background(self, store: Store) -> None: with serve_node(arr, host="127.0.0.1", port=port, background=True) as server: response = httpx.get(f"{server.url}/zarr.json") assert response.status_code == 200 + + +class TestBackgroundServerBoundedShutdown: + """BackgroundServer.shutdown() must not hang forever on a slow or stuck + in-flight request.""" + + def test_shutdown_returns_promptly_with_slow_inflight_request(self) -> None: + """A request that takes far longer than shutdown_timeout must not + prevent shutdown() from returning within roughly shutdown_timeout, + via uvicorn's force_exit rather than an unbounded thread join.""" + import asyncio + import threading + import time + + import httpx + from starlette.applications import Starlette + from starlette.responses import Response + from starlette.routing import Route + + from zarr_server._serve import _start_server + + async def slow(request: Any) -> Response: + # Sleeps far longer than shutdown_timeout below, so a correct + # implementation must force the connection closed rather than + # wait for this to finish. + await asyncio.sleep(5) + return Response(status_code=204) + + app = Starlette(routes=[Route("/slow", slow, methods=["GET"])]) + port = _get_free_port() + server = _start_server( + app, host="127.0.0.1", port=port, background=True, shutdown_timeout=1 + ) + assert server is not None + + request_errors: list[BaseException] = [] + + def make_slow_request() -> None: + try: + httpx.get(f"http://127.0.0.1:{port}/slow", timeout=10) + except Exception as exc: # connection drop when the server force-closes is expected + request_errors.append(exc) + + request_thread = threading.Thread(target=make_slow_request, daemon=True) + request_thread.start() + time.sleep(0.2) # give the request time to actually start + + start = time.monotonic() + server.shutdown() + elapsed = time.monotonic() - start + + # shutdown_timeout=1 plus some scheduling slack; well under the + # 5-second handler sleep an unbounded join would have waited out. + assert elapsed < 3.0, f"shutdown() took {elapsed:.2f}s, expected well under 3s" + + request_thread.join(timeout=10) From 3eb111ff442480b1368e41b3aeb21de7c15d937f Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 20:41:01 +0200 Subject: [PATCH 22/29] fix(zarr-server)!: only accept HTTP methods the handler implements _handle_request special-cased PUT and let every other verb fall through to the read path, so a server built with DELETE/POST/PATCH answered them with the key's contents and changed nothing. HTTPMethod advertised all of them. Narrow HTTPMethod to GET/PUT/HEAD and reject anything else when the app is built. Doing this before the first release avoids narrowing a published type later. Assisted-by: ClaudeCode:claude-fable-5 --- .../zarr-server/src/zarr_server/_serve.py | 37 ++++++++++++++++--- packages/zarr-server/tests/test_serve.py | 29 +++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/packages/zarr-server/src/zarr_server/_serve.py b/packages/zarr-server/src/zarr_server/_serve.py index b64ae30b66..5bd980c9c5 100644 --- a/packages/zarr-server/src/zarr_server/_serve.py +++ b/packages/zarr-server/src/zarr_server/_serve.py @@ -34,7 +34,15 @@ class CorsOptions(TypedDict): allow_methods: list[str] -HTTPMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"] +HTTPMethod = Literal["GET", "PUT", "HEAD"] +"""An HTTP method this server implements. + +`GET` and `HEAD` read a key; `PUT` writes one. Other verbs are not accepted: +the handler has no behavior for them, so serving them would silently answer +as if they were `GET`. +""" + +_SUPPORTED_METHODS: frozenset[str] = frozenset({"GET", "PUT", "HEAD"}) class BackgroundServer: @@ -204,7 +212,13 @@ def _make_starlette_app( methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, ) -> Starlette: - """Create a Starlette app with the request handler.""" + """Create a Starlette app with the request handler. + + Raises + ------ + ValueError + If `methods` contains anything outside `GET`, `PUT`, and `HEAD`. + """ from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware from starlette.routing import Route @@ -212,6 +226,13 @@ def _make_starlette_app( if methods is None: methods = {"GET"} + unsupported = sorted(set(methods) - _SUPPORTED_METHODS) + if unsupported: + raise ValueError( + f"Unsupported HTTP method(s): {', '.join(unsupported)}. " + f"Accepted methods are {', '.join(sorted(_SUPPORTED_METHODS))}." + ) + app = Starlette( routes=[Route("/{path:path}", _handle_request, methods=list(methods))], ) @@ -276,7 +297,8 @@ def store_app( store : Store The zarr store to serve. methods : set of HTTPMethod, optional - The HTTP methods to accept. Defaults to ``{"GET"}``. + The HTTP methods to accept: any of `"GET"`, `"HEAD"`, and `"PUT"`. + Defaults to `{"GET"}`. Passing any other method raises `ValueError`. cors_options : CorsOptions, optional If provided, CORS middleware will be added with the given options. @@ -316,7 +338,8 @@ def node_app( node : Array or Group The zarr array or group to serve. methods : set of HTTPMethod, optional - The HTTP methods to accept. Defaults to ``{"GET"}``. + The HTTP methods to accept: any of `"GET"`, `"HEAD"`, and `"PUT"`. + Defaults to `{"GET"}`. Passing any other method raises `ValueError`. cors_options : CorsOptions, optional If provided, CORS middleware will be added with the given options. @@ -382,7 +405,8 @@ def serve_store( port : int, optional The port to bind to. Defaults to ``8000``. methods : set of HTTPMethod, optional - The HTTP methods to accept. Defaults to ``{"GET"}``. + The HTTP methods to accept: any of `"GET"`, `"HEAD"`, and `"PUT"`. + Defaults to `{"GET"}`. Passing any other method raises `ValueError`. cors_options : CorsOptions, optional If provided, CORS middleware will be added with the given options. background : bool, optional @@ -469,7 +493,8 @@ def serve_node( port : int, optional The port to bind to. Defaults to ``8000``. methods : set of HTTPMethod, optional - The HTTP methods to accept. Defaults to ``{"GET"}``. + The HTTP methods to accept: any of `"GET"`, `"HEAD"`, and `"PUT"`. + Defaults to `{"GET"}`. Passing any other method raises `ValueError`. cors_options : CorsOptions, optional If provided, CORS middleware will be added with the given options. background : bool, optional diff --git a/packages/zarr-server/tests/test_serve.py b/packages/zarr-server/tests/test_serve.py index 00e7591604..e5ffa74bd0 100644 --- a/packages/zarr-server/tests/test_serve.py +++ b/packages/zarr-server/tests/test_serve.py @@ -336,6 +336,35 @@ def test_put_to_valid_chunk_key_succeeds(self, group_with_arrays: zarr.Group) -> assert get_response.content == payload +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestMethodValidation: + """Only the methods the handler implements may be served.""" + + def test_supported_methods_are_served(self, store: Store) -> None: + """GET, PUT and HEAD each behave as their verb implies.""" + app = store_app(store, methods={"GET", "PUT", "HEAD"}) + client = TestClient(app) + + assert client.put("/zarr.json", content=b'{"a":1}').status_code == 204 + assert client.get("/zarr.json").content == b'{"a":1}' + + # HEAD reports the same status as GET but carries no body. + head = client.head("/zarr.json") + assert head.status_code == 200 + assert head.content == b"" + + @pytest.mark.parametrize("method", ["DELETE", "POST", "PATCH", "OPTIONS", "TRACE"]) + def test_unsupported_method_raises(self, store: Store, method: str) -> None: + """A verb the handler cannot implement is rejected when the app is + built, rather than silently answering as if it were a GET.""" + for build in ( + lambda: store_app(store, methods={"GET", method}), # type: ignore[arg-type] + lambda: node_app(zarr.open_group(store, mode="a"), methods={"GET", method}), # type: ignore[arg-type] + ): + with pytest.raises(ValueError, match=method): + build() + + @pytest.mark.parametrize("store", ["memory"], indirect=True) class TestStoreAppEdgeCases: """Edge cases for store_app.""" From a2616a760b2aefc2574093aa4b78e600bba85940 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 20:42:39 +0200 Subject: [PATCH 23/29] docs(zarr-server): document client deps; lint the package's changelog in CI The README's round-trip example reads back through FsspecStore, which needs an HTTP-capable fsspec that zarr-server does not depend on, so a clean install failed on the PyPI landing page's headline snippet. Same for examples/serve.py under a plain interpreter, which needs httpx. check_changelogs.yml also never visited packages/zarr-server/changes, so a malformed fragment would have passed PR CI and failed at release time. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/check_changelogs.yml | 3 +++ packages/zarr-server/README.md | 19 ++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check_changelogs.yml b/.github/workflows/check_changelogs.yml index 0033b43db2..dd0b73897e 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-server changelog entries + run: uv run --no-sync python ci/check_changelog_entries.py packages/zarr-server/changes diff --git a/packages/zarr-server/README.md b/packages/zarr-server/README.md index b54dc68c0d..6dc109da84 100644 --- a/packages/zarr-server/README.md +++ b/packages/zarr-server/README.md @@ -69,6 +69,15 @@ Pass `background=True` to start the server in a daemon thread and return immediately. The returned `BackgroundServer` can be used as a context manager for automatic shutdown: +The example below also *reads back* over HTTP, which is a client-side +concern: `zarr.open_array(server.url)` goes through `FsspecStore`, which needs +an HTTP-capable fsspec that `zarr-server` does not pull in. + +```bash +pip install "fsspec[http]" +``` + + ```python import numpy as np @@ -136,12 +145,16 @@ A `PUT` request stores the request body at the given path and returns 204 (No Co `serve_node`, and fetches the `zarr.json` metadata document and a raw chunk using `httpx`. +Running it with uv is the simplest route — the script declares its own +dependencies inline, so uv installs them for you: + ```bash -python examples/serve.py +uv run examples/serve.py ``` -Or run with uv: +To run it with a plain interpreter, install its `httpx` dependency first: ```bash -uv run examples/serve.py +pip install httpx +python examples/serve.py ``` From 760600ea2f81f7606604a518c609bc15c081ae4e Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 20:50:52 +0200 Subject: [PATCH 24/29] test(zarr-server): add docs dependency group and cover the README round-trip Reading a served array back with zarr.open_array(url) routes through FsspecStore -- zarr's only URL-string backend -- so the README's headline example needs an HTTP-capable fsspec that the package itself has no reason to depend on. Carry that in a docs group, mirroring the root project's group for running examples, and use it to test the round-trip in CI so the example cannot rot. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/zarr-server.yml | 9 +- packages/zarr-server/pyproject.toml | 7 + packages/zarr-server/tests/test_serve.py | 27 ++ packages/zarr-server/uv.lock | 517 +++++++++++++++++++++++ 4 files changed, 557 insertions(+), 3 deletions(-) diff --git a/.github/workflows/zarr-server.yml b/.github/workflows/zarr-server.yml index 450dc0fcaf..d73c571e11 100644 --- a/.github/workflows/zarr-server.yml +++ b/.github/workflows/zarr-server.yml @@ -41,10 +41,13 @@ jobs: enable-cache: true - name: Set up Python ${{ matrix.python-version }} run: uv python install ${{ matrix.python-version }} - - name: Sync test dependency group - run: uv sync --group test --python ${{ matrix.python-version }} + - name: Sync test dependency groups + # The docs group carries the deps the README examples need, so the + # test that reads a served array back with a zarr client runs here + # instead of silently skipping. + run: uv sync --group test --group docs --python ${{ matrix.python-version }} - name: Run pytest - run: uv run --group test pytest tests + run: uv run --group test --group docs pytest tests ruff: name: ruff diff --git a/packages/zarr-server/pyproject.toml b/packages/zarr-server/pyproject.toml index 63c8758830..606da626f0 100644 --- a/packages/zarr-server/pyproject.toml +++ b/packages/zarr-server/pyproject.toml @@ -45,6 +45,13 @@ Documentation = "https://github.com/zarr-developers/zarr-python/blob/main/packag [dependency-groups] test = ["pytest", "httpx", "httpx2"] +docs = [ + # Optional dependencies to run the README examples. Reading a served + # array back with `zarr.open_array(url)` goes through FsspecStore, which + # needs an HTTP-capable fsspec; `examples/serve.py` uses httpx. + "fsspec[http]", + "httpx", +] # Dev-only: resolve zarr from the repo root so package tests run against # in-repo zarr. Affects uv resolution only, not published metadata. diff --git a/packages/zarr-server/tests/test_serve.py b/packages/zarr-server/tests/test_serve.py index e5ffa74bd0..9386e70aa3 100644 --- a/packages/zarr-server/tests/test_serve.py +++ b/packages/zarr-server/tests/test_serve.py @@ -830,6 +830,33 @@ def test_serve_node_background(self, store: Store) -> None: assert response.status_code == 200 +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestReadBackWithZarrClient: + """The round-trip the README leads with: serve an array, then open it + with a zarr client over HTTP. + + This needs an HTTP-capable fsspec, which is a client-side concern that + `zarr-server` deliberately does not depend on -- it lives in the `docs` + dependency group, alongside the other deps the README examples need. + """ + + def test_served_array_reads_back_identically(self, store: Store) -> None: + """`zarr.open_array(server.url)` should return the same data that was + served, so the README's headline example stays true.""" + pytest.importorskip("fsspec") + pytest.importorskip("aiohttp") + + from zarr_server import serve_node + + expected = np.arange(100, dtype="uint8").reshape(10, 10) + arr = zarr.create_array(store, data=expected, chunks=(5, 5), write_data=True) + + port = _get_free_port() + with serve_node(arr, host="127.0.0.1", port=port, background=True) as server: + remote = zarr.open_array(server.url, mode="r") + np.testing.assert_array_equal(remote[:], expected) + + class TestBackgroundServerBoundedShutdown: """BackgroundServer.shutdown() must not hang forever on a slow or stuck in-flight request.""" diff --git a/packages/zarr-server/uv.lock b/packages/zarr-server/uv.lock index 0abfd55a8a..a5b0b5c3af 100644 --- a/packages/zarr-server/uv.lock +++ b/packages/zarr-server/uv.lock @@ -2,6 +2,128 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "anyio" version = "4.14.2" @@ -15,6 +137,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -57,6 +188,109 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/d5/c5db1ea3394c6e1732fb3286b3bd878b59507a8f77d32a2cebda7d7b7cd4/donfig-0.8.1.post1-py3-none-any.whl", hash = "sha256:2a3175ce74a06109ff9307d90a230f81215cbac9a751f4d1c6194644b8204f9d", size = 21592, upload-time = "2024-05-23T14:13:55.283Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/78/f34251dadb8f3921264a1d9b8946f5e542014ee2614b285261b4e40e6775/fsspec-2026.7.0.tar.gz", hash = "sha256:c803c40f4cf860b49dea58ee3e1c33cb9c790520e233537e1340049f89b82a88", size = 317040, upload-time = "2026-07-28T16:34:51.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/3c/6a2bf344106328fd04963664a60b9bb6496fc25df8e962fcdc1367285fb9/fsspec-2026.7.0-py3-none-any.whl", hash = "sha256:b57ddbafedfaef7018c1ecab32aa200a9d7ca26b77965f64e48b70061249d279", size = 206583, upload-time = "2026-07-28T16:34:49.538Z" }, +] + +[package.optional-dependencies] +http = [ + { name = "aiohttp" }, +] + [[package]] name = "google-crc32c" version = "1.8.0" @@ -164,6 +398,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "numcodecs" version = "0.16.5" @@ -260,6 +593,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -375,6 +802,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, ] +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] + [[package]] name = "zarr" source = { editable = "../../" } @@ -501,6 +1010,10 @@ dependencies = [ ] [package.dev-dependencies] +docs = [ + { name = "fsspec", extra = ["http"] }, + { name = "httpx" }, +] test = [ { name = "httpx" }, { name = "httpx2" }, @@ -515,6 +1028,10 @@ requires-dist = [ ] [package.metadata.requires-dev] +docs = [ + { name = "fsspec", extras = ["http"] }, + { name = "httpx" }, +] test = [ { name = "httpx" }, { name = "httpx2" }, From d41e796fd90078455f9847bb7faec3cb28d18daa Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 21:22:07 +0200 Subject: [PATCH 25/29] refactor(zarr-http-server)!: rename zarr-server to zarr-http-server The package is HTTP-specific end to end -- Starlette/ASGI, byte-range headers, CORS, HTTP verbs -- so the unqualified name overclaimed its scope and squatted the generic name that a future transport (an S3-compatible or WebDAV frontend) would want. Renames the distribution, the zarr_http_server module, the package directory, the zarr_http_server-v* release tags, both workflows and their artifact and PyPI environment names. Nothing is published yet, so this costs nothing now and would be permanent after the first upload. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/check_changelogs.yml | 4 +-- ...lease.yml => zarr-http-server-release.yml} | 28 +++++++++---------- .../{zarr-server.yml => zarr-http-server.yml} | 20 ++++++------- .../CHANGELOG.md | 0 .../LICENSE.txt | 0 .../README.md | 18 ++++++------ .../changes/3732.feature.md | 2 +- .../changes/README.md | 10 +++---- .../examples/serve.py | 4 +-- .../pyproject.toml | 20 ++++++------- .../src/zarr_http_server}/__init__.py | 4 +-- .../src/zarr_http_server}/_keys.py | 0 .../src/zarr_http_server}/_serve.py | 2 +- .../src/zarr_http_server}/py.typed | 0 .../tests/conftest.py | 0 .../tests/test_serve.py | 12 ++++---- .../{zarr-server => zarr-http-server}/uv.lock | 2 +- pyproject.toml | 6 ++-- 18 files changed, 66 insertions(+), 66 deletions(-) rename .github/workflows/{zarr-server-release.yml => zarr-http-server-release.yml} (82%) rename .github/workflows/{zarr-server.yml => zarr-http-server.yml} (85%) rename packages/{zarr-server => zarr-http-server}/CHANGELOG.md (100%) rename packages/{zarr-server => zarr-http-server}/LICENSE.txt (100%) rename packages/{zarr-server => zarr-http-server}/README.md (91%) rename packages/{zarr-server => zarr-http-server}/changes/3732.feature.md (58%) rename packages/{zarr-server => zarr-http-server}/changes/README.md (71%) rename packages/{zarr-server => zarr-http-server}/examples/serve.py (88%) rename packages/{zarr-server => zarr-http-server}/pyproject.toml (87%) rename packages/{zarr-server/src/zarr_server => zarr-http-server/src/zarr_http_server}/__init__.py (83%) rename packages/{zarr-server/src/zarr_server => zarr-http-server/src/zarr_http_server}/_keys.py (100%) rename packages/{zarr-server/src/zarr_server => zarr-http-server/src/zarr_http_server}/_serve.py (99%) rename packages/{zarr-server/src/zarr_server => zarr-http-server/src/zarr_http_server}/py.typed (100%) rename packages/{zarr-server => zarr-http-server}/tests/conftest.py (100%) rename packages/{zarr-server => zarr-http-server}/tests/test_serve.py (98%) rename packages/{zarr-server => zarr-http-server}/uv.lock (99%) diff --git a/.github/workflows/check_changelogs.yml b/.github/workflows/check_changelogs.yml index dd0b73897e..b1e8fa8f32 100644 --- a/.github/workflows/check_changelogs.yml +++ b/.github/workflows/check_changelogs.yml @@ -30,5 +30,5 @@ 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-server changelog entries - run: uv run --no-sync python ci/check_changelog_entries.py packages/zarr-server/changes + - name: Check zarr-http-server changelog entries + run: uv run --no-sync python ci/check_changelog_entries.py packages/zarr-http-server/changes diff --git a/.github/workflows/zarr-server-release.yml b/.github/workflows/zarr-http-server-release.yml similarity index 82% rename from .github/workflows/zarr-server-release.yml rename to .github/workflows/zarr-http-server-release.yml index 6c215a4f2d..29489c7c0a 100644 --- a/.github/workflows/zarr-server-release.yml +++ b/.github/workflows/zarr-http-server-release.yml @@ -1,10 +1,10 @@ -name: zarr-server release +name: zarr-http-server release on: workflow_dispatch: push: tags: - - 'zarr_server-v*' + - 'zarr_http_server-v*' permissions: contents: read @@ -20,7 +20,7 @@ jobs: defaults: run: shell: bash - working-directory: packages/zarr-server + working-directory: packages/zarr-http-server steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -37,8 +37,8 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: zarr-server-dist - path: packages/zarr-server/dist + name: zarr-http-server-dist + path: packages/zarr-http-server/dist test_artifacts: name: Test built artifacts @@ -47,7 +47,7 @@ jobs: steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: zarr-server-dist + name: zarr-http-server-dist path: dist - name: Install uv @@ -62,23 +62,23 @@ jobs: run: | wheel=$(ls dist/*.whl) uv run --with "${wheel}" --python 3.12 --no-project \ - python -c "import zarr_server; print('zarr_server', zarr_server.__version__)" + python -c "import zarr_http_server; print('zarr_http_server', zarr_http_server.__version__)" upload_pypi: name: Upload to PyPI needs: [build, test_artifacts] - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/zarr_server-v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/zarr_http_server-v') runs-on: ubuntu-latest environment: - name: zarr-server-releases - url: https://pypi.org/p/zarr-server + name: zarr-http-server-releases + url: https://pypi.org/p/zarr-http-server 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-server-dist + name: zarr-http-server-dist path: dist - name: Generate artifact attestation @@ -95,15 +95,15 @@ jobs: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest environment: - name: zarr-server-releases-test - url: https://test.pypi.org/p/zarr-server + name: zarr-http-server-releases-test + url: https://test.pypi.org/p/zarr-http-server permissions: id-token: write attestations: write steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: zarr-server-dist + name: zarr-http-server-dist path: dist - name: Generate artifact attestation diff --git a/.github/workflows/zarr-server.yml b/.github/workflows/zarr-http-server.yml similarity index 85% rename from .github/workflows/zarr-server.yml rename to .github/workflows/zarr-http-server.yml index d73c571e11..384d2aee25 100644 --- a/.github/workflows/zarr-server.yml +++ b/.github/workflows/zarr-http-server.yml @@ -1,15 +1,15 @@ -name: zarr-server +name: zarr-http-server on: push: branches: [main] paths: - - 'packages/zarr-server/**' - - '.github/workflows/zarr-server.yml' + - 'packages/zarr-http-server/**' + - '.github/workflows/zarr-http-server.yml' pull_request: paths: - - 'packages/zarr-server/**' - - '.github/workflows/zarr-server.yml' + - 'packages/zarr-http-server/**' + - '.github/workflows/zarr-http-server.yml' workflow_dispatch: permissions: @@ -26,7 +26,7 @@ jobs: defaults: run: shell: bash - working-directory: packages/zarr-server + working-directory: packages/zarr-http-server strategy: fail-fast: false matrix: @@ -55,7 +55,7 @@ jobs: defaults: run: shell: bash - working-directory: packages/zarr-server + working-directory: packages/zarr-http-server steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -71,7 +71,7 @@ jobs: defaults: run: shell: bash - working-directory: packages/zarr-server + working-directory: packages/zarr-http-server steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -87,8 +87,8 @@ jobs: - name: Run mypy run: uv run --group test --with mypy mypy src - zarr-server-complete: - name: zarr-server complete + zarr-http-server-complete: + name: zarr-http-server complete needs: [test, ruff, mypy] if: always() runs-on: ubuntu-latest diff --git a/packages/zarr-server/CHANGELOG.md b/packages/zarr-http-server/CHANGELOG.md similarity index 100% rename from packages/zarr-server/CHANGELOG.md rename to packages/zarr-http-server/CHANGELOG.md diff --git a/packages/zarr-server/LICENSE.txt b/packages/zarr-http-server/LICENSE.txt similarity index 100% rename from packages/zarr-server/LICENSE.txt rename to packages/zarr-http-server/LICENSE.txt diff --git a/packages/zarr-server/README.md b/packages/zarr-http-server/README.md similarity index 91% rename from packages/zarr-server/README.md rename to packages/zarr-http-server/README.md index 6dc109da84..753bf7dee4 100644 --- a/packages/zarr-server/README.md +++ b/packages/zarr-http-server/README.md @@ -1,8 +1,8 @@ -# zarr-server +# zarr-http-server HTTP server for Zarr stores, arrays, and groups. -`zarr-server` exposes a Zarr `Store`, `Array`, or `Group` over HTTP via an +`zarr-http-server` exposes a Zarr `Store`, `Array`, or `Group` over HTTP via an ASGI app, so any HTTP-capable client (including zarr-python itself, via `FsspecStore` or `ObjectStore`) can read the data. The app is built on [Starlette](https://www.starlette.io/) and can be run with any ASGI server; @@ -12,7 +12,7 @@ ASGI app, so any HTTP-capable client (including zarr-python itself, via ## Installation ```bash -pip install zarr-server +pip install zarr-http-server ``` ### Building an ASGI App @@ -24,7 +24,7 @@ everything the store contains, with no per-key filtering: ```python import zarr -from zarr_server import store_app +from zarr_http_server import store_app store = zarr.storage.MemoryStore() zarr.create_array(store, shape=(100, 100), chunks=(10, 10), dtype="float64") @@ -41,7 +41,7 @@ receive a 404, even if those keys exist in the underlying store: ```python import zarr -from zarr_server import node_app +from zarr_http_server import node_app store = zarr.storage.MemoryStore() root = zarr.open_group(store) @@ -60,7 +60,7 @@ build an ASGI app *and* start a [Uvicorn](https://www.uvicorn.org/) server. By default they block until the server is shut down: ```python -from zarr_server import serve_store +from zarr_http_server import serve_store serve_store(store, host="127.0.0.1", port=8000) ``` @@ -71,7 +71,7 @@ can be used as a context manager for automatic shutdown: The example below also *reads back* over HTTP, which is a client-side concern: `zarr.open_array(server.url)` goes through `FsspecStore`, which needs -an HTTP-capable fsspec that `zarr-server` does not pull in. +an HTTP-capable fsspec that `zarr-http-server` does not pull in. ```bash pip install "fsspec[http]" @@ -82,7 +82,7 @@ pip install "fsspec[http]" import numpy as np import zarr -from zarr_server import serve_node +from zarr_http_server import serve_node from zarr.storage import MemoryStore store = MemoryStore() @@ -104,7 +104,7 @@ Both `store_app` and `node_app` (and their `serve_*` counterparts) accept a browser-based clients: ```python -from zarr_server import CorsOptions, store_app +from zarr_http_server import CorsOptions, store_app app = store_app( store, diff --git a/packages/zarr-server/changes/3732.feature.md b/packages/zarr-http-server/changes/3732.feature.md similarity index 58% rename from packages/zarr-server/changes/3732.feature.md rename to packages/zarr-http-server/changes/3732.feature.md index 52178f54a2..accd91ab81 100644 --- a/packages/zarr-server/changes/3732.feature.md +++ b/packages/zarr-http-server/changes/3732.feature.md @@ -1,3 +1,3 @@ -Initial release of `zarr-server`: an HTTP server exposing Zarr stores, +Initial release of `zarr-http-server`: an HTTP server exposing Zarr stores, arrays, and groups over an ASGI app, extracted from the `zarr.experimental.serve` prototype in zarr-python. diff --git a/packages/zarr-server/changes/README.md b/packages/zarr-http-server/changes/README.md similarity index 71% rename from packages/zarr-server/changes/README.md rename to packages/zarr-http-server/changes/README.md index 2a9084f761..80f0be782c 100644 --- a/packages/zarr-server/changes/README.md +++ b/packages/zarr-http-server/changes/README.md @@ -1,9 +1,9 @@ -Writing a changelog entry for `zarr-server` +Writing a changelog entry for `zarr-http-server` --------------------------------------------- -Fragments in **this** directory are released notes for the `zarr-server` +Fragments in **this** directory are released notes for the `zarr-http-server` package only — kept separate from the parent zarr-python `changes/` -directory so a PR touching only `packages/zarr-server/` produces a +directory so a PR touching only `packages/zarr-http-server/` produces a release note for this package only. Please put a new file in this directory named `xxxx..md`, where @@ -17,9 +17,9 @@ Please put a new file in this directory named `xxxx..md`, where - misc Inside the file, please write a short description of what you have -changed, and how it impacts users of `zarr-server`. +changed, and how it impacts users of `zarr-http-server`. -A `zarr-server` release runs `towncrier build` in `packages/zarr-server/`, +A `zarr-http-server` release runs `towncrier build` in `packages/zarr-http-server/`, which consumes the fragments here and updates `CHANGELOG.md`. Fragments that describe parent zarr-python changes (not the server package) belong in the top-level `changes/` directory, not here. diff --git a/packages/zarr-server/examples/serve.py b/packages/zarr-http-server/examples/serve.py similarity index 88% rename from packages/zarr-server/examples/serve.py rename to packages/zarr-http-server/examples/serve.py index 216cc1e07a..082464f646 100644 --- a/packages/zarr-server/examples/serve.py +++ b/packages/zarr-http-server/examples/serve.py @@ -1,7 +1,7 @@ # /// script # requires-python = ">=3.12" # dependencies = [ -# "zarr-server @ git+https://github.com/zarr-developers/zarr-python.git@main#subdirectory=packages/zarr-server", +# "zarr-http-server @ git+https://github.com/zarr-developers/zarr-python.git@main#subdirectory=packages/zarr-http-server", # "httpx", # ] # /// @@ -20,7 +20,7 @@ import zarr from zarr.storage import MemoryStore -from zarr_server import serve_node +from zarr_http_server import serve_node # -- create an array -------------------------------------------------------- store = MemoryStore() diff --git a/packages/zarr-server/pyproject.toml b/packages/zarr-http-server/pyproject.toml similarity index 87% rename from packages/zarr-server/pyproject.toml rename to packages/zarr-http-server/pyproject.toml index 606da626f0..33adcf74aa 100644 --- a/packages/zarr-server/pyproject.toml +++ b/packages/zarr-http-server/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling>=1.29.0", "hatch-vcs"] build-backend = "hatchling.build" [project] -name = "zarr-server" +name = "zarr-http-server" dynamic = ["version"] description = "HTTP server for Zarr stores, arrays, and groups." readme = "README.md" @@ -38,10 +38,10 @@ dependencies = [ [project.urls] Homepage = "https://github.com/zarr-developers/zarr-python" -Source = "https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-server" +Source = "https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-http-server" Issues = "https://github.com/zarr-developers/zarr-python/issues" -Changelog = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-server/CHANGELOG.md" -Documentation = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-server/README.md" +Changelog = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-http-server/CHANGELOG.md" +Documentation = "https://github.com/zarr-developers/zarr-python/blob/main/packages/zarr-http-server/README.md" [dependency-groups] test = ["pytest", "httpx", "httpx2"] @@ -60,14 +60,14 @@ zarr = { path = "../..", editable = true } [tool.hatch.version] source = "vcs" -tag-pattern = '^zarr_server-v(?P.+)$' -# `git_describe_command` ensures we get the zarr_server tags instead of latest. +tag-pattern = '^zarr_http_server-v(?P.+)$' +# `git_describe_command` ensures we get the zarr_http_server tags instead of latest. # `local_scheme` strips the git commit info so the appending info is just a counter from latest tag. # test-pypi doesn't accept git commit info in tags, and the count should be enough to distinguish unique runs. -raw-options = { root = "../..", git_describe_command = "git describe --dirty --tags --long --match zarr_server-v*", local_scheme = "no-local-version" } +raw-options = { root = "../..", git_describe_command = "git describe --dirty --tags --long --match zarr_http_server-v*", local_scheme = "no-local-version" } [tool.hatch.build.targets.wheel] -packages = ["src/zarr_server"] +packages = ["src/zarr_http_server"] [tool.ruff] extend = "../../pyproject.toml" @@ -110,10 +110,10 @@ checks = [ [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-server/` produces a release note for this package only. +# `packages/zarr-http-server/` produces a release note for this package only. directory = "changes" filename = "CHANGELOG.md" -package = "zarr_server" +package = "zarr_http_server" underlines = ["", "", ""] title_format = "## {version} ({project_date})" issue_format = "[#{issue}](https://github.com/zarr-developers/zarr-python/issues/{issue})" diff --git a/packages/zarr-server/src/zarr_server/__init__.py b/packages/zarr-http-server/src/zarr_http_server/__init__.py similarity index 83% rename from packages/zarr-server/src/zarr_server/__init__.py rename to packages/zarr-http-server/src/zarr_http_server/__init__.py index 79c8473961..aa7f4a3689 100644 --- a/packages/zarr-server/src/zarr_server/__init__.py +++ b/packages/zarr-http-server/src/zarr_http_server/__init__.py @@ -2,7 +2,7 @@ from importlib.metadata import version -from zarr_server._serve import ( +from zarr_http_server._serve import ( BackgroundServer, CorsOptions, HTTPMethod, @@ -12,7 +12,7 @@ store_app, ) -__version__ = version("zarr-server") +__version__ = version("zarr-http-server") __all__ = [ "BackgroundServer", diff --git a/packages/zarr-server/src/zarr_server/_keys.py b/packages/zarr-http-server/src/zarr_http_server/_keys.py similarity index 100% rename from packages/zarr-server/src/zarr_server/_keys.py rename to packages/zarr-http-server/src/zarr_http_server/_keys.py diff --git a/packages/zarr-server/src/zarr_server/_serve.py b/packages/zarr-http-server/src/zarr_http_server/_serve.py similarity index 99% rename from packages/zarr-server/src/zarr_server/_serve.py rename to packages/zarr-http-server/src/zarr_http_server/_serve.py index 5bd980c9c5..a241223811 100644 --- a/packages/zarr-server/src/zarr_server/_serve.py +++ b/packages/zarr-http-server/src/zarr_http_server/_serve.py @@ -8,7 +8,7 @@ from zarr.abc.store import OffsetByteRequest, RangeByteRequest, SuffixByteRequest from zarr.buffer import cpu -from zarr_server._keys import is_valid_node_key +from zarr_http_server._keys import is_valid_node_key if TYPE_CHECKING: import uvicorn diff --git a/packages/zarr-server/src/zarr_server/py.typed b/packages/zarr-http-server/src/zarr_http_server/py.typed similarity index 100% rename from packages/zarr-server/src/zarr_server/py.typed rename to packages/zarr-http-server/src/zarr_http_server/py.typed diff --git a/packages/zarr-server/tests/conftest.py b/packages/zarr-http-server/tests/conftest.py similarity index 100% rename from packages/zarr-server/tests/conftest.py rename to packages/zarr-http-server/tests/conftest.py diff --git a/packages/zarr-server/tests/test_serve.py b/packages/zarr-http-server/tests/test_serve.py similarity index 98% rename from packages/zarr-server/tests/test_serve.py rename to packages/zarr-http-server/tests/test_serve.py index 9386e70aa3..88454c3b83 100644 --- a/packages/zarr-server/tests/test_serve.py +++ b/packages/zarr-http-server/tests/test_serve.py @@ -9,7 +9,7 @@ from starlette.testclient import TestClient from zarr.buffer import cpu -from zarr_server._serve import CorsOptions, _parse_range_header, node_app, store_app +from zarr_http_server._serve import CorsOptions, _parse_range_header, node_app, store_app if TYPE_CHECKING: from collections.abc import Coroutine @@ -799,7 +799,7 @@ def test_serve_store_background(self, store: Store) -> None: that responds to HTTP requests and can be used as a context manager.""" import httpx - from zarr_server import serve_store + from zarr_http_server import serve_store buf = cpu.buffer_prototype.buffer.from_bytes(b"hello") sync(store.set("key", buf)) @@ -819,7 +819,7 @@ def test_serve_node_background(self, store: Store) -> None: that responds to HTTP requests and can be used as a context manager.""" import httpx - from zarr_server import serve_node + from zarr_http_server import serve_node arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8") arr[:] = np.arange(4, dtype="f8") @@ -836,7 +836,7 @@ class TestReadBackWithZarrClient: with a zarr client over HTTP. This needs an HTTP-capable fsspec, which is a client-side concern that - `zarr-server` deliberately does not depend on -- it lives in the `docs` + `zarr-http-server` deliberately does not depend on -- it lives in the `docs` dependency group, alongside the other deps the README examples need. """ @@ -846,7 +846,7 @@ def test_served_array_reads_back_identically(self, store: Store) -> None: pytest.importorskip("fsspec") pytest.importorskip("aiohttp") - from zarr_server import serve_node + from zarr_http_server import serve_node expected = np.arange(100, dtype="uint8").reshape(10, 10) arr = zarr.create_array(store, data=expected, chunks=(5, 5), write_data=True) @@ -874,7 +874,7 @@ def test_shutdown_returns_promptly_with_slow_inflight_request(self) -> None: from starlette.responses import Response from starlette.routing import Route - from zarr_server._serve import _start_server + from zarr_http_server._serve import _start_server async def slow(request: Any) -> Response: # Sleeps far longer than shutdown_timeout below, so a correct diff --git a/packages/zarr-server/uv.lock b/packages/zarr-http-server/uv.lock similarity index 99% rename from packages/zarr-server/uv.lock rename to packages/zarr-http-server/uv.lock index a5b0b5c3af..18b3038b81 100644 --- a/packages/zarr-server/uv.lock +++ b/packages/zarr-http-server/uv.lock @@ -1001,7 +1001,7 @@ test = [ ] [[package]] -name = "zarr-server" +name = "zarr-http-server" source = { editable = "." } dependencies = [ { name = "starlette" }, diff --git a/pyproject.toml b/pyproject.toml index 727071b5a3..f55d2ef296 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -160,9 +160,9 @@ omit = [ version.source = "vcs" # Only consider zarr-python's own `v*` tags when deriving the version. Without # this filter `git describe` matches the most recent tag of any shape, -# including the `zarr_metadata-v*` tags used to release the zarr-metadata -# subpackage — which would make a from-source build report a `0.2.x` version -# instead of `3.x`. +# including the `zarr_metadata-v*` and `zarr_http_server-v*` tags used to +# release the subpackages under `packages/` — which would make a from-source +# build report e.g. a `0.2.x` version instead of `3.x`. version.raw-options = { git_describe_command = "git describe --dirty --tags --long --match v*" } [tool.hatch.build] From f0370433284dca541b46ebe2a901fafe25d523ee Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 21:22:52 +0200 Subject: [PATCH 26/29] fix(zarr-http-server): reject an empty methods set instead of failing open Starlette's Route treats a falsy `methods` as "match every method", and an empty set passes the unsupported-verb check trivially, so store_app(store, methods=set()) served GET, DELETE, POST, PATCH and TRACE alike and accepted PUT writes -- the opposite of what the caller asked for, and the same fail-open the method narrowing set out to close. Assisted-by: ClaudeCode:claude-fable-5 --- .../zarr-http-server/src/zarr_http_server/_serve.py | 8 ++++++++ packages/zarr-http-server/tests/test_serve.py | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/zarr-http-server/src/zarr_http_server/_serve.py b/packages/zarr-http-server/src/zarr_http_server/_serve.py index a241223811..27fb53f47e 100644 --- a/packages/zarr-http-server/src/zarr_http_server/_serve.py +++ b/packages/zarr-http-server/src/zarr_http_server/_serve.py @@ -226,6 +226,14 @@ def _make_starlette_app( if methods is None: methods = {"GET"} + # An empty set must not reach Starlette: `Route` treats a falsy `methods` + # as "match every method", so asking for no methods would serve them all. + if not methods: + raise ValueError( + "methods must name at least one HTTP method; " + f"accepted methods are {', '.join(sorted(_SUPPORTED_METHODS))}." + ) + unsupported = sorted(set(methods) - _SUPPORTED_METHODS) if unsupported: raise ValueError( diff --git a/packages/zarr-http-server/tests/test_serve.py b/packages/zarr-http-server/tests/test_serve.py index 88454c3b83..1aab3eb64e 100644 --- a/packages/zarr-http-server/tests/test_serve.py +++ b/packages/zarr-http-server/tests/test_serve.py @@ -353,6 +353,17 @@ def test_supported_methods_are_served(self, store: Store) -> None: assert head.status_code == 200 assert head.content == b"" + def test_empty_method_set_raises(self, store: Store) -> None: + """Asking for no methods must be rejected rather than producing a + server that answers every verb, including writes: Starlette treats a + falsy `methods` on a Route as "match anything".""" + for build in ( + lambda: store_app(store, methods=set()), + lambda: node_app(zarr.open_group(store, mode="a"), methods=set()), + ): + with pytest.raises(ValueError, match="at least one"): + build() + @pytest.mark.parametrize("method", ["DELETE", "POST", "PATCH", "OPTIONS", "TRACE"]) def test_unsupported_method_raises(self, store: Store, method: str) -> None: """A verb the handler cannot implement is rejected when the app is From 6031b7ce7d2da8920c14aed001840d44e61b1365 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 21:26:28 +0200 Subject: [PATCH 27/29] fix(zarr-http-server): report the bound port, unblock the loop, 404 unopenable children Three defects the adversarial re-review reproduced: BackgroundServer reported the requested port, so port=0 produced http://host:0 while the socket was bound elsewhere -- clients following the documented url reached an unrelated service. Group key validation opened children through zarr's synchronous API directly on the event loop, serializing every concurrent request behind it (20 concurrent requests: 10.5s, now 1.1s) and outlasting the shutdown timeout. Move it to a worker thread. Child lookup caught only KeyError, so a corrupt metadata document or a codec from an uninstalled plugin surfaced as 500 and made a whole subtree unservable during ordinary operation. Assisted-by: ClaudeCode:claude-fable-5 --- .../src/zarr_http_server/_keys.py | 8 +++- .../src/zarr_http_server/_serve.py | 22 +++++++++-- packages/zarr-http-server/tests/test_serve.py | 38 +++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/zarr-http-server/src/zarr_http_server/_keys.py b/packages/zarr-http-server/src/zarr_http_server/_keys.py index cb20246639..55e98d2bbf 100644 --- a/packages/zarr-http-server/src/zarr_http_server/_keys.py +++ b/packages/zarr-http-server/src/zarr_http_server/_keys.py @@ -179,7 +179,13 @@ def is_valid_node_key(node: Array[Any] | Group, key: str) -> bool: try: child = node[child_name] - except KeyError: + except Exception: + # A child that cannot be opened cannot vouch for the key. Beyond a + # missing name (KeyError), this covers metadata this process cannot + # parse -- unreadable JSON, or a codec supplied by a plugin the + # server does not have installed. Validating a key's *shape* never + # needs to decode data, so an unopenable child makes the key + # unverifiable, not the request an error. return False return is_valid_node_key(child, remainder) diff --git a/packages/zarr-http-server/src/zarr_http_server/_serve.py b/packages/zarr-http-server/src/zarr_http_server/_serve.py index 27fb53f47e..77c80f8437 100644 --- a/packages/zarr-http-server/src/zarr_http_server/_serve.py +++ b/packages/zarr-http-server/src/zarr_http_server/_serve.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import ntpath import threading import time @@ -184,8 +185,12 @@ async def _handle_request(request: Request) -> Response: if any(segment in ("", ".", "..") for segment in segments) or ntpath.splitdrive(path)[0]: return Response(status_code=404) - # If serving a node, validate the key before touching the store. - if node is not None and not is_valid_node_key(node, path): + # If serving a node, validate the key before touching the store. Group + # validation opens children through zarr's synchronous API, which drives + # the store to completion and would otherwise block the event loop for + # the duration -- serializing every concurrent request behind it, and + # outlasting the shutdown timeout. + if node is not None and not await asyncio.to_thread(is_valid_node_key, node, path): return Response(status_code=404) # Resolve the full store key by prepending the node's prefix. @@ -289,7 +294,18 @@ def _start_server( raise RuntimeError("Server failed to start within 5 seconds") time.sleep(0.01) - return BackgroundServer(server, thread, host=host, port=port, shutdown_timeout=shutdown_timeout) + # Report the port the socket actually bound rather than the one asked for, + # so `port=0` ("pick a free port") yields a usable `url`. + bound_port = port + for bound in server.servers: + for sock in bound.sockets: + bound_port = int(sock.getsockname()[1]) + break + break + + return BackgroundServer( + server, thread, host=host, port=bound_port, shutdown_timeout=shutdown_timeout + ) def store_app( diff --git a/packages/zarr-http-server/tests/test_serve.py b/packages/zarr-http-server/tests/test_serve.py index 1aab3eb64e..6cc8664fd8 100644 --- a/packages/zarr-http-server/tests/test_serve.py +++ b/packages/zarr-http-server/tests/test_serve.py @@ -841,6 +841,44 @@ def test_serve_node_background(self, store: Store) -> None: assert response.status_code == 200 +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestBackgroundServerReportsBoundPort: + """`port=0` asks the OS for a free port, so the server must report the + port it actually bound rather than the zero it was asked for.""" + + def test_port_zero_reports_the_bound_port(self, store: Store) -> None: + import httpx + + from zarr_http_server import serve_store + + sync(store.set("key", cpu.buffer_prototype.buffer.from_bytes(b"hello"))) + + with serve_store(store, host="127.0.0.1", port=0, background=True) as server: + assert server.port != 0 + assert server.url == f"http://127.0.0.1:{server.port}" + # The reported URL is the one that actually serves the data. + assert httpx.get(f"{server.url}/key").content == b"hello" + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestUnopenableChildIsNotAnError: + """A child a node_app cannot open makes a key unverifiable, not the + request an error -- validating key *shape* never needs to decode data.""" + + def test_child_with_unparseable_metadata_returns_404(self, store: Store) -> None: + """A corrupt child metadata document must yield 404, not a 500 that + makes the whole subtree unservable with a traceback per request.""" + root = zarr.open_group(store, mode="w") + root.create_array("good", shape=(2,), chunks=(2,), dtype="f8") + sync(store.set("junk/zarr.json", cpu.buffer_prototype.buffer.from_bytes(b"not json"))) + + client = TestClient(node_app(root)) + + assert client.get("/good/zarr.json").status_code == 200 + assert client.get("/junk/zarr.json").status_code == 404 + assert client.get("/junk/c/0").status_code == 404 + + @pytest.mark.parametrize("store", ["memory"], indirect=True) class TestReadBackWithZarrClient: """The round-trip the README leads with: serve an array, then open it From 310735007889968893c4142e9a043b154f8fd96b Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Tue, 28 Jul 2026 21:37:55 +0200 Subject: [PATCH 28/29] fix(zarr-http-server): close the S5-S13 follow-ups from the adversarial review Hostile keys the store cannot express (embedded NUL, over-long names) now answer 404 instead of surfacing ValueError/OSError as a 500. PUT bodies are capped at DEFAULT_MAX_BODY_SIZE and answer 413 past it; Store.set takes a whole Buffer, so a body cannot be streamed and one request would otherwise size the server's memory. 206 responses carry Content-Range, and a range beyond the end or an inverted one answers 416 rather than an empty 206. A 0-d v2 array's sole chunk, stored under "0", is now servable: the key decodes to a 1-tuple no 0-d grid could match, so it needs the array's dimensionality to disambiguate. Array and group metadata key sets are split, so a v2 group no longer claims .zarray as its own; bind failures report the likely cause instead of a bare timeout; and serve_* gain a background: bool overload. The two out-of-bounds chunk tests planted no data, so they passed for the wrong reason -- mutating the bounds check to return True left the suite green. They now plant data at the out-of-grid key, and that mutation fails 3 tests. Adds the missing coverage for this branch's one core change, and widens the workflow's path filter to src/zarr, since the package resolves zarr from the repo root and a core change can break it. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/zarr-http-server.yml | 6 + .../src/zarr_http_server/_keys.py | 42 +++-- .../src/zarr_http_server/_serve.py | 146 +++++++++++++++++- packages/zarr-http-server/tests/test_serve.py | 119 +++++++++++++- tests/test_chunk_key_encodings.py | 65 ++++++++ 5 files changed, 361 insertions(+), 17 deletions(-) create mode 100644 tests/test_chunk_key_encodings.py diff --git a/.github/workflows/zarr-http-server.yml b/.github/workflows/zarr-http-server.yml index 384d2aee25..b0f3cc726e 100644 --- a/.github/workflows/zarr-http-server.yml +++ b/.github/workflows/zarr-http-server.yml @@ -6,10 +6,16 @@ on: paths: - 'packages/zarr-http-server/**' - '.github/workflows/zarr-http-server.yml' + # The package resolves zarr from the repo root for its own tests, so a + # core change can break it. Run this suite when core changes too. + - 'src/zarr/**' pull_request: paths: - 'packages/zarr-http-server/**' - '.github/workflows/zarr-http-server.yml' + # The package resolves zarr from the repo root for its own tests, so a + # core change can break it. Run this suite when core changes too. + - 'src/zarr/**' workflow_dispatch: permissions: diff --git a/packages/zarr-http-server/src/zarr_http_server/_keys.py b/packages/zarr-http-server/src/zarr_http_server/_keys.py index 55e98d2bbf..6a6559abbd 100644 --- a/packages/zarr-http-server/src/zarr_http_server/_keys.py +++ b/packages/zarr-http-server/src/zarr_http_server/_keys.py @@ -26,12 +26,14 @@ if TYPE_CHECKING: from zarr import Array, Group -_METADATA_KEYS_V3 = frozenset({ZARR_JSON}) -_METADATA_KEYS_V2 = frozenset({ZARRAY_JSON, ZATTRS_JSON, ZGROUP_JSON, ZMETADATA_V2_JSON}) +_ARRAY_METADATA_KEYS_V3 = frozenset({ZARR_JSON}) +_ARRAY_METADATA_KEYS_V2 = frozenset({ZARRAY_JSON, ZATTRS_JSON}) +_GROUP_METADATA_KEYS_V3 = frozenset({ZARR_JSON}) +_GROUP_METADATA_KEYS_V2 = frozenset({ZGROUP_JSON, ZATTRS_JSON, ZMETADATA_V2_JSON}) -def metadata_keys(zarr_format: int) -> frozenset[str]: - """Return the set of metadata key basenames for a given zarr format version. +def array_metadata_keys(zarr_format: int) -> frozenset[str]: + """Return the metadata key basenames an array owns, for a zarr format. Parameters ---------- @@ -43,8 +45,25 @@ def metadata_keys(zarr_format: int) -> frozenset[str]: frozenset of str """ if zarr_format == 3: - return _METADATA_KEYS_V3 - return _METADATA_KEYS_V2 + return _ARRAY_METADATA_KEYS_V3 + return _ARRAY_METADATA_KEYS_V2 + + +def group_metadata_keys(zarr_format: int) -> frozenset[str]: + """Return the metadata key basenames a group owns, for a zarr format. + + Parameters + ---------- + zarr_format : int + The zarr format version (2 or 3). + + Returns + ------- + frozenset of str + """ + if zarr_format == 3: + return _GROUP_METADATA_KEYS_V3 + return _GROUP_METADATA_KEYS_V2 def decode_chunk_key(array: Array[Any], key: str) -> tuple[int, ...] | None: @@ -65,7 +84,12 @@ def decode_chunk_key(array: Array[Any], key: str) -> tuple[int, ...] | None: try: if array.metadata.zarr_format == 2: parts = key.split(array.metadata.dimension_separator) - return tuple(int(p) for p in parts) + coords = tuple(int(p) for p in parts) + # A 0-d v2 array holds its single chunk under "0", which decodes + # to a 1-tuple that no 0-d grid could match. + if len(array.shape) == 0: + return () if coords == (0,) else None + return coords encoding = array.metadata.chunk_key_encoding separator = getattr(encoding, "separator", "/") @@ -135,7 +159,7 @@ def is_valid_array_key(array: Array[Any], key: str) -> bool: ------- bool """ - if key in metadata_keys(array.metadata.zarr_format): + if key in array_metadata_keys(array.metadata.zarr_format): return True return is_valid_chunk_key(array, key) @@ -166,7 +190,7 @@ def is_valid_node_key(node: Array[Any] | Group, key: str) -> bool: return is_valid_array_key(node, key) # Group - if key in metadata_keys(node.metadata.zarr_format): + if key in group_metadata_keys(node.metadata.zarr_format): return True # Try to match the first path component against a child member. diff --git a/packages/zarr-http-server/src/zarr_http_server/_serve.py b/packages/zarr-http-server/src/zarr_http_server/_serve.py index 77c80f8437..1bf9b2db76 100644 --- a/packages/zarr-http-server/src/zarr_http_server/_serve.py +++ b/packages/zarr-http-server/src/zarr_http_server/_serve.py @@ -45,6 +45,14 @@ class CorsOptions(TypedDict): _SUPPORTED_METHODS: frozenset[str] = frozenset({"GET", "PUT", "HEAD"}) +DEFAULT_MAX_BODY_SIZE = 256 * 1024 * 1024 +"""Default cap on a `PUT` body, in bytes. + +`Store.set` takes a whole `Buffer`, so a body cannot be streamed and is held +in memory in full. Without a cap, one request sizes the server's memory use. +Pass `max_body_size=None` to lift it. +""" + class BackgroundServer: """A running background HTTP server that can be used as a context manager. @@ -143,11 +151,41 @@ def _parse_range_header(range_header: str) -> ByteRequest | None: return OffsetByteRequest(offset=start) # range request: bytes=N-M (HTTP end is inclusive, ByteRequest end is exclusive) end = int(end_str) + 1 + if start >= end: + # An inverted range like "bytes=5-2" is unsatisfiable, not a read + # of negative length. + return None return RangeByteRequest(start=start, end=end) except ValueError: return None +def _content_range(byte_range: ByteRequest, length: int) -> str | None: + """Build a `Content-Range` value for a 206 response, if the offsets are known. + + Parameters + ---------- + byte_range : ByteRequest + The range that was served. + length : int + The number of bytes actually returned. + + Returns + ------- + str or None + A `bytes -/*` value, or `None` for a suffix request, + whose absolute offset cannot be known without the object's size. + """ + if isinstance(byte_range, RangeByteRequest): + start = byte_range.start + elif isinstance(byte_range, OffsetByteRequest): + start = byte_range.offset + else: + return None + # The total length is unknown here; RFC 9110 permits "*" in its place. + return f"bytes {start}-{start + length - 1}/*" + + async def _get_response(store: Store, path: str, byte_range: ByteRequest | None = None) -> Response: """Fetch a key from the store and return an HTTP response.""" from starlette.responses import Response @@ -155,12 +193,27 @@ async def _get_response(store: Store, path: str, byte_range: ByteRequest | None proto = cpu.buffer_prototype content_type = "application/json" if path.endswith("zarr.json") else "application/octet-stream" - buf = await store.get(path, proto, byte_range=byte_range) + try: + buf = await store.get(path, proto, byte_range=byte_range) + except (ValueError, OSError): + # The key passed the shape guard but the store cannot express it -- + # an embedded NUL, or a name longer than the filesystem allows. It + # names nothing that exists, so it is a miss, not a server fault. + return Response(status_code=404) if buf is None: return Response(status_code=404) - status_code = 206 if byte_range is not None else 200 - return Response(content=buf.to_bytes(), status_code=status_code, media_type=content_type) + if byte_range is None: + return Response(content=buf.to_bytes(), status_code=200, media_type=content_type) + + body = buf.to_bytes() + if len(body) == 0: + # The range lies wholly beyond the end of the object. + return Response(status_code=416) + + content_range = _content_range(byte_range, len(body)) + headers = {"Content-Range": content_range} if content_range is not None else None + return Response(content=body, status_code=206, media_type=content_type, headers=headers) async def _handle_request(request: Request) -> Response: @@ -197,9 +250,23 @@ async def _handle_request(request: Request) -> Response: store_key = f"{prefix}/{path}" if prefix else path if request.method == "PUT": + max_body_size: int | None = request.app.state.max_body_size + if max_body_size is not None: + declared = request.headers.get("content-length") + if declared is not None and declared.isdigit() and int(declared) > max_body_size: + return Response(status_code=413) + body = await request.body() + if max_body_size is not None and len(body) > max_body_size: + return Response(status_code=413) + buf = cpu.buffer_prototype.buffer.from_bytes(body) - await store.set(store_key, buf) + try: + await store.set(store_key, buf) + except (ValueError, OSError): + # As on the read path: a key the store cannot express names + # nothing writable, rather than indicating a server fault. + return Response(status_code=404) return Response(status_code=204) range_header = request.headers.get("range") @@ -216,6 +283,7 @@ def _make_starlette_app( *, methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, + max_body_size: int | None = DEFAULT_MAX_BODY_SIZE, ) -> Starlette: """Create a Starlette app with the request handler. @@ -290,6 +358,14 @@ def _start_server( deadline = time.monotonic() + 5.0 while not server.started: + if not thread.is_alive(): + # uvicorn logs the underlying error and calls sys.exit, which in a + # thread ends it without surfacing anything to the caller. The + # overwhelmingly common cause is a port already in use. + raise RuntimeError( + f"Server thread exited before startup completed; {host}:{port} " + "may already be in use. See the server log for the cause." + ) if time.monotonic() > deadline: raise RuntimeError("Server failed to start within 5 seconds") time.sleep(0.01) @@ -313,6 +389,7 @@ def store_app( *, methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, + max_body_size: int | None = DEFAULT_MAX_BODY_SIZE, ) -> Starlette: """Create a Starlette ASGI app that serves every key in a zarr ``Store``. @@ -325,6 +402,11 @@ def store_app( Defaults to `{"GET"}`. Passing any other method raises `ValueError`. cors_options : CorsOptions, optional If provided, CORS middleware will be added with the given options. + max_body_size : int or None, optional + Largest `PUT` body to accept, in bytes; larger requests get a 413. + `Store.set` takes a whole `Buffer`, so bodies cannot be streamed and + are held in memory in full. Defaults to `DEFAULT_MAX_BODY_SIZE`; + pass `None` to lift the cap. Returns ------- @@ -335,6 +417,7 @@ def store_app( app.state.store = store app.state.node = None app.state.prefix = "" + app.state.max_body_size = max_body_size return app @@ -343,6 +426,7 @@ def node_app( *, methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, + max_body_size: int | None = DEFAULT_MAX_BODY_SIZE, ) -> Starlette: """Create a Starlette ASGI app that serves only the keys belonging to a zarr ``Array`` or ``Group``. @@ -366,6 +450,11 @@ def node_app( Defaults to `{"GET"}`. Passing any other method raises `ValueError`. cors_options : CorsOptions, optional If provided, CORS middleware will be added with the given options. + max_body_size : int or None, optional + Largest `PUT` body to accept, in bytes; larger requests get a 413. + `Store.set` takes a whole `Buffer`, so bodies cannot be streamed and + are held in memory in full. Defaults to `DEFAULT_MAX_BODY_SIZE`; + pass `None` to lift the cap. Returns ------- @@ -376,6 +465,7 @@ def node_app( app.state.store = node.store_path.store app.state.node = node app.state.prefix = node.store_path.path + app.state.max_body_size = max_body_size return app @@ -387,6 +477,7 @@ def serve_store( port: int = ..., methods: set[HTTPMethod] | None = ..., cors_options: CorsOptions | None = ..., + max_body_size: int | None = ..., background: Literal[False] = ..., shutdown_timeout: int = ..., ) -> None: ... @@ -400,11 +491,26 @@ def serve_store( port: int = ..., methods: set[HTTPMethod] | None = ..., cors_options: CorsOptions | None = ..., + max_body_size: int | None = ..., background: Literal[True], shutdown_timeout: int = ..., ) -> BackgroundServer: ... +@overload +def serve_store( + store: Store, + *, + host: str = ..., + port: int = ..., + methods: set[HTTPMethod] | None = ..., + cors_options: CorsOptions | None = ..., + max_body_size: int | None = ..., + background: bool, + shutdown_timeout: int = ..., +) -> BackgroundServer | None: ... + + def serve_store( store: Store, *, @@ -412,6 +518,7 @@ def serve_store( port: int = 8000, methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, + max_body_size: int | None = DEFAULT_MAX_BODY_SIZE, background: bool = False, shutdown_timeout: int = 5, ) -> BackgroundServer | None: @@ -433,6 +540,11 @@ def serve_store( Defaults to `{"GET"}`. Passing any other method raises `ValueError`. cors_options : CorsOptions, optional If provided, CORS middleware will be added with the given options. + max_body_size : int or None, optional + Largest `PUT` body to accept, in bytes; larger requests get a 413. + `Store.set` takes a whole `Buffer`, so bodies cannot be streamed and + are held in memory in full. Defaults to `DEFAULT_MAX_BODY_SIZE`; + pass `None` to lift the cap. background : bool, optional If ``False`` (the default), the server blocks until shut down. If ``True``, the server runs in a daemon thread and this function @@ -451,7 +563,7 @@ def serve_store( ``BackgroundServer`` can be used as a context manager for automatic shutdown. """ - app = store_app(store, methods=methods, cors_options=cors_options) + app = store_app(store, methods=methods, cors_options=cors_options, max_body_size=max_body_size) return _start_server( app, host=host, port=port, background=background, shutdown_timeout=shutdown_timeout ) @@ -465,6 +577,7 @@ def serve_node( port: int = ..., methods: set[HTTPMethod] | None = ..., cors_options: CorsOptions | None = ..., + max_body_size: int | None = ..., background: Literal[False] = ..., shutdown_timeout: int = ..., ) -> None: ... @@ -478,11 +591,26 @@ def serve_node( port: int = ..., methods: set[HTTPMethod] | None = ..., cors_options: CorsOptions | None = ..., + max_body_size: int | None = ..., background: Literal[True], shutdown_timeout: int = ..., ) -> BackgroundServer: ... +@overload +def serve_node( + node: Array[Any] | Group, + *, + host: str = ..., + port: int = ..., + methods: set[HTTPMethod] | None = ..., + cors_options: CorsOptions | None = ..., + max_body_size: int | None = ..., + background: bool, + shutdown_timeout: int = ..., +) -> BackgroundServer | None: ... + + def serve_node( node: Array[Any] | Group, *, @@ -490,6 +618,7 @@ def serve_node( port: int = 8000, methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, + max_body_size: int | None = DEFAULT_MAX_BODY_SIZE, background: bool = False, shutdown_timeout: int = 5, ) -> BackgroundServer | None: @@ -521,6 +650,11 @@ def serve_node( Defaults to `{"GET"}`. Passing any other method raises `ValueError`. cors_options : CorsOptions, optional If provided, CORS middleware will be added with the given options. + max_body_size : int or None, optional + Largest `PUT` body to accept, in bytes; larger requests get a 413. + `Store.set` takes a whole `Buffer`, so bodies cannot be streamed and + are held in memory in full. Defaults to `DEFAULT_MAX_BODY_SIZE`; + pass `None` to lift the cap. background : bool, optional If ``False`` (the default), the server blocks until shut down. If ``True``, the server runs in a daemon thread and this function @@ -539,7 +673,7 @@ def serve_node( ``BackgroundServer`` can be used as a context manager for automatic shutdown. """ - app = node_app(node, methods=methods, cors_options=cors_options) + app = node_app(node, methods=methods, cors_options=cors_options, max_body_size=max_body_size) return _start_server( app, host=host, port=port, background=background, shutdown_timeout=shutdown_timeout ) diff --git a/packages/zarr-http-server/tests/test_serve.py b/packages/zarr-http-server/tests/test_serve.py index 6cc8664fd8..2646eb4a45 100644 --- a/packages/zarr-http-server/tests/test_serve.py +++ b/packages/zarr-http-server/tests/test_serve.py @@ -8,10 +8,12 @@ import zarr from starlette.testclient import TestClient from zarr.buffer import cpu +from zarr.storage import LocalStore from zarr_http_server._serve import CorsOptions, _parse_range_header, node_app, store_app if TYPE_CHECKING: + import pathlib from collections.abc import Coroutine from zarr.abc.store import Store @@ -98,13 +100,21 @@ def test_valid_chunk_is_accessible(self, group_with_arrays: zarr.Group) -> None: response = client.get("/regular/c/0/0") assert response.status_code == 200 - def test_out_of_bounds_chunk_key_returns_404(self, group_with_arrays: zarr.Group) -> None: + def test_out_of_bounds_chunk_key_returns_404( + self, store: Store, group_with_arrays: zarr.Group + ) -> None: """A chunk key that is syntactically valid but references indices beyond the array's chunk grid should return 404.""" arr = group_with_arrays["regular"] assert isinstance(arr, zarr.Array) arr[:] = np.ones((4, 4)) + # Put real data at the out-of-grid key, so that a 404 can only come + # from the bounds check -- not from the key merely being absent. + planted = cpu.buffer_prototype.buffer.from_bytes(b"out of grid") + sync(store.set("regular/c/99/99", planted)) + assert sync(store.get("regular/c/99/99", cpu.buffer_prototype)) is not None + app = node_app(group_with_arrays) client = TestClient(app) @@ -586,10 +596,16 @@ def test_out_of_bounds_chunk_returns_404(self, store: Store, zarr_format: ZarrFo arr = zarr.create_array(store, shape=(4,), chunks=(2,), dtype="f8", zarr_format=zarr_format) arr[:] = np.ones(4) + # Plant data at the out-of-grid key so the 404 must come from the + # bounds check rather than from the key being absent. + key = _chunk_key(zarr_format, "99") + sync(store.set(key, cpu.buffer_prototype.buffer.from_bytes(b"out of grid"))) + assert sync(store.get(key, cpu.buffer_prototype)) is not None + app = node_app(arr) client = TestClient(app) - response = client.get(f"/{_chunk_key(zarr_format, '99')}") + response = client.get(f"/{key}") assert response.status_code == 404 def test_non_zarr_key_returns_404(self, store: Store, zarr_format: ZarrFormat) -> None: @@ -841,6 +857,105 @@ def test_serve_node_background(self, store: Store) -> None: assert response.status_code == 200 +class TestHostileKeysAreNotServerErrors: + """A key the store cannot express is a miss, not a server fault: the + server must never answer 5xx for input a client can choose freely. + + This needs a filesystem-backed store -- a `MemoryStore` accepts any key + as a dict key, so only `LocalStore` surfaces the underlying errors (an + embedded NUL, a name longer than the filesystem allows). + """ + + @pytest.mark.parametrize( + "path", ["/x%00y", "/ok.txt%00", "/" + "a" * 3000, "/" + "b" * 3000 + "/zarr.json"] + ) + def test_unexpressable_key_returns_404_not_500(self, tmp_path: pathlib.Path, path: str) -> None: + store = LocalStore(str(tmp_path / "root")) + client = TestClient(store_app(store, methods={"GET", "PUT"})) + + assert client.get(path).status_code == 404 + assert client.put(path, content=b"x").status_code == 404 + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestPutBodyLimit: + """`Store.set` takes a whole buffer, so an unbounded body would let one + request size the server's memory use.""" + + def test_body_over_the_limit_is_rejected(self, store: Store) -> None: + client = TestClient(store_app(store, methods={"GET", "PUT"}, max_body_size=64)) + + assert client.put("/key", content=b"x" * 65).status_code == 413 + # Nothing was written. + assert sync(store.get("key", cpu.buffer_prototype)) is None + # A body within the limit still succeeds. + assert client.put("/key", content=b"x" * 64).status_code == 204 + + def test_limit_can_be_lifted(self, store: Store) -> None: + client = TestClient(store_app(store, methods={"GET", "PUT"}, max_body_size=None)) + assert client.put("/key", content=b"x" * 5000).status_code == 204 + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestRangeResponseCorrectness: + """A 206 must describe which bytes it carries, and a range that cannot be + satisfied must say so rather than returning an empty 206.""" + + def test_206_carries_content_range(self, store: Store) -> None: + sync(store.set("key", cpu.buffer_prototype.buffer.from_bytes(b"0123456789"))) + client = TestClient(store_app(store)) + + response = client.get("/key", headers={"Range": "bytes=2-5"}) + assert response.status_code == 206 + assert response.content == b"2345" + assert response.headers["Content-Range"] == "bytes 2-5/*" + + def test_range_beyond_end_is_416(self, store: Store) -> None: + sync(store.set("key", cpu.buffer_prototype.buffer.from_bytes(b"0123456789"))) + client = TestClient(store_app(store)) + + assert client.get("/key", headers={"Range": "bytes=1000-2000"}).status_code == 416 + + def test_inverted_range_is_416(self, store: Store) -> None: + sync(store.set("key", cpu.buffer_prototype.buffer.from_bytes(b"0123456789"))) + client = TestClient(store_app(store)) + + assert client.get("/key", headers={"Range": "bytes=5-2"}).status_code == 416 + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestZeroDimensionalArray: + """A 0-d array has exactly one chunk, spelled `0` in v2 and `c` in v3.""" + + @pytest.mark.parametrize("zarr_format", [2, 3]) + def test_sole_chunk_is_served(self, store: Store, zarr_format: ZarrFormat) -> None: + arr = zarr.create_array(store, shape=(), dtype="i4", zarr_format=zarr_format) + arr[...] = 7 + + client = TestClient(node_app(arr)) + chunk_key = "0" if zarr_format == 2 else "c" + + assert client.get(f"/{chunk_key}").status_code == 200 + # A 1-d coordinate is not valid for a 0-d grid. + assert client.get("/0/0").status_code == 404 + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestGroupAndArrayMetadataKeysAreDistinct: + """A v2 group owns `.zgroup`; `.zarray` belongs to arrays, and vice versa.""" + + def test_node_does_not_claim_the_other_kind_of_metadata(self, store: Store) -> None: + root = zarr.open_group(store, mode="w", zarr_format=2) + arr = root.create_array("a", shape=(2,), chunks=(2,), dtype="f8") + + # Plant both documents so a 404 reflects the key set, not absence. + sync(store.set(".zarray", cpu.buffer_prototype.buffer.from_bytes(b"{}"))) + sync(store.set("a/.zgroup", cpu.buffer_prototype.buffer.from_bytes(b"{}"))) + + assert TestClient(node_app(root)).get("/.zarray").status_code == 404 + assert TestClient(node_app(arr)).get("/.zgroup").status_code == 404 + + @pytest.mark.parametrize("store", ["memory"], indirect=True) class TestBackgroundServerReportsBoundPort: """`port=0` asks the OS for a free port, so the server must report the diff --git a/tests/test_chunk_key_encodings.py b/tests/test_chunk_key_encodings.py new file mode 100644 index 0000000000..dcc93b9249 --- /dev/null +++ b/tests/test_chunk_key_encodings.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import pytest + +from zarr.core.chunk_key_encodings import DefaultChunkKeyEncoding, V2ChunkKeyEncoding + + +@pytest.mark.parametrize("separator", ["/", "."]) +@pytest.mark.parametrize( + "coords", + [(), (0,), (1, 2), (10, 0, 3)], +) +def test_default_encoding_round_trips(separator: str, coords: tuple[int, ...]) -> None: + """Encoding coordinates and decoding the result returns the coordinates.""" + encoding = DefaultChunkKeyEncoding(separator=separator) # type: ignore[arg-type] + + key = encoding.encode_chunk_key(coords) + assert encoding.decode_chunk_key(key) == coords + + +@pytest.mark.parametrize("separator", ["/", "."]) +@pytest.mark.parametrize("coords", [(0,), (1, 2), (10, 0, 3)]) +def test_v2_encoding_round_trips(separator: str, coords: tuple[int, ...]) -> None: + """The v2 encoding round-trips coordinates for either separator.""" + encoding = V2ChunkKeyEncoding(separator=separator) # type: ignore[arg-type] + + key = encoding.encode_chunk_key(coords) + assert encoding.decode_chunk_key(key) == coords + + +@pytest.mark.parametrize("separator", ["/", "."]) +def test_v2_zero_dimensional_key_is_ambiguous(separator: str) -> None: + """A 0-d v2 array stores its sole chunk under `"0"`, the same key a 1-d + array uses for chunk 0, so decoding cannot recover the empty tuple on its + own -- the array's dimensionality is what disambiguates it.""" + encoding = V2ChunkKeyEncoding(separator=separator) # type: ignore[arg-type] + + assert encoding.encode_chunk_key(()) == "0" + assert encoding.decode_chunk_key("0") == (0,) + + +@pytest.mark.parametrize( + "chunk_key", + [ + "0/1", # no "c" prefix at all + "c0/1", # "c" not followed by the separator + "x/0/1", # wrong prefix character + "", + ], +) +def test_default_encoding_rejects_key_without_prefix(chunk_key: str) -> None: + """A key that does not carry the `c` prefix is not a chunk key + for this encoding, and must be rejected rather than silently decoded.""" + encoding = DefaultChunkKeyEncoding(separator="/") + + with pytest.raises(ValueError, match="Invalid chunk key"): + encoding.decode_chunk_key(chunk_key) + + +def test_default_encoding_rejects_key_using_the_other_separator() -> None: + """A key encoded with `.` is not valid for a `/`-separated encoding.""" + encoding = DefaultChunkKeyEncoding(separator="/") + + with pytest.raises(ValueError, match="Invalid chunk key"): + encoding.decode_chunk_key("c.0.1") From 6f676bc471fd61ebb6fd9d482acd320c39572bd2 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 29 Jul 2026 09:05:41 +0200 Subject: [PATCH 29/29] fix(zarr-http-server): stop reporting I/O failures as misses; enforce the body cap while reading The round-3 review found the previous round's error handling was too broad and its body cap too narrow. except (ValueError, OSError) around store.get and store.set caught the whole errno family, so EACCES, EROFS and ENOSPC all answered 404. Under the v3 spec an absent chunk is an uninitialized one and a reader is right to substitute the array's fill value, so 404 asserts something about the store's contents: an unreadable chunk answered that way has a correct client silently materialize fill values over data that exists, and a failed write looks like a pointless one rather than a failure. Only ENAMETOOLONG and EINVAL now mean "this key names nothing"; everything else surfaces. NUL bytes are rejected in the guard instead, so the store's own errors always mean real I/O trouble. max_body_size only consulted Content-Length, which a chunked request does not send, so request.body() buffered the whole thing before the check: 256 MiB passed a 1 KiB cap at 572 MiB peak RSS. The body is now read incrementally and abandoned at the cap -- the same attack peaks at 59 MiB. Also: a range too wide to allocate answers 416 rather than raising MemoryError; the force_exit fallback join is bounded, so shutdown cannot outlast its timeout; DEFAULT_MAX_BODY_SIZE is importable, since it is the documented default of three public functions; and _make_starlette_app loses a parameter it never read. Adds the coverage the review found missing: shard-grid bounds (the mutant served an out-of-grid shard key against a green suite), wrong-arity chunk keys, the chunked body path, I/O failures as 5xx, and a parser-level assertion for inverted ranges, which the status code alone could not distinguish on a MemoryStore. The drive-letter check stays unconditional. It over-rejects a first-segment node name like a:b, which is legal on POSIX -- but ntpath treats any single character before a colon as a drive, so there is no safe subset, and the guard is a string gate in front of an arbitrary Store whose path semantics this package cannot know. Assisted-by: ClaudeCode:claude-fable-5 --- packages/zarr-http-server/README.md | 36 ++++- .../src/zarr_http_server/__init__.py | 4 +- .../src/zarr_http_server/_serve.py | 114 +++++++++++++--- packages/zarr-http-server/tests/test_serve.py | 123 +++++++++++++++++- 4 files changed, 256 insertions(+), 21 deletions(-) diff --git a/packages/zarr-http-server/README.md b/packages/zarr-http-server/README.md index 753bf7dee4..3ff69ac3be 100644 --- a/packages/zarr-http-server/README.md +++ b/packages/zarr-http-server/README.md @@ -126,7 +126,10 @@ forms defined by [RFC 7233](https://httpwg.org/specs/rfc7233.html) are supported | `bytes=100-` | Everything from byte 100 | | `bytes=-50` | Last 50 bytes | -A successful range request returns HTTP 206 (Partial Content). +A successful range request returns HTTP 206 (Partial Content) with a +`Content-Range` header. A range that cannot be satisfied -- one lying wholly +beyond the end of the object, or an inverted one such as `bytes=5-2` -- +returns 416 (Range Not Satisfiable). ### Write Support @@ -137,7 +140,36 @@ By default only `GET` requests are accepted. To enable writes, pass app = store_app(store, methods={"GET", "PUT"}) ``` -A `PUT` request stores the request body at the given path and returns 204 (No Content). +Accepted methods are `GET`, `HEAD`, and `PUT`; anything else raises +`ValueError` when the app is built, since the handler has no behavior for it. + +A `PUT` request stores the request body at the given path and returns 204 (No +Content). Bodies are capped at `DEFAULT_MAX_BODY_SIZE` (256 MiB) and a larger +one returns 413 (Content Too Large) -- `Store.set` takes a whole buffer, so an +accepted body is held in memory in full. Raise or remove the cap with +`max_body_size`: + +```python +from zarr_http_server import DEFAULT_MAX_BODY_SIZE, store_app + +app = store_app(store, methods={"GET", "PUT"}, max_body_size=None) +``` + +Note that `store_app` exposes every key in the store, so `PUT` grants +unrestricted write access to all of it. `node_app` confines writes to keys +belonging to the node -- though a client that can write a node's metadata can +change what that node contains, and so what it will serve. + +### Shutting Down + +`BackgroundServer.shutdown()` (and leaving the `with` block) waits up to +`shutdown_timeout` seconds -- 5 by default -- for in-flight requests to finish +before forcing the server closed: + +```python +with serve_node(arr, background=True, shutdown_timeout=30) as server: + ... +``` ## Example diff --git a/packages/zarr-http-server/src/zarr_http_server/__init__.py b/packages/zarr-http-server/src/zarr_http_server/__init__.py index aa7f4a3689..5a0237e9e2 100644 --- a/packages/zarr-http-server/src/zarr_http_server/__init__.py +++ b/packages/zarr-http-server/src/zarr_http_server/__init__.py @@ -1,8 +1,9 @@ -"""Zarr-server: HTTP server for Zarr stores, arrays, and groups.""" +"""Zarr-http-server: HTTP server for Zarr stores, arrays, and groups.""" from importlib.metadata import version from zarr_http_server._serve import ( + DEFAULT_MAX_BODY_SIZE, BackgroundServer, CorsOptions, HTTPMethod, @@ -15,6 +16,7 @@ __version__ = version("zarr-http-server") __all__ = [ + "DEFAULT_MAX_BODY_SIZE", "BackgroundServer", "CorsOptions", "HTTPMethod", diff --git a/packages/zarr-http-server/src/zarr_http_server/_serve.py b/packages/zarr-http-server/src/zarr_http_server/_serve.py index 1bf9b2db76..ab378e9084 100644 --- a/packages/zarr-http-server/src/zarr_http_server/_serve.py +++ b/packages/zarr-http-server/src/zarr_http_server/_serve.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import errno import ntpath import threading import time @@ -20,6 +21,7 @@ from zarr.abc.store import ByteRequest, Store __all__ = [ + "DEFAULT_MAX_BODY_SIZE", "BackgroundServer", "CorsOptions", "HTTPMethod", @@ -48,9 +50,10 @@ class CorsOptions(TypedDict): DEFAULT_MAX_BODY_SIZE = 256 * 1024 * 1024 """Default cap on a `PUT` body, in bytes. -`Store.set` takes a whole `Buffer`, so a body cannot be streamed and is held -in memory in full. Without a cap, one request sizes the server's memory use. -Pass `max_body_size=None` to lift it. +`Store.set` takes a whole `Buffer`, so an accepted body is held in memory in +full; the body is read incrementally and abandoned once it passes this cap, +so one request cannot size the server's memory use. Pass +`max_body_size=None` to lift the cap and read the body whole. """ @@ -110,7 +113,9 @@ def shutdown(self) -> None: self._thread.join(timeout=self._shutdown_timeout) if self._thread.is_alive(): self._server.force_exit = True - self._thread.join() + # Bounded again: force_exit is observed by uvicorn's loop, so a + # request wedged outside it would otherwise block here forever. + self._thread.join(timeout=self._shutdown_timeout) def __enter__(self) -> Self: return self @@ -160,6 +165,53 @@ def _parse_range_header(range_header: str) -> ByteRequest | None: return None +def _is_drive_qualified(path: str) -> bool: + """Whether *path* carries a drive or UNC prefix that would discard a store root. + + A drive-qualified key such as `C:/Windows`, or the drive-relative `a:b`, + replaces the root it is joined to rather than extending it. This is + rejected on every platform, not just Windows: the check is a string-level + gate in front of an arbitrary `Store`, and this package cannot know how a + given implementation resolves keys. + + The cost is that a node whose *first* path segment looks like `:...` + is unreachable -- `ntpath` treats any single character before a colon as + a drive, so there is no safe subset to admit. Such names are legal but + rare, and later segments are unaffected (`sub/a:b` is served normally). + + Parameters + ---------- + path : str + The candidate key, with separators already folded to `/`. + + Returns + ------- + bool + """ + return ntpath.splitdrive(path)[0] != "" + + +def _names_nothing(exc: OSError) -> bool: + """Whether an `OSError` says the key cannot name anything, not that I/O failed. + + A key longer than the filesystem permits, or containing bytes it forbids, + can never identify a stored object, so a miss is the honest answer. Every + other `errno` -- a full disk, a read-only mount, a permissions problem, a + device error -- describes a failure to complete the operation and must + surface as such. + + Parameters + ---------- + exc : OSError + The error raised by the store. + + Returns + ------- + bool + """ + return exc.errno in (errno.ENAMETOOLONG, errno.EINVAL) + + def _content_range(byte_range: ByteRequest, length: int) -> str | None: """Build a `Content-Range` value for a 206 response, if the offsets are known. @@ -195,10 +247,20 @@ async def _get_response(store: Store, path: str, byte_range: ByteRequest | None try: buf = await store.get(path, proto, byte_range=byte_range) - except (ValueError, OSError): - # The key passed the shape guard but the store cannot express it -- - # an embedded NUL, or a name longer than the filesystem allows. It - # names nothing that exists, so it is a miss, not a server fault. + except MemoryError: + # A range so wide the store cannot allocate for it cannot be + # satisfied; that is the client's range, not a server fault. + return Response(status_code=416) + except OSError as exc: + if not _names_nothing(exc): + # A real I/O failure, which must not be reported as a miss. Under + # the v3 spec an absent chunk is an uninitialized one, and a + # reader is right to substitute the array's fill value for it -- + # so 404 asserts something about the store's contents. An + # unreadable chunk is not an uninitialized chunk, and answering + # 404 would have a correct client silently materialize fill + # values over data that exists. + raise return Response(status_code=404) if buf is None: return Response(status_code=404) @@ -235,7 +297,13 @@ async def _handle_request(request: Request) -> Response: # (src/zarr/storage/_utils.py) plus a drive check it doesn't need. Legitimate # zarr keys never contain empty, ".", or ".." segments, or a drive letter. segments = path.replace("\\", "/").split("/") - if any(segment in ("", ".", "..") for segment in segments) or ntpath.splitdrive(path)[0]: + if any(segment in ("", ".", "..") for segment in segments) or _is_drive_qualified(path): + return Response(status_code=404) + + # A NUL can never appear in a store key, and reaches the filesystem layer + # as a raised error rather than a miss. Rejecting it here keeps that out + # of the store, so the store's own errors always mean real I/O trouble. + if "\x00" in path: return Response(status_code=404) # If serving a node, validate the key before touching the store. Group @@ -251,21 +319,34 @@ async def _handle_request(request: Request) -> Response: if request.method == "PUT": max_body_size: int | None = request.app.state.max_body_size - if max_body_size is not None: + if max_body_size is None: + body = await request.body() + else: declared = request.headers.get("content-length") if declared is not None and declared.isdigit() and int(declared) > max_body_size: return Response(status_code=413) - body = await request.body() - if max_body_size is not None and len(body) > max_body_size: - return Response(status_code=413) + # Read incrementally and stop at the cap. `request.body()` would + # buffer the whole body first, which a chunked request can use to + # exceed the cap by any amount before it is ever checked. + chunks: list[bytes] = [] + received = 0 + async for chunk in request.stream(): + received += len(chunk) + if received > max_body_size: + return Response(status_code=413) + chunks.append(chunk) + body = b"".join(chunks) buf = cpu.buffer_prototype.buffer.from_bytes(body) try: await store.set(store_key, buf) - except (ValueError, OSError): - # As on the read path: a key the store cannot express names - # nothing writable, rather than indicating a server fault. + except OSError as exc: + if not _names_nothing(exc): + # A real write failure -- a full disk, a read-only mount, a + # permissions problem. Reporting it as 404 would tell the + # client the write is pointless rather than failed. + raise return Response(status_code=404) return Response(status_code=204) @@ -283,7 +364,6 @@ def _make_starlette_app( *, methods: set[HTTPMethod] | None = None, cors_options: CorsOptions | None = None, - max_body_size: int | None = DEFAULT_MAX_BODY_SIZE, ) -> Starlette: """Create a Starlette app with the request handler. diff --git a/packages/zarr-http-server/tests/test_serve.py b/packages/zarr-http-server/tests/test_serve.py index 2646eb4a45..c038951c94 100644 --- a/packages/zarr-http-server/tests/test_serve.py +++ b/packages/zarr-http-server/tests/test_serve.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import os from typing import TYPE_CHECKING, Any, Literal import numpy as np @@ -14,7 +15,7 @@ if TYPE_CHECKING: import pathlib - from collections.abc import Coroutine + from collections.abc import Coroutine, Iterator from zarr.abc.store import Store @@ -234,6 +235,13 @@ def test_non_bytes_unit_returns_416(self, store: Store) -> None: class TestParseRangeHeader: + def test_parser_rejects_an_inverted_range(self) -> None: + """Pin the parser itself: on a MemoryStore an unguarded inverted range + happens to return b"" and still yields 416, so the status code alone + cannot tell whether the guard is present.""" + assert _parse_range_header("bytes=5-2") is None + assert _parse_range_header("bytes=0-0") is not None + """Unit tests for _parse_range_header.""" def test_valid_range(self) -> None: @@ -857,6 +865,119 @@ def test_serve_node_background(self, store: Store) -> None: assert response.status_code == 200 +class TestStoreFailuresAreNotReportedAsMisses: + """Under the v3 spec an absent chunk is an uninitialized one, and a reader + is right to substitute the array's fill value for it. A 404 therefore + asserts something about the store's contents, and an I/O failure must not + borrow it -- that would have a correct client materialize fill values over + data that exists.""" + + @pytest.mark.skipif(os.geteuid() == 0, reason="root bypasses the permission bits under test") + def test_unreadable_key_is_a_server_error(self, tmp_path: pathlib.Path) -> None: + root = tmp_path / "root" + root.mkdir() + (root / "key").write_bytes(b"real data") + os.chmod(root / "key", 0o000) + + client = TestClient(store_app(LocalStore(str(root))), raise_server_exceptions=False) + try: + assert client.get("/key").status_code >= 500 + finally: + os.chmod(root / "key", 0o600) + + @pytest.mark.skipif(os.geteuid() == 0, reason="root bypasses the permission bits under test") + def test_unwritable_store_is_a_server_error(self, tmp_path: pathlib.Path) -> None: + root = tmp_path / "ro" + root.mkdir() + os.chmod(root, 0o500) + + client = TestClient( + store_app(LocalStore(str(root)), methods={"GET", "PUT"}), + raise_server_exceptions=False, + ) + try: + assert client.put("/key", content=b"data").status_code >= 500 + finally: + os.chmod(root, 0o700) + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestShardGridBounds: + """A sharded array's storage grid is its shard grid, not its chunk grid.""" + + def test_out_of_shard_grid_key_returns_404(self, store: Store) -> None: + arr = zarr.create_array(store, shape=(8, 8), chunks=(2, 2), shards=(4, 4), dtype="i4") + arr[:] = np.arange(64, dtype="i4").reshape(8, 8) + + # (8,8) with (4,4) shards has a 2x2 shard grid, so c/3/3 is out of it. + # Plant data there so the 404 must come from the bounds check. + sync(store.set("c/3/3", cpu.buffer_prototype.buffer.from_bytes(b"PLANTED"))) + assert sync(store.get("c/3/3", cpu.buffer_prototype)) is not None + + client = TestClient(node_app(arr)) + assert client.get("/c/0/0").status_code == 200 + assert client.get("/c/3/3").status_code == 404 + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestWrongArityChunkKeys: + """A chunk key with the wrong number of coordinates is invalid, and must + not reach the grid comparison -- zip(strict=True) would raise there.""" + + @pytest.mark.parametrize("key", ["c/0", "c/0/0/0", "c/0/0/0/0"]) + def test_wrong_arity_returns_404(self, store: Store, key: str) -> None: + arr = zarr.create_array(store, shape=(4, 4), chunks=(2, 2), dtype="f8") + arr[:] = np.ones((4, 4)) + + sync(store.set(key, cpu.buffer_prototype.buffer.from_bytes(b"PLANTED"))) + + client = TestClient(node_app(arr), raise_server_exceptions=False) + assert client.get(f"/{key}").status_code == 404 + + +@pytest.mark.parametrize("store", ["memory"], indirect=True) +class TestNodeNamesContainingAColon: + """A leading `:` is a drive reference to `ntpath`, so the guard rejects + it on every platform to keep it a pure string gate in front of any store. + The documented cost is that such a name is unreachable in the first + segment -- but only there.""" + + def test_colon_named_node_in_a_later_segment_is_served(self, store: Store) -> None: + root = zarr.open_group(store, mode="w") + sub = root.create_group("sub") + sub.create_array("a:b", shape=(2,), chunks=(2,), dtype="f8") + + client = TestClient(node_app(root)) + assert client.get("/sub/a:b/zarr.json").status_code == 200 + + def test_colon_named_node_in_the_first_segment_is_rejected(self, store: Store) -> None: + root = zarr.open_group(store, mode="w") + root.create_array("a:b", shape=(2,), chunks=(2,), dtype="f8") + + client = TestClient(node_app(root)) + assert client.get("/a:b/zarr.json").status_code == 404 + + +class TestChunkedBodyIsCapped: + """A chunked request carries no Content-Length, so the cap has to hold + while the body is being read rather than after it is buffered.""" + + def test_chunked_body_over_cap_is_rejected(self, tmp_path: pathlib.Path) -> None: + store = LocalStore(str(tmp_path / "root")) + client = TestClient( + store_app(store, methods={"GET", "PUT"}, max_body_size=64), + raise_server_exceptions=False, + ) + + def body() -> Iterator[bytes]: + for _ in range(20): + yield b"x" * 32 + + # httpx sends an iterator body with Transfer-Encoding: chunked. + assert client.put("/key", content=body()).status_code == 413 + assert not (tmp_path / "root" / "key").exists() + + class TestHostileKeysAreNotServerErrors: """A key the store cannot express is a miss, not a server fault: the server must never answer 5xx for input a client can choose freely.