diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a4141d7356..3f995e8b5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -201,6 +201,32 @@ jobs: run: | HATCH_ENV=test.py3.13-minimal just benchmark + examples: + name: Example scripts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 # required for hatch version discovery when uv builds zarr + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.13' + cache: 'pip' + - name: Install uv + uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + - name: Install Hatch + run: python -m pip install hatch==1.16.5 + - name: Install just + uses: extractions/setup-crate@7577c1bdf2d95e6d65d532788f35ed79d4b1dda2 # v2.0.1 + with: + repo: casey/just + version: 1.58.0 + - name: Run Examples + run: | + HATCH_ENV=test.py3.13-minimal just test-examples + test-complete: name: Test complete @@ -209,7 +235,8 @@ jobs: test, test-upstream-and-min-deps, doctests, - benchmarks + benchmarks, + examples ] if: always() runs-on: ubuntu-latest diff --git a/Justfile b/Justfile index b3f187e231..df96003eef 100644 --- a/Justfile +++ b/Justfile @@ -64,6 +64,10 @@ hypothesis *args: doctest *args: hatch run doctest:pytest tests/test_docs.py -v "$@" +# Run each example script against the local checkout, in its PEP 723 environment +test-examples *args: + hatch run {{ quote(hatch_env) }}:pytest --run-examples tests/test_examples.py -v "$@" + # Run the benchmark suite benchmark *args: hatch run {{ quote(hatch_env) }}:pytest --benchmark-enable tests/benchmarks "$@" diff --git a/examples/README.md b/examples/README.md index a6b5fa2179..fa664d2c58 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,7 +19,7 @@ examples/ To add a new example: 1. Create a new subdirectory: `examples/my_example/` -2. Add your Python code: `examples/my_example/my_example.py` +2. Add your Python code: `examples/my_example/my_example.py`. Start it with a [PEP 723](https://peps.python.org/pep-0723/) header that lists its dependencies, including `zarr`. `just test-examples` runs every example against your local checkout, and CI runs it on every pull request. 3. Create documentation: `examples/my_example/README.md` 4. Create a documentation page at `docs/user-guide/examples/my_example.md`. The documentation page should simply link to the `README.md` and the source code, e.g.: diff --git a/examples/codec_pipeline_performance/codec_pipeline_performance.py b/examples/codec_pipeline_performance/codec_pipeline_performance.py index b821f3e6a7..d146c51f4a 100644 --- a/examples/codec_pipeline_performance/codec_pipeline_performance.py +++ b/examples/codec_pipeline_performance/codec_pipeline_performance.py @@ -1,5 +1,5 @@ # /// script -# requires-python = ">=3.11" +# requires-python = ">=3.12" # dependencies = [ # "zarr @ git+https://github.com/zarr-developers/zarr-python.git@main", # "numpy", diff --git a/examples/sharding_coalescing/sharding_coalescing.py b/examples/sharding_coalescing/sharding_coalescing.py index 5da62ed806..66838d2255 100644 --- a/examples/sharding_coalescing/sharding_coalescing.py +++ b/examples/sharding_coalescing/sharding_coalescing.py @@ -1,5 +1,5 @@ # /// script -# requires-python = ">=3.11" +# requires-python = ">=3.12" # dependencies = [ # "zarr @ git+https://github.com/zarr-developers/zarr-python.git@main", # "numpy", diff --git a/packages/zarr-metadata/changes/4575.feature.md b/packages/zarr-metadata/changes/4575.feature.md new file mode 100644 index 0000000000..bd1d87ab40 --- /dev/null +++ b/packages/zarr-metadata/changes/4575.feature.md @@ -0,0 +1 @@ +Added `zarr_metadata.testing.strategies`, Hypothesis strategies that draw valid Zarr v3 metadata fields, behind a new `testing` extra (`pip install zarr-metadata[testing]`). There is one strategy for every codec, data type, chunk grid and chunk key encoding the package defines that holds no other field. `fields` draws every envelope spelling the spec allows, or only the canonical one with `spellings="canonical"`. Codecs can be drawn for the `Chunk` they are handed, chunk grids for an array shape, and fill values for a data type. The strategies are a mapping keyed by definition, so an extension author can add strategies for their own definitions. diff --git a/packages/zarr-metadata/docs/api/testing.md b/packages/zarr-metadata/docs/api/testing.md new file mode 100644 index 0000000000..a5d6145258 --- /dev/null +++ b/packages/zarr-metadata/docs/api/testing.md @@ -0,0 +1,8 @@ +--- +title: testing.strategies +--- + +::: zarr_metadata.testing.strategies + options: + inherited_members: false + show_if_no_docstring: false diff --git a/packages/zarr-metadata/mkdocs.yml b/packages/zarr-metadata/mkdocs.yml index 10464e3cf0..b19c0932d2 100644 --- a/packages/zarr-metadata/mkdocs.yml +++ b/packages/zarr-metadata/mkdocs.yml @@ -28,6 +28,7 @@ nav: - ' zarr_metadata.model': api/model.md - ' zarr_metadata.problems': api/problems.md - ' zarr_metadata.pydantic': api/pydantic.md + - ' zarr_metadata.testing.strategies': api/testing.md - ' zarr_metadata.typed_json': api/typed_json.md - ' zarr_metadata.v2': api/v2.md - ' zarr_metadata.v3': diff --git a/packages/zarr-metadata/pyproject.toml b/packages/zarr-metadata/pyproject.toml index abd93bc729..25d1c7aead 100644 --- a/packages/zarr-metadata/pyproject.toml +++ b/packages/zarr-metadata/pyproject.toml @@ -43,6 +43,11 @@ dependencies = [ "typing_extensions>=4.16", ] +[project.optional-dependencies] +# `zarr_metadata.testing.strategies`. 6.85: first release with +# `characters(exclude_categories=...)`. +testing = ["hypothesis>=6.85"] + [project.urls] Homepage = "https://github.com/zarr-developers/zarr-python" Source = "https://github.com/zarr-developers/zarr-python/tree/main/packages/zarr-metadata" diff --git a/packages/zarr-metadata/src/zarr_metadata/testing/__init__.py b/packages/zarr-metadata/src/zarr_metadata/testing/__init__.py new file mode 100644 index 0000000000..271a4dfe51 --- /dev/null +++ b/packages/zarr-metadata/src/zarr_metadata/testing/__init__.py @@ -0,0 +1,6 @@ +"""Testing utilities for code that reads or writes Zarr metadata. + +`zarr_metadata.testing.strategies` holds Hypothesis strategies that draw +valid metadata. It needs the `testing` extra: +`pip install zarr-metadata[testing]`. +""" diff --git a/packages/zarr-metadata/src/zarr_metadata/testing/_chunk_grid.py b/packages/zarr-metadata/src/zarr_metadata/testing/_chunk_grid.py new file mode 100644 index 0000000000..b7528a3907 --- /dev/null +++ b/packages/zarr-metadata/src/zarr_metadata/testing/_chunk_grid.py @@ -0,0 +1,105 @@ +"""Strategies for each chunk grid this package defines. + +Over an array of a known shape, a grid is drawn to fit it: a chunk +length for each dimension, never zero ("Chunk sizes must be greater +than zero", https://github.com/zarr-developers/zarr-specs/blob/fc7dd9c9beb5a50b87f9b08b00bf50fc0048482f/docs/v3/chunk-grids/regular-grid/index.rst#L40), +and rectilinear edges that cover each dimension. Over no shape, what is +drawn is what the grid's rules accept. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Final + +from hypothesis import strategies as st + +from zarr_metadata.testing._strategy import MAX_RANK, ChunkGridStrategy +from zarr_metadata.v3.chunk_grid.rectilinear import RECTILINEAR_CHUNK_GRID +from zarr_metadata.v3.chunk_grid.regular import REGULAR_CHUNK_GRID + +if TYPE_CHECKING: + from collections.abc import Mapping + + from zarr_metadata._common import JSONValue + from zarr_metadata.v3._definition import ChunkGridDefinition + +_MAX_LENGTH: Final = 16 +"""The longest chunk drawn along a dimension whose length is unknown.""" + +_MAX_RUNS: Final = 5 +"""The most runs of equal lengths a rectilinear dimension is drawn with.""" + +_MAX_COUNT: Final = 4 +"""The most chunks a run is drawn with.""" + + +def _length(extent: int | None) -> st.SearchStrategy[int]: + """A chunk length along a dimension of `extent`: at least 1, and up to twice the extent when it is known and not 0.""" + if extent is None or extent == 0: + return st.integers(1, _MAX_LENGTH) + return st.integers(1, 2 * extent) + + +def _regular(shape: tuple[int, ...] | None) -> st.SearchStrategy[Mapping[str, JSONValue]]: + """A chunk length for each dimension.""" + if shape is None: + lengths = st.lists(_length(None), max_size=MAX_RANK).map(tuple) + else: + lengths = st.tuples(*(_length(extent) for extent in shape)) + return lengths.map(lambda chunk_shape: {"chunk_shape": chunk_shape}) + + +def _spelled_run(draw: st.DrawFn, size: int, count: int) -> list[JSONValue]: + """`count` chunks of `size`: one `[size, count]` pair, or the size written once for each.""" + if draw(st.booleans()): + return [(size, count)] + return [size] * count + + +@st.composite +def _edges(draw: st.DrawFn, extent: int | None) -> JSONValue: + """Edge lengths along a dimension, covering `extent`, in runs spelled as pairs or written out. + + Runs are drawn apart, so two of one size can sit side by side and a + pair can count one chunk: spellings the canonical form merges. A + dimension of length zero, or of a length not known, may be covered by + no edges at all. + """ + min_runs = 0 if extent is None or extent == 0 else 1 + runs = draw( + st.lists( + st.tuples(st.integers(1, _MAX_LENGTH), st.integers(1, _MAX_COUNT)), + min_size=min_runs, + max_size=_MAX_RUNS, + ) + ) + covered = sum(size * count for size, count in runs) + if extent is not None and covered < extent: + runs.append((extent - covered, 1)) + spelled: list[JSONValue] = [] + for size, count in runs: + spelled.extend(_spelled_run(draw, size, count)) + return tuple(spelled) + + +def _rectilinear_dimension(extent: int | None) -> st.SearchStrategy[JSONValue]: + """A bare length, which repeats until it covers the dimension, or edges that cover it.""" + return _length(extent) | _edges(extent) + + +def _rectilinear(shape: tuple[int, ...] | None) -> st.SearchStrategy[Mapping[str, JSONValue]]: + """Inline chunk shapes, one entry for each dimension.""" + if shape is None: + dimensions = st.lists(_rectilinear_dimension(None), max_size=MAX_RANK).map(tuple) + else: + dimensions = st.tuples(*(_rectilinear_dimension(extent) for extent in shape)) + return dimensions.map(lambda chunk_shapes: {"kind": "inline", "chunk_shapes": chunk_shapes}) + + +CHUNK_GRID_STRATEGIES: Final[Mapping[ChunkGridDefinition[Any], ChunkGridStrategy]] = { + REGULAR_CHUNK_GRID: ChunkGridStrategy(configurations=_regular), + RECTILINEAR_CHUNK_GRID: ChunkGridStrategy(configurations=_rectilinear), +} +"""A strategy for each chunk grid.""" + +__all__ = ["CHUNK_GRID_STRATEGIES"] diff --git a/packages/zarr-metadata/src/zarr_metadata/testing/_chunk_key_encoding.py b/packages/zarr-metadata/src/zarr_metadata/testing/_chunk_key_encoding.py new file mode 100644 index 0000000000..9c525118c9 --- /dev/null +++ b/packages/zarr-metadata/src/zarr_metadata/testing/_chunk_key_encoding.py @@ -0,0 +1,40 @@ +"""Strategies for each chunk key encoding this package defines.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Final + +from hypothesis import strategies as st + +from zarr_metadata.testing._strategy import ChunkKeyEncodingStrategy +from zarr_metadata.v3.chunk_key_encoding.default import ( + DEFAULT_CHUNK_KEY_ENCODING, + DEFAULT_CHUNK_KEY_ENCODING_SEPARATOR, +) +from zarr_metadata.v3.chunk_key_encoding.v2 import ( + V2_CHUNK_KEY_ENCODING, + V2_CHUNK_KEY_ENCODING_SEPARATOR, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + + from zarr_metadata.v3._definition import ChunkKeyEncodingDefinition + +CHUNK_KEY_ENCODING_STRATEGIES: Final[ + Mapping[ChunkKeyEncodingDefinition[Any], ChunkKeyEncodingStrategy] +] = { + DEFAULT_CHUNK_KEY_ENCODING: ChunkKeyEncodingStrategy( + configurations=st.fixed_dictionaries( + {}, optional={"separator": st.sampled_from(DEFAULT_CHUNK_KEY_ENCODING_SEPARATOR)} + ) + ), + V2_CHUNK_KEY_ENCODING: ChunkKeyEncodingStrategy( + configurations=st.fixed_dictionaries( + {}, optional={"separator": st.sampled_from(V2_CHUNK_KEY_ENCODING_SEPARATOR)} + ) + ), +} +"""A strategy for each chunk key encoding: a `separator`, or none, which means the encoding's default.""" + +__all__ = ["CHUNK_KEY_ENCODING_STRATEGIES"] diff --git a/packages/zarr-metadata/src/zarr_metadata/testing/_codec.py b/packages/zarr-metadata/src/zarr_metadata/testing/_codec.py new file mode 100644 index 0000000000..caf7b71a05 --- /dev/null +++ b/packages/zarr-metadata/src/zarr_metadata/testing/_codec.py @@ -0,0 +1,179 @@ +"""Strategies for each codec this package defines that holds no other field. + +A codec handed an array draws what fits the chunk: a `bytes` codec an +`endian` for a type whose values take several bytes, a `transpose` an +`order` with an entry for each axis. Handed `Chunk()`, nothing is known +of the chunk, and what is drawn is what the codec's rules accept. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Final + +from hypothesis import strategies as st + +from zarr_metadata.definition._definition import AcceptedField +from zarr_metadata.testing._data_type import ( + COMPLEX_WIDTHS, + FLOAT_WIDTHS, + INTEGER_RANGES, + FloatWidth, + finite_floats, + float_spellings, +) +from zarr_metadata.testing._strategy import EMPTY_CONFIGURATIONS, MAX_RANK, CodecStrategy +from zarr_metadata.v3._definition import Chunk, storage_of +from zarr_metadata.v3.codec._arithmetic import NOT_NUMBERS +from zarr_metadata.v3.codec.blosc import BLOSC_CNAME, BLOSC_CODEC, BLOSC_NO_SHUFFLE, BLOSC_SHUFFLE +from zarr_metadata.v3.codec.bytes import BYTES_CODEC, ENDIANNESS +from zarr_metadata.v3.codec.crc32c import CRC32C_CODEC +from zarr_metadata.v3.codec.gzip import GZIP_CODEC +from zarr_metadata.v3.codec.scale_offset import SCALE_OFFSET_CODEC +from zarr_metadata.v3.codec.transpose import TRANSPOSE_CODEC +from zarr_metadata.v3.codec.zstd import ZSTD_CODEC, ZSTD_MAX_LEVEL, ZSTD_MIN_LEVEL +from zarr_metadata.v3.data_type.numpy_datetime64 import NUMPY_DATETIME64_DATA_TYPE +from zarr_metadata.v3.data_type.numpy_timedelta64 import NUMPY_TIMEDELTA64_DATA_TYPE + +if TYPE_CHECKING: + from collections.abc import Mapping + + from zarr_metadata._common import JSONValue + from zarr_metadata.v3._definition import CodecDefinition + +_BLOSC_MAX_TYPESIZE: Final = 255 +"""The widest element c-blosc takes: `BLOSC_MAX_TYPESIZE`.""" + +_BLOSC_MAX_BLOCKSIZE: Final = 2**31 - 1 +"""The largest block c-blosc takes, a signed 32-bit size; 0 lets it choose.""" + +_ENDIAN: Final = st.sampled_from(ENDIANNESS) + +_NUMPY_TIMES: Final = frozenset({NUMPY_DATETIME64_DATA_TYPE, NUMPY_TIMEDELTA64_DATA_TYPE}) +"""The numpy time types, each stored as a signed 64-bit integer.""" + +_NUMPY_TIME_RANGE: Final = (-(2**63) + 1, 2**63 - 1) +"""The signed 64-bit integers but the least, which numpy reads as `NaT`: the finite numpy times.""" + + +def _bytes(chunk: Chunk) -> st.SearchStrategy[Mapping[str, JSONValue]] | None: + """An `endian`, required for a data type whose values take several bytes; None for one whose values vary in size.""" + storage = None if chunk.data_type is None else storage_of(chunk.data_type) + if storage == "variable_length": + return None + if storage == "multi_byte": + return st.fixed_dictionaries({"endian": _ENDIAN}) + return st.fixed_dictionaries({}, optional={"endian": _ENDIAN}) + + +@st.composite +def _blosc(draw: st.DrawFn) -> Mapping[str, JSONValue]: + """A compressor, a level, a shuffle and a block size; and a `typesize`, which only `noshuffle` may leave out. + + Under `noshuffle` the spec says `typesize` "is ignored", so it is + drawn there too, a spelling that is not the simplest. + """ + shuffle = draw(st.sampled_from(BLOSC_SHUFFLE)) + typesize = st.integers(1, _BLOSC_MAX_TYPESIZE) + required: dict[str, st.SearchStrategy[JSONValue]] = { + "cname": st.sampled_from(BLOSC_CNAME), + "clevel": st.integers(0, 9), + "shuffle": st.just(shuffle), + "blocksize": st.integers(0, _BLOSC_MAX_BLOCKSIZE), + } + if shuffle == BLOSC_NO_SHUFFLE: + return draw(st.fixed_dictionaries(required, optional={"typesize": typesize})) + return draw(st.fixed_dictionaries({**required, "typesize": typesize})) + + +def _order(rank: int) -> st.SearchStrategy[Mapping[str, JSONValue]]: + return st.permutations(range(rank)).map(lambda order: {"order": tuple(order)}) + + +def _transpose(chunk: Chunk) -> st.SearchStrategy[Mapping[str, JSONValue]]: + """A permutation of the chunk's axes; of some number of axes, when the chunk does not say.""" + if chunk.rank is not None: + return _order(chunk.rank) + return st.integers(0, MAX_RANK).flatmap(_order) + + +def _finite_float_values(width: FloatWidth) -> st.SearchStrategy[JSONValue]: + """A finite float of `width` bits, as a number or the hex string of its bits.""" + return finite_floats(width).flatmap(lambda value: float_spellings(value, width)) + + +def _non_zero_float_values(width: FloatWidth) -> st.SearchStrategy[JSONValue]: + """A finite, non-zero float of `width` bits, as a number or the hex string of its bits.""" + return ( + finite_floats(width) + .filter(lambda value: value != 0) + .flatmap(lambda value: float_spellings(value, width)) + ) + + +def _scale_offset(chunk: Chunk) -> st.SearchStrategy[Mapping[str, JSONValue]] | None: + """An `offset` and a `scale`, each a finite value of the data type the codec is handed, the scale not zero; None for a data type whose values are not numbers. + + Each is a fill value of the chunk's data type + (https://github.com/zarr-developers/zarr-extensions/blob/4da7b37a84f76e660902f6d3de3eaef0e0febae6/codecs/scale_offset/README.md?plain=1#L39-L43), + a float's as a number or the hex string of its bits, a complex + number's as a pair of them. Each is drawn finite, and the scale + non-zero, so that decoding never divides by zero; a scale small + enough that decoding overflows is still drawn. A numpy time is drawn + as the signed 64-bit integer it is stored as, never `NaT`, since it + "is compatible with any codec that supports arrays of signed 64-bit + integers" + (https://github.com/zarr-developers/zarr-extensions/blob/4da7b37a84f76e660902f6d3de3eaef0e0febae6/data-types/numpy.datetime64/README.md?plain=1#L120). + A data type this package does not define, whose values it cannot + draw, takes only the configuration with neither, the identity. + Handed a chunk whose data type is unknown, the values are small + non-negative integers, which every integer and float type holds. + """ + data_type = chunk.data_type + definition = data_type.definition if isinstance(data_type, AcceptedField) else None + if definition is None: + offset: st.SearchStrategy[JSONValue] = st.integers(0, 127) + scale: st.SearchStrategy[JSONValue] = st.integers(1, 127) + elif definition.name in NOT_NUMBERS: + return None + elif definition in INTEGER_RANGES or definition in _NUMPY_TIMES: + low, high = INTEGER_RANGES.get(definition, _NUMPY_TIME_RANGE) + offset = st.integers(low, high) + scale = st.integers(low, high).filter(lambda value: value != 0) + elif definition in FLOAT_WIDTHS: + offset = _finite_float_values(FLOAT_WIDTHS[definition]) + scale = _non_zero_float_values(FLOAT_WIDTHS[definition]) + elif definition in COMPLEX_WIDTHS: + width = COMPLEX_WIDTHS[definition] + offset = st.tuples(_finite_float_values(width), _finite_float_values(width)) + # Non-zero in either part is a non-zero complex number. + scale = st.one_of( + st.tuples(_non_zero_float_values(width), _finite_float_values(width)), + st.tuples(_finite_float_values(width), _non_zero_float_values(width)), + ) + else: + return EMPTY_CONFIGURATIONS + return st.fixed_dictionaries({}, optional={"offset": offset, "scale": scale}) + + +def _unchunked(configurations: st.SearchStrategy[Mapping[str, JSONValue]]) -> CodecStrategy: + """A codec whose configurations do not depend on the chunk it is handed.""" + return CodecStrategy(configurations=lambda _chunk: configurations) + + +CODEC_STRATEGIES: Final[Mapping[CodecDefinition[Any], CodecStrategy]] = { + BLOSC_CODEC: _unchunked(_blosc()), + BYTES_CODEC: CodecStrategy(configurations=_bytes), + CRC32C_CODEC: _unchunked(EMPTY_CONFIGURATIONS), + GZIP_CODEC: _unchunked(st.fixed_dictionaries({"level": st.integers(0, 9)})), + SCALE_OFFSET_CODEC: CodecStrategy(configurations=_scale_offset), + TRANSPOSE_CODEC: CodecStrategy(configurations=_transpose), + ZSTD_CODEC: _unchunked( + st.fixed_dictionaries( + {"level": st.integers(ZSTD_MIN_LEVEL, ZSTD_MAX_LEVEL)}, + optional={"checksum": st.booleans()}, + ) + ), +} +"""A strategy for each codec that holds no other field.""" + +__all__ = ["CODEC_STRATEGIES"] diff --git a/packages/zarr-metadata/src/zarr_metadata/testing/_data_type.py b/packages/zarr-metadata/src/zarr_metadata/testing/_data_type.py new file mode 100644 index 0000000000..dd06df28a0 --- /dev/null +++ b/packages/zarr-metadata/src/zarr_metadata/testing/_data_type.py @@ -0,0 +1,200 @@ +"""Strategies for each data type this package defines that holds no other field. + +A fill value is drawn in every spelling the data type's spec gives it: +a float's as a number, a named non-finite value or a hex string of its +bits, a `bytes` value's as an array of byte values or base64. +""" + +from __future__ import annotations + +import base64 +import struct +from typing import TYPE_CHECKING, Any, Final, Literal, cast, get_args + +from hypothesis import strategies as st + +from zarr_metadata._numpy import NUMPY_TIME_MAX_SCALE_FACTOR +from zarr_metadata.testing._strategy import EMPTY_CONFIGURATIONS, DataTypeStrategy +from zarr_metadata.v3.data_type._float import FloatSpecialFillValue +from zarr_metadata.v3.data_type._numpy_time import NUMPY_TIME_UNIT +from zarr_metadata.v3.data_type.bool import BOOL_DATA_TYPE +from zarr_metadata.v3.data_type.bytes import BYTES_DATA_TYPE +from zarr_metadata.v3.data_type.complex64 import COMPLEX64_DATA_TYPE +from zarr_metadata.v3.data_type.complex128 import COMPLEX128_DATA_TYPE +from zarr_metadata.v3.data_type.float16 import FLOAT16_DATA_TYPE +from zarr_metadata.v3.data_type.float32 import FLOAT32_DATA_TYPE +from zarr_metadata.v3.data_type.float64 import FLOAT64_DATA_TYPE +from zarr_metadata.v3.data_type.int8 import INT8_DATA_TYPE +from zarr_metadata.v3.data_type.int16 import INT16_DATA_TYPE +from zarr_metadata.v3.data_type.int32 import INT32_DATA_TYPE +from zarr_metadata.v3.data_type.int64 import INT64_DATA_TYPE +from zarr_metadata.v3.data_type.numpy_datetime64 import NUMPY_DATETIME64_DATA_TYPE +from zarr_metadata.v3.data_type.numpy_timedelta64 import NUMPY_TIMEDELTA64_DATA_TYPE +from zarr_metadata.v3.data_type.raw import RAW_BYTES_DATA_TYPE, RawBytesConfiguration +from zarr_metadata.v3.data_type.string import STRING_DATA_TYPE +from zarr_metadata.v3.data_type.uint8 import UINT8_DATA_TYPE +from zarr_metadata.v3.data_type.uint16 import UINT16_DATA_TYPE +from zarr_metadata.v3.data_type.uint32 import UINT32_DATA_TYPE +from zarr_metadata.v3.data_type.uint64 import UINT64_DATA_TYPE + +if TYPE_CHECKING: + from collections.abc import Mapping + + from zarr_metadata._common import JSONValue + from zarr_metadata.v3._definition import DataTypeDefinition + +FloatWidth = Literal[16, 32, 64] + +INTEGER_RANGES: Final[Mapping[DataTypeDefinition[Any], tuple[int, int]]] = { + INT8_DATA_TYPE: (-(2**7), 2**7 - 1), + INT16_DATA_TYPE: (-(2**15), 2**15 - 1), + INT32_DATA_TYPE: (-(2**31), 2**31 - 1), + INT64_DATA_TYPE: (-(2**63), 2**63 - 1), + UINT8_DATA_TYPE: (0, 2**8 - 1), + UINT16_DATA_TYPE: (0, 2**16 - 1), + UINT32_DATA_TYPE: (0, 2**32 - 1), + UINT64_DATA_TYPE: (0, 2**64 - 1), +} +"""The values each integer data type holds.""" + +FLOAT_WIDTHS: Final[Mapping[DataTypeDefinition[Any], FloatWidth]] = { + FLOAT16_DATA_TYPE: 16, + FLOAT32_DATA_TYPE: 32, + FLOAT64_DATA_TYPE: 64, +} +"""The width in bits of each floating-point data type.""" + +COMPLEX_WIDTHS: Final[Mapping[DataTypeDefinition[Any], FloatWidth]] = { + COMPLEX64_DATA_TYPE: 32, + COMPLEX128_DATA_TYPE: 64, +} +"""The width in bits of each part of each complex data type.""" + +_MAX_RAW_BYTES: Final = 32 +"""The widest raw bits drawn, in bytes: `r256`.""" + +_MAX_BYTES: Final = 16 +"""The longest `bytes` fill value drawn.""" + + +def finite_floats(width: FloatWidth) -> st.SearchStrategy[float]: + """Finite numbers a float of `width` bits holds exactly.""" + return st.floats(width=width, allow_nan=False, allow_infinity=False) + + +_PACKED: Final[Mapping[FloatWidth, str]] = {16: ">e", 32: ">f", 64: ">d"} +"""How `struct` packs a float of each width, most significant byte first.""" + + +def _hex_spellings(digits: str) -> st.SearchStrategy[str]: + """`0x` and the digits, the letters in either case.""" + return st.sampled_from((f"0x{digits}", f"0x{digits.upper()}")) + + +def float_spellings(value: float, width: FloatWidth) -> st.SearchStrategy[JSONValue]: + """`value`, a float of `width` bits, as a number or as the hex string of its bits.""" + return st.just(value) | _hex_spellings(struct.pack(_PACKED[width], value).hex()) + + +def _float_fill_values(width: FloatWidth) -> st.SearchStrategy[JSONValue]: + """A number, an integer the type holds exactly, a named non-finite value, or the bits in hex. + + A float's fill value is any of these + (https://github.com/zarr-developers/zarr-specs/blob/fc7dd9c9beb5a50b87f9b08b00bf50fc0048482f/docs/v3/data-types/index.rst#L63-L79). + Numbers are drawn from those the type holds, so none is rounded: a + `float16` takes `1e300` by the letter of the spec, and a reader is + free to make of it what it will. + """ + # Every integer of magnitude up to 2**11 is a float16, the narrowest type. + numbers = finite_floats(width) | st.integers(-(2**11), 2**11) + named = st.sampled_from(get_args(FloatSpecialFillValue)) + bits = st.integers(0, 2**width - 1).flatmap( + lambda value: _hex_spellings(f"{value:0{width // 4}x}") + ) + return st.one_of(numbers, named, bits) + + +def _fill_values_of(values: st.SearchStrategy[JSONValue]) -> DataTypeStrategy: + """A data type with nothing to configure, whose fill values are `values`.""" + return DataTypeStrategy( + configurations=EMPTY_CONFIGURATIONS, fill_values=lambda _configuration: values + ) + + +def _byte_values(size: int) -> st.SearchStrategy[JSONValue]: + """`size` byte values, each an integer in `[0, 255]`, as a JSON array.""" + return st.lists(st.integers(0, 255), min_size=size, max_size=size).map( + lambda values: cast("JSONValue", tuple(values)) + ) + + +def _raw_bytes_fill_values(configuration: Mapping[str, JSONValue]) -> st.SearchStrategy[JSONValue]: + """One byte value for each 8 bits of the size.""" + return _byte_values(cast("RawBytesConfiguration", configuration)["bits"] // 8) + + +def _base64(value: bytes) -> JSONValue: + return base64.b64encode(value).decode("ascii") + + +def _complex_fill_values(width: FloatWidth) -> st.SearchStrategy[JSONValue]: + """A pair of float fill values of `width` bits, real then imaginary.""" + component = _float_fill_values(width) + return st.tuples(component, component).map(lambda pair: cast("JSONValue", pair)) + + +_BYTES_FILL_VALUES: Final = st.integers(0, _MAX_BYTES).flatmap(_byte_values) | st.binary( + max_size=_MAX_BYTES +).map(_base64) +"""A `bytes` fill value: byte values, or the bytes in standard-alphabet base64.""" + +_TEXT: Final = st.text(st.characters(exclude_categories=("Cs",)), max_size=16) +"""Text a JSON document can hold: no lone surrogate, which is no Unicode scalar value.""" + +_NUMPY_TIME: Final = DataTypeStrategy( + configurations=st.fixed_dictionaries( + { + "unit": st.sampled_from(NUMPY_TIME_UNIT), + "scale_factor": st.integers(1, NUMPY_TIME_MAX_SCALE_FACTOR), + } + ), + fill_values=lambda _configuration: st.integers(-(2**63), 2**63 - 1) | st.just("NaT"), +) +"""Both numpy time types: a unit, and how many of it one tick is; a signed 64-bit count of ticks, or `"NaT"`.""" + +DATA_TYPE_STRATEGIES: Final[Mapping[DataTypeDefinition[Any], DataTypeStrategy]] = { + BOOL_DATA_TYPE: _fill_values_of(st.booleans()), + **{ + definition: _fill_values_of(st.integers(low, high)) + for definition, (low, high) in INTEGER_RANGES.items() + }, + **{ + definition: _fill_values_of(_float_fill_values(width)) + for definition, width in FLOAT_WIDTHS.items() + }, + **{ + definition: _fill_values_of(_complex_fill_values(width)) + for definition, width in COMPLEX_WIDTHS.items() + }, + RAW_BYTES_DATA_TYPE: DataTypeStrategy( + configurations=st.integers(1, _MAX_RAW_BYTES).map( + lambda size: RawBytesConfiguration(bits=8 * size) + ), + fill_values=_raw_bytes_fill_values, + ), + BYTES_DATA_TYPE: _fill_values_of(_BYTES_FILL_VALUES), + STRING_DATA_TYPE: _fill_values_of(_TEXT), + NUMPY_DATETIME64_DATA_TYPE: _NUMPY_TIME, + NUMPY_TIMEDELTA64_DATA_TYPE: _NUMPY_TIME, +} +"""A strategy for each data type that holds no other field.""" + +__all__ = [ + "COMPLEX_WIDTHS", + "DATA_TYPE_STRATEGIES", + "FLOAT_WIDTHS", + "INTEGER_RANGES", + "FloatWidth", + "finite_floats", + "float_spellings", +] diff --git a/packages/zarr-metadata/src/zarr_metadata/testing/_strategy.py b/packages/zarr-metadata/src/zarr_metadata/testing/_strategy.py new file mode 100644 index 0000000000..73de0f8f5f --- /dev/null +++ b/packages/zarr-metadata/src/zarr_metadata/testing/_strategy.py @@ -0,0 +1,147 @@ +"""What draws one definition's configurations: a record for each kind of definition. + +Each record holds what its kind's configurations depend on, the way +each kind of definition does: a data type's fill values depend on its +configuration, a chunk grid's on the shape of the array, a codec's on +the chunk it is handed. A chunk key encoding's depend on nothing. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, ClassVar, Final, TypeAlias, cast + +from hypothesis import strategies as st + +from zarr_metadata.definition._definition import EmptyConfiguration +from zarr_metadata.v3._definition import ( + Chunk, + ChunkGridDefinition, + ChunkKeyEncodingDefinition, + CodecDefinition, + DataTypeDefinition, +) + +if TYPE_CHECKING: + from hypothesis.strategies import SearchStrategy + + from zarr_metadata._common import JSONValue + from zarr_metadata.definition._definition import Definition + +Configurations: TypeAlias = "SearchStrategy[Mapping[str, JSONValue]]" +"""Configurations of one definition, each the mapping every configuration is.""" + +NOTHING_KNOWN: Final = Chunk() +"""A chunk nothing is known of, which every codec takes.""" + +MAX_RANK: Final = 5 +"""The most axes drawn where the number of axes is not known.""" + +EMPTY_CONFIGURATIONS: Final = st.builds(EmptyConfiguration) +"""A fresh empty configuration for each draw, so a test that changes one changes no other.""" + + +class DefinitionStrategy(ABC): + """How to draw the configurations of one kind of definition.""" + + __slots__ = () + + kind: ClassVar[type[Definition[Any]]] + """The kind of definition whose configurations it draws.""" + + @abstractmethod + def unconstrained(self) -> Configurations: + """Configurations when nothing is known of what they depend on: what the definition's rules accept.""" + + +@dataclass(frozen=True, kw_only=True, slots=True) +class DataTypeStrategy(DefinitionStrategy): + """How to draw a data type's configurations, and the fill values an array of one takes.""" + + kind: ClassVar[type[Definition[Any]]] = DataTypeDefinition + + configurations: Configurations + """Configurations its definition accepts.""" + fill_values: Callable[[Mapping[str, JSONValue]], SearchStrategy[JSONValue]] + """Fill values of the data type a configuration describes.""" + + def unconstrained(self) -> Configurations: + return self.configurations + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ChunkGridStrategy(DefinitionStrategy): + """How to draw a chunk grid's configurations over an array of a shape.""" + + kind: ClassVar[type[Definition[Any]]] = ChunkGridDefinition + + configurations: Callable[[tuple[int, ...] | None], Configurations] + """Configurations of grids that fit an array of the shape. + + Handed None, no shape is known, and what is drawn is what the grid's + rules accept, whatever shape it is put over. + """ + + def unconstrained(self) -> Configurations: + return self.configurations(None) + + +@dataclass(frozen=True, kw_only=True, slots=True) +class ChunkKeyEncodingStrategy(DefinitionStrategy): + """How to draw a chunk key encoding's configurations.""" + + kind: ClassVar[type[Definition[Any]]] = ChunkKeyEncodingDefinition + + configurations: Configurations + """Configurations its definition accepts.""" + + def unconstrained(self) -> Configurations: + return self.configurations + + +@dataclass(frozen=True, kw_only=True, slots=True) +class CodecStrategy(DefinitionStrategy): + """How to draw a codec's configurations, handed a chunk. + + Every codec takes `Chunk()`, a chunk nothing is known of, so a + strategy that draws nothing for it is refused when it is built. + """ + + kind: ClassVar[type[Definition[Any]]] = CodecDefinition + + configurations: Callable[[Chunk], Configurations | None] + """Configurations of the codec handed the chunk; None when no configuration takes it. + + A `bytes` codec handed a `string` chunk is None, since the spec gives + that data type a codec of its own. + """ + + def __post_init__(self) -> None: + if self.configurations(NOTHING_KNOWN) is None: + msg = "a codec strategy draws configurations for Chunk(), a chunk nothing is known of; got None" + raise ValueError(msg) + + def unconstrained(self) -> Configurations: + return cast("Configurations", self.configurations(NOTHING_KNOWN)) + + +Strategies: TypeAlias = Mapping[Any, DefinitionStrategy] +"""How to draw each definition's configurations, by the definition. + +The key is `Any`, not `Definition[Any]`: a mapping's key type is +invariant, so `{MY_CODEC: CodecStrategy(...)}`, a mapping keyed by a +`CodecDefinition`, would not be a `Mapping[Definition[Any], ...]`. +""" + +__all__ = [ + "NOTHING_KNOWN", + "ChunkGridStrategy", + "ChunkKeyEncodingStrategy", + "CodecStrategy", + "Configurations", + "DataTypeStrategy", + "DefinitionStrategy", + "Strategies", +] diff --git a/packages/zarr-metadata/src/zarr_metadata/testing/strategies.py b/packages/zarr-metadata/src/zarr_metadata/testing/strategies.py new file mode 100644 index 0000000000..fd13c43839 --- /dev/null +++ b/packages/zarr-metadata/src/zarr_metadata/testing/strategies.py @@ -0,0 +1,299 @@ +"""Hypothesis strategies for valid Zarr v3 metadata fields. + +Every strategy here draws metadata its definition accepts, and that the +spec allows: a reader, writer or converter can test itself against the +whole space of a codec's, data type's, chunk grid's or chunk key +encoding's configurations instead of the handful its authors thought to +write down. + +```python +from hypothesis import given +from zarr_metadata.testing.strategies import fields +from zarr_metadata.v3.codec.blosc import BLOSC_CODEC + +@given(fields(BLOSC_CODEC)) +def test_my_reader_reads_every_blosc_codec(field): + my_reader.read_codec(field) +``` + +What a configuration depends on, each kind's function takes: +`fill_values` a data type's configuration, `chunk_grid_configurations` +the shape of the array, `codec_configurations` the `Chunk` the codec is +handed. Left out, nothing is known of it, and what is drawn is what the +definition's rules accept. `fields` wraps configurations in the +envelopes a document writes them in. + +How each definition's configurations are drawn is a value, +`Strategies`, keyed by the definition: `DEFAULT_STRATEGIES` holds one +for each definition this package defines that holds no other field. An +extension author adds their own with +`DEFAULT_STRATEGIES | {MY_CODEC: CodecStrategy(...)}`. + +Configurations are drawn at the data level the TypedDicts declare, JSON +arrays as tuples; `json.dumps` takes them as they are. + +Requires the `testing` extra (`pip install zarr-metadata[testing]`). +""" + +from __future__ import annotations + +from functools import partial +from types import MappingProxyType +from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, cast + +from hypothesis import strategies as st + +from zarr_metadata._sentinel import UNSET +from zarr_metadata.definition._definition import Definition, as_kind, canonicalize, kind_of +from zarr_metadata.testing._chunk_grid import CHUNK_GRID_STRATEGIES +from zarr_metadata.testing._chunk_key_encoding import CHUNK_KEY_ENCODING_STRATEGIES +from zarr_metadata.testing._codec import CODEC_STRATEGIES +from zarr_metadata.testing._data_type import DATA_TYPE_STRATEGIES +from zarr_metadata.testing._strategy import ( + NOTHING_KNOWN, + ChunkGridStrategy, + ChunkKeyEncodingStrategy, + CodecStrategy, + Configurations, + DataTypeStrategy, + DefinitionStrategy, + Strategies, +) +from zarr_metadata.v3._context import ZarrV3Context +from zarr_metadata.v3._core import CORE +from zarr_metadata.v3._definition import Chunk, DataTypeDefinition + +if TYPE_CHECKING: + from collections.abc import Mapping + + from hypothesis.strategies import SearchStrategy + + from zarr_metadata._common import JSONValue + from zarr_metadata.v3 import ZarrV3MetadataFieldJSON + from zarr_metadata.v3._context import ZarrV3ContextSlots + from zarr_metadata.v3._definition import ( + ChunkGridDefinition, + ChunkKeyEncodingDefinition, + CodecDefinition, + ) + +C = TypeVar("C") + +Spellings = Literal["all", "canonical"] +"""How fields are written: every spelling the spec allows, or only the simplest. + +`"all"` draws each envelope the spec gives a field -- a bare name, an +object without a configuration, an empty configuration, a +`must_understand` of `true` -- and configurations that say what a +simpler one says, as a `blosc` `typesize` under `noshuffle` does. +`"canonical"` draws each in the spelling `canonicalize` gives it, which +is what a writer that writes the fewest words writes. +""" + +DEFAULT_STRATEGIES: Final[MappingProxyType[Definition[Any], DefinitionStrategy]] = MappingProxyType( + { + **DATA_TYPE_STRATEGIES, + **CHUNK_GRID_STRATEGIES, + **CHUNK_KEY_ENCODING_STRATEGIES, + **CODEC_STRATEGIES, + } +) +"""A strategy for each definition this package defines that holds no other field.""" + + +def _strategy_for(definition: Definition[Any], strategies: Strategies) -> DefinitionStrategy: + """The strategy `strategies` holds for `definition`, which draws configurations of its kind.""" + found = strategies.get(definition) + if found is None: + msg = f"no strategy for {definition.name!r}; pass one in `strategies`" + raise LookupError(msg) + if not isinstance(definition, found.kind): + msg = ( + f"the strategy for {definition.name!r}, a {type(definition).__name__}, draws " + f"configurations of a {found.kind.__name__}: {found!r}" + ) + raise TypeError(msg) + return found + + +def data_type_configurations( + definition: DataTypeDefinition[C], *, strategies: Strategies = DEFAULT_STRATEGIES +) -> SearchStrategy[C]: + """Configurations of the data type `definition` defines.""" + found = cast("DataTypeStrategy", _strategy_for(definition, strategies)) + return cast("SearchStrategy[C]", found.configurations) + + +def fill_values( + definition: DataTypeDefinition[C], + configuration: C, + *, + strategies: Strategies = DEFAULT_STRATEGIES, +) -> SearchStrategy[JSONValue]: + """Fill values of the data type `definition` and `configuration` describe, in every spelling the spec gives them.""" + found = cast("DataTypeStrategy", _strategy_for(definition, strategies)) + return found.fill_values(cast("Mapping[str, JSONValue]", configuration)) + + +def chunk_grid_configurations( + definition: ChunkGridDefinition[C], + shape: tuple[int, ...] | None = None, + *, + strategies: Strategies = DEFAULT_STRATEGIES, +) -> SearchStrategy[C]: + """Configurations of the chunk grid `definition` defines, that fit an array of `shape`. + + With no shape, what is drawn is what the grid's rules accept. + """ + found = cast("ChunkGridStrategy", _strategy_for(definition, strategies)) + return cast("SearchStrategy[C]", found.configurations(shape)) + + +def chunk_key_encoding_configurations( + definition: ChunkKeyEncodingDefinition[C], *, strategies: Strategies = DEFAULT_STRATEGIES +) -> SearchStrategy[C]: + """Configurations of the chunk key encoding `definition` defines.""" + found = cast("ChunkKeyEncodingStrategy", _strategy_for(definition, strategies)) + return cast("SearchStrategy[C]", found.configurations) + + +def codec_configurations( + definition: CodecDefinition[C], + chunk: Chunk = NOTHING_KNOWN, + *, + strategies: Strategies = DEFAULT_STRATEGIES, +) -> SearchStrategy[C] | None: + """Configurations of the codec `definition` defines, handed `chunk`; None when none takes it. + + With no chunk, nothing is known of it, and what is drawn is what the + codec's rules accept. + """ + found = cast("CodecStrategy", _strategy_for(definition, strategies)) + return cast("SearchStrategy[C] | None", found.configurations(chunk)) + + +def _written_bare(definition: Definition[Any]) -> bool: + """Whether the spec has the field written as its bare name: a data type it defines itself. + + "If the data type is defined in this specification, then the value + must be the data type identifier provided as a string" + (https://github.com/zarr-developers/zarr-specs/blob/fc7dd9c9beb5a50b87f9b08b00bf50fc0048482f/docs/v3/core/index.rst#L509-L512). + """ + if not isinstance(definition, DataTypeDefinition): + return False + return CORE.claimant(DataTypeDefinition, definition.name) is definition + + +@st.composite +def _any_spelling( + draw: st.DrawFn, definition: Definition[Any], configurations: SearchStrategy[Any] +) -> ZarrV3MetadataFieldJSON: + """A configuration of `definition` in one of the envelopes the spec allows it. + + A configuration with nothing in it is written as the bare name, an + object without one, or an object with an empty one; any object may + say `must_understand` is `true`, which is what absence means. A name + that carries the configuration, as `r16` does, and a data type the + spec defines itself, are written as the bare name. + """ + configuration = cast("Mapping[str, JSONValue]", draw(configurations)) + carried = definition._carrying_name(configuration) # pyright: ignore[reportPrivateUsage] + if carried is not None: + return carried + if _written_bare(definition): + return definition.name + empty = len(configuration) == 0 + if empty and draw(st.booleans()): + return definition.name + field: dict[str, JSONValue] = {"name": definition.name} + if not empty or draw(st.booleans()): + field["configuration"] = configuration + if draw(st.booleans()): + field["must_understand"] = True + return cast("ZarrV3MetadataFieldJSON", field) + + +def _scope_of(definition: Definition[Any]) -> ZarrV3Context: + """The scope that claims `definition` and nothing else; a `TypeError` for a definition no slot of a Zarr v3 scope holds.""" + for slot, kind in ZarrV3Context.kinds.items(): + if isinstance(definition, kind): + return ZarrV3Context(**cast("ZarrV3ContextSlots", {slot: (definition,)})) + msg = ( + f"{definition.name!r} is a {type(definition).__name__}, " + "which no slot of a Zarr v3 scope holds" + ) + raise TypeError(msg) + + +def _checked( + field: ZarrV3MetadataFieldJSON, + *, + definition: Definition[Any], + kind: type[Definition[Any]], + scope: ZarrV3Context, + spellings: Spellings, +) -> ZarrV3MetadataFieldJSON: + """`field`, or its canonical spelling, once it reads without a problem in `scope`; a `ValueError` when it does not.""" + canonical, problems = canonicalize(field, kind, scope) + if len(problems) != 0: + msg = f"{field!r}, drawn for {definition.name!r}, which is refused: {problems!r}" + raise ValueError(msg) + if spellings == "canonical": + return cast("ZarrV3MetadataFieldJSON", canonical) + return field + + +def fields( + definition: Definition[C], + configurations: SearchStrategy[C] | UNSET = UNSET, + *, + spellings: Spellings = "all", + strategies: Strategies = DEFAULT_STRATEGIES, +) -> SearchStrategy[ZarrV3MetadataFieldJSON]: + """Metadata fields of `definition`: its name and a configuration, in an envelope a document writes. + + `configurations` is what to draw the configuration from, by default + the definition's own with nothing known of what they depend on: pass + `codec_configurations(definition, chunk)` for a codec handed a chunk, + once it is known not to be None. `spellings` says whether every + spelling the spec allows is drawn, or only the simplest. + + Each field is read in a scope of its definition alone, with no chunk + known, and one its definition's rules refuse, as they refuse one + drawn from `configurations` they do not accept, raises a + `ValueError`. A codec's chunk rules are not checked: what fits the + chunk is what `codec_configurations` draws. + """ + drawn: Configurations | SearchStrategy[C] = ( + _strategy_for(definition, strategies).unconstrained() + if configurations is UNSET + else configurations + ) + checked = partial( + _checked, + definition=definition, + kind=as_kind(kind_of(definition)), + scope=_scope_of(definition), + spellings=spellings, + ) + return _any_spelling(definition, drawn).map(checked) + + +__all__ = [ + "DEFAULT_STRATEGIES", + "NOTHING_KNOWN", + "ChunkGridStrategy", + "ChunkKeyEncodingStrategy", + "CodecStrategy", + "Configurations", + "DataTypeStrategy", + "DefinitionStrategy", + "Spellings", + "Strategies", + "chunk_grid_configurations", + "chunk_key_encoding_configurations", + "codec_configurations", + "data_type_configurations", + "fields", + "fill_values", +] diff --git a/packages/zarr-metadata/tests/test_public_api.py b/packages/zarr-metadata/tests/test_public_api.py index e0095aec31..0baad413c2 100644 --- a/packages/zarr-metadata/tests/test_public_api.py +++ b/packages/zarr-metadata/tests/test_public_api.py @@ -277,6 +277,9 @@ def test_all_is_grouped_and_unique() -> None: # example, `ZarrV3CodecField`, read in the same scope as the field that # contains it. "Field", + # How to draw one kind of definition's configurations, in + # `zarr_metadata.testing.strategies`: `CodecStrategy`. + "Strategy", ) _EXTENSION_NAME = re.compile(r"^(?:[A-Z][a-z0-9]*)+?(?:" + "|".join(_EXTENSION_ROLES) + r")$") @@ -294,6 +297,9 @@ def test_all_is_grouped_and_unique() -> None: # two definitions of one name. "ClaimKey", "Conflict", + # What one definition's configurations are drawn from, in + # `zarr_metadata.testing.strategies`. + "Configurations", "ScopeConflictError", # A reader's problems, as the signatures of every reader name them. "Problems", @@ -339,6 +345,8 @@ def test_all_is_grouped_and_unique() -> None: "ScalarMap", "ScalarMapEntry", "ShardingIndexLocation", + "Spellings", + "Strategies", "Struct", "StructField", "ValidationProblem", diff --git a/packages/zarr-metadata/tests/testing/__init__.py b/packages/zarr-metadata/tests/testing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/zarr-metadata/tests/testing/test_strategies.py b/packages/zarr-metadata/tests/testing/test_strategies.py new file mode 100644 index 0000000000..c0d8aa0e3d --- /dev/null +++ b/packages/zarr-metadata/tests/testing/test_strategies.py @@ -0,0 +1,432 @@ +"""The strategies against the definitions they draw for, and against what they ought to reach. + +Every draw is read in `CORE_AND_EXTENSIONS` and must read without a +problem, by the definition it was drawn for: the field alone, a fill +value beside its data type, a chunk grid over the shape it was drawn +for, a codec handed the chunk it was drawn for. A strategy that only +drew valid metadata could draw almost none, so reach is checked too, +with targets worked out from each configuration's TypedDict rather than +from the strategies: every optional key present and absent, every +`Literal` value, and for a definition with a simplest spelling, a +configuration it simplifies. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, Literal, cast, get_args, get_origin + +import pytest +from hypothesis import event, find, given, settings +from hypothesis import strategies as st + +from zarr_metadata._typed_json import typeddict_keys +from zarr_metadata.definition import ( + AcceptedField, + Definition, + EmptyConfiguration, + fill_value_problems, + resolve, +) +from zarr_metadata.definition._definition import as_kind, canonicalize, kind_of, unchanged +from zarr_metadata.testing.strategies import ( + DEFAULT_STRATEGIES, + ChunkKeyEncodingStrategy, + CodecStrategy, + Spellings, + chunk_grid_configurations, + codec_configurations, + data_type_configurations, + fields, + fill_values, +) +from zarr_metadata.v2.definition import CORE_V2 +from zarr_metadata.v3._definition import chunk_grid_lengths +from zarr_metadata.v3.chunk_grid.rectilinear import RECTILINEAR_CHUNK_GRID +from zarr_metadata.v3.codec.cast_value import CAST_VALUE_CODEC +from zarr_metadata.v3.codec.gzip import GZIP_CODEC +from zarr_metadata.v3.codec.scale_offset import SCALE_OFFSET_CODEC +from zarr_metadata.v3.codec.sharding_indexed import SHARDING_INDEXED_CODEC +from zarr_metadata.v3.data_type._float import FloatSpecialFillValue +from zarr_metadata.v3.data_type.bytes import BYTES_DATA_TYPE +from zarr_metadata.v3.data_type.complex64 import COMPLEX64_DATA_TYPE +from zarr_metadata.v3.data_type.complex128 import COMPLEX128_DATA_TYPE +from zarr_metadata.v3.data_type.float16 import FLOAT16_DATA_TYPE +from zarr_metadata.v3.data_type.float32 import FLOAT32_DATA_TYPE +from zarr_metadata.v3.data_type.float64 import FLOAT64_DATA_TYPE +from zarr_metadata.v3.data_type.numpy_datetime64 import NUMPY_DATETIME64_DATA_TYPE +from zarr_metadata.v3.data_type.numpy_timedelta64 import NUMPY_TIMEDELTA64_DATA_TYPE +from zarr_metadata.v3.data_type.struct import STRUCT_DATA_TYPE +from zarr_metadata.v3.definition import ( + CORE, + CORE_AND_EXTENSIONS, + Chunk, + ChunkGridDefinition, + CodecDefinition, + DataTypeDefinition, + ZarrV3Context, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + + from zarr_metadata._common import JSONValue + from zarr_metadata.v3 import ZarrV3MetadataFieldJSON + +_HOLDING_FIELDS = frozenset({SHARDING_INDEXED_CODEC, CAST_VALUE_CODEC, STRUCT_DATA_TYPE}) +"""The definitions whose configurations hold other fields, which have no strategy yet.""" + +_IN_SCOPE = frozenset( + ( + *CORE_AND_EXTENSIONS.data_types.values(), + *CORE_AND_EXTENSIONS.chunk_grids.values(), + *CORE_AND_EXTENSIONS.chunk_key_encodings.values(), + *CORE_AND_EXTENSIONS.codecs.values(), + *CORE_AND_EXTENSIONS.storage_transformers.values(), + ) +) +"""The definitions at the spec's extension points that `CORE_AND_EXTENSIONS` holds; members of a document it defines are not fields.""" +_CORE_DATA_TYPES = frozenset(CORE.data_types.values()) + +_DEFINITIONS = sorted(DEFAULT_STRATEGIES, key=lambda definition: definition.name) +_DATA_TYPES = [d for d in _DEFINITIONS if isinstance(d, DataTypeDefinition)] +_CHUNK_GRIDS = [d for d in _DEFINITIONS if isinstance(d, ChunkGridDefinition)] +_CODECS = [d for d in _DEFINITIONS if isinstance(d, CodecDefinition)] + +_SHAPES = st.lists(st.integers(0, 12), max_size=4).map(tuple) + + +def _id(value: object) -> str: + if isinstance(value, Definition): + return f"{type(value).__name__}:{value.name}" + return str(value) + + +def _read(definition: Definition[Any], field: object) -> Any: # noqa: ANN401 + """`field` read in `CORE_AND_EXTENSIONS`, asserted to read without a problem by `definition`.""" + resolved, problems = resolve(field, as_kind(kind_of(definition)), CORE_AND_EXTENSIONS) + assert problems == () + assert isinstance(resolved, AcceptedField) + assert resolved.definition is definition + return resolved + + +def test_every_definition_has_a_strategy() -> None: + """Each definition in scope has a strategy, but those that hold other fields; and none is out of scope.""" + assert _IN_SCOPE - frozenset(DEFAULT_STRATEGIES) == _HOLDING_FIELDS + assert frozenset(DEFAULT_STRATEGIES) <= _IN_SCOPE + + +@pytest.mark.parametrize("spellings", get_args(Spellings)) +@pytest.mark.parametrize("definition", _DEFINITIONS, ids=_id) +@given(data=st.data()) +def test_fields_read( + definition: Definition[Any], spellings: Spellings, data: st.DataObject +) -> None: + """A drawn field reads by its definition, through JSON too; a canonical one is its own simplest spelling.""" + field = data.draw(fields(definition, spellings=spellings)) + _read(definition, field) + _read(definition, json.loads(json.dumps(field))) + if definition in _CORE_DATA_TYPES: + assert isinstance(field, str) + if spellings == "canonical": + canonical, _ = canonicalize(field, as_kind(kind_of(definition)), CORE_AND_EXTENSIONS) + assert canonical == field + + +@pytest.mark.parametrize("definition", _DEFINITIONS, ids=_id) +@given(data=st.data()) +def test_a_changed_draw_changes_no_other(definition: Definition[Any], data: st.DataObject) -> None: + """Each draw is a fresh value: a test that changes the configuration it drew does not change the next draw, which Hypothesis would report as flaky.""" + field = data.draw(fields(definition)) + if isinstance(field, dict) and "configuration" in field: + configuration = cast("dict[str, JSONValue]", field["configuration"]) + assert "changed" not in configuration + configuration["changed"] = True + + +@pytest.mark.parametrize("definition", _DATA_TYPES, ids=_id) +@given(data=st.data()) +def test_fill_values_fit_their_data_type( + definition: DataTypeDefinition[Any], data: st.DataObject +) -> None: + configuration = data.draw(data_type_configurations(definition)) + data_type = _read(definition, data.draw(fields(definition, st.just(configuration)))) + value = data.draw(fill_values(definition, configuration)) + assert fill_value_problems(data_type, value) == () + assert fill_value_problems(data_type, json.loads(json.dumps(value))) == () + + +@pytest.mark.parametrize("definition", _CHUNK_GRIDS, ids=_id) +@given(data=st.data(), shape=_SHAPES) +def test_chunk_grids_fit_their_shape( + definition: ChunkGridDefinition[Any], data: st.DataObject, shape: tuple[int, ...] +) -> None: + configurations = chunk_grid_configurations(definition, shape) + chunk_grid = _read(definition, data.draw(fields(definition, configurations))) + lengths, problems = chunk_grid_lengths(chunk_grid, shape) + assert problems == () + # "Chunk sizes must be greater than zero", which the regular grid's rules do not yet refuse. + assert all(0 not in axis for axis in lengths if axis is not None) + + +@st.composite +def _chunks(draw: st.DrawFn) -> Chunk: + """A chunk of a data type drawn from the strategies, or of none known, with lengths known along some axes, all, or none.""" + definition = draw(st.none() | st.sampled_from(_DATA_TYPES)) + data_type = None if definition is None else _read(definition, draw(fields(definition))) + axis = st.none() | st.integers(1, 8).map(lambda length: frozenset({length})) + lengths = draw(st.none() | st.lists(axis, max_size=4).map(tuple)) + return Chunk(lengths, data_type) + + +@pytest.mark.parametrize("definition", _CODECS, ids=_id) +@given(data=st.data(), chunk=_chunks()) +def test_codecs_take_their_chunk( + definition: CodecDefinition[Any], data: st.DataObject, chunk: Chunk +) -> None: + """A codec drawn for a chunk takes it; one drawn for none is one the spec does not give it, which needs the data type known.""" + configurations = codec_configurations(definition, chunk) + if configurations is None: + assert chunk.data_type is not None + assert chunk.data_type.definition is not None + data_type_name = chunk.data_type.definition.name + event(f"takes no {data_type_name}") + codec = _read(definition, data.draw(fields(definition))) + assert list(definition.chunk_rules(codec.configuration, codec.nested, chunk)) != [] + return + codec = _read(definition, data.draw(fields(definition, configurations))) + assert list(definition.chunk_rules(codec.configuration, codec.nested, chunk)) == [] + + +Target = tuple[Literal["present", "absent"], str] | tuple[Literal["value"], str, object] +FieldForm = Literal["bare", "object", "configured", "must_understand"] + + +def _configuration_targets( + definition: Definition[Any], +) -> list[Target | tuple[Literal["simplified"]]]: + """What a strategy for `definition` ought to reach, by its configuration's TypedDict.""" + targets: list[Target | tuple[Literal["simplified"]]] = [] + for key, (annotation, required) in typeddict_keys(definition.configuration).members.items(): + if not required: + targets.extend([("present", key), ("absent", key)]) + if get_origin(annotation) is Literal: + targets.extend(("value", key, value) for value in get_args(annotation)) + if definition.canonical is not unchanged: + targets.append(("simplified",)) + return targets + + +def _field_forms(definition: Definition[Any]) -> list[FieldForm]: + """The envelopes the spec allows a field of `definition` to be written in.""" + if definition in _CORE_DATA_TYPES: + return [] # only ever bare, which `test_fields_read` asserts + if definition.requires_configuration: + return ["configured", "must_understand"] + return ["bare", "object", "configured", "must_understand"] + + +def _form_of(field: ZarrV3MetadataFieldJSON) -> set[FieldForm]: + if isinstance(field, str): + return {"bare"} + forms: set[FieldForm] = {"configured" if "configuration" in field else "object"} + if "must_understand" in field: + forms.add("must_understand") + return forms + + +def _hits( + definition: Definition[Any], + target: Target | tuple[Literal["simplified"]], + configuration: Mapping[str, JSONValue], +) -> bool: + match target: + case ("present", key): + return key in configuration + case ("absent", key): + return key not in configuration + case ("value", key, value): + return configuration.get(key) == value + case ("simplified",): + return definition.canonical(configuration) != configuration + + +_REACH = settings(max_examples=2000, database=None) + + +@pytest.mark.parametrize( + ("definition", "target"), + [(d, target) for d in _DEFINITIONS for target in _configuration_targets(d)], + ids=_id, +) +def test_configurations_reach( + definition: Definition[Any], target: Target | tuple[Literal["simplified"]] +) -> None: + configurations = fields(definition).map(lambda field: _read(definition, field).configuration) + find( + configurations, + lambda configuration: _hits(definition, target, configuration), + settings=_REACH, + ) + + +@pytest.mark.parametrize( + ("definition", "form"), + [(d, form) for d in _DEFINITIONS for form in _field_forms(d)], + ids=_id, +) +def test_fields_reach(definition: Definition[Any], form: FieldForm) -> None: + find(fields(definition), lambda field: form in _form_of(field), settings=_REACH) + + +def test_an_added_strategy_is_drawn_from() -> None: + """A strategy joined to the defaults, as an extension author adds one, is the one drawn from.""" + level_three = CodecStrategy(configurations=lambda _chunk: st.just({"level": 3})) + strategies = DEFAULT_STRATEGIES | {GZIP_CODEC: level_three} + field = find(fields(GZIP_CODEC, strategies=strategies), lambda _field: True, settings=_REACH) + assert _read(GZIP_CODEC, field).configuration == {"level": 3} + + +def test_a_definition_without_a_strategy_is_a_lookup_error() -> None: + with pytest.raises(LookupError, match="no strategy for 'sharding_indexed'"): + fields(SHARDING_INDEXED_CODEC) + + +def test_a_strategy_of_another_kind_is_a_type_error() -> None: + # Unannotated, as an extension author writes it: a mapping keyed by a + # `CodecDefinition` is a `Strategies`. + strategies = {GZIP_CODEC: ChunkKeyEncodingStrategy(configurations=st.just({}))} + with pytest.raises(TypeError, match="draws configurations of a ChunkKeyEncodingDefinition"): + codec_configurations(GZIP_CODEC, strategies=strategies) + + +def test_a_codec_strategy_refusing_an_unknown_chunk_is_a_value_error() -> None: + with pytest.raises(ValueError, match=r"Chunk\(\), a chunk nothing is known of"): + CodecStrategy(configurations=lambda _chunk: None) + + +@pytest.mark.parametrize("spellings", get_args(Spellings)) +def test_a_refused_draw_is_a_value_error(spellings: Spellings) -> None: + refused = fields(GZIP_CODEC, st.just({"level": 99}), spellings=spellings) + with pytest.raises(ValueError, match="which is refused"): + find(refused, lambda _field: True, settings=settings(database=None)) + + +_BFLOAT16 = DataTypeDefinition(name="bfloat16", configuration=EmptyConfiguration) +"""A number type this package does not define, as an extension author would.""" + + +@given(data=st.data()) +def test_scale_offset_takes_a_data_type_the_package_does_not_define(data: st.DataObject) -> None: + """Values of a type the package does not define cannot be drawn, so the configuration is the identity, which its rules take.""" + data_type, problems = resolve( + "bfloat16", DataTypeDefinition, ZarrV3Context(data_types=[_BFLOAT16]) + ) + assert problems == () + chunk = Chunk(None, data_type) + configurations = codec_configurations(SCALE_OFFSET_CODEC, chunk) + assert configurations is not None + configuration = data.draw(configurations) + assert configuration == {} + assert list(SCALE_OFFSET_CODEC.chunk_rules(configuration, {}, chunk)) == [] + + +def test_a_definition_no_v3_scope_holds_is_a_type_error() -> None: + zlib = CORE_V2.codecs["zlib"] + with pytest.raises(TypeError, match="which no slot of a Zarr v3 scope holds"): + fields(zlib, st.just({})) + + +@pytest.mark.parametrize( + "definition", [FLOAT16_DATA_TYPE, FLOAT32_DATA_TYPE, FLOAT64_DATA_TYPE], ids=_id +) +@pytest.mark.parametrize("member", ["offset", "scale"]) +def test_scale_offset_reaches_a_value_written_in_hex( + definition: DataTypeDefinition[Any], member: str +) -> None: + """A float's `offset` and `scale` are fill values of its type, which the bits in hex spell too.""" + chunk = Chunk(None, _read(definition, definition.name)) + configurations = codec_configurations(SCALE_OFFSET_CODEC, chunk) + assert configurations is not None + find( + configurations, + lambda configuration: isinstance(configuration.get(member), str), + settings=_REACH, + ) + + +def _has_pair(configuration: Mapping[str, Any]) -> bool: + return any( + isinstance(entry, tuple) + for dimension in configuration["chunk_shapes"] + if isinstance(dimension, tuple) + for entry in cast("tuple[object, ...]", dimension) + ) + + +def test_rectilinear_edges_reach_a_run_written_as_a_pair() -> None: + """The `[size, count]` spelling, which no TypedDict target asks for: it is one arm of a union.""" + find(chunk_grid_configurations(RECTILINEAR_CHUNK_GRID), _has_pair, settings=_REACH) + + +FillValueSpelling = Literal[ + "number", "named", "hex lowercase", "hex uppercase", "array", "base64", "NaT" +] + +_FILL_VALUE_SPELLINGS: list[tuple[DataTypeDefinition[Any], FillValueSpelling]] = [ + *( + (definition, spelling) + for definition in ( + FLOAT16_DATA_TYPE, + FLOAT32_DATA_TYPE, + FLOAT64_DATA_TYPE, + COMPLEX64_DATA_TYPE, + COMPLEX128_DATA_TYPE, + ) + for spelling in get_args(FillValueSpelling)[:4] + ), + (BYTES_DATA_TYPE, "array"), + (BYTES_DATA_TYPE, "base64"), + (NUMPY_DATETIME64_DATA_TYPE, "number"), + (NUMPY_DATETIME64_DATA_TYPE, "NaT"), + (NUMPY_TIMEDELTA64_DATA_TYPE, "number"), + (NUMPY_TIMEDELTA64_DATA_TYPE, "NaT"), +] +"""The spellings the specs give a fill value, by the data types whose fill values take them.""" + + +def _spellings_of(value: object) -> set[FillValueSpelling]: + """How a fill value is spelled; a complex one, how either component is.""" + if isinstance(value, bool): + return set() + if isinstance(value, int | float): + return {"number"} + if isinstance(value, tuple): + parts = cast("tuple[object, ...]", value) + if all(isinstance(part, int) for part in parts): + return {"array"} + return {spelling for part in parts for spelling in _spellings_of(part)} + if not isinstance(value, str): + return set() + if value == "NaT": + return {"NaT"} + if value in get_args(FloatSpecialFillValue): + return {"named"} + if value.startswith("0x"): + digits = value[2:] + return { + *(("hex lowercase",) if any(c in "abcdef" for c in digits) else ()), + *(("hex uppercase",) if any(c in "ABCDEF" for c in digits) else ()), + } + return {"base64"} + + +@pytest.mark.parametrize(("definition", "spelling"), _FILL_VALUE_SPELLINGS, ids=_id) +def test_fill_values_reach( + definition: DataTypeDefinition[Any], spelling: FillValueSpelling +) -> None: + values = data_type_configurations(definition).flatmap( + lambda configuration: fill_values(definition, configuration) + ) + find(values, lambda value: spelling in _spellings_of(value), settings=_REACH) diff --git a/packages/zarr-metadata/uv.lock b/packages/zarr-metadata/uv.lock index 9c21f5a4c9..1679e33309 100644 --- a/packages/zarr-metadata/uv.lock +++ b/packages/zarr-metadata/uv.lock @@ -1233,6 +1233,11 @@ dependencies = [ { name = "typing-extensions" }, ] +[package.optional-dependencies] +testing = [ + { name = "hypothesis" }, +] + [package.dev-dependencies] docs = [ { name = "griffe-inherited-docstrings" }, @@ -1254,8 +1259,10 @@ test = [ [package.metadata] requires-dist = [ { name = "annotated-types", specifier = ">=0.6" }, + { name = "hypothesis", marker = "extra == 'testing'", specifier = ">=6.85" }, { name = "typing-extensions", specifier = ">=4.16" }, ] +provides-extras = ["testing"] [package.metadata.requires-dev] docs = [ diff --git a/pyproject.toml b/pyproject.toml index 2554180f38..136f757dac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,6 @@ test = [ "pytest-xdist==3.8.0", "pytest-benchmark==5.3.0", "pytest-codspeed==5.0.3", - "tomlkit==0.15.2", "uv==0.13.5", ] remote-tests = [ @@ -463,6 +462,7 @@ markers = [ "gpu: mark a test as requiring CuPy and GPU", "s3: mark a test as requiring a (mock) S3 backend via moto", "slow_hypothesis: slow hypothesis tests", + "examples: runs a script in examples/; skipped unless --run-examples is passed", ] [tool.repo-review] diff --git a/tests/conftest.py b/tests/conftest.py index 3e87a724e0..89eb90a1d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -262,15 +262,29 @@ def pytest_addoption(parser: Any) -> None: default=False, help="run slow hypothesis tests", ) + parser.addoption( + "--run-examples", + action="store_true", + default=False, + help="run the scripts in examples/", + ) + + +# Tests with one of these markers are skipped unless the matching option is passed. +_OPT_IN_MARKERS = { + "slow_hypothesis": "--run-slow-hypothesis", + "examples": "--run-examples", +} def pytest_collection_modifyitems(config: Any, items: Any) -> None: - if config.getoption("--run-slow-hypothesis"): - return - skip_slow_hyp = pytest.mark.skip(reason="need --run-slow-hypothesis option to run") - for item in items: - if "slow_hypothesis" in item.keywords: - item.add_marker(skip_slow_hyp) + for marker, option in _OPT_IN_MARKERS.items(): + if config.getoption(option): + continue + skip = pytest.mark.skip(reason=f"need {option} option to run") + for item in items: + if marker in item.keywords: + item.add_marker(skip) settings.register_profile( diff --git a/tests/test_examples.py b/tests/test_examples.py index a6634e8cc6..2421a3d0ca 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,87 +1,45 @@ from __future__ import annotations -import re import subprocess -import sys from pathlib import Path -from typing import Final import pytest -import tomlkit -from packaging.requirements import Requirement -examples_dir = "examples" -script_paths = tuple(Path(examples_dir).rglob("*.py")) +ZARR_PROJECT_PATH = Path(__file__).parent.parent +EXAMPLE_PATHS = tuple(sorted((ZARR_PROJECT_PATH / "examples").glob("*/*.py"))) -PEP_723_REGEX: Final = r"(?m)^# /// (?P[a-zA-Z0-9-]+)$\s(?P(^#(| .*)$\s)+)^# ///$" -# This is the absolute path to the local Zarr installation. Moving this test to a different directory will break it. -ZARR_PROJECT_PATH = Path(".").absolute() +def test_example_paths() -> None: + """The example scripts are found, so the parametrized test below is not empty.""" + assert len(EXAMPLE_PATHS) > 0 -def set_dep(script: str, dependency: str) -> str: +@pytest.mark.examples +@pytest.mark.parametrize("script_path", EXAMPLE_PATHS, ids=lambda path: path.stem) +def test_example_runs(script_path: Path, tmp_path: Path) -> None: """ - 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. + Each example runs against this checkout, in the environment its PEP 723 header defines. - Source code modified from - https://packaging.python.org/en/latest/specifications/inline-script-metadata/#reference-implementation + The test copies the script and uses `uv add` to point the copy's zarr requirement at + this checkout. `uv add` also resolves the header, so a header that cannot be + satisfied fails here. """ - match = re.search(PEP_723_REGEX, script) - - if match is None: - 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 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: - """ - Test that our test fixture is working properly and collecting script paths. - """ - assert len(script_paths) > 0 - - -@pytest.mark.skipif( - 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, 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", "--refresh", str(dest_path)], capture_output=True, text=True, check=False + dest_path.write_text(script_path.read_text()) + + add = subprocess.run( + ["uv", "add", "--quiet", "--script", str(dest_path), str(ZARR_PROJECT_PATH)], + capture_output=True, + text=True, + check=False, ) - assert result.returncode == 0, ( - f"Script at {script_path} failed to run. Output: {result.stdout} Error: {result.stderr}" + assert add.returncode == 0, f"uv add failed for {script_path}:\n{add.stderr}" + + # `--reinstall-package` rebuilds zarr, so the run sees the current source. + run = subprocess.run( + ["uv", "run", "--reinstall-package", "zarr", str(dest_path)], + capture_output=True, + text=True, + check=False, ) + assert run.returncode == 0, f"{script_path} failed:\n{run.stdout}\n{run.stderr}" diff --git a/uv.lock b/uv.lock index f8094ec90e..e78182c154 100644 --- a/uv.lock +++ b/uv.lock @@ -3435,15 +3435,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610, upload-time = "2024-10-24T14:58:28.029Z" }, ] -[[package]] -name = "tomlkit" -version = "0.15.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz", hash = "sha256:83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6", size = 190522, upload-time = "2026-10-16T04:07:08.166Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl", hash = "sha256:05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437", size = 52225, upload-time = "2026-10-16T04:07:09.216Z" }, -] - [[package]] name = "towncrier" version = "26.9.0" @@ -3846,7 +3837,6 @@ dev = [ { name = "requests" }, { name = "ruff" }, { name = "s3fs" }, - { name = "tomlkit" }, { name = "towncrier" }, { name = "universal-pathlib" }, { name = "uv" }, @@ -3888,7 +3878,6 @@ remote-tests = [ { name = "pytest-xdist" }, { name = "requests" }, { name = "s3fs" }, - { name = "tomlkit" }, { name = "uv" }, ] test = [ @@ -3903,7 +3892,6 @@ test = [ { name = "pytest-cov" }, { name = "pytest-reportlog" }, { name = "pytest-xdist" }, - { name = "tomlkit" }, { name = "uv" }, ] @@ -3956,7 +3944,6 @@ dev = [ { name = "requests", specifier = "==2.34.2" }, { name = "ruff", specifier = "==0.17.1" }, { name = "s3fs", specifier = ">=2023.10.0" }, - { name = "tomlkit", specifier = "==0.15.2" }, { name = "towncrier", specifier = "==26.9.0" }, { name = "universal-pathlib" }, { name = "uv", specifier = "==0.13.5" }, @@ -3996,7 +3983,6 @@ remote-tests = [ { name = "pytest-xdist", specifier = "==3.8.0" }, { name = "requests", specifier = "==2.34.2" }, { name = "s3fs", specifier = ">=2023.10.0" }, - { name = "tomlkit", specifier = "==0.15.2" }, { name = "uv", specifier = "==0.13.5" }, ] test = [ @@ -4011,6 +3997,5 @@ test = [ { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-reportlog", specifier = "==1.0.0" }, { name = "pytest-xdist", specifier = "==3.8.0" }, - { name = "tomlkit", specifier = "==0.15.2" }, { name = "uv", specifier = "==0.13.5" }, ]