From a521d7a0c81450db2c441454fbaf88c4ccca0094 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 12 Aug 2026 18:12:03 +0200 Subject: [PATCH 1/2] fix(python): handle non-row-major and zero-size input arrays Two input-handling bugs in the bindings, both reachable from Zarr: 1. The bindings handed the input array's raw buffer to the conversion kernels via numpy::PyReadonlyArray::as_slice, whose guard accepts column-major arrays as well as row-major ones. The kernels then wrote those elements into a freshly allocated row-major output of the same shape, silently transposing the data. Arrays that were neither C- nor F-contiguous were rejected with "Input array must be contiguous". The Zarr transpose codec hands the next codec a transposed view -- column-major in 2-D, strided in higher dimensions -- so both cases occur in routine pipelines (zarr-developers/zarr-python#4237). The Python wrapper now normalizes the input with np.asarray(arr, order="C"): a no-op for row-major arrays, a copy for anything else, and 0-d-preserving (unlike np.ascontiguousarray). The binding keeps a strict backstop for direct callers of the private module, rejecting non-row-major input instead of misreading it. Normalizing on the Rust side instead (ndarray's as_standard_layout) was tried and rejected: ndarray's raw-view stride assertions panic on layouts numpy considers legal, and numpy's own normalization is authoritative. 2. numpy gives every zero-size array strides of 0, which ndarray's debug-build stride assertions reject as self-overlapping -- so casting something as plain as np.zeros((4, 0)) panicked in any maturin develop build (release builds compile the assertion out and were unaffected). Zero-size arrays now skip the conversion entirely; there is nothing to convert. Also corrects the cast_array_into output error message, which said "contiguous" where it meant row-major, and gives cast_array_into a Python wrapper (it was previously re-exported raw) so both entry points normalize identically. Assisted-by: ClaudeCode:claude-opus-4.8 --- python/cast_value_rs/__init__.py | 57 ++++++++++++- python/src/lib.rs | 137 +++++++++++++++++++++---------- 2 files changed, 148 insertions(+), 46 deletions(-) diff --git a/python/cast_value_rs/__init__.py b/python/cast_value_rs/__init__.py index ff0bfc0..2ace349 100644 --- a/python/cast_value_rs/__init__.py +++ b/python/cast_value_rs/__init__.py @@ -5,7 +5,7 @@ import numpy as np from cast_value_rs._cast_value_rs import cast_array as _cast_array -from cast_value_rs._cast_value_rs import cast_array_into +from cast_value_rs._cast_value_rs import cast_array_into as _cast_array_into if TYPE_CHECKING: from collections.abc import Iterable @@ -64,6 +64,21 @@ def _resolve_dtype(target_dtype: DTypeName | np.dtype[np.generic] | type[np.gene return name +def _as_row_major(arr: npt.NDArray[np.generic]) -> npt.NDArray[np.generic]: + """Return ``arr`` in row-major (C) layout with well-formed strides. + + The extension module requires row-major input; ``np.asarray`` with + ``order="C"`` is a no-op for arrays that already are, and copies views of + any other layout (e.g. the transposed views the Zarr transpose codec + produces). Unlike ``np.ascontiguousarray``, it preserves 0-d shapes. + + Zero-size arrays pass through unchanged (numpy flags them all + C-contiguous, whatever their strides); the extension module skips the + conversion for them. + """ + return np.asarray(arr, order="C") + + def cast_array( arr: npt.NDArray[np.generic], *, @@ -98,7 +113,7 @@ def cast_array( A new numpy array with the target dtype. """ return _cast_array( - arr, + _as_row_major(arr), target_dtype=_resolve_dtype(target_dtype), rounding_mode=rounding_mode, out_of_range_mode=out_of_range_mode, @@ -106,4 +121,42 @@ def cast_array( ) +def cast_array_into( + arr: npt.NDArray[np.generic], + out: npt.NDArray[np.generic], + *, + rounding_mode: RoundingMode, + out_of_range_mode: OutOfRangeMode | None = None, + scalar_map_entries: ( + dict[float, float] | Iterable[tuple[float, float]] | None + ) = None, +) -> None: + """Cast a numpy array to a new dtype, writing into a pre-allocated array. + + Parameters + ---------- + arr + Input numpy array. + out + Output numpy array. Determines the target dtype; must be row-major + (C-contiguous), writeable, and the same shape as ``arr``. + rounding_mode + How to round values during conversion. + out_of_range_mode + How to handle values outside the target type's range. + ``None`` means out-of-range values raise an error. + scalar_map_entries + Mapping of special source values to target values. + """ + # The output array is the caller's buffer, so it cannot be copied; the + # extension module rejects it if it is not row-major and writeable. + _cast_array_into( + _as_row_major(arr), + out, + rounding_mode=rounding_mode, + out_of_range_mode=out_of_range_mode, + scalar_map_entries=scalar_map_entries, + ) + + __all__ = ["cast_array", "cast_array_into"] diff --git a/python/src/lib.rs b/python/src/lib.rs index 43238d9..a764f81 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -217,6 +217,39 @@ fn array_dtype_key(arr: &Bound<'_, PyUntypedArray>) -> PyResult<&'static str> { // Per-path conversion helpers (avoid duplicating the numpy I/O boilerplate) // --------------------------------------------------------------------------- +/// Borrow a readonly view of `arr`, requiring row-major (C) memory layout. +/// +/// The conversion kernels read the input buffer as a flat slice and write +/// their results into a freshly allocated row-major output, so the buffer +/// they read must be in row-major element order. +/// +/// `PyReadonlyArray::as_slice` alone is not a sufficient guard: it accepts +/// column-major arrays too, and handing a column-major buffer to the kernels +/// silently transposes the data. Normalizing the layout here (e.g. via +/// `ndarray::as_standard_layout`) is also off the table: `ndarray`'s raw-view +/// stride checks panic on layouts numpy considers legal, such as zero-size +/// arrays with negative strides. So the binding strictly validates, and the +/// Python wrapper normalizes arbitrary layouts with +/// `np.asarray(arr, order="C")` -- numpy's own, authoritative implementation +/// -- before calling in. For callers of the wrapper this rejection is +/// unreachable; it is a backstop for direct users of the private extension +/// module. +fn readonly_row_major<'py, T: numpy::Element>( + arr: &Bound<'py, PyUntypedArray>, +) -> PyResult> { + // NB: numpy flags 0-d and zero-size arrays as C-contiguous, so those + // always pass. + if !arr.is_c_contiguous() { + return Err(pyo3::exceptions::PyValueError::new_err( + "Input array must be row-major (C-contiguous)", + )); + } + Ok(arr.downcast::>()?.readonly()) +} + +/// Panic message for `as_slice` on an array `readonly_row_major` accepted. +const C_CONTIGUOUS_SLICE: &str = "a C-contiguous array's buffer is a valid slice"; + /// Perform a float→int conversion on numpy arrays. fn do_float_to_int_alloc<'py, Src, Dst>( py: Python<'py>, @@ -231,10 +264,8 @@ where Src: CastFloat + CastInto + ExtractFromPy + numpy::Element + 'static, Dst: CastInt + ExtractFromPy + numpy::Element + 'static, { - let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::>()?.readonly(); - let src_slice = input_arr - .as_slice() - .map_err(|_| pyo3::exceptions::PyValueError::new_err("Input array must be contiguous"))?; + let input_arr: PyReadonlyArrayDyn<'_, Src> = readonly_row_major(arr)?; + let src_slice = input_arr.as_slice().expect(C_CONTIGUOUS_SLICE); let config = FloatToIntConfig { map_entries: parse_map_entries::(map_entries_py, src_dtype, tgt_dtype)?, rounding, @@ -242,7 +273,10 @@ where }; let shape: Vec = arr.shape().to_vec(); let output = PyArrayDyn::::zeros(py, &shape[..], false); - { + // Zero-size arrays skip the conversion: there is nothing to convert, and + // numpy gives all zero-size arrays strides of 0, which `as_array_mut`'s + // debug-build stride assertions reject as self-overlapping. + if !shape.contains(&0) { // SAFETY: We just created `output` and hold the GIL, so no other // code can alias this array. The mutable reference is valid for // the duration of this block. @@ -269,17 +303,18 @@ where Src: CastInt + CastInto + ExtractFromPy + numpy::Element, Dst: CastInt + ExtractFromPy + numpy::Element, { - let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::>()?.readonly(); - let src_slice = input_arr - .as_slice() - .map_err(|_| pyo3::exceptions::PyValueError::new_err("Input array must be contiguous"))?; + let input_arr: PyReadonlyArrayDyn<'_, Src> = readonly_row_major(arr)?; + let src_slice = input_arr.as_slice().expect(C_CONTIGUOUS_SLICE); let config = IntToIntConfig { map_entries: parse_map_entries::(map_entries_py, src_dtype, tgt_dtype)?, out_of_range: oor, }; let shape: Vec = arr.shape().to_vec(); let output = PyArrayDyn::::zeros(py, &shape[..], false); - { + // Zero-size arrays skip the conversion: there is nothing to convert, and + // numpy gives all zero-size arrays strides of 0, which `as_array_mut`'s + // debug-build stride assertions reject as self-overlapping. + if !shape.contains(&0) { // SAFETY: We just created `output` and hold the GIL, so no other // code can alias this array. let mut output_rw = unsafe { output.as_array_mut() }; @@ -306,10 +341,8 @@ where Src: CastFloat + CastInto + ExtractFromPy + numpy::Element + 'static, Dst: CastFloat + ExtractFromPy + numpy::Element + 'static, { - let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::>()?.readonly(); - let src_slice = input_arr - .as_slice() - .map_err(|_| pyo3::exceptions::PyValueError::new_err("Input array must be contiguous"))?; + let input_arr: PyReadonlyArrayDyn<'_, Src> = readonly_row_major(arr)?; + let src_slice = input_arr.as_slice().expect(C_CONTIGUOUS_SLICE); let config = FloatToFloatConfig { map_entries: parse_map_entries::(map_entries_py, src_dtype, tgt_dtype)?, rounding, @@ -317,7 +350,10 @@ where }; let shape: Vec = arr.shape().to_vec(); let output = PyArrayDyn::::zeros(py, &shape[..], false); - { + // Zero-size arrays skip the conversion: there is nothing to convert, and + // numpy gives all zero-size arrays strides of 0, which `as_array_mut`'s + // debug-build stride assertions reject as self-overlapping. + if !shape.contains(&0) { // SAFETY: We just created `output` and hold the GIL, so no other // code can alias this array. let mut output_rw = unsafe { output.as_array_mut() }; @@ -343,17 +379,18 @@ where Src: CastInt + CastInto + ExtractFromPy + numpy::Element, Dst: CastFloat + ExtractFromPy + numpy::Element, { - let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::>()?.readonly(); - let src_slice = input_arr - .as_slice() - .map_err(|_| pyo3::exceptions::PyValueError::new_err("Input array must be contiguous"))?; + let input_arr: PyReadonlyArrayDyn<'_, Src> = readonly_row_major(arr)?; + let src_slice = input_arr.as_slice().expect(C_CONTIGUOUS_SLICE); let config = IntToFloatConfig { map_entries: parse_map_entries::(map_entries_py, src_dtype, tgt_dtype)?, rounding, }; let shape: Vec = arr.shape().to_vec(); let output = PyArrayDyn::::zeros(py, &shape[..], false); - { + // Zero-size arrays skip the conversion: there is nothing to convert, and + // numpy gives all zero-size arrays strides of 0, which `as_array_mut`'s + // debug-build stride assertions reject as self-overlapping. + if !shape.contains(&0) { // SAFETY: We just created `output` and hold the GIL, so no other // code can alias this array. let mut output_rw = unsafe { output.as_array_mut() }; @@ -384,22 +421,25 @@ where Src: CastFloat + CastInto + ExtractFromPy + numpy::Element + 'static, Dst: CastInt + ExtractFromPy + numpy::Element + 'static, { - let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::>()?.readonly(); - let src_slice = input_arr - .as_slice() - .map_err(|_| pyo3::exceptions::PyValueError::new_err("Input array must be contiguous"))?; + let input_arr: PyReadonlyArrayDyn<'_, Src> = readonly_row_major(arr)?; + let src_slice = input_arr.as_slice().expect(C_CONTIGUOUS_SLICE); let config = FloatToIntConfig { map_entries: parse_map_entries::(map_entries_py, src_dtype, tgt_dtype)?, rounding, out_of_range: oor, }; let out_arr: &Bound<'_, PyArrayDyn> = out.downcast()?; - { + // Zero-size arrays skip the conversion: there is nothing to convert, and + // numpy gives all zero-size arrays strides of 0, which `as_array_mut`'s + // debug-build stride assertions reject as self-overlapping. + if !out.shape().contains(&0) { // SAFETY: The GIL is held and `out_arr` is a distinct array from // `input_arr` (different dtypes). No aliasing occurs. let mut output_rw = unsafe { out_arr.as_array_mut() }; let dst_slice = output_rw.as_slice_mut().ok_or_else(|| { - pyo3::exceptions::PyValueError::new_err("Output array must be contiguous and writeable") + pyo3::exceptions::PyValueError::new_err( + "Output array must be row-major (C-contiguous) and writeable", + ) })?; zarr_cast_value::convert_slice_float_to_int(src_slice, dst_slice, &config) .map_err(cast_error_to_pyerr)?; @@ -420,21 +460,24 @@ where Src: CastInt + CastInto + ExtractFromPy + numpy::Element, Dst: CastInt + ExtractFromPy + numpy::Element, { - let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::>()?.readonly(); - let src_slice = input_arr - .as_slice() - .map_err(|_| pyo3::exceptions::PyValueError::new_err("Input array must be contiguous"))?; + let input_arr: PyReadonlyArrayDyn<'_, Src> = readonly_row_major(arr)?; + let src_slice = input_arr.as_slice().expect(C_CONTIGUOUS_SLICE); let config = IntToIntConfig { map_entries: parse_map_entries::(map_entries_py, src_dtype, tgt_dtype)?, out_of_range: oor, }; let out_arr: &Bound<'_, PyArrayDyn> = out.downcast()?; - { + // Zero-size arrays skip the conversion: there is nothing to convert, and + // numpy gives all zero-size arrays strides of 0, which `as_array_mut`'s + // debug-build stride assertions reject as self-overlapping. + if !out.shape().contains(&0) { // SAFETY: The GIL is held and `out_arr` is a distinct array from // `input_arr`. No aliasing occurs. let mut output_rw = unsafe { out_arr.as_array_mut() }; let dst_slice = output_rw.as_slice_mut().ok_or_else(|| { - pyo3::exceptions::PyValueError::new_err("Output array must be contiguous and writeable") + pyo3::exceptions::PyValueError::new_err( + "Output array must be row-major (C-contiguous) and writeable", + ) })?; zarr_cast_value::convert_slice_int_to_int(src_slice, dst_slice, &config) .map_err(cast_error_to_pyerr)?; @@ -456,22 +499,25 @@ where Src: CastFloat + CastInto + ExtractFromPy + numpy::Element + 'static, Dst: CastFloat + ExtractFromPy + numpy::Element + 'static, { - let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::>()?.readonly(); - let src_slice = input_arr - .as_slice() - .map_err(|_| pyo3::exceptions::PyValueError::new_err("Input array must be contiguous"))?; + let input_arr: PyReadonlyArrayDyn<'_, Src> = readonly_row_major(arr)?; + let src_slice = input_arr.as_slice().expect(C_CONTIGUOUS_SLICE); let config = FloatToFloatConfig { map_entries: parse_map_entries::(map_entries_py, src_dtype, tgt_dtype)?, rounding, out_of_range: oor, }; let out_arr: &Bound<'_, PyArrayDyn> = out.downcast()?; - { + // Zero-size arrays skip the conversion: there is nothing to convert, and + // numpy gives all zero-size arrays strides of 0, which `as_array_mut`'s + // debug-build stride assertions reject as self-overlapping. + if !out.shape().contains(&0) { // SAFETY: The GIL is held and `out_arr` is a distinct array from // `input_arr`. No aliasing occurs. let mut output_rw = unsafe { out_arr.as_array_mut() }; let dst_slice = output_rw.as_slice_mut().ok_or_else(|| { - pyo3::exceptions::PyValueError::new_err("Output array must be contiguous and writeable") + pyo3::exceptions::PyValueError::new_err( + "Output array must be row-major (C-contiguous) and writeable", + ) })?; zarr_cast_value::convert_slice_float_to_float(src_slice, dst_slice, &config) .map_err(cast_error_to_pyerr)?; @@ -492,21 +538,24 @@ where Src: CastInt + CastInto + ExtractFromPy + numpy::Element, Dst: CastFloat + ExtractFromPy + numpy::Element, { - let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::>()?.readonly(); - let src_slice = input_arr - .as_slice() - .map_err(|_| pyo3::exceptions::PyValueError::new_err("Input array must be contiguous"))?; + let input_arr: PyReadonlyArrayDyn<'_, Src> = readonly_row_major(arr)?; + let src_slice = input_arr.as_slice().expect(C_CONTIGUOUS_SLICE); let config = IntToFloatConfig { map_entries: parse_map_entries::(map_entries_py, src_dtype, tgt_dtype)?, rounding, }; let out_arr: &Bound<'_, PyArrayDyn> = out.downcast()?; - { + // Zero-size arrays skip the conversion: there is nothing to convert, and + // numpy gives all zero-size arrays strides of 0, which `as_array_mut`'s + // debug-build stride assertions reject as self-overlapping. + if !out.shape().contains(&0) { // SAFETY: The GIL is held and `out_arr` is a distinct array from // `input_arr`. No aliasing occurs. let mut output_rw = unsafe { out_arr.as_array_mut() }; let dst_slice = output_rw.as_slice_mut().ok_or_else(|| { - pyo3::exceptions::PyValueError::new_err("Output array must be contiguous and writeable") + pyo3::exceptions::PyValueError::new_err( + "Output array must be row-major (C-contiguous) and writeable", + ) })?; zarr_cast_value::convert_slice_int_to_float(src_slice, dst_slice, &config) .map_err(cast_error_to_pyerr)?; From 8d161f90ad2b246b22161d793545025b663b2ab4 Mon Sep 17 00:00:00 2001 From: Davis Vann Bennett Date: Wed, 12 Aug 2026 18:12:03 +0200 Subject: [PATCH 2/2] test(python): layout coverage for every conversion path, plus properties Example-based coverage: parametrize the memory-layout tests over one dtype pair per conversion path (float->int, int->int, float->float, int->float) plus a float16 source, for both cast_array and cast_array_into, with shared fixtures in conftest. Dedicated cases pin the SIMD clamp fast path and scalar-map matching on non-contiguous views, and a backstop test pins the private module's rejection of non-row-major input. Property-based coverage (hypothesis): layout invariance -- casting an arbitrarily-strided view must behave exactly like casting its C-contiguous copy, and cast_array_into must agree with cast_array -- sampled over the full dtype grid and random transpose/slice views. These properties found the zero-size-array panic that the hand-picked empty-array case missed. test_non_contiguous_input is removed: it asserted the rejection that was itself the bug. Assisted-by: ClaudeCode:claude-opus-4.8 --- python/pyproject.toml | 2 +- python/tests/conftest.py | 34 +++++++ python/tests/test_cast_array.py | 61 ++++++++++- python/tests/test_cast_array_into.py | 38 ++++++- python/tests/test_errors.py | 29 +++--- python/tests/test_properties.py | 116 +++++++++++++++++++++ python/uv.lock | 145 ++++++++++++++++++++++++--- 7 files changed, 393 insertions(+), 32 deletions(-) create mode 100644 python/tests/test_properties.py diff --git a/python/pyproject.toml b/python/pyproject.toml index 560e90a..13fed3d 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ dynamic = ["version"] [dependency-groups] -test = ["pytest", "numpy"] +test = ["pytest", "numpy", "hypothesis"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/python/tests/conftest.py b/python/tests/conftest.py index ece5a5d..967dde2 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -40,3 +40,37 @@ class ExpectFail: def check(self, fn: Callable[..., Any]) -> None: with pytest.raises(self.exception, match=self.match): fn(**self.input) + + +# One (src, tgt) pair per conversion path in the bindings, so a layout +# regression in any of the four helpers is caught, plus float16 for its +# special handling. +LAYOUT_DTYPE_PATHS = [ + ("float64", "uint16"), # float -> int + ("int32", "uint8"), # int -> int + ("float64", "float32"), # float -> float + ("int32", "float32"), # int -> float + ("float16", "uint8"), # f16 source +] + + +def layout_arrays(dtype: str) -> list[tuple[str, np.ndarray]]: + """Integer-valued arrays of `dtype` in every memory layout we support. + + Integer values in [0, 20) are exactly representable in every dtype in + LAYOUT_DTYPE_PATHS, so the expected result is independent of rounding + and clamping. + """ + flat = np.arange(20, dtype=dtype) + grid = np.arange(12, dtype=dtype).reshape(3, 4) + cube = np.arange(20, dtype=dtype).reshape(5, 2, 2) + return [ + ("row-major", grid), + ("column-major", grid.T), + ("transposed-3d", cube.transpose(1, 2, 0)), + ("strided", flat[::2]), + ("negative-stride", grid[::-1]), + ("sliced-view", grid[:, 1:3]), + ("zero-dim", np.array(7, dtype=dtype)), + ("empty", np.zeros((0, 3), dtype=dtype)), + ] diff --git a/python/tests/test_cast_array.py b/python/tests/test_cast_array.py index 9ecbf26..5a00b9d 100644 --- a/python/tests/test_cast_array.py +++ b/python/tests/test_cast_array.py @@ -7,7 +7,7 @@ from cast_value_rs import cast_array -from .conftest import Expect, ExpectFail, nan_eq +from .conftest import LAYOUT_DTYPE_PATHS, Expect, ExpectFail, layout_arrays, nan_eq # --------------------------------------------------------------------------- # float -> int @@ -397,3 +397,62 @@ def test_shape(case: Expect): result = cast_array(**case.input) assert result.shape == case.expected.shape case.check(result) + + +# --------------------------------------------------------------------------- +# Memory layout +# --------------------------------------------------------------------------- + +# A numpy array's buffer only matches logical (row-major) order when the array +# is row-major. Casting must follow the logical order for every layout, not the +# order the elements happen to sit in memory. +MEMORY_LAYOUT_CASES = [ + Expect( + input=dict(arr=arr, target_dtype=tgt, rounding_mode="nearest-even"), + expected=arr.astype(tgt), + id=f"{layout}-{src}-to-{tgt}", + ) + for src, tgt in LAYOUT_DTYPE_PATHS + for layout, arr in layout_arrays(src) +] + + +@pytest.mark.parametrize( + "case", MEMORY_LAYOUT_CASES, ids=[c.id for c in MEMORY_LAYOUT_CASES] +) +def test_memory_layout(case: Expect): + """Casting depends on logical element order, never on memory layout.""" + result = cast_array(**case.input) + assert result.shape == case.input["arr"].shape + case.check(result) + + +def test_memory_layout_clamp(): + """Non-contiguous input through the clamp fast path. + + Clamp with a supported rounding mode selects the SIMD kernels; make sure + layout normalization holds on that path too, not just the scalar one. + """ + arr = np.array([[-1.0, 300.0, 5.0], [7.0, 260.0, -9.0]], dtype=np.float32).T + assert not arr.flags["C_CONTIGUOUS"] + result = cast_array( + arr, + target_dtype="uint8", + rounding_mode="nearest-even", + out_of_range_mode="clamp", + ) + assert np.array_equal(result, np.clip(arr, 0, 255).astype(np.uint8)) + + +def test_memory_layout_scalar_map(): + """Scalar map entries match values, not memory positions, on a view.""" + arr = np.arange(12, dtype=np.float64).reshape(3, 4).T + result = cast_array( + arr, + target_dtype="uint16", + rounding_mode="nearest-even", + scalar_map_entries=[(5.0, 500)], + ) + expected = arr.astype(np.uint16) + expected[arr == 5.0] = 500 + assert np.array_equal(result, expected) diff --git a/python/tests/test_cast_array_into.py b/python/tests/test_cast_array_into.py index 7d0ed29..44515fb 100644 --- a/python/tests/test_cast_array_into.py +++ b/python/tests/test_cast_array_into.py @@ -7,7 +7,7 @@ from cast_value_rs import cast_array_into -from .conftest import Expect, ExpectFail +from .conftest import LAYOUT_DTYPE_PATHS, Expect, ExpectFail, layout_arrays def _run_into(case: Expect) -> None: @@ -111,6 +111,15 @@ def test_cast_array_into_returns_none(): exception=ValueError, match="Shape mismatch", id="shape-mismatch", ), + ExpectFail( + input=dict( + arr=np.arange(6, dtype=np.float64).reshape(2, 3), + out=np.zeros((3, 2), dtype=np.uint8).T, + rounding_mode="nearest-even", + ), + exception=ValueError, match="row-major", + id="column-major-output", + ), ] @@ -119,3 +128,30 @@ def test_cast_array_into_returns_none(): ) def test_cast_array_into_errors(case: ExpectFail): case.check(cast_array_into) + + +# --------------------------------------------------------------------------- +# Memory layout +# --------------------------------------------------------------------------- + +INPUT_LAYOUT_CASES = [ + Expect( + input=dict( + arr=arr, + out=np.zeros(arr.shape, dtype=tgt), + rounding_mode="nearest-even", + ), + expected=arr.astype(tgt), + id=f"{layout}-{src}-to-{tgt}", + ) + for src, tgt in LAYOUT_DTYPE_PATHS + for layout, arr in layout_arrays(src) +] + + +@pytest.mark.parametrize( + "case", INPUT_LAYOUT_CASES, ids=[c.id for c in INPUT_LAYOUT_CASES] +) +def test_input_memory_layout(case: Expect): + """The input is read in logical element order for any memory layout.""" + _run_into(case) diff --git a/python/tests/test_errors.py b/python/tests/test_errors.py index 6255dbb..0d05678 100644 --- a/python/tests/test_errors.py +++ b/python/tests/test_errors.py @@ -86,11 +86,6 @@ def test_cast_array_into_positional_args_rejected(): ) -# --------------------------------------------------------------------------- -# Non-contiguous input -# --------------------------------------------------------------------------- - - # --------------------------------------------------------------------------- # Numpy dtype objects as target_dtype # --------------------------------------------------------------------------- @@ -112,15 +107,21 @@ def test_numpy_dtype_as_target_dtype(target_dtype): # --------------------------------------------------------------------------- -# Non-contiguous input +# Row-major backstop in the private extension module # --------------------------------------------------------------------------- -def test_non_contiguous_input(): - arr = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64)[::2] - assert not arr.flags["C_CONTIGUOUS"] - case = ExpectFail( - input=dict(arr=arr, target_dtype="uint8", rounding_mode="nearest-even"), - exception=ValueError, match="contiguous", - ) - case.check(cast_array) +def test_private_module_rejects_non_row_major(): + """The wrapper normalizes layout; the raw binding must still reject + non-row-major input rather than read its buffer in the wrong order.""" + from cast_value_rs._cast_value_rs import cast_array as raw_cast_array + + arr = np.arange(12, dtype=np.float64).reshape(3, 4).T + with pytest.raises(ValueError, match="row-major"): + raw_cast_array( + arr, + target_dtype="uint16", + rounding_mode="nearest-even", + out_of_range_mode=None, + scalar_map_entries=None, + ) diff --git a/python/tests/test_properties.py b/python/tests/test_properties.py new file mode 100644 index 0000000..bf48b7b --- /dev/null +++ b/python/tests/test_properties.py @@ -0,0 +1,116 @@ +"""Property-based tests. + +The layout-invariance property uses the function as its own oracle: casting +an arbitrarily-strided view must behave exactly like casting a C-contiguous +copy of it -- same values, same shape, or the same error. This needs no +reimplementation of the casting semantics, and it samples two spaces the +example-based tests only probe pointwise: the full (source, target) dtype +grid (121 monomorphized conversion paths) and the space of memory layouts +(dimensionality x axis permutations x slices with steps). +""" + +import numpy as np +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st +from hypothesis.extra import numpy as npst + +from cast_value_rs import cast_array, cast_array_into + +DTYPES = [ + "int8", + "int16", + "int32", + "int64", + "uint8", + "uint16", + "uint32", + "uint64", + "float16", + "float32", + "float64", +] + +ROUNDING_MODES = [ + "nearest-even", + "towards-zero", + "towards-positive", + "towards-negative", + "nearest-away", +] + + +@st.composite +def views(draw: st.DrawFn) -> np.ndarray: + """An array of any supported dtype, seen through a random transpose and + basic-index view. + + Yields every layout class the bindings must handle: C-contiguous, + F-contiguous, strided, negative-stride, zero-size, and 0-d. + """ + dtype = draw(st.sampled_from(DTYPES)) + base = draw( + npst.arrays( + dtype=dtype, + shape=npst.array_shapes(min_dims=0, max_dims=4, min_side=0, max_side=7), + ) + ) + if base.ndim == 0: + return base + perm = draw(st.permutations(range(base.ndim))) + transposed = base.transpose(perm) + view = transposed[draw(npst.basic_indices(transposed.shape))] + # A full-integer index yields a numpy scalar; re-wrap it as a 0-d array. + return view if isinstance(view, np.ndarray) else np.asarray(view) + + +LAYOUT_ARGS = given( + arr=views(), + target_dtype=st.sampled_from(DTYPES), + rounding_mode=st.sampled_from(ROUNDING_MODES), +) + + +@LAYOUT_ARGS +@settings(deadline=None) +def test_cast_array_layout_invariance( + arr: np.ndarray, target_dtype: str, rounding_mode: str +) -> None: + """Casting a view is equivalent to casting a C-contiguous copy of it.""" + kwargs = dict( + target_dtype=target_dtype, + rounding_mode=rounding_mode, + out_of_range_mode="clamp", + ) + try: + expected = cast_array(arr.copy(order="C"), **kwargs) + except ValueError: + # e.g. NaN input with an integer target: the error must not depend + # on the input's memory layout either. + with pytest.raises(ValueError): + cast_array(arr, **kwargs) + return + actual = cast_array(arr, **kwargs) + assert actual.dtype == expected.dtype + assert actual.shape == expected.shape + assert np.array_equal(actual, expected, equal_nan=actual.dtype.kind == "f") + + +@LAYOUT_ARGS +@settings(deadline=None) +def test_cast_array_into_matches_cast_array( + arr: np.ndarray, target_dtype: str, rounding_mode: str +) -> None: + """cast_array_into agrees with cast_array for any input memory layout.""" + kwargs = dict(rounding_mode=rounding_mode, out_of_range_mode="clamp") + out = np.zeros(arr.shape, dtype=target_dtype) + try: + expected = cast_array( + arr.copy(order="C"), target_dtype=target_dtype, **kwargs + ) + except ValueError: + with pytest.raises(ValueError): + cast_array_into(arr, out, **kwargs) + return + cast_array_into(arr, out, **kwargs) + assert np.array_equal(out, expected, equal_nan=out.dtype.kind == "f") diff --git a/python/uv.lock b/python/uv.lock index 43eab80..5bf75b6 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -7,12 +7,23 @@ resolution-markers = [ "python_full_version < '3.10'", ] +[[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 = "cast-value-rs" source = { editable = "." } [package.dev-dependencies] test = [ + { name = "hypothesis", version = "6.141.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "hypothesis", version = "6.165.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" }, { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -24,6 +35,7 @@ test = [ [package.metadata.requires-dev] test = [ + { name = "hypothesis" }, { name = "numpy" }, { name = "pytest" }, ] @@ -42,13 +54,107 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "hypothesis" +version = "6.141.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] +dependencies = [ + { name = "attrs" }, + { name = "exceptiongroup" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/20/8aa62b3e69fea68bb30d35d50be5395c98979013acd8152d64dc927e4cdb/hypothesis-6.141.1.tar.gz", hash = "sha256:8ef356e1e18fbeaa8015aab3c805303b7fe4b868e5b506e87ad83c0bf951f46f", size = 467389, upload-time = "2025-10-15T19:12:25.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/9a/f901858f139694dd669776983781b08a7c1717911025da6720e526bd8ce3/hypothesis-6.141.1-py3-none-any.whl", hash = "sha256:a5b3c39c16d98b7b4c3c5c8d4262e511e3b2255e6814ced8023af49087ad60b3", size = 535000, upload-time = "2025-10-15T19:12:21.659Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.165.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/7a/7a277ac07776191be594f74f6425649d529e4876f7d3ff1ee96d393ffdbc/hypothesis-6.165.3.tar.gz", hash = "sha256:687c5abb1a9c11478577c2cf18685c0eb82150d278477d3e14da290a1ef2a098", size = 502263, upload-time = "2026-08-11T01:23:09.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/c7/18152acad5f85f91554b2030000319b952a54151509953651ec40f37d50d/hypothesis-6.165.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:56af539c811b11ab5475704c300b8f0b46cc6dd0edc267e02a16487e803c77f8", size = 781671, upload-time = "2026-08-11T01:22:09.176Z" }, + { url = "https://files.pythonhosted.org/packages/d3/77/4293ea8a7fdb713956a8bf460b9070115df69f8216a900507633f9cdb225/hypothesis-6.165.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f40c10cfdb1ea2cd75e5d4e6e0cfdcb6198ab8406e8922666480e6dc11eea341", size = 777291, upload-time = "2026-08-11T01:22:15.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/fa/fa2071a6afaefc082dc7a033f41ae61436caf442d5973ba8ca9c29a69460/hypothesis-6.165.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a0854b1de4577f7e1beb1d681360285b5d678b65a809787ff4eab5b8b25efca", size = 1106490, upload-time = "2026-08-11T01:22:07.858Z" }, + { url = "https://files.pythonhosted.org/packages/ba/86/de724b7f9cd10e3be4efa21770457172e549d7576b1d8e29d6177eef5e47/hypothesis-6.165.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:360991cda8e488924905af48949033b90d4877ac97b9ad5d826d4d0f5a4b8cfb", size = 1135054, upload-time = "2026-08-11T01:22:29.499Z" }, + { url = "https://files.pythonhosted.org/packages/12/6a/96721cf447bd3c64b5e6843dde4444b20f3ddd901ad366cc73d0e7314bf5/hypothesis-6.165.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf502000f4a8ef4c9ab9493ca3b4fe17ae3033c18a8e2a31cdd69515dc7d97be", size = 1155997, upload-time = "2026-08-11T01:21:48.496Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a793cce6497f233b155f97684bf7d0e424c25613dd87b8af8a4e87820232/hypothesis-6.165.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:b9fcf47ad18f87f7c15bd36289bd45708bbfd250129d73bf554653e2f9afc931", size = 1111326, upload-time = "2026-08-11T01:22:21.9Z" }, + { url = "https://files.pythonhosted.org/packages/28/8d/dc3cdfd55843d038effa2458a9c9bd73002218a8c0fd58c2c0ab7fa328db/hypothesis-6.165.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fb05529cbcab5a317d03d7bb0e90d382f79ef1643e3568577916d0e24bfe70b", size = 1148079, upload-time = "2026-08-11T01:21:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/2f62924ac41f3d3482b29ded4c213f27ff4a103e56e84eeb528d4900cac7/hypothesis-6.165.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:57eae10a64340cd621a78eae9cb0459bd68ea99fbaa933c4f00e34d5087b6376", size = 1281862, upload-time = "2026-08-11T01:21:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d4/01c78b7b7348b6e8cef9b999109dfb93b14c7e1e38bc22170129f8b17181/hypothesis-6.165.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:19df0f2239052e9a870634a1d9bcdff95e2a2ab508573e5dd5c3d1ca545f5b3c", size = 1408437, upload-time = "2026-08-11T01:22:13.243Z" }, + { url = "https://files.pythonhosted.org/packages/35/76/e940b5a5aaf75bcd4784f1f3f9bf2b9a642a706bc0a9639077ca84f1325f/hypothesis-6.165.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9781a8026adff4b4516404cf0e5f2cadcb471318c2882a264e1c57c4c092266f", size = 1281168, upload-time = "2026-08-11T01:21:58.964Z" }, + { url = "https://files.pythonhosted.org/packages/fc/84/b153e81a614f45e0902e3b9e8a8b079e64214c50abb6fbe9acc62ccf686d/hypothesis-6.165.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1dd7e05f88e3e108a5e4f5f71a3eaf205559e8951e3c1f1ffd04cea82ed3b731", size = 1323263, upload-time = "2026-08-11T01:21:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/e5144d9e91ab7260650cb1ee032ca23208d49ebb1334845baf6407c1a9d9/hypothesis-6.165.3-cp310-abi3-win32.whl", hash = "sha256:d1389bda38cb222acc109aef5b31643ce799a39a76294a50ad8b84e32f92d76d", size = 667499, upload-time = "2026-08-11T01:21:47.36Z" }, + { url = "https://files.pythonhosted.org/packages/a9/18/f008b6f1f1c293d51c2776f8815d95bccb777dcf87df2a0ab56b273b47dc/hypothesis-6.165.3-cp310-abi3-win_amd64.whl", hash = "sha256:10cda6988ca4b1da389548b6fdd71af236b588a601fc1757e56eb8988e4240d8", size = 673643, upload-time = "2026-08-11T01:22:46.975Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e9/2aaded93d3e299452988132a98921670e5992963a33b987e6ebd0cdf7b3c/hypothesis-6.165.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:244b2dfe86b5b98f58f5228b082b797e983259e213a86896c964498a31de6dea", size = 782413, upload-time = "2026-08-11T01:23:07.214Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2a/0e170b864bab77ad801145d8d632292fd4b5d48e8c0f525730e13be2118d/hypothesis-6.165.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:83957338a78ae69c5f6fc12390abad82e48f8716f67beb6b9dab6debf78405f4", size = 778143, upload-time = "2026-08-11T01:22:58.684Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a1/f1501c877afeea9d3e48006e2e2394021fb88371d130afe5675709be1cdf/hypothesis-6.165.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9faab7c0f9a229945a931b8daafc9c5d72a0647d2c62377deb94adfe92fd60d8", size = 1106988, upload-time = "2026-08-11T01:22:32.538Z" }, + { url = "https://files.pythonhosted.org/packages/11/4f/c0e84fd51e6d0d8fd1c540b5acc4a9ced141179282b5336df2b0a8c0ab94/hypothesis-6.165.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d203a1116ebbb2e339d2595788553cee29f3a313dc59f35d435bd958431c9586", size = 1156587, upload-time = "2026-08-11T01:22:17.685Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b9/b7b406cbbd557736674d0a8b8e05171c4e4f0d32d6fa8674930b4834dc75/hypothesis-6.165.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b137d855d6f3301a7099e5a329099e7a32e8f4c81672a9b56e251bc11ed3d4df", size = 1282604, upload-time = "2026-08-11T01:23:01.905Z" }, + { url = "https://files.pythonhosted.org/packages/bd/43/3edd0dbc97c3a345d165bd824249e570144a3ec771b79b7415220340d103/hypothesis-6.165.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:82d794f885502d772c85a531097294e2cc52bc86c424dc2a5506a99d67c84cda", size = 1323579, upload-time = "2026-08-11T01:22:11.672Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b0/8cf87f258888cb056e8f5e440995d3526d61b5bd49dfeb6d27d42c669198/hypothesis-6.165.3-cp310-cp310-win_amd64.whl", hash = "sha256:416a716dd383bc7b03feaf1d046744ca7a02c4ea55bbf79fca2394ee9a83b534", size = 673537, upload-time = "2026-08-11T01:22:03.905Z" }, + { url = "https://files.pythonhosted.org/packages/42/61/81db899aaedd6fd05d4aed078e2dd683513c941fa281264b5430f9c32021/hypothesis-6.165.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:874bbf116bc99684a40b61d9b2a073bf67fb2354365853d4e9b8cb6e1d025324", size = 782183, upload-time = "2026-08-11T01:22:51.73Z" }, + { url = "https://files.pythonhosted.org/packages/87/32/282f7a78a554f776cc66157392d7cdf9bf6655f13a66a1dab6ff0a6a4907/hypothesis-6.165.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:00c63ce0d532368edcb7c201c2a29dc7cbef81ac15b96d7b97179de6390739ca", size = 777964, upload-time = "2026-08-11T01:22:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/a0/15/c883a514925b2bacef5d6749d281f437c916b85ff0b2aaca173055b3f2f3/hypothesis-6.165.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fd3b62c67b4b9fca4ced59d86ffd4204ea44cf182b9eba8200a881592000150f", size = 1106849, upload-time = "2026-08-11T01:22:57.019Z" }, + { url = "https://files.pythonhosted.org/packages/12/43/a40fe426c902e6c2e30a5ee4cb1405786be378ddc7432fe0a6944b0e4653/hypothesis-6.165.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d587a6df45237276b79ecd6d9e0245f16b1b21ea2c35f9fc49756df3ef3daa19", size = 1156325, upload-time = "2026-08-11T01:21:46.2Z" }, + { url = "https://files.pythonhosted.org/packages/2b/be/2cffa1a57ee1e213a929b1b28fd67c392cce3f1fc822a1593dccaf2f5a42/hypothesis-6.165.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:084e113e0f5b70902d5872d2fbd27b6acb4481cd431f3fae6a54f4dac4601738", size = 1282183, upload-time = "2026-08-11T01:21:49.737Z" }, + { url = "https://files.pythonhosted.org/packages/23/d6/dd92c23788a11931d8ec1c528c25f0050e540e667fd95bfbb346fa7b7443/hypothesis-6.165.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2f805bd7b3c2a4449d1c9129a57750bae4b3d1f056ff96581a78f9efc6c0e7a0", size = 1323523, upload-time = "2026-08-11T01:22:26.145Z" }, + { url = "https://files.pythonhosted.org/packages/94/7a/257d4c0d0777f7ecffd71c1f1717922a70ac6ed43fff8d169631da730c44/hypothesis-6.165.3-cp311-cp311-win_amd64.whl", hash = "sha256:67c0d3cb622415e72022beba1fe272502e9ecdbb614a36bee0f4c816810cf3f1", size = 673355, upload-time = "2026-08-11T01:21:43.991Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3d/e7eade134bd7f57d4071d82ae84691bc3017fe6945af0bb149578ba8b565/hypothesis-6.165.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f88fe4915f8dd4f8999a197f9e22c3a7177042aa994d35c0c7c7b22541d2885b", size = 783298, upload-time = "2026-08-11T01:22:14.706Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a5/668810f493feaf886a9240ad689792fb32450667c8e5770c3bcaa7fabeac/hypothesis-6.165.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c7d9f6c36b812f6069c7436492e12812cf541391954e21d5bd7fcafc9fb46700", size = 774856, upload-time = "2026-08-11T01:22:35.836Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a0/0af93a70f5128763079ab714277ccad4a7de42d442d67dd43e3029178162/hypothesis-6.165.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423ca8e30087bb41db7e6f47dbf98690a2155241b9f1057e366dc138d7dcc4fe", size = 1105274, upload-time = "2026-08-11T01:23:03.591Z" }, + { url = "https://files.pythonhosted.org/packages/7b/00/ddcdc99beee469573addf5cfcd817c9a20a71832b1ca88404b6d3f99c44c/hypothesis-6.165.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c58c66f3e1b8d4091bb52664d5af4b0c1715293c6822d5344b166494534cd498", size = 1155379, upload-time = "2026-08-11T01:22:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/de/10/d574b21e63f16a1cad9c0cf4592aa170285940f034d04bb33a1e08025397/hypothesis-6.165.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3774882c4685e5474b7940697da55963e591b71c6dce593d90ac4128766371ad", size = 1279259, upload-time = "2026-08-11T01:21:53.63Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/13f6b6c7d7d0b9bb570a18617eb659b015c312b1d8d1d3aaf9f2edea9628/hypothesis-6.165.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb3a397a5422c67387f4408989dbdfc2b1e0306f883f2aa79b9472c80958464", size = 1322605, upload-time = "2026-08-11T01:21:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/6c/dd/243317f5fb8497601dc65d3ded984834eb5f36b8ec59e7853ef753ec0ee1/hypothesis-6.165.3-cp312-cp312-win_amd64.whl", hash = "sha256:dbb74811d54b6317ba0d2047aad269c09afefaa25d1849f8f33f80a638b0c3af", size = 670812, upload-time = "2026-08-11T01:22:50.245Z" }, + { url = "https://files.pythonhosted.org/packages/03/3e/95cba31dbe775b99a4548cdae192e1ad15cee7b64fbdfe6cd4c9d00031b4/hypothesis-6.165.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:447f139d6dd70a5d8b178ef507463fb0430ace9ce42e3b2351d2803a391fe774", size = 783183, upload-time = "2026-08-11T01:22:45.286Z" }, + { url = "https://files.pythonhosted.org/packages/56/0e/51bf125cdf7855b69097b8f59c73ef3cf5f4e3d68a16e808d2d1f08a1ff1/hypothesis-6.165.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6152c718606f1705e673c6b30a6ebd3ff08d340da85291dd3c432c73b28a9b3a", size = 774820, upload-time = "2026-08-11T01:22:24.825Z" }, + { url = "https://files.pythonhosted.org/packages/0a/69/b954f742b97441a5c49f8f8704826ee0637a6cce3a7d06ce85fbefc54ac5/hypothesis-6.165.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27fe7826ad83ccc2e8062f0fab43b137bab34cef1a149a926b34a7b8382ee22c", size = 1105186, upload-time = "2026-08-11T01:21:41.733Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8a/33e41d9cc1be7661e0b4129c225a93c3f12544714300aadb95ae7eedf894/hypothesis-6.165.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1ff92876a324f7b9cdb92cedf103e380b7a12aa7df55ebcb16dd0f495a879e8", size = 1155215, upload-time = "2026-08-11T01:22:06.604Z" }, + { url = "https://files.pythonhosted.org/packages/ee/53/ba09526c9100ace5752908ac7251d2dc3960ce7e0e97a31152aaa26c33ee/hypothesis-6.165.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:53f1564c97d27fc109f212404d49cd71d7789777dbe0685628ffe9838df56240", size = 1279245, upload-time = "2026-08-11T01:21:56.182Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/fc637d355791a65364a3409ff06ade7ba5d3fc6f1d07a729781dec315fa0/hypothesis-6.165.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:788a9b0a7aae719a2b71a1c2f07e51deb1d0fe990164a9090c686833ed4bfbad", size = 1322370, upload-time = "2026-08-11T01:22:48.492Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8d/826053ba0263143fed2b0e8af009dc868d8a932e7246e191deb8ca7ce8ff/hypothesis-6.165.3-cp313-cp313-win_amd64.whl", hash = "sha256:37830f0795abfdf738d2a5b6f829a73f3ab498de45a2e61b0bf3bd38d8c9ddb9", size = 670804, upload-time = "2026-08-11T01:22:00.103Z" }, + { url = "https://files.pythonhosted.org/packages/e7/27/3230f8de3d853b2b547731916ae1d1026bd197cd3f2d35dafc0b445da46b/hypothesis-6.165.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:38826441dbf528cc156388d0a05526086a12da3e1348353d3fa14de03e57c4b2", size = 783286, upload-time = "2026-08-11T01:22:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/6c/28/9f9ca830d376c50babe55c616f6d99eea886c6ebcd8b512dcd5d56f9e40c/hypothesis-6.165.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:87490115edd34a246a4ba8b1144cbdf571438c46c406ece05caf65908667c9a9", size = 774963, upload-time = "2026-08-11T01:23:05.409Z" }, + { url = "https://files.pythonhosted.org/packages/33/3c/3c81f08ec1edce160da509c5785d78c0e25a7913899c4b9ff724bfd01420/hypothesis-6.165.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f51f4346cfa26bca68c68f7bbbd2b1812208bc9f572187c95ecab080ed402153", size = 1105730, upload-time = "2026-08-11T01:22:42.311Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b5/f6f81b9aec9999ec63920d168617cab67a038be05487eff3410ccd072bfe/hypothesis-6.165.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4da89eb4b36b3260ff714d2ecc3274b9bd599fd96687d2d9ed53d5e1a801a7a7", size = 1155383, upload-time = "2026-08-11T01:21:57.58Z" }, + { url = "https://files.pythonhosted.org/packages/dd/27/7f3a8c6101675bf95c80cd8c9173d65892ca0b7b640551156dc4537fab1f/hypothesis-6.165.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eb6d31c14d7bdfe03e501d88ee296c149a74cc93e3d01c76ea335e64ee5f33ec", size = 1279606, upload-time = "2026-08-11T01:22:37.56Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/0c23e06a24e421e532f62a95021fae34f685f3a194c081c6991b4ab202b3/hypothesis-6.165.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0863e1a9258bc103abe616fa9471cfa66a1535ea404dd8a0bf360e0a29502397", size = 1322697, upload-time = "2026-08-11T01:22:27.857Z" }, + { url = "https://files.pythonhosted.org/packages/dc/56/8356dadf45e5c635b46aa2b57fa74f3210250a8e38b860b6b75f50ed0b42/hypothesis-6.165.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:53c56155f2cfbb45ec97fef9ea3b8453b4a34c48c3c5cacee16f97dd2a037994", size = 614859, upload-time = "2026-08-11T01:22:01.304Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/124d4faf235219acd685c359760a5cb3995609bc50ce465e54c3249841ee/hypothesis-6.165.3-cp314-cp314-win_amd64.whl", hash = "sha256:c48f41e950b5e602e2fdf8f92dcc8ac7bf715a003bf822afb7c9d5cbc41bc344", size = 670600, upload-time = "2026-08-11T01:22:10.356Z" }, + { url = "https://files.pythonhosted.org/packages/01/7a/41ac5e68d9ce079d1b76d4c54126354df61b948c3d519d1289aca877eedc/hypothesis-6.165.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9563d3040178fb1f522665bcec6458cc0d21ab77d7c637058a8be4ea8c01d236", size = 781746, upload-time = "2026-08-11T01:22:34.472Z" }, + { url = "https://files.pythonhosted.org/packages/5d/fb/7ecc21aae63a83dbc8036f9a0544c6b3d798db566b97b5202bdf8e770f80/hypothesis-6.165.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1fe1783543b43ba9808c016950e5e84b3804dc3365ba77c37c427b5896a558a1", size = 773382, upload-time = "2026-08-11T01:22:55.279Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f2/9cc2a4768f9a483b12e307ba585f5eb9c7f5500bd16ff82ddbf62a9a1b88/hypothesis-6.165.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ea34806a4df4e8305a096dcf8e53cdd903c96c1e0d2dd5b001d2283f639c3f1", size = 1103911, upload-time = "2026-08-11T01:23:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/54/9e/b551a494f84976ee5bb9374c197ccc126dea2ec6f22098d5f70705237473/hypothesis-6.165.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d188454b95ce46ba991e3c52161255d76af25170ad28591f6b30b045e501216e", size = 1154060, upload-time = "2026-08-11T01:22:20.413Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/8e22a236f1f1e599525549a34672fb0523109f571486fe209b12a84a942e/hypothesis-6.165.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a1c47b15ce97a9b1346bc7d7013c5f215380f78ae01c0f73a1638bd8b98bdd76", size = 1277631, upload-time = "2026-08-11T01:22:30.965Z" }, + { url = "https://files.pythonhosted.org/packages/13/0f/feb33bfc23853b4ba6360ff5e34235cd8bea0d7dd1eb21e17491f581c4e2/hypothesis-6.165.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:996077ef7a3bb332b6638f698ddf7555c82784b58dde80eeba3f07c0a322b40f", size = 1321326, upload-time = "2026-08-11T01:21:45.089Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3b/ad56b56540a0719f493edec0dd442ebb21272147d2482ef505d19760a6d3/hypothesis-6.165.3-cp314-cp314t-win_amd64.whl", hash = "sha256:57a8273bdafe3f450afe66999fd130d4935d775eaf4ef63fcac0bee8015fc512", size = 670613, upload-time = "2026-08-11T01:22:05.294Z" }, + { url = "https://files.pythonhosted.org/packages/6f/22/e734aff77ab104d931151ae6271f9284f6a75f4f81692bdf1a91a27115e2/hypothesis-6.165.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d7d366a66116c3aff4b9f8dcfd1762b20ff7d53bdd77960c14ff2f52a31ad149", size = 783103, upload-time = "2026-08-11T01:21:54.961Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4c/142b6e79c43feade6a0a7992691d7500024954c2545bf37bb85c53754f70/hypothesis-6.165.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6133f89de9a13ca7322ac2fe256d2c13dcaba3ef43873e1c8704d964239addb0", size = 778953, upload-time = "2026-08-11T01:22:53.398Z" }, + { url = "https://files.pythonhosted.org/packages/92/d2/e192179a59d7e2eb59c203ca849eb2b54a5871bde38b46dfe2ecb7b9e071/hypothesis-6.165.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:812f05d49ad7970673d73ace920f17a251f8843369850dedb87932ee9b0f0386", size = 1107830, upload-time = "2026-08-11T01:22:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/29/dd/b75399a8cddc49510b20c910364c536db131765e05f8a0c3d20e20ed90d8/hypothesis-6.165.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d4f95c93ae6afdae545f83374d3bf9585edb4555e4623d93140686ea612b933", size = 1157576, upload-time = "2026-08-11T01:22:39.196Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b7/7f5be4883a60b78665bb071a320d6f5c10341385bf708120d34712513683/hypothesis-6.165.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f93f0d8e8e1fa9e2069ba1c95b75a771f79d46efc212db518219838906039254", size = 674476, upload-time = "2026-08-11T01:22:43.842Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -311,13 +417,13 @@ resolution-markers = [ "python_full_version < '3.10'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "packaging", marker = "python_full_version < '3.10'" }, - { name = "pluggy", marker = "python_full_version < '3.10'" }, - { name = "pygments", marker = "python_full_version < '3.10'" }, - { name = "tomli", marker = "python_full_version < '3.10'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } wheels = [ @@ -333,19 +439,28 @@ resolution-markers = [ "python_full_version == '3.10.*'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "tomli" version = "2.4.1"