From a8fc77e74caf914c01651c184d6b264e632cc280 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Thu, 25 Jun 2026 10:14:44 +0200 Subject: [PATCH 01/31] first try at array-api integration --- src/scanpy/_compat.py | 22 +++++++++ src/scanpy/_utils/__init__.py | 29 +++++++++++ src/scanpy/metrics/_common.py | 9 ++-- src/scanpy/neighbors/__init__.py | 5 ++ .../preprocessing/_highly_variable_genes.py | 10 +++- src/scanpy/preprocessing/_normalization.py | 7 +++ src/scanpy/preprocessing/_scale.py | 48 ++++++++++++++----- src/scanpy/preprocessing/_simple.py | 8 ++++ src/scanpy/tools/_rank_genes_groups.py | 5 ++ 9 files changed, 128 insertions(+), 15 deletions(-) diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index 3027a81107..575877b9f3 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -22,6 +22,8 @@ "DaskArray", "SpBase", "fullname", + "get_namespace", + "is_array_api", "pkg_metadata", "pkg_version", "set_module", @@ -66,6 +68,26 @@ def pkg_metadata(package: str) -> PackageMetadata: return metadata(package) +def is_array_api(x: object) -> bool: + # returns true if x is array api compatible + # exclusing the ones that are already handled by the script + from array_api_compat import is_array_api_obj + + # excluding packages that are handled by both array-api-compat and script + if isinstance(x, DaskArray): + return False + if isinstance(x, DaskArray): + return False + return is_array_api_obj(x) + + +def get_namespace(x): + # get array-api namespace for x + from array_api_compat import get_namespace + + return get_namespace(x) + + @cache def pkg_version(package: str) -> Version: from importlib.metadata import version diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index f0d67b550d..d61c150461 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -602,8 +602,22 @@ def axis_mul_or_truediv( allow_divide_by_zero: bool = True, out: ArrayLike | None = None, ) -> np.ndarray: + from .._compat import get_namespace, is_array_api + _check_op(op) scaling_array = _broadcast_axis(scaling_array, axis) + # array api version + if is_array_api(x): ### double check if numpy skips this + xp = get_namespace(x) + scaling_array = xp.asarray(scaling_array) + if op is mul: + return x * scaling_array + if not allow_divide_by_zero: + scaling_array = xp.where( + scaling_array == 0, xp.ones_like(scaling_array), scaling_array + ) + return x / scaling_array + # numpy version if op is mul: return np.multiply(x, scaling_array, out=out) if not allow_divide_by_zero: @@ -725,6 +739,12 @@ def _[T: (DaskArray, np.ndarray)]( @singledispatch def axis_nnz(x: ArrayLike, /, axis: Literal[0, 1]) -> np.ndarray: + from .._compat import get_namespace, is_array_api + + if is_array_api(x): + xp = get_namespace(x) + return xp.count_nonzero(x, axis=axis) + return np.count_nonzero(x, axis=axis) @@ -758,6 +778,15 @@ def _(x: DaskArray, /, axis: Literal[0, 1]) -> DaskArray: @singledispatch def check_nonnegative_integers(x: _SupportedArray, /) -> bool | DaskArray: """Check values of X to ensure it is count data.""" + from .._compat import get_namespace, is_array_api + + if is_array_api(x): + xp = get_namespace(x) + if bool(xp.any(x < 0)): + return False + if xp.isdtype(x.dtype, "integral"): + return True + return not bool(xp.any((x % 1) != 0)) ### double check raise NotImplementedError diff --git a/src/scanpy/metrics/_common.py b/src/scanpy/metrics/_common.py index c9c90e1dc8..87c2cbd45d 100644 --- a/src/scanpy/metrics/_common.py +++ b/src/scanpy/metrics/_common.py @@ -12,8 +12,6 @@ from .._utils import NeighborsView if TYPE_CHECKING: - from typing import NoReturn - from anndata import AnnData from numpy.typing import NDArray @@ -93,7 +91,12 @@ def _resolve_vals(val: pd.DataFrame | pd.Series) -> NDArray: ... @singledispatch -def _resolve_vals(val: object) -> NoReturn: +def _resolve_vals(val: object): ### double check + from .._compat import is_array_api + + if is_array_api(val): + # Moran's I / Geary's C use numba kernels, so need to convert at boundary + return np.asarray(val) msg = f"Unsupported type {type(val)}" raise TypeError(msg) diff --git a/src/scanpy/neighbors/__init__.py b/src/scanpy/neighbors/__init__.py index 7bc2470df3..2a4aafbf41 100644 --- a/src/scanpy/neighbors/__init__.py +++ b/src/scanpy/neighbors/__init__.py @@ -641,7 +641,12 @@ def compute_neighbors( self._rp_forest = None self.n_neighbors = n_neighbors self.knn = knn + from .._compat import is_array_api + x = _choose_representation(self._adata, use_rep=use_rep, n_pcs=n_pcs) + if is_array_api(x): + # sklearn transformers require numpy, so need to convert at boundary + x = np.asarray(x) self._distances = transformer.fit_transform(x) knn_indices, knn_distances = _get_indices_distances_from_sparse_matrix( self._distances, n_neighbors diff --git a/src/scanpy/preprocessing/_highly_variable_genes.py b/src/scanpy/preprocessing/_highly_variable_genes.py index d5f3d2cc79..788f4c3e5f 100644 --- a/src/scanpy/preprocessing/_highly_variable_genes.py +++ b/src/scanpy/preprocessing/_highly_variable_genes.py @@ -363,13 +363,21 @@ def _highly_variable_genes_single_batch( if n_removed: x = x[:, filt].copy() + from .._compat import get_namespace, is_array_api ### double check + if flavor == "seurat": x = x.copy() if (base := adata.uns.get("log1p", {}).get("base")) is not None: - x *= np.log(base) + if is_array_api(x): + x = x * float(np.log(base)) + else: + x *= np.log(base) # use out if possible. only possible since we copy the data matrix if isinstance(x, np.ndarray): np.expm1(x, out=x) + elif is_array_api(x): + xp = get_namespace(x) + x = xp.expm1(x) else: x = np.expm1(x) diff --git a/src/scanpy/preprocessing/_normalization.py b/src/scanpy/preprocessing/_normalization.py index 79eb5cf0d1..4836e231de 100644 --- a/src/scanpy/preprocessing/_normalization.py +++ b/src/scanpy/preprocessing/_normalization.py @@ -19,8 +19,15 @@ def _compute_nnz_median(counts: np.ndarray | DaskArray) -> np.floating: """Given a 1D array of counts, compute the median of the non-zero counts.""" + from .._compat import is_array_api + if isinstance(counts, DaskArray): counts = counts.compute() + + if is_array_api(counts): + # there is no xp.median? ### double check + counts = np.asarray(counts) + counts_greater_than_zero = counts[counts > 0] median = np.median(counts_greater_than_zero) return median diff --git a/src/scanpy/preprocessing/_scale.py b/src/scanpy/preprocessing/_scale.py index 1a1a047544..6bb09d7c3b 100644 --- a/src/scanpy/preprocessing/_scale.py +++ b/src/scanpy/preprocessing/_scale.py @@ -32,6 +32,15 @@ def clip[A: _Array]( x: ArrayLike | A, *, max_value: float, zero_center: bool = True ) -> A: + # clip_array cannot trace JAX arrays = example + from .._compat import get_namespace, is_array_api + + if is_array_api(x): + xp = get_namespace(x) + if zero_center: + return xp.clip(x, -max_value, max_value) ### double check + return xp.clip(x, None, max_value) + return clip_array(x, max_value=max_value, zero_center=zero_center) @@ -169,8 +178,15 @@ def scale_array[A: _Array]( logg.info( # Be careful of what? This should be more specific "... be careful when using `max_value` without `zero_center`." ) + from .._compat import get_namespace, is_array_api - if np.issubdtype(x.dtype, np.integer): + if is_array_api(x): + xp = get_namespace(x) + if xp.isdtype(x.dtype, "integral"): ### double check if integral is needed + logg.info("...") + x = xp.astype(x, xp.float64) + + elif np.issubdtype(x.dtype, np.integer): logg.info( "... as scaling leads to float results, integer " "input is cast to float, returning copy." @@ -192,18 +208,28 @@ def scale_array[A: _Array]( max_value=max_value, return_mean_std=return_mean_std, ) + from .._compat import get_namespace, is_array_api mean, var = mean_var(x, axis=0, correction=1) - std = np.sqrt(var) - std[std == 0] = 1 - if zero_center: - if isinstance(x, CSBase) or ( - isinstance(x, DaskArray) and isinstance(x._meta, CSBase) - ): - msg = "zero-centering a sparse array/matrix densifies it." - warn(msg, UserWarning) - x -= mean - x = dematrix(x) + + if is_array_api(x): + xp = get_namespace(x) + std = xp.sqrt(var) + std = xp.where(std == 0, xp.ones_like(std), std) + + if zero_center: + x = x - mean ### double check this formula + else: + std = np.sqrt(var) + std[std == 0] = 1 + if zero_center: + if isinstance(x, CSBase) or ( + isinstance(x, DaskArray) and isinstance(x._meta, CSBase) + ): + msg = "zero-centering a sparse array/matrix densifies it." + warn(msg, UserWarning) + x -= mean + x = dematrix(x) x = axis_mul_or_truediv( x, diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index fb325dec35..ee42f54267 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -349,9 +349,17 @@ def log1p( Returns or updates `data`, depending on `copy`. """ + from .._compat import get_namespace, is_array_api + check_array_function_arguments( chunked=chunked, chunk_size=chunk_size, layer=layer, obsm=obsm ) + if is_array_api(data): + xp = get_namespace(data) + result = xp.log1p(data) + if base is not None: + result = result / float(np.log(base)) + return result return log1p_array(data, copy=copy, base=base) diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index eb32fb4bdb..c91564cb3b 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -645,6 +645,11 @@ def rank_genes_groups( # noqa: PLR0912, PLR0913, PLR0915 """ from scanpy import settings + from .._compat import is_array_api + + # rank_genes_groups uses numba kernels internally, so need convert at entry. + if is_array_api(adata.X): ### double check + adata.X = np.asarray(adata.X) if isinstance(mask_var, Default): mask_var = settings.preset.rank_genes_groups.mask_var if isinstance(mean_in_log_space, Default): From 790ddde6b45bee06b3e8cded1ef2398517ba0bb8 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Thu, 25 Jun 2026 10:17:50 +0200 Subject: [PATCH 02/31] typo + forgot numpy exclusion --- src/scanpy/_compat.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index 575877b9f3..c1b2bcece4 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -71,13 +71,16 @@ def pkg_metadata(package: str) -> PackageMetadata: def is_array_api(x: object) -> bool: # returns true if x is array api compatible # exclusing the ones that are already handled by the script + import numpy as np from array_api_compat import is_array_api_obj # excluding packages that are handled by both array-api-compat and script - if isinstance(x, DaskArray): + if isinstance(x, np.ndarray): return False if isinstance(x, DaskArray): return False + if isinstance(x, SpBase): + return False return is_array_api_obj(x) From fe9337aabfe59de62a1544980d58a4a08a6a87a9 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Wed, 15 Jul 2026 20:36:32 +0200 Subject: [PATCH 03/31] jax/array-api fixes --- src/scanpy/_utils/__init__.py | 89 +++++++++++++------ src/scanpy/neighbors/__init__.py | 3 +- .../preprocessing/_highly_variable_genes.py | 13 ++- src/scanpy/preprocessing/_scale.py | 35 ++++---- src/scanpy/preprocessing/_simple.py | 5 +- src/testing/scanpy/_pytest/__init__.py | 5 ++ src/testing/scanpy/_pytest/marks.py | 1 + src/testing/scanpy/_pytest/params.py | 7 +- tests/test_aggregated.py | 11 ++- tests/test_pca.py | 4 +- 10 files changed, 117 insertions(+), 56 deletions(-) diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index d61c150461..4a3a2727c6 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -30,6 +30,7 @@ import numpy as np import pandas as pd from anndata._core.sparse_dataset import BaseCompressedSparseDataset +from fast_array_utils.types import HasArrayNamespace from packaging.version import Version from .. import logging as logg @@ -602,22 +603,22 @@ def axis_mul_or_truediv( allow_divide_by_zero: bool = True, out: ArrayLike | None = None, ) -> np.ndarray: - from .._compat import get_namespace, is_array_api + raise NotImplementedError + +@axis_mul_or_truediv.register(np.ndarray) +def _( + x: np.ndarray, + /, + scaling_array: np.ndarray, + axis: Literal[0, 1], + op: Callable[[Any, Any], Any], + *, + allow_divide_by_zero: bool = True, + out: ArrayLike | None = None, +) -> np.ndarray: _check_op(op) scaling_array = _broadcast_axis(scaling_array, axis) - # array api version - if is_array_api(x): ### double check if numpy skips this - xp = get_namespace(x) - scaling_array = xp.asarray(scaling_array) - if op is mul: - return x * scaling_array - if not allow_divide_by_zero: - scaling_array = xp.where( - scaling_array == 0, xp.ones_like(scaling_array), scaling_array - ) - return x / scaling_array - # numpy version if op is mul: return np.multiply(x, scaling_array, out=out) if not allow_divide_by_zero: @@ -625,6 +626,32 @@ def axis_mul_or_truediv( return np.true_divide(x, scaling_array, out=out) +@axis_mul_or_truediv.register(HasArrayNamespace) +def _( + x: HasArrayNamespace, + /, + scaling_array: np.ndarray, + axis: Literal[0, 1], + op: Callable[[Any, Any], Any], + *, + allow_divide_by_zero: bool = True, + out: ArrayLike | None = None, +) -> Any: + from .._compat import get_namespace + + _check_op(op) + scaling_array = _broadcast_axis(scaling_array, axis) + xp = get_namespace(x) + scaling_array = xp.asarray(scaling_array) + if op is mul: + return x * scaling_array + if not allow_divide_by_zero: + scaling_array = xp.where( + scaling_array == 0, xp.ones_like(scaling_array), scaling_array + ) + return x / scaling_array + + @axis_mul_or_truediv.register(CSBase) def _( x: CSBase, @@ -739,15 +766,22 @@ def _[T: (DaskArray, np.ndarray)]( @singledispatch def axis_nnz(x: ArrayLike, /, axis: Literal[0, 1]) -> np.ndarray: - from .._compat import get_namespace, is_array_api + raise NotImplementedError - if is_array_api(x): - xp = get_namespace(x) - return xp.count_nonzero(x, axis=axis) +@axis_nnz.register(np.ndarray) +def _(x: np.ndarray, /, axis: Literal[0, 1]) -> np.ndarray: return np.count_nonzero(x, axis=axis) +@axis_nnz.register(HasArrayNamespace) +def _(x: HasArrayNamespace, /, axis: Literal[0, 1]) -> Any: + from .._compat import get_namespace + + xp = get_namespace(x) + return xp.count_nonzero(x, axis=axis) + + if pkg_version("scipy") >= Version("1.15"): # newer scipy versions support the `axis` argument for count_nonzero @axis_nnz.register(CSBase) @@ -778,18 +812,21 @@ def _(x: DaskArray, /, axis: Literal[0, 1]) -> DaskArray: @singledispatch def check_nonnegative_integers(x: _SupportedArray, /) -> bool | DaskArray: """Check values of X to ensure it is count data.""" - from .._compat import get_namespace, is_array_api - - if is_array_api(x): - xp = get_namespace(x) - if bool(xp.any(x < 0)): - return False - if xp.isdtype(x.dtype, "integral"): - return True - return not bool(xp.any((x % 1) != 0)) ### double check raise NotImplementedError +@check_nonnegative_integers.register(HasArrayNamespace) +def _check_nonnegative_integers_array_api(x: HasArrayNamespace, /) -> bool: + from .._compat import get_namespace + + xp = get_namespace(x) + if bool(xp.any(x < 0)): + return False + if xp.isdtype(x.dtype, "integral"): + return True + return not bool(xp.any((x % 1) != 0)) + + @check_nonnegative_integers.register(np.ndarray) @check_nonnegative_integers.register(CSBase) def _check_nonnegative_integers_in_mem(x: _MemoryArray, /) -> bool: diff --git a/src/scanpy/neighbors/__init__.py b/src/scanpy/neighbors/__init__.py index 2a4aafbf41..fe77b468d5 100644 --- a/src/scanpy/neighbors/__init__.py +++ b/src/scanpy/neighbors/__init__.py @@ -10,6 +10,7 @@ import numpy as np import scipy +from fast_array_utils.types import HasArrayNamespace from packaging.version import Version from scipy import sparse @@ -644,7 +645,7 @@ def compute_neighbors( from .._compat import is_array_api x = _choose_representation(self._adata, use_rep=use_rep, n_pcs=n_pcs) - if is_array_api(x): + if isinstance(x, HasArrayNamespace): # sklearn transformers require numpy, so need to convert at boundary x = np.asarray(x) self._distances = transformer.fit_transform(x) diff --git a/src/scanpy/preprocessing/_highly_variable_genes.py b/src/scanpy/preprocessing/_highly_variable_genes.py index 788f4c3e5f..1abf7d4781 100644 --- a/src/scanpy/preprocessing/_highly_variable_genes.py +++ b/src/scanpy/preprocessing/_highly_variable_genes.py @@ -11,6 +11,7 @@ import pandas as pd from anndata import AnnData from fast_array_utils import stats +from fast_array_utils.types import HasArrayNamespace from .. import logging as logg from .._compat import CSBase, CSRBase, DaskArray, warn @@ -363,19 +364,16 @@ def _highly_variable_genes_single_batch( if n_removed: x = x[:, filt].copy() - from .._compat import get_namespace, is_array_api ### double check + from .._compat import get_namespace ### double check if flavor == "seurat": x = x.copy() if (base := adata.uns.get("log1p", {}).get("base")) is not None: - if is_array_api(x): - x = x * float(np.log(base)) - else: - x *= np.log(base) + x *= np.log(base) # use out if possible. only possible since we copy the data matrix if isinstance(x, np.ndarray): np.expm1(x, out=x) - elif is_array_api(x): + elif isinstance(x, HasArrayNamespace): xp = get_namespace(x) x = xp.expm1(x) else: @@ -383,7 +381,8 @@ def _highly_variable_genes_single_batch( mean, var = materialize_as_ndarray(stats.mean_var(x, axis=0, correction=1)) # now actually compute the dispersion - mean[mean == 0] = 1e-12 # set entries equal to zero to small value + # allocating a fresh writiable array = jax issue + mean = np.where(mean == 0, 1e-12, mean) # set entries equal to zero to small value dispersion = var / mean if flavor == "seurat": # logarithmized mean as in Seurat dispersion[dispersion == 0] = np.nan diff --git a/src/scanpy/preprocessing/_scale.py b/src/scanpy/preprocessing/_scale.py index 6bb09d7c3b..bd15488bcf 100644 --- a/src/scanpy/preprocessing/_scale.py +++ b/src/scanpy/preprocessing/_scale.py @@ -9,9 +9,10 @@ from anndata import AnnData from fast_array_utils.numba import njit from fast_array_utils.stats import mean_var +from fast_array_utils.types import HasArrayNamespace from .. import logging as logg -from .._compat import CSBase, CSCBase, CSRBase, DaskArray, warn +from .._compat import CSBase, CSCBase, CSRBase, DaskArray, get_namespace, warn from .._settings import Default, settings from .._utils import ( axis_mul_or_truediv, @@ -32,18 +33,20 @@ def clip[A: _Array]( x: ArrayLike | A, *, max_value: float, zero_center: bool = True ) -> A: - # clip_array cannot trace JAX arrays = example - from .._compat import get_namespace, is_array_api + raise NotImplementedError - if is_array_api(x): - xp = get_namespace(x) - if zero_center: - return xp.clip(x, -max_value, max_value) ### double check - return xp.clip(x, None, max_value) +@clip.register(np.ndarray) +def _(x: np.ndarray, *, max_value: float, zero_center: bool = True) -> np.ndarray: return clip_array(x, max_value=max_value, zero_center=zero_center) +@clip.register(HasArrayNamespace) +def _(x, *, max_value: float, zero_center: bool = True): + xp = get_namespace(x) + return xp.clip(x, min=-max_value if zero_center else None, max=max_value) + + @clip.register(CSBase) def _(x: CSBase, *, max_value: float, zero_center: bool = True) -> CSBase: x.data = clip(x.data, max_value=max_value, zero_center=zero_center) @@ -150,6 +153,7 @@ def scale[A: _Array]( @scale.register(np.ndarray) @scale.register(DaskArray) @scale.register(CSBase) +@scale.register(HasArrayNamespace) def scale_array[A: _Array]( x: A, *, @@ -178,12 +182,14 @@ def scale_array[A: _Array]( logg.info( # Be careful of what? This should be more specific "... be careful when using `max_value` without `zero_center`." ) - from .._compat import get_namespace, is_array_api - if is_array_api(x): + if isinstance(x, HasArrayNamespace): xp = get_namespace(x) - if xp.isdtype(x.dtype, "integral"): ### double check if integral is needed - logg.info("...") + if xp.isdtype(x.dtype, "integral"): + logg.info( + "... as scaling leads to float results, integer " + "input is cast to float, returning copy." + ) x = xp.astype(x, xp.float64) elif np.issubdtype(x.dtype, np.integer): @@ -208,17 +214,16 @@ def scale_array[A: _Array]( max_value=max_value, return_mean_std=return_mean_std, ) - from .._compat import get_namespace, is_array_api mean, var = mean_var(x, axis=0, correction=1) - if is_array_api(x): + if isinstance(x, HasArrayNamespace): xp = get_namespace(x) std = xp.sqrt(var) std = xp.where(std == 0, xp.ones_like(std), std) if zero_center: - x = x - mean ### double check this formula + x = x - mean else: std = np.sqrt(var) std[std == 0] = 1 diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index ee42f54267..cb3b2c36bd 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -18,6 +18,7 @@ from fast_array_utils import stats from fast_array_utils.conv import to_dense from fast_array_utils.numba import njit +from fast_array_utils.types import HasArrayNamespace from numpy._typing._array_like import NDArray from pandas.api.types import CategoricalDtype from sklearn.utils import check_array @@ -828,7 +829,9 @@ def sample( # noqa: PLR0912 return subset.to_memory() if data.isbacked else subset.copy() # overload 3: return array and indices - assert isinstance(subset, np.ndarray | CSBase | DaskArray), type(subset) + assert isinstance(subset, np.ndarray | CSBase | DaskArray | HasArrayNamespace), ( + type(subset) + ) if copy: subset = subset.copy() return subset, indices diff --git a/src/testing/scanpy/_pytest/__init__.py b/src/testing/scanpy/_pytest/__init__.py index 0c07046a48..4b1a6b2d1a 100644 --- a/src/testing/scanpy/_pytest/__init__.py +++ b/src/testing/scanpy/_pytest/__init__.py @@ -4,6 +4,7 @@ import os import sys +from importlib.util import find_spec from types import MappingProxyType from typing import TYPE_CHECKING @@ -17,6 +18,10 @@ if TYPE_CHECKING: from collections.abc import Generator, Iterable, Mapping +if find_spec("jax"): + import jax + + jax.config.update("jax_enable_x64", True) # noqa: FBT003 MARK_RETRY_DOWNLOAD = pytest.mark.flaky( reruns=5, diff --git a/src/testing/scanpy/_pytest/marks.py b/src/testing/scanpy/_pytest/marks.py index 1e83404614..9b5fed7c18 100644 --- a/src/testing/scanpy/_pytest/marks.py +++ b/src/testing/scanpy/_pytest/marks.py @@ -33,6 +33,7 @@ def _generate_next_value_( dask_ml = auto() fa2 = auto() gprofiler = "gprofiler-official" + jax = auto() leidenalg = auto() louvain = auto() openpyxl = auto() diff --git a/src/testing/scanpy/_pytest/params.py b/src/testing/scanpy/_pytest/params.py index ce4094206d..b491e47299 100644 --- a/src/testing/scanpy/_pytest/params.py +++ b/src/testing/scanpy/_pytest/params.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING import pytest -from anndata.tests.helpers import asarray +from anndata.tests.helpers import as_dense_jax_array, asarray from packaging.version import Version from scipy import sparse @@ -80,7 +80,10 @@ def wrapper(a: np.ndarray) -> DaskArray: tuple[Literal["mem", "dask"], Literal["dense", "sparse"]], tuple[ParameterSet, ...], ] = { - ("mem", "dense"): (pytest.param(asarray, id="numpy_ndarray"),), + ("mem", "dense"): ( + pytest.param(asarray, id="numpy_ndarray"), + pytest.param(as_dense_jax_array, marks=[needs.jax], id="jax_array"), + ), ("mem", "sparse"): ( pytest.param(sparse.csr_matrix, id="scipy_csr_mat"), # noqa: TID251 pytest.param(sparse.csc_matrix, id="scipy_csc_mat"), # noqa: TID251 diff --git a/tests/test_aggregated.py b/tests/test_aggregated.py index 9d5db64e08..37b3b17be6 100644 --- a/tests/test_aggregated.py +++ b/tests/test_aggregated.py @@ -16,7 +16,7 @@ from testing.scanpy._helpers.data import pbmc3k_processed from testing.scanpy._pytest.marks import needs from testing.scanpy._pytest.params import ARRAY_TYPES as ARRAY_TYPES_ALL -from testing.scanpy._pytest.params import ARRAY_TYPES_MEM +from testing.scanpy._pytest.params import ARRAY_TYPES_MEM, param_with if TYPE_CHECKING: from collections.abc import Callable @@ -27,7 +27,14 @@ from scanpy._compat import CSRBase VALID_ARRAY_TYPES = [ - at + param_with( + at, + marks=[ + pytest.mark.xfail(reason="aggregate not implemented for array-api arrays") + ], + ) + if at.id == "jax_array" + else at for at in ARRAY_TYPES_ALL if at.id not in { diff --git a/tests/test_pca.py b/tests/test_pca.py index 52b1680f0d..b87f924c35 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -158,9 +158,9 @@ def possible_solvers( svd_solvers = {"arpack"} | SKLEARN_ADDITIONAL case (type() as dc, False) if issubclass(dc, CSBase): svd_solvers = {"arpack", "randomized"} - case (helpers.asarray, True): + case (helpers.asarray | helpers.as_dense_jax_array, True): svd_solvers = {"auto", "full", "arpack", "randomized"} | SKLEARN_ADDITIONAL - case (helpers.asarray, False): + case (helpers.asarray | helpers.as_dense_jax_array, False): svd_solvers = {"arpack", "randomized"} case _: pytest.fail(f"Unknown {array_type=} ({zero_center=}) ({id=})") From 5dc2d1913df9635ede12786d74301f046cabe5ed Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Thu, 16 Jul 2026 01:08:49 +0200 Subject: [PATCH 04/31] addressing failing tests --- tests/test_aggregated.py | 9 ++++++++- tests/test_pca.py | 5 ++++- tests/test_rank_genes_groups.py | 13 ++++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/test_aggregated.py b/tests/test_aggregated.py index 37b3b17be6..5157191178 100644 --- a/tests/test_aggregated.py +++ b/tests/test_aggregated.py @@ -6,6 +6,7 @@ import numpy as np import pandas as pd import pytest +from anndata.tests import helpers from scipy import sparse import scanpy as sc @@ -555,12 +556,18 @@ def test_nan() -> None: @pytest.mark.parametrize("array_type", ARRAY_TYPES_MEM) -def test_var_no_catastrophic_cancellation(array_type) -> None: +def test_var_no_catastrophic_cancellation( + request: pytest.FixtureRequest, array_type +) -> None: # Values of the form `offset + tiny_noise` make the textbook two-pass # formula sum(x**2)/n - (sum(x)/n)**2 lose ~all precision: both terms are # ~n*offset**2 ≈ 1e19 in float64 (precision ~1e3) but their difference is # the variance ~1e-3, far below the rounding noise. Welford's online # algorithm avoids the subtraction entirely. + if array_type is helpers.as_dense_jax_array: + request.applymarker( + pytest.mark.xfail(reason="aggregate not implemented for jax arrays") + ) n_per_group, n_features = 1000, 4 offset, std = 1e8, 1e-3 groups = ["a", "b"] diff --git a/tests/test_pca.py b/tests/test_pca.py index b87f924c35..32b3b90426 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -483,12 +483,15 @@ def test_mask(request: pytest.FixtureRequest, array_type): ) -def test_mask_defaults(array_type, float_dtype): +def test_mask_defaults(request: pytest.FixtureRequest, array_type, float_dtype): """Test if PCA behavior in relation to highly variable genes. 1. That it’s equal withwithout and with – but mask is None 2. If pca takes highly variable as mask as default """ + if array_type is helpers.as_dense_jax_array: + reason = "anndata IndexManager.get_for_array uses from_dlpack on a read only numpy index (numpy/numpy#20742): https://github.com/numpy/numpy/issues/20742" + request.applymarker(pytest.mark.xfail(reason=reason)) a = array_type(A_list).astype("float64") adata = AnnData(a) diff --git a/tests/test_rank_genes_groups.py b/tests/test_rank_genes_groups.py index d7424353e1..aae6cdba0e 100644 --- a/tests/test_rank_genes_groups.py +++ b/tests/test_rank_genes_groups.py @@ -9,6 +9,7 @@ import pandas as pd import pytest from anndata import AnnData +from anndata.tests import helpers from scipy.stats import mannwhitneyu import scanpy as sc @@ -107,7 +108,10 @@ def test_results( @pytest.mark.parametrize("method", ["t-test", "wilcoxon"]) @pytest.mark.parametrize("array_type", ARRAY_TYPES_MEM) def test_results_layers( - subtests: pytest.Subtests, array_type, method: Literal["t-test", "wilcoxon"] + request: pytest.FixtureRequest, + subtests: pytest.Subtests, + array_type, + method: Literal["t-test", "wilcoxon"], ) -> None: adata = get_example_data(array_type, rng=_LegacyRng(1234)) adata.layers["to_test"] = adata.X.copy() @@ -117,6 +121,13 @@ def test_results_layers( adata.X = array_type(x) scores = get_true_scores(method)["scores"] + if array_type is helpers.as_dense_jax_array: + request.applymarker( + pytest.mark.xfail( + reason="test mutates .X in-place; jax arrays are immutable" + ) + ) + with subtests.test("layer"): rank_genes_groups( adata, From b88b8fda6951daa76dacbf5b072d00fba3f6bd7e Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Thu, 16 Jul 2026 03:11:51 +0200 Subject: [PATCH 05/31] fixing test errors 2 --- src/scanpy/_utils/__init__.py | 13 ++++++++---- src/scanpy/preprocessing/_scale.py | 33 +++++++++++++++--------------- tests/test_pca.py | 5 +---- tests/test_rank_genes_groups.py | 14 ++++++------- 4 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index 81fe915c20..f1c636617c 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -34,7 +34,15 @@ from packaging.version import Version from .. import logging as logg -from .._compat import CSBase, DaskArray, SpBase, warn +from .._compat import ( + CSBase, + DaskArray, + SpBase, + _CSArray, + get_namespace, + pkg_version, + warn, +) from ._numba import _numba_thread_limit if TYPE_CHECKING: @@ -646,7 +654,6 @@ def _( allow_divide_by_zero: bool = True, out: ArrayLike | None = None, ) -> Any: - from .._compat import get_namespace _check_op(op) scaling_array = _broadcast_axis(scaling_array, axis) @@ -785,7 +792,6 @@ def _(x: np.ndarray, /, axis: Literal[0, 1]) -> np.ndarray: @axis_nnz.register(HasArrayNamespace) def _(x: HasArrayNamespace, /, axis: Literal[0, 1]) -> Any: - from .._compat import get_namespace xp = get_namespace(x) return xp.count_nonzero(x, axis=axis) @@ -826,7 +832,6 @@ def check_nonnegative_integers(x: _SupportedArray, /) -> bool | DaskArray: @check_nonnegative_integers.register(HasArrayNamespace) def _check_nonnegative_integers_array_api(x: HasArrayNamespace, /) -> bool: - from .._compat import get_namespace xp = get_namespace(x) if bool(xp.any(x < 0)): diff --git a/src/scanpy/preprocessing/_scale.py b/src/scanpy/preprocessing/_scale.py index 7012d49d50..d2903b8d19 100644 --- a/src/scanpy/preprocessing/_scale.py +++ b/src/scanpy/preprocessing/_scale.py @@ -190,8 +190,14 @@ def scale_array[A: _Array]( logg.info( # Be careful of what? This should be more specific "... be careful when using `max_value` without `zero_center`." ) - - if isinstance(x, HasArrayNamespace): + if isinstance(x, np.ndarray | CSBase | DaskArray): + if np.issubdtype(x.dtype, np.integer): + logg.info( + "... as scaling leads to float results, integer " + "input is cast to float, returning copy." + ) + x = x.astype(np.float64) + else: xp = get_namespace(x) if xp.isdtype(x.dtype, "integral"): logg.info( @@ -200,13 +206,6 @@ def scale_array[A: _Array]( ) x = xp.astype(x, xp.float64) - elif np.issubdtype(x.dtype, np.integer): - logg.info( - "... as scaling leads to float results, integer " - "input is cast to float, returning copy." - ) - x = x.astype(np.float64) - mask_obs = ( # For CSR matrices, default to a set mask to take the `scale_array_masked` path. # This is faster than the maskless `axis_mul_or_truediv` path. @@ -225,14 +224,7 @@ def scale_array[A: _Array]( mean, var = mean_var(x, axis=0, correction=1) - if isinstance(x, HasArrayNamespace): - xp = get_namespace(x) - std = xp.sqrt(var) - std = xp.where(std == 0, xp.ones_like(std), std) - - if zero_center: - x = x - mean - else: + if isinstance(x, np.ndarray | CSBase | DaskArray): std = np.sqrt(var) std[std == 0] = 1 if zero_center: @@ -243,6 +235,13 @@ def scale_array[A: _Array]( warn(msg, UserWarning) x -= mean x = dematrix(x) + else: + xp = get_namespace(x) + std = xp.sqrt(var) + std = xp.where(std == 0, xp.ones_like(std), std) + + if zero_center: + x = x - mean x = axis_mul_or_truediv( x, diff --git a/tests/test_pca.py b/tests/test_pca.py index 4bd6807211..1fe8388886 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -482,15 +482,12 @@ def test_mask(request: pytest.FixtureRequest, array_type): ) -def test_mask_defaults(request: pytest.FixtureRequest, array_type, float_dtype): +def test_mask_defaults(array_type, float_dtype): """Test if PCA behavior in relation to highly variable genes. 1. That it’s equal withwithout and with – but mask is None 2. If pca takes highly variable as mask as default """ - if array_type is helpers.as_dense_jax_array: - reason = "anndata IndexManager.get_for_array uses from_dlpack on a read only numpy index (numpy/numpy#20742): https://github.com/numpy/numpy/issues/20742" - request.applymarker(pytest.mark.xfail(reason=reason)) a = array_type(A_list).astype("float64") adata = AnnData(a) diff --git a/tests/test_rank_genes_groups.py b/tests/test_rank_genes_groups.py index a60c06c846..03488eb651 100644 --- a/tests/test_rank_genes_groups.py +++ b/tests/test_rank_genes_groups.py @@ -134,13 +134,6 @@ def test_results_layers( array_type, method: Literal["t-test", "wilcoxon"], ) -> None: - adata = get_example_data(array_type, rng=_LegacyRng(1234)) - adata.layers["to_test"] = adata.X.copy() - x = adata.X.tolil() if isinstance(adata.X, CSBase) else adata.X - mask = np.random.default_rng().integers(0, 2, adata.shape, dtype=bool) - x[mask] = 0 - adata.X = array_type(x) - scores = get_true_scores(method)["scores"] if array_type is helpers.as_dense_jax_array: request.applymarker( @@ -148,6 +141,13 @@ def test_results_layers( reason="test mutates .X in-place; jax arrays are immutable" ) ) + adata = get_example_data(array_type, rng=_LegacyRng(1234)) + adata.layers["to_test"] = adata.X.copy() + x = adata.X.tolil() if isinstance(adata.X, CSBase) else adata.X + mask = np.random.default_rng().integers(0, 2, adata.shape, dtype=bool) + x[mask] = 0 + adata.X = array_type(x) + scores = get_true_scores(method)["scores"] with subtests.test("layer"): rank_genes_groups( From ccd76833fa7b0714e943906d513029b1495ac71a Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Thu, 16 Jul 2026 03:35:08 +0200 Subject: [PATCH 06/31] cleanup --- src/scanpy/_compat.py | 17 ----------------- src/scanpy/neighbors/__init__.py | 1 - src/scanpy/preprocessing/_scale.py | 2 +- 3 files changed, 1 insertion(+), 19 deletions(-) diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index c1b2bcece4..6c0fc48bf8 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -23,7 +23,6 @@ "SpBase", "fullname", "get_namespace", - "is_array_api", "pkg_metadata", "pkg_version", "set_module", @@ -68,22 +67,6 @@ def pkg_metadata(package: str) -> PackageMetadata: return metadata(package) -def is_array_api(x: object) -> bool: - # returns true if x is array api compatible - # exclusing the ones that are already handled by the script - import numpy as np - from array_api_compat import is_array_api_obj - - # excluding packages that are handled by both array-api-compat and script - if isinstance(x, np.ndarray): - return False - if isinstance(x, DaskArray): - return False - if isinstance(x, SpBase): - return False - return is_array_api_obj(x) - - def get_namespace(x): # get array-api namespace for x from array_api_compat import get_namespace diff --git a/src/scanpy/neighbors/__init__.py b/src/scanpy/neighbors/__init__.py index 129876fe6c..66986137dc 100644 --- a/src/scanpy/neighbors/__init__.py +++ b/src/scanpy/neighbors/__init__.py @@ -635,7 +635,6 @@ def compute_neighbors( self._rp_forest = None self.n_neighbors = n_neighbors self.knn = knn - from .._compat import is_array_api x = _choose_representation(self._adata, use_rep=use_rep, n_pcs=n_pcs) if isinstance(x, HasArrayNamespace): diff --git a/src/scanpy/preprocessing/_scale.py b/src/scanpy/preprocessing/_scale.py index d2903b8d19..7b53206b3e 100644 --- a/src/scanpy/preprocessing/_scale.py +++ b/src/scanpy/preprocessing/_scale.py @@ -162,7 +162,7 @@ def scale[A: _Array]( @scale.register(DaskArray) @scale.register(CSBase) @scale.register(HasArrayNamespace) -def scale_array[A: _Array]( +def scale_array[A: _Array]( # noqa: PLR0912 x: A, *, zero_center: bool | Default = Default(preset=("scale", "zero_center")), From 9eed5e555bda7b83131033a77d3d35fdb7be3822 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Thu, 16 Jul 2026 03:43:42 +0200 Subject: [PATCH 07/31] docs/release-notes/4179.feat.md --- docs/release-notes/4179.feat.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/release-notes/4179.feat.md diff --git a/docs/release-notes/4179.feat.md b/docs/release-notes/4179.feat.md new file mode 100644 index 0000000000..cf616609b1 --- /dev/null +++ b/docs/release-notes/4179.feat.md @@ -0,0 +1 @@ +Add Array-API support, enabling JAX and other array-api backends in `adata.X` {smaller}`A. Karesh` From 4fd83353e00b5e057544c081698fe503363ca00b Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Thu, 16 Jul 2026 11:33:52 +0200 Subject: [PATCH 08/31] dependcies --- pyproject.toml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3c73b926b7..3d7870ef9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,9 @@ classifiers = [ dynamic = [ "version" ] dependencies = [ "anndata>=0.11.2", + "array-api-compat", "certifi", - "fast-array-utils[accel,sparse]>=1.4", + "fast-array-utils[accel,sparse] @ git+https://github.com/scverse/fast-array-utils.git@main", "h5py>=3.11", "joblib", # skip 3.11.0 series for now: https://github.com/matplotlib/matplotlib/issues/31575 @@ -87,6 +88,7 @@ scripts.scanpy = "scanpy.cli:console_main" [project.optional-dependencies] bbknn = [ "bbknn" ] dask = [ "anndata[dask]", "dask[array]>=2024.5.1" ] +jax = [ "jax" ] # PCA acceleration dask-ml = [ "dask-ml", "scanpy[dask]" ] leiden = [ "igraph>=0.10.8", "leidenalg>=0.10.1" ] @@ -110,6 +112,7 @@ test = [ "scanpy[dask-ml]", "scanpy[dask]", "scanpy[illico]", + "scanpy[jax]", "scanpy[leiden]", "scanpy[plotting]", "scanpy[scrublet]", @@ -118,11 +121,11 @@ test = [ { include-group = "test-min" }, ] docs = [ - "ipython>=8.27", # for nbsphinx code highlighting + "ipython>=8.27", # for nbsphinx code highlighting "myst-nb>=1.4", "myst-parser>=2", "nbsphinx>=0.9", - "numpy>=2.4", # type aliases + "numpy>=2.4", # type aliases "sam-algorithm", # TODO: remove necessity for being able to import doc-linked classes "scanpy[dask-ml,leiden,paga,plotting]", @@ -155,6 +158,7 @@ test-min = [ version.source = "vcs" version.raw-options.version_scheme = "release-branch-semver" build.targets.wheel.packages = [ "src/scanpy", "src/testing" ] +metadata.allow-direct-references = true [tool.ruff] src = [ "src" ] @@ -213,7 +217,7 @@ lint.per-file-ignores."tests/**/*.py" = [ "D100", "D101", "D103", "PLR0913" ] lint.allowed-confusables = [ "×", "–", "‘", "’", "α" ] lint.external = [ "PLR0917" ] lint.flake8-bugbear.extend-immutable-calls = [ "scanpy._settings.Default" ] -lint.flake8-type-checking.exempt-modules = [] +lint.flake8-type-checking.exempt-modules = [ ] lint.flake8-type-checking.runtime-evaluated-base-classes = [ "scverse_misc.Settings" ] lint.flake8-type-checking.strict = true lint.isort.known-first-party = [ "scanpy", "testing.scanpy" ] @@ -320,7 +324,7 @@ filename = "docs/release-notes/{version}.md" title_format = "(v{version})=\n### {version} {{small}}`{project_date}`" issue_format = "{{pr}}`{issue}`" single_file = false -fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) +fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) fragment.chore.name = "Miscellaneous changes" fragment.docs.name = "Documentation" # Valid fragments should be a subset of conventional commit types (except for `breaking`): From c861c569c28b4a51096f5dabbd07b7659b1710cd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:34:21 +0000 Subject: [PATCH 09/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyproject.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3d7870ef9e..cb734a2451 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,11 +121,11 @@ test = [ { include-group = "test-min" }, ] docs = [ - "ipython>=8.27", # for nbsphinx code highlighting + "ipython>=8.27", # for nbsphinx code highlighting "myst-nb>=1.4", "myst-parser>=2", "nbsphinx>=0.9", - "numpy>=2.4", # type aliases + "numpy>=2.4", # type aliases "sam-algorithm", # TODO: remove necessity for being able to import doc-linked classes "scanpy[dask-ml,leiden,paga,plotting]", @@ -157,8 +157,8 @@ test-min = [ [tool.hatch] version.source = "vcs" version.raw-options.version_scheme = "release-branch-semver" -build.targets.wheel.packages = [ "src/scanpy", "src/testing" ] metadata.allow-direct-references = true +build.targets.wheel.packages = [ "src/scanpy", "src/testing" ] [tool.ruff] src = [ "src" ] @@ -217,7 +217,7 @@ lint.per-file-ignores."tests/**/*.py" = [ "D100", "D101", "D103", "PLR0913" ] lint.allowed-confusables = [ "×", "–", "‘", "’", "α" ] lint.external = [ "PLR0917" ] lint.flake8-bugbear.extend-immutable-calls = [ "scanpy._settings.Default" ] -lint.flake8-type-checking.exempt-modules = [ ] +lint.flake8-type-checking.exempt-modules = [] lint.flake8-type-checking.runtime-evaluated-base-classes = [ "scverse_misc.Settings" ] lint.flake8-type-checking.strict = true lint.isort.known-first-party = [ "scanpy", "testing.scanpy" ] @@ -324,7 +324,7 @@ filename = "docs/release-notes/{version}.md" title_format = "(v{version})=\n### {version} {{small}}`{project_date}`" issue_format = "{{pr}}`{issue}`" single_file = false -fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) +fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) fragment.chore.name = "Miscellaneous changes" fragment.docs.name = "Documentation" # Valid fragments should be a subset of conventional commit types (except for `breaking`): From 23e02bf88e958f98f7a86c6d87e071bedf3d88b7 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Thu, 16 Jul 2026 11:58:29 +0200 Subject: [PATCH 10/31] is_array_api missed, removed, fixed --- src/scanpy/metrics/_common.py | 14 ++++++++------ src/scanpy/preprocessing/_normalization.py | 11 ++++------- src/scanpy/preprocessing/_simple.py | 19 ++++++++++--------- src/scanpy/tools/_rank_genes_groups.py | 5 ++--- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/src/scanpy/metrics/_common.py b/src/scanpy/metrics/_common.py index 87c2cbd45d..ff9c05b82a 100644 --- a/src/scanpy/metrics/_common.py +++ b/src/scanpy/metrics/_common.py @@ -7,6 +7,7 @@ import numpy as np import pandas as pd +from fast_array_utils.types import HasArrayNamespace from .._compat import CSRBase, DaskArray, SpBase, fullname, warn from .._utils import NeighborsView @@ -91,16 +92,17 @@ def _resolve_vals(val: pd.DataFrame | pd.Series) -> NDArray: ... @singledispatch -def _resolve_vals(val: object): ### double check - from .._compat import is_array_api - - if is_array_api(val): - # Moran's I / Geary's C use numba kernels, so need to convert at boundary - return np.asarray(val) +def _resolve_vals(val: object): msg = f"Unsupported type {type(val)}" raise TypeError(msg) +@_resolve_vals.register(HasArrayNamespace) +def _(val: HasArrayNamespace) -> NDArray: + # Moran's I / Geary's C use numba kernels, so convert at the boundary + return np.asarray(val) + + @_resolve_vals.register(np.ndarray) @_resolve_vals.register(CSRBase) @_resolve_vals.register(DaskArray) diff --git a/src/scanpy/preprocessing/_normalization.py b/src/scanpy/preprocessing/_normalization.py index 4836e231de..f2e3799634 100644 --- a/src/scanpy/preprocessing/_normalization.py +++ b/src/scanpy/preprocessing/_normalization.py @@ -15,19 +15,16 @@ if TYPE_CHECKING: from anndata import AnnData + from fast_array_utils.types import HasArrayNamespace -def _compute_nnz_median(counts: np.ndarray | DaskArray) -> np.floating: +def _compute_nnz_median( + counts: np.ndarray | DaskArray | HasArrayNamespace, +) -> np.floating: """Given a 1D array of counts, compute the median of the non-zero counts.""" - from .._compat import is_array_api - if isinstance(counts, DaskArray): counts = counts.compute() - if is_array_api(counts): - # there is no xp.median? ### double check - counts = np.asarray(counts) - counts_greater_than_zero = counts[counts > 0] median = np.median(counts_greater_than_zero) return median diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index cb3b2c36bd..964b602f59 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -24,7 +24,7 @@ from sklearn.utils import check_array from .. import logging as logg -from .._compat import CSBase, CSRBase, DaskArray +from .._compat import CSBase, CSRBase, DaskArray, get_namespace from .._docs import doc_rng from .._settings import settings from .._utils import ( @@ -350,20 +350,21 @@ def log1p( Returns or updates `data`, depending on `copy`. """ - from .._compat import get_namespace, is_array_api - check_array_function_arguments( chunked=chunked, chunk_size=chunk_size, layer=layer, obsm=obsm ) - if is_array_api(data): - xp = get_namespace(data) - result = xp.log1p(data) - if base is not None: - result = result / float(np.log(base)) - return result return log1p_array(data, copy=copy, base=base) +@log1p.register(HasArrayNamespace) +def log1p_array_api(x, *, base: Number | None = None, copy: bool = False): + xp = get_namespace(x) + result = xp.log1p(x) + if base is not None: + result = result / float(np.log(base)) + return result + + @log1p.register(CSBase) def log1p_sparse(x: CSBase, *, base: Number | None = None, copy: bool = False): x = check_array( diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index a4363552eb..61e425c1aa 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -10,6 +10,7 @@ from anndata import AnnData from fast_array_utils.numba import njit from fast_array_utils.stats import mean_var +from fast_array_utils.types import HasArrayNamespace from scipy import sparse from .. import _utils @@ -714,10 +715,8 @@ def rank_genes_groups( # noqa: PLR0912, PLR0913, PLR0915 """ from scanpy import settings - from .._compat import is_array_api - # rank_genes_groups uses numba kernels internally, so need convert at entry. - if is_array_api(adata.X): ### double check + if isinstance(adata.X, HasArrayNamespace) and not isinstance(adata.X, np.ndarray): adata.X = np.asarray(adata.X) if isinstance(mask_var, Default): mask_var = settings.preset.rank_genes_groups.mask_var From 7980d7a2c26d2384808b38dab6d65cf72a9e710e Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Thu, 16 Jul 2026 12:26:55 +0200 Subject: [PATCH 11/31] fix --- src/scanpy/metrics/_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scanpy/metrics/_common.py b/src/scanpy/metrics/_common.py index ff9c05b82a..e491a12a0a 100644 --- a/src/scanpy/metrics/_common.py +++ b/src/scanpy/metrics/_common.py @@ -98,7 +98,7 @@ def _resolve_vals(val: object): @_resolve_vals.register(HasArrayNamespace) -def _(val: HasArrayNamespace) -> NDArray: +def _resolve_vals_array_api(val: HasArrayNamespace) -> NDArray: # Moran's I / Geary's C use numba kernels, so convert at the boundary return np.asarray(val) From 374cac0d7eb4b873c554f8d00c345d2745b99dd7 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Thu, 16 Jul 2026 12:28:39 +0200 Subject: [PATCH 12/31] anndata version, was installing 11.2 --- pyproject.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cb734a2451..4c40986141 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ classifiers = [ ] dynamic = [ "version" ] dependencies = [ - "anndata>=0.11.2", + "anndata>=0.13.2", "array-api-compat", "certifi", "fast-array-utils[accel,sparse] @ git+https://github.com/scverse/fast-array-utils.git@main", @@ -121,11 +121,11 @@ test = [ { include-group = "test-min" }, ] docs = [ - "ipython>=8.27", # for nbsphinx code highlighting + "ipython>=8.27", # for nbsphinx code highlighting "myst-nb>=1.4", "myst-parser>=2", "nbsphinx>=0.9", - "numpy>=2.4", # type aliases + "numpy>=2.4", # type aliases "sam-algorithm", # TODO: remove necessity for being able to import doc-linked classes "scanpy[dask-ml,leiden,paga,plotting]", @@ -217,7 +217,7 @@ lint.per-file-ignores."tests/**/*.py" = [ "D100", "D101", "D103", "PLR0913" ] lint.allowed-confusables = [ "×", "–", "‘", "’", "α" ] lint.external = [ "PLR0917" ] lint.flake8-bugbear.extend-immutable-calls = [ "scanpy._settings.Default" ] -lint.flake8-type-checking.exempt-modules = [] +lint.flake8-type-checking.exempt-modules = [ ] lint.flake8-type-checking.runtime-evaluated-base-classes = [ "scverse_misc.Settings" ] lint.flake8-type-checking.strict = true lint.isort.known-first-party = [ "scanpy", "testing.scanpy" ] @@ -324,7 +324,7 @@ filename = "docs/release-notes/{version}.md" title_format = "(v{version})=\n### {version} {{small}}`{project_date}`" issue_format = "{{pr}}`{issue}`" single_file = false -fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) +fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) fragment.chore.name = "Miscellaneous changes" fragment.docs.name = "Documentation" # Valid fragments should be a subset of conventional commit types (except for `breaking`): From ef4d3d868961f57263ee942e2f910913bc91d277 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:29:29 +0000 Subject: [PATCH 13/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4c40986141..c15d5044eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,11 +121,11 @@ test = [ { include-group = "test-min" }, ] docs = [ - "ipython>=8.27", # for nbsphinx code highlighting + "ipython>=8.27", # for nbsphinx code highlighting "myst-nb>=1.4", "myst-parser>=2", "nbsphinx>=0.9", - "numpy>=2.4", # type aliases + "numpy>=2.4", # type aliases "sam-algorithm", # TODO: remove necessity for being able to import doc-linked classes "scanpy[dask-ml,leiden,paga,plotting]", @@ -217,7 +217,7 @@ lint.per-file-ignores."tests/**/*.py" = [ "D100", "D101", "D103", "PLR0913" ] lint.allowed-confusables = [ "×", "–", "‘", "’", "α" ] lint.external = [ "PLR0917" ] lint.flake8-bugbear.extend-immutable-calls = [ "scanpy._settings.Default" ] -lint.flake8-type-checking.exempt-modules = [ ] +lint.flake8-type-checking.exempt-modules = [] lint.flake8-type-checking.runtime-evaluated-base-classes = [ "scverse_misc.Settings" ] lint.flake8-type-checking.strict = true lint.isort.known-first-party = [ "scanpy", "testing.scanpy" ] @@ -324,7 +324,7 @@ filename = "docs/release-notes/{version}.md" title_format = "(v{version})=\n### {version} {{small}}`{project_date}`" issue_format = "{{pr}}`{issue}`" single_file = false -fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) +fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) fragment.chore.name = "Miscellaneous changes" fragment.docs.name = "Documentation" # Valid fragments should be a subset of conventional commit types (except for `breaking`): From 97e733d8a5aa1585bb5ebd5aac555d90c152e595 Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Fri, 17 Jul 2026 15:39:24 +0200 Subject: [PATCH 14/31] fix deps --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c15d5044eb..3961344c16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,10 +51,10 @@ classifiers = [ ] dynamic = [ "version" ] dependencies = [ - "anndata>=0.13.2", + "anndata>=0.11.2", "array-api-compat", "certifi", - "fast-array-utils[accel,sparse] @ git+https://github.com/scverse/fast-array-utils.git@main", + "fast-array-utils[accel,sparse]>=1.5", "h5py>=3.11", "joblib", # skip 3.11.0 series for now: https://github.com/matplotlib/matplotlib/issues/31575 From 4ef21c6c6be929371edddc102a30baddf23069b5 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Sun, 26 Jul 2026 16:35:46 +0200 Subject: [PATCH 15/31] addressing comments --- src/scanpy/_compat.py | 4 +- src/scanpy/_utils/__init__.py | 105 ++++++++---------- src/scanpy/metrics/_common.py | 14 ++- .../preprocessing/_highly_variable_genes.py | 9 +- src/scanpy/preprocessing/_normalization.py | 5 +- src/scanpy/preprocessing/_scale.py | 26 ++--- src/scanpy/preprocessing/_simple.py | 18 +-- src/testing/scanpy/_pytest/__init__.py | 2 + 8 files changed, 84 insertions(+), 99 deletions(-) diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index 6c0fc48bf8..04d254501e 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -4,7 +4,7 @@ from functools import cache from importlib.util import find_spec from pathlib import Path -from types import FunctionType +from types import FunctionType, ModuleType from typing import TYPE_CHECKING from packaging.version import Version @@ -67,7 +67,7 @@ def pkg_metadata(package: str) -> PackageMetadata: return metadata(package) -def get_namespace(x): +def get_namespace(x) -> ModuleType: # get array-api namespace for x from array_api_compat import get_namespace diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index f1c636617c..1683022c56 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -31,16 +31,13 @@ import pandas as pd from anndata._core.sparse_dataset import BaseCompressedSparseDataset from fast_array_utils.types import HasArrayNamespace -from packaging.version import Version from .. import logging as logg from .._compat import ( CSBase, DaskArray, SpBase, - _CSArray, get_namespace, - pkg_version, warn, ) from ._numba import _numba_thread_limit @@ -643,31 +640,6 @@ def _( return np.true_divide(x, scaling_array, out=out) -@axis_mul_or_truediv.register(HasArrayNamespace) -def _( - x: HasArrayNamespace, - /, - scaling_array: np.ndarray, - axis: Literal[0, 1], - op: Callable[[Any, Any], Any], - *, - allow_divide_by_zero: bool = True, - out: ArrayLike | None = None, -) -> Any: - - _check_op(op) - scaling_array = _broadcast_axis(scaling_array, axis) - xp = get_namespace(x) - scaling_array = xp.asarray(scaling_array) - if op is mul: - return x * scaling_array - if not allow_divide_by_zero: - scaling_array = xp.where( - scaling_array == 0, xp.ones_like(scaling_array), scaling_array - ) - return x / scaling_array - - @axis_mul_or_truediv.register(CSBase) def _( x: CSBase, @@ -780,6 +752,31 @@ def _[T: (DaskArray, np.ndarray)]( ) +@axis_mul_or_truediv.register(HasArrayNamespace) +def _( + x: HasArrayNamespace, + /, + scaling_array: np.ndarray, + axis: Literal[0, 1], + op: Callable[[Any, Any], Any], + *, + allow_divide_by_zero: bool = True, + out: ArrayLike | None = None, +) -> Any: + + _check_op(op) + scaling_array = _broadcast_axis(scaling_array, axis) + xp = get_namespace(x) + scaling_array = xp.asarray(scaling_array) + if op is mul: + return x * scaling_array + if not allow_divide_by_zero: + scaling_array = xp.where( + scaling_array == 0, xp.ones_like(scaling_array), scaling_array + ) + return x / scaling_array + + @singledispatch def axis_nnz(x: ArrayLike, /, axis: Literal[0, 1]) -> np.ndarray: raise NotImplementedError @@ -790,28 +787,9 @@ def _(x: np.ndarray, /, axis: Literal[0, 1]) -> np.ndarray: return np.count_nonzero(x, axis=axis) -@axis_nnz.register(HasArrayNamespace) -def _(x: HasArrayNamespace, /, axis: Literal[0, 1]) -> Any: - - xp = get_namespace(x) - return xp.count_nonzero(x, axis=axis) - - -if pkg_version("scipy") >= Version("1.15"): - # newer scipy versions support the `axis` argument for count_nonzero - @axis_nnz.register(CSBase) - def _(x: CSBase, /, axis: Literal[0, 1]) -> np.ndarray: - return x.count_nonzero(axis=axis) - -else: - # older scipy versions don’t have any way to get the nnz of a sparse array - @axis_nnz.register(CSBase) - def _(x: CSBase, /, axis: Literal[0, 1]) -> np.ndarray: - if isinstance(x, _CSArray): - from scipy.sparse import csc_array, csr_array # noqa: TID251 - - x = (csr_array if x.format == "csr" else csc_array)(x) - return x.getnnz(axis=axis) +@axis_nnz.register(CSBase) +def _(x: CSBase, /, axis: Literal[0, 1]) -> np.ndarray: + return x.count_nonzero(axis=axis) @axis_nnz.register(DaskArray) @@ -824,23 +802,18 @@ def _(x: DaskArray, /, axis: Literal[0, 1]) -> DaskArray: ) +@axis_nnz.register(HasArrayNamespace) +def _(x: HasArrayNamespace, /, axis: Literal[0, 1]) -> Any: + xp = get_namespace(x) + return xp.count_nonzero(x, axis=axis) + + @singledispatch def check_nonnegative_integers(x: _SupportedArray, /) -> bool | DaskArray: """Check values of X to ensure it is count data.""" raise NotImplementedError -@check_nonnegative_integers.register(HasArrayNamespace) -def _check_nonnegative_integers_array_api(x: HasArrayNamespace, /) -> bool: - - xp = get_namespace(x) - if bool(xp.any(x < 0)): - return False - if xp.isdtype(x.dtype, "integral"): - return True - return not bool(xp.any((x % 1) != 0)) - - @check_nonnegative_integers.register(np.ndarray) @check_nonnegative_integers.register(CSBase) def _check_nonnegative_integers_in_mem(x: _MemoryArray, /) -> bool: @@ -856,6 +829,16 @@ def _check_nonnegative_integers_in_mem(x: _MemoryArray, /) -> bool: return not np.any((data % 1) != 0) +@check_nonnegative_integers.register(HasArrayNamespace) +def _check_nonnegative_integers_array_api(x: HasArrayNamespace, /) -> bool: + xp = get_namespace(x) + if bool(xp.any(x < 0)): + return False + if xp.isdtype(x.dtype, "integral"): + return True + return not bool(xp.any((x % 1) != 0)) + + @check_nonnegative_integers.register(DaskArray) def _check_nonnegative_integers_dask(x: DaskArray, /) -> DaskArray: return x.map_blocks(check_nonnegative_integers, dtype=bool, drop_axis=(0, 1)) diff --git a/src/scanpy/metrics/_common.py b/src/scanpy/metrics/_common.py index e491a12a0a..4cce330f73 100644 --- a/src/scanpy/metrics/_common.py +++ b/src/scanpy/metrics/_common.py @@ -89,6 +89,8 @@ def _resolve_vals[T: NDArray | DaskArray](val: T) -> T: ... def _resolve_vals(val: SpBase) -> CSRBase: ... @overload def _resolve_vals(val: pd.DataFrame | pd.Series) -> NDArray: ... +@overload +def _resolve_vals(val: HasArrayNamespace) -> NDArray: ... @singledispatch @@ -97,12 +99,6 @@ def _resolve_vals(val: object): raise TypeError(msg) -@_resolve_vals.register(HasArrayNamespace) -def _resolve_vals_array_api(val: HasArrayNamespace) -> NDArray: - # Moran's I / Geary's C use numba kernels, so convert at the boundary - return np.asarray(val) - - @_resolve_vals.register(np.ndarray) @_resolve_vals.register(CSRBase) @_resolve_vals.register(DaskArray) @@ -127,6 +123,12 @@ def _(val: pd.DataFrame | pd.Series) -> NDArray: return val.to_numpy() +@_resolve_vals.register(HasArrayNamespace) +def _resolve_vals_array_api(val: HasArrayNamespace) -> NDArray: + # Moran's I / Geary's C use numba kernels, so convert at the boundary + return np.asarray(val) + + def _vals_heterogeneous[V: NDArray | CSRBase]( vals: V, ) -> tuple[V, NDArray[np.bool] | slice, NDArray[np.float64]]: diff --git a/src/scanpy/preprocessing/_highly_variable_genes.py b/src/scanpy/preprocessing/_highly_variable_genes.py index 81c5df0dab..7a43a7e220 100644 --- a/src/scanpy/preprocessing/_highly_variable_genes.py +++ b/src/scanpy/preprocessing/_highly_variable_genes.py @@ -14,7 +14,7 @@ from fast_array_utils.types import HasArrayNamespace from .. import logging as logg -from .._compat import CSBase, CSRBase, DaskArray, warn +from .._compat import CSBase, CSRBase, DaskArray, get_namespace, warn from .._settings import Default, Verbosity, settings from .._utils import ( check_nonnegative_integers, @@ -399,8 +399,6 @@ def _highly_variable_genes_single_batch( if n_removed: x = x[:, filt].copy() - from .._compat import get_namespace ### double check - if flavor == "seurat": x = x.copy() if (base := adata.uns.get("log1p", {}).get("base")) is not None: @@ -416,8 +414,9 @@ def _highly_variable_genes_single_batch( mean, var = materialize_as_ndarray(stats.mean_var(x, axis=0, correction=1)) # now actually compute the dispersion - # allocating a fresh writiable array = jax issue - mean = np.where(mean == 0, 1e-12, mean) # set entries equal to zero to small value + # JAX arrays are immutable, so in-place assignment (mean[mean == 0] = ...) + # fails; np.where allocates a fresh array instead + mean = np.where(mean == 0, 1e-12, mean) # set zero entries to a small value dispersion = var / mean if flavor == "seurat": # logarithmized mean as in Seurat dispersion[dispersion == 0] = np.nan diff --git a/src/scanpy/preprocessing/_normalization.py b/src/scanpy/preprocessing/_normalization.py index f2e3799634..b95aa8c5b0 100644 --- a/src/scanpy/preprocessing/_normalization.py +++ b/src/scanpy/preprocessing/_normalization.py @@ -9,7 +9,7 @@ from fast_array_utils.numba import njit from .. import logging as logg -from .._compat import CSBase, CSCBase, CSRBase, DaskArray, warn +from .._compat import CSBase, CSCBase, CSRBase, DaskArray, get_namespace, warn from .._utils import axis_mul_or_truediv, dematrix, view_to_actual from ..get import _get_obs_rep, _set_obs_rep @@ -25,8 +25,9 @@ def _compute_nnz_median( if isinstance(counts, DaskArray): counts = counts.compute() + xp = get_namespace(counts) counts_greater_than_zero = counts[counts > 0] - median = np.median(counts_greater_than_zero) + median = xp.median(counts_greater_than_zero) return median diff --git a/src/scanpy/preprocessing/_scale.py b/src/scanpy/preprocessing/_scale.py index 7b53206b3e..ef40c2fd3d 100644 --- a/src/scanpy/preprocessing/_scale.py +++ b/src/scanpy/preprocessing/_scale.py @@ -41,12 +41,6 @@ def _(x: np.ndarray, *, max_value: float, zero_center: bool = True) -> np.ndarra return clip_array(x, max_value=max_value, zero_center=zero_center) -@clip.register(HasArrayNamespace) -def _(x, *, max_value: float, zero_center: bool = True): - xp = get_namespace(x) - return xp.clip(x, min=-max_value if zero_center else None, max=max_value) - - @clip.register(CSBase) def _(x: CSBase, *, max_value: float, zero_center: bool = True) -> CSBase: x.data = clip(x.data, max_value=max_value, zero_center=zero_center) @@ -60,6 +54,12 @@ def _(x: DaskArray, *, max_value: float, zero_center: bool = True) -> DaskArray: ) +@clip.register(HasArrayNamespace) +def _(x, *, max_value: float, zero_center: bool = True): + xp = get_namespace(x) + return xp.clip(x, min=-max_value if zero_center else None, max=max_value) + + @njit def clip_array( x: NDArray[np.floating], /, *, max_value: float, zero_center: bool @@ -190,20 +190,18 @@ def scale_array[A: _Array]( # noqa: PLR0912 logg.info( # Be careful of what? This should be more specific "... be careful when using `max_value` without `zero_center`." ) + int_msg = ( + "... as scaling leads to float results, integer " + "input is cast to float, returning copy." + ) if isinstance(x, np.ndarray | CSBase | DaskArray): if np.issubdtype(x.dtype, np.integer): - logg.info( - "... as scaling leads to float results, integer " - "input is cast to float, returning copy." - ) + logg.info(int_msg) x = x.astype(np.float64) else: xp = get_namespace(x) if xp.isdtype(x.dtype, "integral"): - logg.info( - "... as scaling leads to float results, integer " - "input is cast to float, returning copy." - ) + logg.info(int_msg) x = xp.astype(x, xp.float64) mask_obs = ( diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index 964b602f59..48e6405991 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -356,15 +356,6 @@ def log1p( return log1p_array(data, copy=copy, base=base) -@log1p.register(HasArrayNamespace) -def log1p_array_api(x, *, base: Number | None = None, copy: bool = False): - xp = get_namespace(x) - result = xp.log1p(x) - if base is not None: - result = result / float(np.log(base)) - return result - - @log1p.register(CSBase) def log1p_sparse(x: CSBase, *, base: Number | None = None, copy: bool = False): x = check_array( @@ -388,6 +379,15 @@ def log1p_array(x: np.ndarray, *, base: Number | None = None, copy: bool = False return x +@log1p.register(HasArrayNamespace) +def log1p_array_api(x, *, base: Number | None = None, copy: bool = False): + xp = get_namespace(x) + result = xp.log1p(x) + if base is not None: + result = result / float(np.log(base)) + return result + + @log1p.register(AnnData) def log1p_anndata( adata: AnnData, diff --git a/src/testing/scanpy/_pytest/__init__.py b/src/testing/scanpy/_pytest/__init__.py index 4b1a6b2d1a..0cc067fcad 100644 --- a/src/testing/scanpy/_pytest/__init__.py +++ b/src/testing/scanpy/_pytest/__init__.py @@ -21,6 +21,8 @@ if find_spec("jax"): import jax + # JAX defaults to 32-bit dtypes; enable 64-bit so results match the numpy + # reference values in the tests. jax.config.update("jax_enable_x64", True) # noqa: FBT003 MARK_RETRY_DOWNLOAD = pytest.mark.flaky( From 1bce79afaa6475ab8e39884538852a86f3c725d4 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Sun, 26 Jul 2026 17:37:41 +0200 Subject: [PATCH 16/31] ruff check --- src/scanpy/_compat.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index 04d254501e..6fe1346e8d 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -4,7 +4,7 @@ from functools import cache from importlib.util import find_spec from pathlib import Path -from types import FunctionType, ModuleType +from types import FunctionType from typing import TYPE_CHECKING from packaging.version import Version @@ -13,6 +13,7 @@ if TYPE_CHECKING: from collections.abc import Callable from importlib.metadata import PackageMetadata + from types import ModuleType __all__ = [ From e3cdf5e4e13640b74d4e2811900ba8ce04146be5 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Sun, 26 Jul 2026 17:40:18 +0200 Subject: [PATCH 17/31] ruff request --- tests/test_rank_genes_groups.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_rank_genes_groups.py b/tests/test_rank_genes_groups.py index b22e6ec8b5..1b2c230c86 100644 --- a/tests/test_rank_genes_groups.py +++ b/tests/test_rank_genes_groups.py @@ -134,6 +134,7 @@ def test_results( @pytest.mark.parametrize("method", ["t-test", "wilcoxon"]) @pytest.mark.parametrize("array_type", ARRAY_TYPES_MEM) def test_results_layers( + request: pytest.FixtureRequest, subtests: pytest.Subtests, data_dir: Path, array_type, From 25acbca1c45554f3ff8b9d79cba86424b3483c4f Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Sun, 26 Jul 2026 18:54:19 +0200 Subject: [PATCH 18/31] anndata version fix --- src/testing/scanpy/_pytest/params.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/testing/scanpy/_pytest/params.py b/src/testing/scanpy/_pytest/params.py index 303e47b5ee..dc608b025e 100644 --- a/src/testing/scanpy/_pytest/params.py +++ b/src/testing/scanpy/_pytest/params.py @@ -7,13 +7,18 @@ from typing import TYPE_CHECKING import pytest -from anndata.tests.helpers import as_dense_jax_array, asarray +from anndata.tests.helpers import asarray from packaging.version import Version from scipy import sparse from .._helpers import as_dense_dask_array, as_sparse_dask_matrix from .._pytest.marks import needs +try: + from anndata.tests.helpers import as_dense_jax_array +except ImportError: + as_dense_jax_array = None + if TYPE_CHECKING: from collections.abc import Callable, Iterable from typing import Any, Literal @@ -77,7 +82,11 @@ def wrapper(a: np.ndarray) -> DaskArray: ] = { ("mem", "dense"): ( pytest.param(asarray, id="numpy_ndarray"), - pytest.param(as_dense_jax_array, marks=[needs.jax], id="jax_array"), + *( + [pytest.param(as_dense_jax_array, marks=[needs.jax], id="jax_array")] + if as_dense_jax_array is not None + else [] + ), ), ("mem", "sparse"): ( pytest.param(sparse.csr_matrix, id="scipy_csr_mat"), # noqa: TID251 From 03c6e5e20858ab5588f115179e61322f962ccab1 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Mon, 10 Aug 2026 13:49:12 +0200 Subject: [PATCH 19/31] comments addressed --- docs/conf.py | 8 ++++---- docs/extensions/array_support.py | 16 ++++++++++++++-- pyproject.toml | 9 ++++----- src/scanpy/_compat.py | 8 +++++--- src/scanpy/_utils/_docs.py | 19 +++++++++++++++++-- 5 files changed, 44 insertions(+), 16 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 26bda3c8b8..decb1eeb64 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -184,14 +184,14 @@ "pp.filter_cells": (["np", "sp", "da"], []), "pp.filter_genes": (["np", "sp", "da"], []), "pp.harmony_integrate": (["np"], []), - "pp.highly_variable_genes": (["np", "sp", "da"], ["da[sp[csc]]"]), - "pp.log1p": (["np", "sp", "da"], []), + "pp.highly_variable_genes": (["np", "sp", "da", "aa"], ["da[sp[csc]]"]), + "pp.log1p": (["np", "sp", "da", "aa"], []), "pp.neighbors": (["np", "sp"], []), - "pp.normalize_total": (["np", "sp[csr]", "da"], []), + "pp.normalize_total": (["np", "sp[csr]", "da", "aa"], []), "pp.pca": (["np", "sp", "da"], ["da[sp[csc]]"]), "pp.regress_out": (["np"], []), "pp.sample": (["np", "sp", "da"], []), - "pp.scale": (["np", "sp", "da"], []), + "pp.scale": (["np", "sp", "da", "aa"], []), "pp.scrublet": (["np", "sp"], []), "pp.scrublet_simulate_doublets": (["np", "sp"], []), "tl.dendrogram": (["np", "sp"], []), diff --git a/docs/extensions/array_support.py b/docs/extensions/array_support.py index b79499fb2a..ae6b183a22 100644 --- a/docs/extensions/array_support.py +++ b/docs/extensions/array_support.py @@ -54,11 +54,22 @@ def run(self) -> list[nodes.Node]: # noqa: D102 )) title = nodes.title("", "", *self.parse_inline(":ref:`array-support`")[0]) - rows = self._render_support_data(data) + rows = [ + *self._render_support_data(data), + self._render_row( + self._render_array_type(_docs.ArrayApi()), + support=_docs.ArrayApi() in array_types, + in_dask=False, + ), + ] return self._render_table(headers, rows, title=title) def _render_overview(self) -> list[nodes.Node]: - headers = ["Function", *(at.rst(short=True) for at in ALL_INNER)] + headers = [ + "Function", + *(at.rst(short=True) for at in ALL_INNER), + _docs.ArrayApi().rst(short=True), + ] rows: list[nodes.row] = [] for fn, (include, exclude) in self._array_support.items(): row_header, _ = self.parse_inline(f":func:`scanpy.{fn}`") @@ -71,6 +82,7 @@ def _render_overview(self) -> list[nodes.Node]: ALL_INNER, map(_docs.DaskArray, ALL_INNER), strict=True ) ), + self._render_support(_docs.ArrayApi() in ats), ] rows.append( nodes.row( diff --git a/pyproject.toml b/pyproject.toml index b50c56b0ed..4262f4854c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,11 +120,11 @@ test = [ { include-group = "test-min" }, ] docs = [ - "ipython>=8.27", # for nbsphinx code highlighting + "ipython>=8.27", # for nbsphinx code highlighting "myst-nb>=1.4", "myst-parser>=2", "nbsphinx>=0.9", - "numpy>=2.4", # type aliases + "numpy>=2.4", # type aliases "plotly", "sam-algorithm", # TODO: remove necessity for being able to import doc-linked classes @@ -157,7 +157,6 @@ test-min = [ [tool.hatch] version.source = "vcs" version.raw-options.version_scheme = "release-branch-semver" -metadata.allow-direct-references = true build.targets.wheel.packages = [ "src/scanpy", "src/testing" ] [tool.ruff] @@ -217,7 +216,7 @@ lint.per-file-ignores."tests/**/*.py" = [ "D100", "D101", "D103", "PLR0913" ] lint.allowed-confusables = [ "×", "–", "‘", "’", "α" ] lint.external = [ "PLR0917" ] lint.flake8-bugbear.extend-immutable-calls = [ "scanpy._settings.Default" ] -lint.flake8-type-checking.exempt-modules = [] +lint.flake8-type-checking.exempt-modules = [ ] lint.flake8-type-checking.runtime-evaluated-base-classes = [ "scverse_misc.Settings" ] lint.flake8-type-checking.strict = true lint.isort.known-first-party = [ "scanpy", "testing.scanpy" ] @@ -326,7 +325,7 @@ filename = "docs/release-notes/{version}.md" title_format = "(v{version})=\n### {version} {{small}}`{project_date}`" issue_format = "{{pr}}`{issue}`" single_file = false -fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) +fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) fragment.chore.name = "Miscellaneous changes" fragment.docs.name = "Documentation" # Valid fragments should be a subset of conventional commit types (except for `breaking`): diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index 6fe1346e8d..0c801c93d7 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -15,6 +15,8 @@ from importlib.metadata import PackageMetadata from types import ModuleType + from fast_array_utils.types import HasArrayNamespace + __all__ = [ "CSBase", @@ -68,11 +70,11 @@ def pkg_metadata(package: str) -> PackageMetadata: return metadata(package) -def get_namespace(x) -> ModuleType: +def get_namespace(x: HasArrayNamespace) -> ModuleType: # get array-api namespace for x - from array_api_compat import get_namespace + from array_api_compat import array_namespace - return get_namespace(x) + return array_namespace(x) @cache diff --git a/src/scanpy/_utils/_docs.py b/src/scanpy/_utils/_docs.py index a14a32cd4a..1e7ced6c78 100644 --- a/src/scanpy/_utils/_docs.py +++ b/src/scanpy/_utils/_docs.py @@ -12,7 +12,7 @@ from typing import Literal -__all__ = ["ArrayType", "DaskArray", "Numpy", "ScipySparse", "parse"] +__all__ = ["ArrayApi", "ArrayType", "DaskArray", "Numpy", "ScipySparse", "parse"] class ArrayType(ABC): @@ -32,6 +32,16 @@ def rst(self, *, short: bool = False) -> str: # pragma: no cover return f":class:`{'~' if short else ''}{self}`" +@dataclass(unsafe_hash=True, frozen=True) +class ArrayApi(ArrayType): + def __str__(self) -> str: # pragma: no cover + return "array-api" + + def rst(self, *, short: bool = False) -> str: # pragma: no cover + # No single class to link to, so link to the standard itself + return "`Array API `__" + + @dataclass(unsafe_hash=True, frozen=True) class ScipySparse(ArrayType): format: Literal["csr", "csc"] @@ -79,7 +89,7 @@ def parse( yield from (t for t in parse(include) if t not in excluded) return - inner_includes = [i for i in include if not i.startswith("da")] + inner_includes = [i for i in include if not i.startswith(("da", "aa"))] for t in include: if ( match := re.fullmatch(r"([^\[]+)(?:\[(.+)\])?", t) @@ -103,6 +113,11 @@ def _parse_mod( msg = f"`np` takes no tags {tags!r}" raise ValueError(msg) yield Numpy() + case "aa": + if tags: # pragma: no cover + msg = f"`aa` takes no tags {tags!r}" + raise ValueError(msg) + yield ArrayApi() case "sp": if tags - {"csr", "csc"}: # pragma: no cover msg = f"invalid tags {tags!r}" From 8060092e84288e8905a8538e00e735361d94e51d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:25:16 +0000 Subject: [PATCH 20/31] [autofix.ci] apply automated fixes --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b578dfe63c..72bd563a84 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,11 +120,11 @@ test = [ { include-group = "test-min" }, ] docs = [ - "ipython>=8.27", # for nbsphinx code highlighting + "ipython>=8.27", # for nbsphinx code highlighting "myst-nb>=1.4", "myst-parser>=2", "nbsphinx>=0.9", - "numpy>=2.4", # type aliases + "numpy>=2.4", # type aliases "plotly", "sam-algorithm", # TODO: remove necessity for being able to import doc-linked classes @@ -215,7 +215,7 @@ lint.per-file-ignores."src/scanpy/tools/_sim.py" = [ "N" ] lint.per-file-ignores."tests/**/*.py" = [ "D100", "D101", "D103", "PLR0913" ] lint.allowed-confusables = [ "×", "–", "‘", "’", "α" ] lint.flake8-bugbear.extend-immutable-calls = [ "scanpy._settings.Default" ] -lint.flake8-type-checking.exempt-modules = [ ] +lint.flake8-type-checking.exempt-modules = [] lint.flake8-type-checking.runtime-evaluated-base-classes = [ "scverse_misc.Settings" ] lint.flake8-type-checking.strict = true lint.isort.known-first-party = [ "scanpy", "testing.scanpy" ] @@ -324,7 +324,7 @@ filename = "docs/release-notes/{version}.md" title_format = "(v{version})=\n### {version} {{small}}`{project_date}`" issue_format = "{{pr}}`{issue}`" single_file = false -fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) +fragment.breaking.name = "Breaking changes" # add `!` to commit type (e.g. “feature!:”) fragment.chore.name = "Miscellaneous changes" fragment.docs.name = "Documentation" # Valid fragments should be a subset of conventional commit types (except for `breaking`): From 7ab57b34ef88ffde83b0413db6e8f8d00291118b Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Mon, 17 Aug 2026 13:06:32 +0200 Subject: [PATCH 21/31] array_namespace fix --- pyproject.toml | 5 +++-- src/scanpy/_compat.py | 11 ----------- src/scanpy/_utils/__init__.py | 8 ++++---- src/scanpy/preprocessing/_highly_variable_genes.py | 5 +++-- src/scanpy/preprocessing/_normalization.py | 5 +++-- src/scanpy/preprocessing/_scale.py | 9 +++++---- src/scanpy/preprocessing/_simple.py | 5 +++-- tests/test_pca.py | 5 +++-- 8 files changed, 24 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4262f4854c..3c4c0ede02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,8 +104,9 @@ scanpy2 = [ "anndata>=0.13.2", "hv-anndata>=0.0.3a5", "igraph>=0.10.8", "scanpy[ [dependency-groups] dev = [ - "scipy-stubs", # static typing and IDE support - "towncrier", # release note management + "scipy-stubs", # static typing and IDE support + "types-array-api", + "towncrier", # release note management ] test = [ "scanpy[dask-ml]", diff --git a/src/scanpy/_compat.py b/src/scanpy/_compat.py index 0c801c93d7..3027a81107 100644 --- a/src/scanpy/_compat.py +++ b/src/scanpy/_compat.py @@ -13,9 +13,6 @@ if TYPE_CHECKING: from collections.abc import Callable from importlib.metadata import PackageMetadata - from types import ModuleType - - from fast_array_utils.types import HasArrayNamespace __all__ = [ @@ -25,7 +22,6 @@ "DaskArray", "SpBase", "fullname", - "get_namespace", "pkg_metadata", "pkg_version", "set_module", @@ -70,13 +66,6 @@ def pkg_metadata(package: str) -> PackageMetadata: return metadata(package) -def get_namespace(x: HasArrayNamespace) -> ModuleType: - # get array-api namespace for x - from array_api_compat import array_namespace - - return array_namespace(x) - - @cache def pkg_version(package: str) -> Version: from importlib.metadata import version diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index 0ae28d2452..38e675c4dd 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -30,6 +30,7 @@ import numpy as np import pandas as pd from anndata._core.sparse_dataset import BaseCompressedSparseDataset +from array_api_compat import array_namespace from fast_array_utils.types import HasArrayNamespace from .. import logging as logg @@ -37,7 +38,6 @@ CSBase, DaskArray, SpBase, - get_namespace, warn, ) from ._numba import _numba_thread_limit @@ -768,7 +768,7 @@ def _( _check_op(op) scaling_array = _broadcast_axis(scaling_array, axis) - xp = get_namespace(x) + xp = array_namespace(x) scaling_array = xp.asarray(scaling_array) if op is mul: return x * scaling_array @@ -806,7 +806,7 @@ def _(x: DaskArray, /, axis: Literal[0, 1]) -> DaskArray: @axis_nnz.register(HasArrayNamespace) def _(x: HasArrayNamespace, /, axis: Literal[0, 1]) -> Any: - xp = get_namespace(x) + xp = array_namespace(x) return xp.count_nonzero(x, axis=axis) @@ -833,7 +833,7 @@ def _check_nonnegative_integers_in_mem(x: _MemoryArray, /) -> bool: @check_nonnegative_integers.register(HasArrayNamespace) def _check_nonnegative_integers_array_api(x: HasArrayNamespace, /) -> bool: - xp = get_namespace(x) + xp = array_namespace(x) if bool(xp.any(x < 0)): return False if xp.isdtype(x.dtype, "integral"): diff --git a/src/scanpy/preprocessing/_highly_variable_genes.py b/src/scanpy/preprocessing/_highly_variable_genes.py index d1b76ddb6d..c1a855c016 100644 --- a/src/scanpy/preprocessing/_highly_variable_genes.py +++ b/src/scanpy/preprocessing/_highly_variable_genes.py @@ -10,11 +10,12 @@ import numpy as np import pandas as pd from anndata import AnnData +from array_api_compat import array_namespace from fast_array_utils import stats from fast_array_utils.types import HasArrayNamespace from .. import logging as logg -from .._compat import CSBase, CSRBase, DaskArray, get_namespace, warn +from .._compat import CSBase, CSRBase, DaskArray, warn from .._settings import Default, Verbosity, settings from .._utils import ( check_nonnegative_integers, @@ -408,7 +409,7 @@ def _highly_variable_genes_single_batch( if isinstance(x, np.ndarray): np.expm1(x, out=x) elif isinstance(x, HasArrayNamespace): - xp = get_namespace(x) + xp = array_namespace(x) x = xp.expm1(x) else: x = np.expm1(x) diff --git a/src/scanpy/preprocessing/_normalization.py b/src/scanpy/preprocessing/_normalization.py index bd84aff3c8..4184b899ff 100644 --- a/src/scanpy/preprocessing/_normalization.py +++ b/src/scanpy/preprocessing/_normalization.py @@ -5,11 +5,12 @@ import numba import numpy as np +from array_api_compat import array_namespace from fast_array_utils import stats from fast_array_utils.numba import njit from .. import logging as logg -from .._compat import CSBase, CSCBase, CSRBase, DaskArray, get_namespace, warn +from .._compat import CSBase, CSCBase, CSRBase, DaskArray, warn from .._utils import axis_mul_or_truediv, dematrix, view_to_actual from ..get import _get_arr, _set_obs_rep @@ -25,7 +26,7 @@ def _compute_nnz_median( if isinstance(counts, DaskArray): counts = counts.compute() - xp = get_namespace(counts) + xp = array_namespace(counts) counts_greater_than_zero = counts[counts > 0] median = xp.median(counts_greater_than_zero) return median diff --git a/src/scanpy/preprocessing/_scale.py b/src/scanpy/preprocessing/_scale.py index 38e619971a..66921e6297 100644 --- a/src/scanpy/preprocessing/_scale.py +++ b/src/scanpy/preprocessing/_scale.py @@ -7,12 +7,13 @@ import numba import numpy as np from anndata import AnnData +from array_api_compat import array_namespace from fast_array_utils.numba import njit from fast_array_utils.stats import mean_var from fast_array_utils.types import HasArrayNamespace from .. import logging as logg -from .._compat import CSBase, CSCBase, CSRBase, DaskArray, get_namespace, warn +from .._compat import CSBase, CSCBase, CSRBase, DaskArray, warn from .._settings import Default, settings from .._utils import ( axis_mul_or_truediv, @@ -56,7 +57,7 @@ def _(x: DaskArray, *, max_value: float, zero_center: bool = True) -> DaskArray: @clip.register(HasArrayNamespace) def _(x, *, max_value: float, zero_center: bool = True): - xp = get_namespace(x) + xp = array_namespace(x) return xp.clip(x, min=-max_value if zero_center else None, max=max_value) @@ -199,7 +200,7 @@ def scale_array[A: _Array]( # noqa: PLR0912 logg.info(int_msg) x = x.astype(np.float64) else: - xp = get_namespace(x) + xp = array_namespace(x) if xp.isdtype(x.dtype, "integral"): logg.info(int_msg) x = xp.astype(x, xp.float64) @@ -234,7 +235,7 @@ def scale_array[A: _Array]( # noqa: PLR0912 x -= mean x = dematrix(x) else: - xp = get_namespace(x) + xp = array_namespace(x) std = xp.sqrt(var) std = xp.where(std == 0, xp.ones_like(std), std) diff --git a/src/scanpy/preprocessing/_simple.py b/src/scanpy/preprocessing/_simple.py index 759652b16a..e4a8444ded 100644 --- a/src/scanpy/preprocessing/_simple.py +++ b/src/scanpy/preprocessing/_simple.py @@ -15,6 +15,7 @@ import numba import numpy as np from anndata import AnnData +from array_api_compat import array_namespace from fast_array_utils import stats from fast_array_utils.conv import to_dense from fast_array_utils.numba import njit @@ -24,7 +25,7 @@ from sklearn.utils import check_array from .. import logging as logg -from .._compat import CSBase, CSRBase, DaskArray, get_namespace +from .._compat import CSBase, CSRBase, DaskArray from .._docs import doc_rng from .._settings import settings from .._utils import ( @@ -381,7 +382,7 @@ def log1p_array(x: np.ndarray, *, base: Number | None = None, copy: bool = False @log1p.register(HasArrayNamespace) def log1p_array_api(x, *, base: Number | None = None, copy: bool = False): - xp = get_namespace(x) + xp = array_namespace(x) result = xp.log1p(x) if base is not None: result = result / float(np.log(base)) diff --git a/tests/test_pca.py b/tests/test_pca.py index 1fe8388886..cf29bef752 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -20,6 +20,7 @@ from scanpy.preprocessing._pca._dask import _cov_sparse_dask from testing.scanpy import _helpers from testing.scanpy._helpers.data import pbmc3k_normalized +from testing.scanpy._pytest import params from testing.scanpy._pytest.marks import needs from testing.scanpy._pytest.params import ARRAY_TYPES as ARRAY_TYPES_ALL from testing.scanpy._pytest.params import param_with @@ -159,9 +160,9 @@ def possible_solvers( svd_solvers = {"arpack"} | SKLEARN_ADDITIONAL case (type() as dc, False) if issubclass(dc, CSBase): svd_solvers = {"arpack", "randomized"} - case (helpers.asarray | helpers.as_dense_jax_array, True): + case (helpers.asarray | params.as_dense_jax_array, True): svd_solvers = {"auto", "full", "arpack", "randomized"} | SKLEARN_ADDITIONAL - case (helpers.asarray | helpers.as_dense_jax_array, False): + case (helpers.asarray | params.as_dense_jax_array, False): svd_solvers = {"arpack", "randomized"} case _: pytest.fail(f"Unknown {array_type=} ({zero_center=}) ({id=})") From 41165bfc9d8ebf75120fd49d8708f5bda528737d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:21:20 +0000 Subject: [PATCH 22/31] [autofix.ci] apply automated fixes --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b5941447d6..fbe7119734 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,8 +106,8 @@ scanpy2 = [ "anndata>=0.13.2", "hv-anndata>=0.0.3a5", "igraph>=0.10.8", "scanpy[ [dependency-groups] dev = [ "scipy-stubs", # static typing and IDE support - "types-array-api", "towncrier", # release note management + "types-array-api", ] test = [ "scanpy[dask-ml]", From 72bb3d5b0d094d31604e060f5cd7455c32ed8c79 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Thu, 10 Sep 2026 10:54:30 +0200 Subject: [PATCH 23/31] rank comment addressed. --- src/scanpy/tools/_rank_genes_groups.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index 4f8f69bfea..ad383dea85 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -285,6 +285,10 @@ def __init__( adata_comp = adata.raw x = adata_comp.X raise_not_implemented_error_if_backed_type(x, "rank_genes_groups") + if isinstance(adata.X, HasArrayNamespace) and not isinstance( + adata.X, np.ndarray + ): + adata.X = np.asarray(adata.X) # for correct getnnz calculation if isinstance(x, CSBase): @@ -882,8 +886,6 @@ def rank_genes_groups( # noqa: PLR0912, PLR0913, PLR0915 from scanpy import settings # rank_genes_groups uses numba kernels internally, so need convert at entry. - if isinstance(adata.X, HasArrayNamespace) and not isinstance(adata.X, np.ndarray): - adata.X = np.asarray(adata.X) if isinstance(mask_var, Default): mask_var = settings.preset.rank_genes_groups.mask_var if isinstance(mean_in_log_space, Default): From 7e74847d892973986af78f43a68d9e04f3e0b0bc Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Thu, 10 Sep 2026 11:27:32 +0200 Subject: [PATCH 24/31] ruff checks --- docs/conf.py | 2 -- docs/extensions/autosummary_skip_deprecated.py | 2 +- docs/extensions/autosummary_skip_inherited.py | 2 +- docs/extensions/debug_docstrings.py | 2 +- docs/extensions/function_images.py | 2 +- docs/extensions/returns_prose_wrap.py | 4 ++-- src/scanpy/get/_aggregated.py | 2 +- src/scanpy/neighbors/__init__.py | 2 +- src/testing/scanpy/_pytest/fixtures/__init__.py | 2 +- tests/test_pca.py | 2 +- 10 files changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0c70096c81..ec76638f7a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -187,8 +187,6 @@ "pp.filter_cells": (["np", "sp", "da"], []), "pp.filter_genes": (["np", "sp", "da"], []), "pp.harmony_integrate": (["np"], []), - "pp.highly_variable_genes": (["np", "sp", "da", "aa"], ["da[sp[csc]]"]), - "pp.log1p": (["np", "sp", "da", "aa"], []), "pp.hashsolo": (["np", "sp"], []), "pp.highly_variable_genes": (["np", "sp", "da"], ["da[sp[csc]]"]), "pp.log1p": (["np", "sp", "da"], []), diff --git a/docs/extensions/autosummary_skip_deprecated.py b/docs/extensions/autosummary_skip_deprecated.py index abd562b350..bf60555bd1 100644 --- a/docs/extensions/autosummary_skip_deprecated.py +++ b/docs/extensions/autosummary_skip_deprecated.py @@ -13,7 +13,7 @@ from sphinx.ext.autodoc import Options -def skip_deprecated( # noqa: PLR0917 +def skip_deprecated( app: Sphinx, what: Literal[ "module", "class", "exception", "function", "method", "attribute", "property" diff --git a/docs/extensions/autosummary_skip_inherited.py b/docs/extensions/autosummary_skip_inherited.py index 694f8062d9..9f435ad8ee 100644 --- a/docs/extensions/autosummary_skip_inherited.py +++ b/docs/extensions/autosummary_skip_inherited.py @@ -14,7 +14,7 @@ from sphinx.ext.autodoc import Options -def skip_inherited( # noqa: PLR0917 +def skip_inherited( app: Sphinx, what: Literal[ "module", "class", "exception", "function", "method", "attribute", "property" diff --git a/docs/extensions/debug_docstrings.py b/docs/extensions/debug_docstrings.py index 3a1f2ddc30..bbe2eecc5d 100644 --- a/docs/extensions/debug_docstrings.py +++ b/docs/extensions/debug_docstrings.py @@ -17,7 +17,7 @@ _pd_orig = sphinx.ext.napoleon._process_docstring -def pd_new(app, what, name, obj, options, lines) -> None: # noqa: PLR0917 +def pd_new(app, what, name, obj, options, lines) -> None: """Wrap ``sphinx.ext.napoleon._process_docstring``.""" _pd_orig(app, what, name, obj, options, lines) print(*lines, sep="\n") diff --git a/docs/extensions/function_images.py b/docs/extensions/function_images.py index bdbc8eaf1f..0b758435ec 100644 --- a/docs/extensions/function_images.py +++ b/docs/extensions/function_images.py @@ -14,7 +14,7 @@ from sphinx.ext.autodoc import Options -def insert_function_images( # noqa: PLR0917 +def insert_function_images( app: Sphinx, what: str, name: str, obj: Any, options: Options, lines: list[str] ) -> None: """Insert images for plot functions.""" diff --git a/docs/extensions/returns_prose_wrap.py b/docs/extensions/returns_prose_wrap.py index bead59e979..edaaa11998 100644 --- a/docs/extensions/returns_prose_wrap.py +++ b/docs/extensions/returns_prose_wrap.py @@ -69,7 +69,7 @@ def _first_content(lines: list[str], start: int, end: int) -> int | None: return None -def _wrap( # noqa: PLR0917 +def _wrap( app: Sphinx, objtype: str, name: str, obj: object, options: object, lines: list[str] ) -> None: """Wrap prose Returns content under a dummy type entry (priority 50).""" @@ -92,7 +92,7 @@ def _wrap( # noqa: PLR0917 ] -def _unwrap( # noqa: PLR0917 +def _unwrap( app: Sphinx, objtype: str, name: str, obj: object, options: object, lines: list[str] ) -> None: """Remove the dummy type entry and restore prose (priority 200).""" diff --git a/src/scanpy/get/_aggregated.py b/src/scanpy/get/_aggregated.py index 4a17068bc5..28e5f3d74b 100644 --- a/src/scanpy/get/_aggregated.py +++ b/src/scanpy/get/_aggregated.py @@ -522,7 +522,7 @@ def _block_moments( @numba.njit(inline="always") # noqa: TID251 -def _chan_combine( # noqa: PLR0917 +def _chan_combine( n_a: float, mean_a: float, m2_a: float, n_b: float, mean_b: float, m2_b: float ) -> tuple[float, float, float]: """Combine two ``(count, mean, M2)`` groups pairwise.""" diff --git a/src/scanpy/neighbors/__init__.py b/src/scanpy/neighbors/__init__.py index 1071e54050..8ee2add54f 100644 --- a/src/scanpy/neighbors/__init__.py +++ b/src/scanpy/neighbors/__init__.py @@ -594,7 +594,7 @@ def compute_neighbors( self.n_neighbors = n_neighbors self.knn = knn - x = _choose_representation(self._adata, use_rep=use_rep, n_pcs=n_pcs) + x = _choose_representation_compat(self._adata, use_rep=use_rep, n_pcs=n_pcs) if isinstance(x, HasArrayNamespace): # sklearn transformers require numpy, so need to convert at boundary x = np.asarray(x) diff --git a/src/testing/scanpy/_pytest/fixtures/__init__.py b/src/testing/scanpy/_pytest/fixtures/__init__.py index 6ad59d906b..a155e0d5de 100644 --- a/src/testing/scanpy/_pytest/fixtures/__init__.py +++ b/src/testing/scanpy/_pytest/fixtures/__init__.py @@ -43,7 +43,7 @@ def float_dtype(request): def _doctest_env(cache: pytest.Cache, tmp_path: Path) -> Generator[None, None, None]: showwarning_orig = warnings.showwarning - def showwarning(message, category, filename, lineno, file=None, line=None) -> None: # noqa: PLR0917 + def showwarning(message, category, filename, lineno, file=None, line=None) -> None: if file is None: if line is None: import linecache diff --git a/tests/test_pca.py b/tests/test_pca.py index 437f9471e9..6333a7c2aa 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -156,7 +156,7 @@ def possible_solvers( case (type() as dc, False) if issubclass(dc, CSBase): svd_solvers = {"arpack", "randomized"} case (helpers.asarray | params.as_dense_jax_array, True): - svd_solvers = {"auto", "full", "arpack", "randomized"} | SKLEARN_ADDITIONAL + svd_solvers = {"auto", "full", "arpack", "randomized", "covariance_eigh"} case (helpers.asarray | params.as_dense_jax_array, False): svd_solvers = {"arpack", "randomized"} case _: From aa58269aeb03326afab5bc4350701ccf4f2b44df Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 08:20:08 +0000 Subject: [PATCH 25/31] [autofix.ci] apply automated fixes --- src/scanpy/tools/_rank_genes_groups.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index 4372f946d9..229fd374de 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -29,7 +29,6 @@ ) from ..get import _check_mask, _get_arr, aggregate from ..get._aggregated import _chan_combine -from ..get.get import _mask_arg if TYPE_CHECKING: from collections.abc import Generator, Iterable From 150ff64db56c758872acc5a9fd2c7a0dddb07999 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Mon, 14 Sep 2026 14:06:24 +0200 Subject: [PATCH 26/31] error fixes --- src/scanpy/tools/_rank_genes_groups.py | 6 ++---- tests/test_highly_variable_genes.py | 7 +++++++ tests/test_preprocessing.py | 28 +++++++++++++++++++++++--- tests/test_rank_genes_groups.py | 9 ++++++--- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/scanpy/tools/_rank_genes_groups.py b/src/scanpy/tools/_rank_genes_groups.py index 229fd374de..4595512a40 100644 --- a/src/scanpy/tools/_rank_genes_groups.py +++ b/src/scanpy/tools/_rank_genes_groups.py @@ -290,10 +290,8 @@ def __init__( adata_comp = adata.raw x = adata_comp.X raise_not_implemented_error_if_backed_type(x, "rank_genes_groups") - if isinstance(adata.X, HasArrayNamespace) and not isinstance( - adata.X, np.ndarray - ): - adata.X = np.asarray(adata.X) + if isinstance(x, HasArrayNamespace) and not isinstance(x, np.ndarray): + x = np.asarray(x) # for correct getnnz calculation if isinstance(x, CSBase): diff --git a/tests/test_highly_variable_genes.py b/tests/test_highly_variable_genes.py index 3f320b6d33..00abd04979 100644 --- a/tests/test_highly_variable_genes.py +++ b/tests/test_highly_variable_genes.py @@ -384,6 +384,13 @@ def test_compare_to_upstream( ref_path: Path, array_type: Callable, ): + if array_type is as_dense_jax_array: + request.applymarker( + pytest.mark.xfail( + reason="as_dense_jax_array hits DLPack readonly BufferError on this JAX version", + strict=False, + ) + ) hvg_info = pd.read_csv(ref_path) pbmc = pbmc68k_reduced() diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 2708cb3836..9f572e49cd 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -22,7 +22,11 @@ maybe_dask_process_context, ) from testing.scanpy._helpers.data import pbmc3k, pbmc68k_reduced -from testing.scanpy._pytest.params import ARRAY_TYPES, ARRAY_TYPES_SPARSE +from testing.scanpy._pytest.params import ( + ARRAY_TYPES, + ARRAY_TYPES_SPARSE, + as_dense_jax_array, +) if TYPE_CHECKING: from collections.abc import Callable @@ -618,7 +622,16 @@ def test_recipe_weinreb(): (None, None, None, 20), ], ) -def test_filter_genes(array_type, max_cells, max_counts, min_cells, min_counts): +def test_filter_genes( + request, array_type, max_cells, max_counts, min_cells, min_counts +): + if array_type is as_dense_jax_array: + request.applymarker( + pytest.mark.xfail( + reason="as_dense_jax_array hits DLPack read only BufferError on this JAX version", + strict=False, + ) + ) adata = pbmc68k_reduced() adata.X = adata.raw.X adata_casted = adata.copy() @@ -652,7 +665,16 @@ def test_filter_genes(array_type, max_cells, max_counts, min_cells, min_counts): pytest.param(None, None, None, 20, id="min_counts"), ], ) -def test_filter_cells(array_type, max_genes, max_counts, min_genes, min_counts): +def test_filter_cells( + request, array_type, max_genes, max_counts, min_genes, min_counts +): + if array_type is as_dense_jax_array: + request.applymarker( + pytest.mark.xfail( + reason="as_dense_jax_array hits DLPack read only BufferError on this JAX version", + strict=False, + ) + ) adata = pbmc68k_reduced() adata.X = adata.raw.X adata_casted = adata.copy() diff --git a/tests/test_rank_genes_groups.py b/tests/test_rank_genes_groups.py index 842d76a3ef..ffe24b8583 100644 --- a/tests/test_rank_genes_groups.py +++ b/tests/test_rank_genes_groups.py @@ -9,7 +9,6 @@ import pandas as pd import pytest from anndata import AnnData -from anndata.tests import helpers from scipy.stats import mannwhitneyu import scanpy as sc @@ -22,7 +21,11 @@ from testing.scanpy._helpers import random_mask from testing.scanpy._helpers.data import pbmc68k_reduced from testing.scanpy._pytest.marks import needs -from testing.scanpy._pytest.params import ARRAY_TYPES, ARRAY_TYPES_MEM +from testing.scanpy._pytest.params import ( + ARRAY_TYPES, + ARRAY_TYPES_MEM, + as_dense_jax_array, +) if TYPE_CHECKING: from collections.abc import Callable, Sequence @@ -141,7 +144,7 @@ def test_results_layers( method: Literal["t-test", "wilcoxon"], ) -> None: - if array_type is helpers.as_dense_jax_array: + if array_type is as_dense_jax_array: request.applymarker( pytest.mark.xfail( reason="test mutates .X in-place; jax arrays are immutable" From 796f4128c31a9b15c6b4e4f16403ceb97b6029b6 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Mon, 14 Sep 2026 14:17:59 +0200 Subject: [PATCH 27/31] tests fix --- tests/test_aggregated.py | 9 ++++++--- tests/test_highly_variable_genes.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_aggregated.py b/tests/test_aggregated.py index b7f1d1a5a9..90d3990170 100644 --- a/tests/test_aggregated.py +++ b/tests/test_aggregated.py @@ -7,7 +7,6 @@ import numpy as np import pandas as pd import pytest -from anndata.tests import helpers from scipy import sparse import scanpy as sc @@ -18,7 +17,11 @@ from testing.scanpy._helpers.data import pbmc3k_processed from testing.scanpy._pytest.marks import needs from testing.scanpy._pytest.params import ARRAY_TYPES as ARRAY_TYPES_ALL -from testing.scanpy._pytest.params import ARRAY_TYPES_MEM, param_with +from testing.scanpy._pytest.params import ( + ARRAY_TYPES_MEM, + as_dense_jax_array, + param_with, +) if TYPE_CHECKING: from collections.abc import Callable @@ -783,7 +786,7 @@ def test_var_no_catastrophic_cancellation( # ~n*offset**2 ≈ 1e19 in float64 (precision ~1e3) but their difference is # the variance ~1e-3, far below the rounding noise. Welford's online # algorithm avoids the subtraction entirely. - if array_type is helpers.as_dense_jax_array: + if array_type is as_dense_jax_array: request.applymarker( pytest.mark.xfail(reason="aggregate not implemented for jax arrays") ) diff --git a/tests/test_highly_variable_genes.py b/tests/test_highly_variable_genes.py index 00abd04979..7abff25432 100644 --- a/tests/test_highly_variable_genes.py +++ b/tests/test_highly_variable_genes.py @@ -19,7 +19,7 @@ from testing.scanpy._helpers import _check_check_values_warnings from testing.scanpy._helpers.data import pbmc3k, pbmc68k_reduced from testing.scanpy._pytest.marks import needs -from testing.scanpy._pytest.params import ARRAY_TYPES +from testing.scanpy._pytest.params import ARRAY_TYPES, as_dense_jax_array if TYPE_CHECKING: from collections.abc import Callable From 02f17c52fd2f29b67e0abbb071a497c822d5f6b7 Mon Sep 17 00:00:00 2001 From: amalia-k510 Date: Mon, 14 Sep 2026 14:31:14 +0200 Subject: [PATCH 28/31] tests fixed --- tests/test_highly_variable_genes.py | 8 ++++++++ tests/test_pca.py | 23 ++++++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/test_highly_variable_genes.py b/tests/test_highly_variable_genes.py index 7abff25432..cc3d2821ae 100644 --- a/tests/test_highly_variable_genes.py +++ b/tests/test_highly_variable_genes.py @@ -657,6 +657,7 @@ def test_seurat_v3_bad_chunking(adata, array_type, flavor): ) @pytest.mark.parametrize("batch_key", [None, "batch"]) def test_subset_inplace_consistency( + request, subtests: pytest.Subtests, flavor: Literal["seurat", "cell_ranger", "seurat_v3", "seurat_v3_paper"], array_type, @@ -669,6 +670,13 @@ def test_subset_inplace_consistency( - for dask arrays and non-dask arrays - for both with and without batch_key """ + if array_type is as_dense_jax_array: + request.applymarker( + pytest.mark.xfail( + reason="as_dense_jax_array hits DLPack readonly BufferError on this JAX version", + strict=False, + ) + ) rng = np.random.default_rng(0) adata = ( sc.datasets.blobs(n_observations=20, n_variables=80, rng=rng) diff --git a/tests/test_pca.py b/tests/test_pca.py index ab375f825f..be9178151e 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -22,7 +22,7 @@ from testing.scanpy._pytest import params from testing.scanpy._pytest.marks import needs from testing.scanpy._pytest.params import ARRAY_TYPES as ARRAY_TYPES_ALL -from testing.scanpy._pytest.params import param_with +from testing.scanpy._pytest.params import as_dense_jax_array, param_with if TYPE_CHECKING: from collections.abc import Callable, Generator @@ -333,8 +333,18 @@ def test_pca_sparse(key_added: str | None, keys: _PcaKeys): @pytest.mark.parametrize("rng_arg", ["rng", "random_state"]) def test_pca_reproducible( - subtests: pytest.Subtests, array_type, rng_arg: Literal["rng", "random_state"] + request, + subtests: pytest.Subtests, + array_type, + rng_arg: Literal["rng", "random_state"], ): + if array_type is as_dense_jax_array: + request.applymarker( + pytest.mark.xfail( + reason="as_dense_jax_array hits DLPack readonly BufferError on this JAX version", + strict=False, + ) + ) pbmc = pbmc3k_normalized() pbmc.X = array_type(pbmc.X) @@ -487,12 +497,19 @@ def test_mask(request: pytest.FixtureRequest, array_type): ) -def test_mask_defaults(array_type, float_dtype): +def test_mask_defaults(request, array_type, float_dtype): """Test if PCA behavior in relation to highly variable genes. 1. That it’s equal withwithout and with – but mask is None 2. If pca takes highly variable as mask as default """ + if array_type is as_dense_jax_array: + request.applymarker( + pytest.mark.xfail( + reason="as_dense_jax_array hits DLPack readonly BufferError on this JAX version", + strict=False, + ) + ) a = array_type(A_list).astype("float64") adata = AnnData(a) From 3639aef17bed849fa89f1508849decf8ebb669c5 Mon Sep 17 00:00:00 2001 From: Phil Schaf Date: Thu, 17 Sep 2026 14:09:46 +0200 Subject: [PATCH 29/31] ruff --- .../extensions/autosummary_skip_deprecated.py | 2 +- docs/extensions/autosummary_skip_inherited.py | 2 +- docs/extensions/debug_docstrings.py | 2 +- docs/extensions/function_images.py | 2 +- docs/extensions/returns_prose_wrap.py | 4 +- src/scanpy/get/_aggregated.py | 2 +- src/scanpy/preprocessing/_scale.py | 97 +++++++++---------- .../scanpy/_pytest/fixtures/__init__.py | 2 +- tests/test_preprocessing.py | 22 +++-- 9 files changed, 72 insertions(+), 63 deletions(-) diff --git a/docs/extensions/autosummary_skip_deprecated.py b/docs/extensions/autosummary_skip_deprecated.py index bf60555bd1..abd562b350 100644 --- a/docs/extensions/autosummary_skip_deprecated.py +++ b/docs/extensions/autosummary_skip_deprecated.py @@ -13,7 +13,7 @@ from sphinx.ext.autodoc import Options -def skip_deprecated( +def skip_deprecated( # noqa: PLR0917 app: Sphinx, what: Literal[ "module", "class", "exception", "function", "method", "attribute", "property" diff --git a/docs/extensions/autosummary_skip_inherited.py b/docs/extensions/autosummary_skip_inherited.py index 9f435ad8ee..694f8062d9 100644 --- a/docs/extensions/autosummary_skip_inherited.py +++ b/docs/extensions/autosummary_skip_inherited.py @@ -14,7 +14,7 @@ from sphinx.ext.autodoc import Options -def skip_inherited( +def skip_inherited( # noqa: PLR0917 app: Sphinx, what: Literal[ "module", "class", "exception", "function", "method", "attribute", "property" diff --git a/docs/extensions/debug_docstrings.py b/docs/extensions/debug_docstrings.py index bbe2eecc5d..3a1f2ddc30 100644 --- a/docs/extensions/debug_docstrings.py +++ b/docs/extensions/debug_docstrings.py @@ -17,7 +17,7 @@ _pd_orig = sphinx.ext.napoleon._process_docstring -def pd_new(app, what, name, obj, options, lines) -> None: +def pd_new(app, what, name, obj, options, lines) -> None: # noqa: PLR0917 """Wrap ``sphinx.ext.napoleon._process_docstring``.""" _pd_orig(app, what, name, obj, options, lines) print(*lines, sep="\n") diff --git a/docs/extensions/function_images.py b/docs/extensions/function_images.py index 0b758435ec..bdbc8eaf1f 100644 --- a/docs/extensions/function_images.py +++ b/docs/extensions/function_images.py @@ -14,7 +14,7 @@ from sphinx.ext.autodoc import Options -def insert_function_images( +def insert_function_images( # noqa: PLR0917 app: Sphinx, what: str, name: str, obj: Any, options: Options, lines: list[str] ) -> None: """Insert images for plot functions.""" diff --git a/docs/extensions/returns_prose_wrap.py b/docs/extensions/returns_prose_wrap.py index edaaa11998..bead59e979 100644 --- a/docs/extensions/returns_prose_wrap.py +++ b/docs/extensions/returns_prose_wrap.py @@ -69,7 +69,7 @@ def _first_content(lines: list[str], start: int, end: int) -> int | None: return None -def _wrap( +def _wrap( # noqa: PLR0917 app: Sphinx, objtype: str, name: str, obj: object, options: object, lines: list[str] ) -> None: """Wrap prose Returns content under a dummy type entry (priority 50).""" @@ -92,7 +92,7 @@ def _wrap( ] -def _unwrap( +def _unwrap( # noqa: PLR0917 app: Sphinx, objtype: str, name: str, obj: object, options: object, lines: list[str] ) -> None: """Remove the dummy type entry and restore prose (priority 200).""" diff --git a/src/scanpy/get/_aggregated.py b/src/scanpy/get/_aggregated.py index 39c26deebb..b58ae16d76 100644 --- a/src/scanpy/get/_aggregated.py +++ b/src/scanpy/get/_aggregated.py @@ -522,7 +522,7 @@ def _block_moments( @numba.njit(inline="always") # noqa: TID251 -def _chan_combine( +def _chan_combine( # noqa: PLR0917 n_a: float, mean_a: float, m2_a: float, n_b: float, mean_b: float, m2_b: float ) -> tuple[float, float, float]: """Combine two ``(count, mean, M2)`` groups pairwise.""" diff --git a/src/scanpy/preprocessing/_scale.py b/src/scanpy/preprocessing/_scale.py index 6939d43fc7..bc5921588d 100644 --- a/src/scanpy/preprocessing/_scale.py +++ b/src/scanpy/preprocessing/_scale.py @@ -34,6 +34,7 @@ from ..get.get import Mask type _Array = CSBase | np.ndarray | DaskArray +type _Stat = NDArray[np.float64] | DaskArray @singledispatch @@ -87,6 +88,49 @@ def clip_array( return x +def _cast_to_float[A: _Array](x: A) -> A: + """Cast integer input to float, as scaling leads to float results.""" + msg = ( + "... as scaling leads to float results, integer " + "input is cast to float, returning copy." + ) + if isinstance(x, np.ndarray | CSBase | DaskArray): + if not np.issubdtype(x.dtype, np.integer): + return x + logg.info(msg) + return x.astype(np.float64) + xp = array_namespace(x) + if not xp.isdtype(x.dtype, "integral"): + return x + logg.info(msg) + return xp.astype(x, xp.float64) + + +def _center_and_std[A: _Array](x: A, *, zero_center: bool) -> tuple[A, _Stat, _Stat]: + """Subtract the mean (if `zero_center`) and return the standard deviation.""" + mean, var = mean_var(x, axis=0, correction=1) + + if isinstance(x, np.ndarray | CSBase | DaskArray): + std = np.sqrt(var) + std[std == 0] = 1 + if zero_center: + if isinstance(x, CSBase) or ( + isinstance(x, DaskArray) and isinstance(x._meta, CSBase) + ): + msg = "zero-centering a sparse array/matrix densifies it." + warn(msg, UserWarning) + x -= mean + x = dematrix(x) + else: + xp = array_namespace(x) + std = xp.sqrt(var) + std = xp.where(std == 0, xp.ones_like(std), std) + if zero_center: + x = x - mean + + return x, mean, std + + @_doc_params( mask=doc_mask( "Restrict both the derivation of scaling parameters and the scaling itself\n" @@ -193,14 +237,7 @@ def scale_array[A: _Array]( return_mean_std: bool = False, mask: NDArray[np.bool] | None = None, mask_obs: NDArray[np.bool] | None = None, -) -> ( - A - | tuple[ - A, - NDArray[np.float64] | DaskArray, - NDArray[np.float64], - ] -): +) -> A | tuple[A, _Stat, _Stat]: if copy: x = x.copy() @@ -213,19 +250,7 @@ def scale_array[A: _Array]( logg.info( # Be careful of what? This should be more specific "... be careful when using `max_value` without `zero_center`." ) - int_msg = ( - "... as scaling leads to float results, integer " - "input is cast to float, returning copy." - ) - if isinstance(x, np.ndarray | CSBase | DaskArray): - if np.issubdtype(x.dtype, np.integer): - logg.info(int_msg) - x = x.astype(np.float64) - else: - xp = array_namespace(x) - if xp.isdtype(x.dtype, "integral"): - logg.info(int_msg) - x = xp.astype(x, xp.float64) + x = _cast_to_float(x) mask = _mask_arg(mask, mask_obs, dim="obs") mask = ( @@ -244,26 +269,7 @@ def scale_array[A: _Array]( return_mean_std=return_mean_std, ) - mean, var = mean_var(x, axis=0, correction=1) - - if isinstance(x, np.ndarray | CSBase | DaskArray): - std = np.sqrt(var) - std[std == 0] = 1 - if zero_center: - if isinstance(x, CSBase) or ( - isinstance(x, DaskArray) and isinstance(x._meta, CSBase) - ): - msg = "zero-centering a sparse array/matrix densifies it." - warn(msg, UserWarning) - x -= mean - x = dematrix(x) - else: - xp = array_namespace(x) - std = xp.sqrt(var) - std = xp.where(std == 0, xp.ones_like(std), std) - - if zero_center: - x = x - mean + x, mean, std = _center_and_std(x, zero_center=zero_center) x = axis_mul_or_truediv( x, @@ -289,14 +295,7 @@ def scale_array_masked[A: _Array]( zero_center: bool = True, max_value: float | None = None, return_mean_std: bool = False, -) -> ( - A - | tuple[ - A, - NDArray[np.float64] | DaskArray, - NDArray[np.float64], - ] -): +) -> A | tuple[A, _Stat, _Stat]: if isinstance(x, CSBase) and not zero_center: if isinstance(x, CSCBase): x = x.tocsr() diff --git a/src/testing/scanpy/_pytest/fixtures/__init__.py b/src/testing/scanpy/_pytest/fixtures/__init__.py index a155e0d5de..6ad59d906b 100644 --- a/src/testing/scanpy/_pytest/fixtures/__init__.py +++ b/src/testing/scanpy/_pytest/fixtures/__init__.py @@ -43,7 +43,7 @@ def float_dtype(request): def _doctest_env(cache: pytest.Cache, tmp_path: Path) -> Generator[None, None, None]: showwarning_orig = warnings.showwarning - def showwarning(message, category, filename, lineno, file=None, line=None) -> None: + def showwarning(message, category, filename, lineno, file=None, line=None) -> None: # noqa: PLR0917 if file is None: if line is None: import linecache diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 9f572e49cd..6041d9cf09 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -622,9 +622,14 @@ def test_recipe_weinreb(): (None, None, None, 20), ], ) -def test_filter_genes( - request, array_type, max_cells, max_counts, min_cells, min_counts -): +def test_filter_genes( # noqa: PLR0917 + request: pytest.FixtureRequest, + array_type, + max_cells: int | None, + max_counts: int | None, + min_cells: int | None, + min_counts: int | None, +) -> None: if array_type is as_dense_jax_array: request.applymarker( pytest.mark.xfail( @@ -665,9 +670,14 @@ def test_filter_genes( pytest.param(None, None, None, 20, id="min_counts"), ], ) -def test_filter_cells( - request, array_type, max_genes, max_counts, min_genes, min_counts -): +def test_filter_cells( # noqa: PLR0917 + request: pytest.FixtureRequest, + array_type, + max_genes: int | None, + max_counts: int | None, + min_genes: int | None, + min_counts: int | None, +) -> None: if array_type is as_dense_jax_array: request.applymarker( pytest.mark.xfail( From 1fc2ecb93f36914f48aac2264bfec3bd06db7e4e Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 18 Sep 2026 12:35:52 +0200 Subject: [PATCH 30/31] no jax extra --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b9719252f8..3699370e1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,6 @@ dynamic = [ "version" ] dependencies = [ "anndata>=0.12.14", "array-api-compat", - "certifi", "fast-array-utils[accel,sparse]>=1.5", "h5py>=3.11", "joblib", @@ -88,7 +87,6 @@ scripts.scanpy = "scanpy.cli:console_main" [project.optional-dependencies] bbknn = [ "bbknn" ] dask = [ "anndata[dask]", "dask[array]>=2024.10" ] -jax = [ "jax" ] # PCA acceleration dask-ml = [ "dask-ml", "scanpy[dask]" ] leiden = [ "igraph>=0.10.8", "leidenalg>=0.10.1" ] @@ -110,10 +108,10 @@ dev = [ "types-array-api", ] test = [ + "jax", # Array API tests "scanpy[dask-ml]", "scanpy[dask]", "scanpy[illico]", - "scanpy[jax]", "scanpy[leiden]", "scanpy[plotting]", "scanpy[scrublet]", From 99d7fad7053ec6cb14c2013ff97913fa931bf878 Mon Sep 17 00:00:00 2001 From: "Philipp A." Date: Fri, 18 Sep 2026 14:41:45 +0200 Subject: [PATCH 31/31] undo test circumvention --- src/scanpy/_utils/__init__.py | 7 +-- tests/test_highly_variable_genes.py | 18 +----- tests/test_pca.py | 23 +------- tests/test_preprocessing.py | 87 ++++------------------------- tests/test_rank_genes_groups.py | 16 ++---- 5 files changed, 19 insertions(+), 132 deletions(-) diff --git a/src/scanpy/_utils/__init__.py b/src/scanpy/_utils/__init__.py index 12d89f5065..626bc2f0b1 100644 --- a/src/scanpy/_utils/__init__.py +++ b/src/scanpy/_utils/__init__.py @@ -34,12 +34,7 @@ from fast_array_utils.types import HasArrayNamespace from .. import logging as logg -from .._compat import ( - CSBase, - DaskArray, - SpBase, - warn, -) +from .._compat import CSBase, DaskArray, SpBase, warn from ._numba import _numba_thread_limit if TYPE_CHECKING: diff --git a/tests/test_highly_variable_genes.py b/tests/test_highly_variable_genes.py index cc3d2821ae..d8a7049ac0 100644 --- a/tests/test_highly_variable_genes.py +++ b/tests/test_highly_variable_genes.py @@ -19,7 +19,7 @@ from testing.scanpy._helpers import _check_check_values_warnings from testing.scanpy._helpers.data import pbmc3k, pbmc68k_reduced from testing.scanpy._pytest.marks import needs -from testing.scanpy._pytest.params import ARRAY_TYPES, as_dense_jax_array +from testing.scanpy._pytest.params import ARRAY_TYPES if TYPE_CHECKING: from collections.abc import Callable @@ -378,19 +378,11 @@ def test_pearson_residuals_batch( @pytest.mark.parametrize("array_type", ARRAY_TYPES) def test_compare_to_upstream( *, - request: pytest.FixtureRequest, flavor: Literal["seurat", "cell_ranger"], params: Any, ref_path: Path, array_type: Callable, ): - if array_type is as_dense_jax_array: - request.applymarker( - pytest.mark.xfail( - reason="as_dense_jax_array hits DLPack readonly BufferError on this JAX version", - strict=False, - ) - ) hvg_info = pd.read_csv(ref_path) pbmc = pbmc68k_reduced() @@ -657,7 +649,6 @@ def test_seurat_v3_bad_chunking(adata, array_type, flavor): ) @pytest.mark.parametrize("batch_key", [None, "batch"]) def test_subset_inplace_consistency( - request, subtests: pytest.Subtests, flavor: Literal["seurat", "cell_ranger", "seurat_v3", "seurat_v3_paper"], array_type, @@ -670,13 +661,6 @@ def test_subset_inplace_consistency( - for dask arrays and non-dask arrays - for both with and without batch_key """ - if array_type is as_dense_jax_array: - request.applymarker( - pytest.mark.xfail( - reason="as_dense_jax_array hits DLPack readonly BufferError on this JAX version", - strict=False, - ) - ) rng = np.random.default_rng(0) adata = ( sc.datasets.blobs(n_observations=20, n_variables=80, rng=rng) diff --git a/tests/test_pca.py b/tests/test_pca.py index be9178151e..ab375f825f 100644 --- a/tests/test_pca.py +++ b/tests/test_pca.py @@ -22,7 +22,7 @@ from testing.scanpy._pytest import params from testing.scanpy._pytest.marks import needs from testing.scanpy._pytest.params import ARRAY_TYPES as ARRAY_TYPES_ALL -from testing.scanpy._pytest.params import as_dense_jax_array, param_with +from testing.scanpy._pytest.params import param_with if TYPE_CHECKING: from collections.abc import Callable, Generator @@ -333,18 +333,8 @@ def test_pca_sparse(key_added: str | None, keys: _PcaKeys): @pytest.mark.parametrize("rng_arg", ["rng", "random_state"]) def test_pca_reproducible( - request, - subtests: pytest.Subtests, - array_type, - rng_arg: Literal["rng", "random_state"], + subtests: pytest.Subtests, array_type, rng_arg: Literal["rng", "random_state"] ): - if array_type is as_dense_jax_array: - request.applymarker( - pytest.mark.xfail( - reason="as_dense_jax_array hits DLPack readonly BufferError on this JAX version", - strict=False, - ) - ) pbmc = pbmc3k_normalized() pbmc.X = array_type(pbmc.X) @@ -497,19 +487,12 @@ def test_mask(request: pytest.FixtureRequest, array_type): ) -def test_mask_defaults(request, array_type, float_dtype): +def test_mask_defaults(array_type, float_dtype): """Test if PCA behavior in relation to highly variable genes. 1. That it’s equal withwithout and with – but mask is None 2. If pca takes highly variable as mask as default """ - if array_type is as_dense_jax_array: - request.applymarker( - pytest.mark.xfail( - reason="as_dense_jax_array hits DLPack readonly BufferError on this JAX version", - strict=False, - ) - ) a = array_type(A_list).astype("float64") adata = AnnData(a) diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 6041d9cf09..9128e78541 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -25,7 +25,6 @@ from testing.scanpy._pytest.params import ( ARRAY_TYPES, ARRAY_TYPES_SPARSE, - as_dense_jax_array, ) if TYPE_CHECKING: @@ -613,96 +612,30 @@ def test_recipe_weinreb(): @pytest.mark.parametrize("array_type", ARRAY_TYPES) -@pytest.mark.parametrize( - ("max_cells", "max_counts", "min_cells", "min_counts"), - [ - (100, None, None, None), - (None, 100, None, None), - (None, None, 20, None), - (None, None, None, 20), - ], -) -def test_filter_genes( # noqa: PLR0917 - request: pytest.FixtureRequest, - array_type, - max_cells: int | None, - max_counts: int | None, - min_cells: int | None, - min_counts: int | None, -) -> None: - if array_type is as_dense_jax_array: - request.applymarker( - pytest.mark.xfail( - reason="as_dense_jax_array hits DLPack read only BufferError on this JAX version", - strict=False, - ) - ) +@pytest.mark.parametrize("arg", ["max_cells", "max_counts", "min_cells", "min_counts"]) +def test_filter_genes(array_type, arg: str) -> None: + kw = {arg: 100 if arg.startswith("max") else 20} adata = pbmc68k_reduced() adata.X = adata.raw.X adata_casted = adata.copy() adata_casted.X = array_type(adata_casted.raw.X) - sc.pp.filter_genes( - adata, - max_cells=max_cells, - max_counts=max_counts, - min_cells=min_cells, - min_counts=min_counts, - ) - sc.pp.filter_genes( - adata_casted, - max_cells=max_cells, - max_counts=max_counts, - min_cells=min_cells, - min_counts=min_counts, - ) + sc.pp.filter_genes(adata, **kw) + sc.pp.filter_genes(adata_casted, **kw) adata_casted.X = conv.to_dense(adata_casted.X, to_cpu_memory=True) adata.X = conv.to_dense(adata.X) assert_allclose(adata_casted.X, adata.X, rtol=1e-5, atol=1e-5) @pytest.mark.parametrize("array_type", ARRAY_TYPES) -@pytest.mark.parametrize( - ("max_genes", "max_counts", "min_genes", "min_counts"), - [ - pytest.param(100, None, None, None, id="max_genes"), - pytest.param(None, 100, None, None, id="max_counts"), - pytest.param(None, None, 20, None, id="min_genes"), - pytest.param(None, None, None, 20, id="min_counts"), - ], -) -def test_filter_cells( # noqa: PLR0917 - request: pytest.FixtureRequest, - array_type, - max_genes: int | None, - max_counts: int | None, - min_genes: int | None, - min_counts: int | None, -) -> None: - if array_type is as_dense_jax_array: - request.applymarker( - pytest.mark.xfail( - reason="as_dense_jax_array hits DLPack read only BufferError on this JAX version", - strict=False, - ) - ) +@pytest.mark.parametrize("arg", ["max_genes", "max_counts", "min_genes", "min_counts"]) +def test_filter_cells(array_type, arg: str) -> None: + kw = {arg: 100 if arg.startswith("max") else 20} adata = pbmc68k_reduced() adata.X = adata.raw.X adata_casted = adata.copy() adata_casted.X = array_type(adata_casted.raw.X) - sc.pp.filter_cells( - adata, - max_genes=max_genes, - max_counts=max_counts, - min_genes=min_genes, - min_counts=min_counts, - ) - sc.pp.filter_cells( - adata_casted, - max_genes=max_genes, - max_counts=max_counts, - min_genes=min_genes, - min_counts=min_counts, - ) + sc.pp.filter_cells(adata, **kw) + sc.pp.filter_cells(adata_casted, **kw) adata_casted.X = conv.to_dense(adata_casted.X, to_cpu_memory=True) adata.X = conv.to_dense(adata.X) assert_allclose(adata_casted.X, adata.X, rtol=1e-5, atol=1e-5) diff --git a/tests/test_rank_genes_groups.py b/tests/test_rank_genes_groups.py index ffe24b8583..1fb32e1ab7 100644 --- a/tests/test_rank_genes_groups.py +++ b/tests/test_rank_genes_groups.py @@ -9,6 +9,7 @@ import pandas as pd import pytest from anndata import AnnData +from anndata.tests.helpers import asarray from scipy.stats import mannwhitneyu import scanpy as sc @@ -24,7 +25,6 @@ from testing.scanpy._pytest.params import ( ARRAY_TYPES, ARRAY_TYPES_MEM, - as_dense_jax_array, ) if TYPE_CHECKING: @@ -137,24 +137,16 @@ def test_results( @pytest.mark.parametrize("method", ["t-test", "wilcoxon"]) @pytest.mark.parametrize("array_type", ARRAY_TYPES_MEM) def test_results_layers( - request: pytest.FixtureRequest, subtests: pytest.Subtests, data_dir: Path, array_type, method: Literal["t-test", "wilcoxon"], ) -> None: - - if array_type is as_dense_jax_array: - request.applymarker( - pytest.mark.xfail( - reason="test mutates .X in-place; jax arrays are immutable" - ) - ) adata = get_example_data(array_type, rng=_LegacyRng(1234)) adata.layers["to_test"] = adata.X.copy() - x = adata.X.tolil() if isinstance(adata.X, CSBase) else adata.X - mask = np.random.default_rng().integers(0, 2, adata.shape, dtype=bool) - x[mask] = 0 + # zero out random entries in a writable numpy copy (jax arrays are immutable) + x = asarray(adata.X).copy() + x[np.random.default_rng().integers(0, 2, adata.shape, dtype=bool)] = 0 adata.X = array_type(x) scores = get_true_scores(data_dir, method)["scores"]