From f56d1520e52f34cd68da8908aa6208ab64956f81 Mon Sep 17 00:00:00 2001 From: "endrebak.ada" Date: Sun, 25 Oct 2026 18:49:28 +0100 Subject: [PATCH] Normalize strided inputs before calling Rust kernels --- .github/workflows/build_and_publish.yml | 7 +- Cargo.toml | 2 +- pyproject.toml | 2 +- ruranges/numpy.py | 21 +++-- tests/test_strided_input.py | 100 ++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 tests/test_strided_input.py diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml index e9b5c7e..8fc529c 100644 --- a/.github/workflows/build_and_publish.yml +++ b/.github/workflows/build_and_publish.yml @@ -110,6 +110,8 @@ jobs: matrix: python-version: ["3.12", "3.13", "3.14"] steps: + - uses: actions/checkout@v7 + - name: Set up Python uses: actions/setup-python@v7 with: @@ -123,9 +125,10 @@ jobs: - name: Install and test the stable-ABI wheel run: | - python -m pip install --upgrade pip numpy + python -m pip install --upgrade pip numpy pytest python -m pip install --no-index --find-links dist ruranges - python -c "import numpy as np, ruranges.numpy as k; s=np.array([1,5],dtype=np.int64); e=np.array([4,9],dtype=np.int64); g=np.zeros(2,dtype=np.uint32); assert len(k.merge(starts=s,ends=e,groups=g,slack=0)[0])==2; print('ruranges ABI3 ok')" + pytest --import-mode=importlib tests + python -I -c "import numpy as np, ruranges.numpy as k; s=np.array([1,5],dtype=np.int64); e=np.array([4,9],dtype=np.int64); g=np.zeros(2,dtype=np.uint32); assert len(k.merge(starts=s,ends=e,groups=g,slack=0)[0])==2; print('ruranges ABI3 ok')" publish: name: Verify and publish diff --git a/Cargo.toml b/Cargo.toml index 9f0935a..a41bb97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ruranges-py" # Keep this synchronized with `project.version` in pyproject.toml. -version = "0.2.7" +version = "0.2.8" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/pyproject.toml b/pyproject.toml index 060c6fb..132ab12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ ] # Keep this synchronized with `package.version` in Cargo.toml. -version = "0.2.7" +version = "0.2.8" [tool.maturin] module-name = "ruranges.ruranges" diff --git a/ruranges/numpy.py b/ruranges/numpy.py index 345f61a..d300cf4 100644 --- a/ruranges/numpy.py +++ b/ruranges/numpy.py @@ -412,7 +412,11 @@ def sort_intervals( groups=groups, starts=starts, ends=ends, - sort_reverse_direction=sort_reverse_direction, + sort_reverse_direction=( + None + if sort_reverse_direction is None + else _as_1d_array(sort_reverse_direction, name="sort_reverse_direction") + ), ) @@ -826,7 +830,7 @@ def window( groups = _groups_or_arange(groups, len(starts)), starts=starts, ends=ends, - negative_strand=negative_strand, + negative_strand=_as_1d_array(negative_strand, name="negative_strand"), window_size=window_size, ) @@ -872,7 +876,7 @@ def tile( groups = None, starts=starts, ends=ends, - negative_strand=negative_strand, + negative_strand=_as_1d_array(negative_strand, name="negative_strand"), tile_size=tile_size, ) @@ -920,7 +924,7 @@ def spliced_subsequence( # same public signature starts, ends, groups, - strand_flags=strand_flags, + strand_flags=_as_1d_array(strand_flags, name="strand_flags"), slice_starts=slice_starts, slice_ends=slice_ends, force_plus_strand=force_plus_strand, @@ -994,7 +998,7 @@ def extend( starts=starts, ends=ends, groups=groups, - negative_strand=negative_strand, + negative_strand=_as_1d_array(negative_strand, name="negative_strand"), ext_3=ext_3, ext_5=ext_5, ) @@ -1048,7 +1052,7 @@ def group_cumsum( starts=starts, ends=ends, groups=groups, - negative_strand=negative_strand, + negative_strand=_as_1d_array(negative_strand, name="negative_strand"), sort=sort, ) @@ -1343,10 +1347,13 @@ def _resolve_rust_fn( def _as_1d_array(a: NDArray | Sequence[Any], *, name: str) -> np.ndarray: + # The Rust kernels read their inputs as slices, which needs C-contiguous + # memory; a strided view (pandas >= 3.1 can return one from Series.values) + # is copied here rather than rejected there. arr = np.asarray(a) if arr.ndim != 1: raise ValueError(f"`{name}` must be a 1-D array.") - return arr + return np.ascontiguousarray(arr) def _int_bounds(arr: np.ndarray, *, name: str) -> tuple[int, int]: diff --git a/tests/test_strided_input.py b/tests/test_strided_input.py new file mode 100644 index 0000000..b7bec2b --- /dev/null +++ b/tests/test_strided_input.py @@ -0,0 +1,100 @@ +"""Every kernel accepts strided arrays, as pandas >= 3.1 can return from Series.values. + +The Rust kernels read their inputs as slices, which need C-contiguous memory. +Each case runs with small coordinates, which the wrapper casts to int32 (a +copy), and with coordinates past int32, which it passes through as int64 (no +copy), so a strided array reaches the kernel unless the wrapper fixes it. +""" + +import numpy as np +import pytest + +from ruranges import numpy as rn + +BIG = 2**40 + + +def strided(values, dtype=np.int64): + block = np.zeros((len(values), 3), dtype=dtype) + block[:, 1] = values + column = block[:, 1] + assert not column.flags.c_contiguous + return column + + +def starts(lo): + return strided([lo, lo + 4]) + + +def ends(lo): + return strided([lo + 2, lo + 8]) + + +def groups(): + return strided([0, 0], np.uint32) + + +def negative_strand(): + return strided([False, True], np.bool_) + + +def pair(lo): + return dict(starts=starts(lo), ends=ends(lo), starts2=starts(lo), ends2=ends(lo), groups=groups(), groups2=groups()) + + +CASES = { + "overlaps": lambda lo: rn.overlaps(**pair(lo)), + "nearest": lambda lo: rn.nearest(**pair(lo)), + "subtract": lambda lo: rn.subtract(**pair(lo)), + "count_overlaps": lambda lo: rn.count_overlaps(**pair(lo)), + "sort_intervals": lambda lo: rn.sort_intervals( + starts=starts(lo), ends=ends(lo), groups=groups(), sort_reverse_direction=negative_strand() + ), + "cluster": lambda lo: rn.cluster(starts=starts(lo), ends=ends(lo), groups=groups()), + "merge": lambda lo: rn.merge(starts=starts(lo), ends=ends(lo), groups=groups()), + "max_disjoint": lambda lo: rn.max_disjoint(starts=starts(lo), ends=ends(lo), groups=groups()), + "extend": lambda lo: rn.extend( + starts=starts(lo), ends=ends(lo), groups=groups(), negative_strand=negative_strand(), ext_3=1, ext_5=1 + ), + "window": lambda lo: rn.window( + starts=starts(lo), ends=ends(lo), groups=groups(), negative_strand=negative_strand(), window_size=2 + ), + "tile": lambda lo: rn.tile(starts=starts(lo), ends=ends(lo), negative_strand=negative_strand(), tile_size=2), + "group_cumsum": lambda lo: rn.group_cumsum( + starts=starts(lo), ends=ends(lo), groups=groups(), negative_strand=negative_strand() + ), + "spliced_subsequence": lambda lo: rn.spliced_subsequence( + starts=starts(lo), ends=ends(lo), groups=groups(), strand_flags=negative_strand(), start=0, end=1 + ), + "complement": lambda lo: rn.complement( + starts=starts(lo), + ends=ends(lo), + groups=groups(), + chrom_len_ids=strided([0, 1], np.uint32), + chrom_lens=strided([lo + 100, lo + 100]), + ), + "genome_bounds": lambda lo: rn.genome_bounds( + groups=groups(), starts=starts(lo), ends=ends(lo), chrom_length=strided([lo + 100, lo + 100]) + ), + "map_to_global": lambda lo: rn.map_to_global( + starts=starts(0), + ends=ends(0), + groups=groups(), + strand=negative_strand(), + starts2=starts(0), + ends2=ends(0), + groups2=groups(), + chr_code2=groups(), + genome_start2=starts(lo), + genome_end2=ends(lo), + strand2=negative_strand(), + ), + "sort_groups": lambda lo: rn.sort_groups(groups()), + "fold_ranks": lambda lo: rn.fold_ranks(groups(), 1, groups(), 1), +} + + +@pytest.mark.parametrize("lo", [1, BIG]) +@pytest.mark.parametrize("name", CASES) +def test_kernel_accepts_strided_input(name, lo): + CASES[name](lo)