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
57 changes: 55 additions & 2 deletions python/cast_value_rs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
*,
Expand Down Expand Up @@ -98,12 +113,50 @@ 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,
scalar_map_entries=scalar_map_entries,
)


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"]
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ classifiers = [
dynamic = ["version"]

[dependency-groups]
test = ["pytest", "numpy"]
test = ["pytest", "numpy", "hypothesis"]

[tool.pytest.ini_options]
testpaths = ["tests"]
137 changes: 93 additions & 44 deletions python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PyReadonlyArrayDyn<'py, T>> {
// 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::<PyArrayDyn<T>>()?.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>,
Expand All @@ -231,18 +264,19 @@ where
Src: CastFloat + CastInto<Dst> + ExtractFromPy + numpy::Element + 'static,
Dst: CastInt + ExtractFromPy + numpy::Element + 'static,
{
let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::<PyArrayDyn<Src>>()?.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::<Src, Dst>(map_entries_py, src_dtype, tgt_dtype)?,
rounding,
out_of_range: oor,
};
let shape: Vec<usize> = arr.shape().to_vec();
let output = PyArrayDyn::<Dst>::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.
Expand All @@ -269,17 +303,18 @@ where
Src: CastInt + CastInto<Dst> + ExtractFromPy + numpy::Element,
Dst: CastInt + ExtractFromPy + numpy::Element,
{
let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::<PyArrayDyn<Src>>()?.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::<Src, Dst>(map_entries_py, src_dtype, tgt_dtype)?,
out_of_range: oor,
};
let shape: Vec<usize> = arr.shape().to_vec();
let output = PyArrayDyn::<Dst>::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() };
Expand All @@ -306,18 +341,19 @@ where
Src: CastFloat + CastInto<Dst> + ExtractFromPy + numpy::Element + 'static,
Dst: CastFloat + ExtractFromPy + numpy::Element + 'static,
{
let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::<PyArrayDyn<Src>>()?.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::<Src, Dst>(map_entries_py, src_dtype, tgt_dtype)?,
rounding,
out_of_range: oor,
};
let shape: Vec<usize> = arr.shape().to_vec();
let output = PyArrayDyn::<Dst>::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() };
Expand All @@ -343,17 +379,18 @@ where
Src: CastInt + CastInto<Dst> + ExtractFromPy + numpy::Element,
Dst: CastFloat + ExtractFromPy + numpy::Element,
{
let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::<PyArrayDyn<Src>>()?.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::<Src, Dst>(map_entries_py, src_dtype, tgt_dtype)?,
rounding,
};
let shape: Vec<usize> = arr.shape().to_vec();
let output = PyArrayDyn::<Dst>::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() };
Expand Down Expand Up @@ -384,22 +421,25 @@ where
Src: CastFloat + CastInto<Dst> + ExtractFromPy + numpy::Element + 'static,
Dst: CastInt + ExtractFromPy + numpy::Element + 'static,
{
let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::<PyArrayDyn<Src>>()?.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::<Src, Dst>(map_entries_py, src_dtype, tgt_dtype)?,
rounding,
out_of_range: oor,
};
let out_arr: &Bound<'_, PyArrayDyn<Dst>> = 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)?;
Expand All @@ -420,21 +460,24 @@ where
Src: CastInt + CastInto<Dst> + ExtractFromPy + numpy::Element,
Dst: CastInt + ExtractFromPy + numpy::Element,
{
let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::<PyArrayDyn<Src>>()?.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::<Src, Dst>(map_entries_py, src_dtype, tgt_dtype)?,
out_of_range: oor,
};
let out_arr: &Bound<'_, PyArrayDyn<Dst>> = 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)?;
Expand All @@ -456,22 +499,25 @@ where
Src: CastFloat + CastInto<Dst> + ExtractFromPy + numpy::Element + 'static,
Dst: CastFloat + ExtractFromPy + numpy::Element + 'static,
{
let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::<PyArrayDyn<Src>>()?.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::<Src, Dst>(map_entries_py, src_dtype, tgt_dtype)?,
rounding,
out_of_range: oor,
};
let out_arr: &Bound<'_, PyArrayDyn<Dst>> = 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)?;
Expand All @@ -492,21 +538,24 @@ where
Src: CastInt + CastInto<Dst> + ExtractFromPy + numpy::Element,
Dst: CastFloat + ExtractFromPy + numpy::Element,
{
let input_arr: PyReadonlyArrayDyn<'_, Src> = arr.downcast::<PyArrayDyn<Src>>()?.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::<Src, Dst>(map_entries_py, src_dtype, tgt_dtype)?,
rounding,
};
let out_arr: &Bound<'_, PyArrayDyn<Dst>> = 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)?;
Expand Down
34 changes: 34 additions & 0 deletions python/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
]
Loading
Loading