Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/4260.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The `cast_value` codec now requires `cast-value-rs>=0.4.2`. Earlier versions of that backend silently corrupted data when handed an array that was not row-major — the layout the `transpose` codec produces — so a `cast_value` codec next to a `transpose` codec would either write transposed values with no error or fail with `ValueError: Input array must be contiguous`. The minimum version is enforced at runtime as well as in the package metadata, so an environment that already has an older `cast-value-rs` installed now raises `ImportError` when the codec is used, instead of corrupting data.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ remote = [
gpu = [
"cupy-cuda12x; sys_platform != 'darwin'",
]
cast-value-rs = ["cast-value-rs"]
cast-value-rs = ["cast-value-rs>=0.4.2"]
cli = ["typer"]
optional = ["universal-pathlib"]

Expand Down
45 changes: 36 additions & 9 deletions src/zarr/codecs/cast_value.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@
rounding, out-of-range handling, and explicit scalar mappings.

Requires the optional ``cast-value-rs`` package for the actual casting
logic. Install it with: ``pip install cast-value-rs``.
logic. Install it with: ``pip install 'cast-value-rs>=0.4.2'``.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass, replace
from importlib.metadata import PackageNotFoundError, version
from typing import TYPE_CHECKING, Final, Literal, TypedDict, cast

import numpy as np
from packaging.version import Version

from zarr.abc.codec import ArrayArrayCodec
from zarr.core.common import JSON, parse_named_configuration
Expand Down Expand Up @@ -123,12 +125,40 @@ def parse_scalar_map(obj: ScalarMapJSON | ScalarMap) -> ScalarMap:
# Backend: cast-value-rs
# ---------------------------------------------------------------------------

# Versions below this silently transpose input arrays that are not row-major -
# the layout `transpose` hands to the next codec - writing corrupted data with
# no error. Keep in sync with the `cast-value-rs` extra in pyproject.toml.
CAST_VALUE_RS_MIN_VERSION: Final = "0.4.2"

_INSTALL_HINT: Final = f"Install it with: pip install 'cast-value-rs>={CAST_VALUE_RS_MIN_VERSION}'"


def _check_backend_version() -> str | None:
"""Return a message describing an unusable backend version, or `None` if it is usable."""
try:
installed = version("cast-value-rs")
except PackageNotFoundError:
# Importable but without distribution metadata, e.g. a `maturin develop`
# build. There is no version to compare, so let it through.
return None
if Version(installed) < Version(CAST_VALUE_RS_MIN_VERSION):
return (
f"The cast_value codec requires cast-value-rs >= {CAST_VALUE_RS_MIN_VERSION}, "
f"but version {installed} is installed. Earlier versions silently corrupt data "
f"when the input array is not row-major. {_INSTALL_HINT}"
)
return None


# Set once at import; raised from `_do_cast`, so an unusable backend does not
# make `import zarr` fail for users who never touch this codec.
_BACKEND_ERROR: str | None
try:
from cast_value_rs import cast_array as cast_array_rs

_HAS_RUST_BACKEND = True
except ModuleNotFoundError:
_HAS_RUST_BACKEND = False
_BACKEND_ERROR = f"The cast_value codec requires the 'cast-value-rs' package. {_INSTALL_HINT}"
else:
_BACKEND_ERROR = _check_backend_version()


def _check_representable(
Expand Down Expand Up @@ -305,11 +335,8 @@ def _do_cast(
target_dtype: np.dtype,
scalar_map: Mapping[str | float | int, str | float | int] | None,
) -> np.ndarray:
if not _HAS_RUST_BACKEND:
raise ImportError(
"The cast_value codec requires the 'cast-value-rs' package. "
"Install it with: pip install cast-value-rs"
)
if _BACKEND_ERROR is not None:
raise ImportError(_BACKEND_ERROR)
scalar_map_entries: dict[float | int, float | int] | None = None
if scalar_map is not None:
src_dtype = arr.dtype
Expand Down
134 changes: 134 additions & 0 deletions tests/test_codecs/test_cast_value.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@

import numpy as np
import pytest
from numpy.testing import assert_array_equal

import zarr
from tests.conftest import Expect, ExpectFail
from zarr.codecs import BytesCodec, TransposeCodec
from zarr.codecs.cast_value import CastValue
from zarr.storage import MemoryStore

try:
import cast_value_rs # noqa: F401
Expand Down Expand Up @@ -477,3 +480,134 @@ def test_parse_scalar_map(case: Expect[Any, Any]) -> None:
from zarr.codecs.cast_value import parse_scalar_map

assert parse_scalar_map(case.input) == case.output


# ---------------------------------------------------------------------------
# Backend version guard
# ---------------------------------------------------------------------------


@pytest.mark.parametrize(
"case",
[
Expect(input="0.4.2", output=None, id="exactly-minimum"),
Expect(input="0.4.3", output=None, id="newer-patch"),
Expect(input="0.5.0", output=None, id="newer-minor"),
Expect(input="1.0.0", output=None, id="newer-major"),
Expect(input="0.4.2.post1", output=None, id="post-release"),
Expect(input="0.4.0", output="0.4.0", id="known-corrupting"),
Expect(input="0.4.1", output="0.4.1", id="one-patch-below"),
Expect(input="0.3.0", output="0.3.0", id="older-minor"),
Expect(input="0.4.2.dev1", output="0.4.2.dev1", id="pre-release-of-minimum"),
],
ids=lambda c: c.id,
)
def test_check_backend_version(
case: Expect[str, str | None], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Versions at or above the minimum pass; older ones report the installed version."""
from zarr.codecs import cast_value as mod

monkeypatch.setattr(mod, "version", lambda _: case.input)
result = mod._check_backend_version()

if case.output is None:
assert result is None
else:
assert result is not None
assert case.output in result
assert mod.CAST_VALUE_RS_MIN_VERSION in result


def test_check_backend_version_allows_missing_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
"""A backend without distribution metadata is allowed: there is no version to compare."""
from importlib.metadata import PackageNotFoundError

from zarr.codecs import cast_value as mod

def raise_not_found(_: str) -> str:
raise PackageNotFoundError

monkeypatch.setattr(mod, "version", raise_not_found)
assert mod._check_backend_version() is None


def test_encode_rejects_outdated_backend(monkeypatch: pytest.MonkeyPatch) -> None:
"""Using the codec with an outdated backend raises instead of corrupting data."""
from zarr.codecs import cast_value as mod

monkeypatch.setattr(mod, "_BACKEND_ERROR", "outdated backend")
codec = CastValue(data_type="uint16")

with pytest.raises(ImportError, match="outdated backend"):
codec._do_cast(
np.arange(4, dtype=np.float32), target_dtype=np.dtype("uint16"), scalar_map=None
)


def test_min_version_matches_pyproject() -> None:
"""The runtime floor and the packaging floor must not drift apart."""
import re
import tomllib
from pathlib import Path

from zarr.codecs.cast_value import CAST_VALUE_RS_MIN_VERSION

pyproject = Path(__file__).parents[2] / "pyproject.toml"
if not pyproject.is_file():
pytest.skip("pyproject.toml is not available in an installed checkout")

with pyproject.open("rb") as f:
extras = tomllib.load(f)["project"]["optional-dependencies"]

(requirement,) = extras["cast-value-rs"]
match = re.fullmatch(r"cast-value-rs>=(?P<version>[\w.]+)", requirement)
assert match is not None, f"unexpected requirement form: {requirement!r}"
assert match.group("version") == CAST_VALUE_RS_MIN_VERSION


# ---------------------------------------------------------------------------
# Non-contiguous input (regression for #4237)
# ---------------------------------------------------------------------------


@requires_cast_value_rs
def test_enforce_contiguous_arrays() -> None:
"""
Transpose codec produces non-contiguous arrays.
Ensure cast_value makes them contiguous before processing.
"""
data = np.arange(20, dtype=np.float32).reshape(5, 2, 2)

def make_array(filters: list[Any]) -> Any:
return zarr.create_array(
store=MemoryStore(),
shape=data.shape,
dtype=data.dtype,
chunks=data.shape,
filters=filters,
serializer=BytesCodec(endian="little"),
compressors=None,
zarr_format=3,
)

# Cast before transpose
array = make_array(
[
CastValue(data_type="uint16"),
TransposeCodec(order=(1, 2, 0)),
]
)
array[:] = data
assert_array_equal(array[:], data)

# Cast after transpose
array = make_array(
[
TransposeCodec(order=(1, 2, 0)),
CastValue(data_type="uint16"),
]
)

array[:] = data
assert_array_equal(array[:], data)
Loading
Loading