From 1224be7e8c0be3c93caff4e6f847e798eb3864ab Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:33:27 -0500 Subject: [PATCH 01/20] ENH: Add better lazy_xp_function support for class methods --- src/array_api_extra/testing.py | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/array_api_extra/testing.py b/src/array_api_extra/testing.py index d40fea1a..e8ba27a0 100644 --- a/src/array_api_extra/testing.py +++ b/src/array_api_extra/testing.py @@ -10,8 +10,8 @@ import enum import warnings from collections.abc import Callable, Generator, Iterator, Sequence -from functools import wraps -from types import ModuleType +from functools import update_wrapper, wraps +from types import FunctionType, ModuleType from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast from ._lib._utils._compat import is_dask_namespace, is_jax_namespace @@ -48,8 +48,22 @@ class Deprecated(enum.Enum): DEPRECATED = Deprecated.DEPRECATED +def _clone_function(f): + """Returns a clone of an existing function.""" + f_new = FunctionType( + f.__code__, + f.__globals__, + name=f.__name__, + argdefs=f.__defaults__, + closure=f.__closure__, + ) + f_new.__kwdefaults__ = f.__kwdefaults__ + update_wrapper(f_new, f) + return f_new + + def lazy_xp_function( - func: Callable[..., Any], + func: Callable[..., Any] | Tuple[type, str], *, allow_dask_compute: bool | int = False, jax_jit: bool = True, @@ -69,8 +83,9 @@ def lazy_xp_function( Parameters ---------- - func : callable - Function to be tested. + func : callable | tuple[type, str] + Function to be tested, or a tuple containing an (uninstantiated) class and a + method name to specify a class method to be tested. allow_dask_compute : bool | int, optional Whether `func` is allowed to internally materialize the Dask graph, or maximum number of times it is allowed to do so. This is typically triggered by @@ -209,6 +224,15 @@ def test_myfunc(xp): "jax_jit": jax_jit, } + if isinstance(func, tuple): + # Replace the method with a clone before adding tags + # to avoid adding unwanted tags to a parent method when + # the method was inherited from a parent class. + cls, method_name = func + method = getattr(cls, method_name) + setattr(cls, method_name, _clone_function(method)) + func = getattr(cls, method_name) + try: func._lazy_xp_function = tags # type: ignore[attr-defined] # pylint: disable=protected-access # pyright: ignore[reportFunctionMemberAccess] except AttributeError: # @cython.vectorize From 5034b6082e05c7584799e7e9b0ee475092d60deb Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:33:44 -0500 Subject: [PATCH 02/20] TST: test that lazy_xp_function can handle inherited methods --- tests/test_testing.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_testing.py b/tests/test_testing.py index 7e72ffbf..0763f468 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -321,6 +321,37 @@ def test_lazy_xp_function_cython_ufuncs(xp: ModuleType, library: Backend): xp_assert_equal(cast(Array, erf(x)), xp.asarray([1.0, 1.0])) +class A: + def __init__(self, x): + xp = array_namespace(x) + self._xp = xp + self.x = np.asarray(x) + + def f(self, y): + y = np.asarray(y) + return self._xp.asarray(np.matmul(self.x, y)) + + def g(self, y, z): + return self.f(y) + self.f(z) + + +class B(A): + def __init__(self, x): + xp = array_namespace(x) + self._xp = xp + self.x = xp.asarray(x) + + def f(self, y): + return self._xp.matmul(self.x, y) + + +lazy_xp_function((B, "g")) + +def test_lazy_xp_function_class_inheritance(xp: ModuleType): + assert hasattr(B.g, "_lazy_xp_function") + assert not hasattr(A.g, "_lazy_xp_function") + + def dask_raises(x: Array) -> Array: def _raises(x: Array) -> Array: # Test that map_blocks doesn't eagerly call the function; From c6306102ffd91659e4bb4c3539fae8f37739d19c Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Sat, 3 Jan 2026 16:18:12 -0500 Subject: [PATCH 03/20] MAINT: Fix typing issues --- src/array_api_extra/testing.py | 15 ++++++++------- tests/test_testing.py | 29 ++++++++++++++++------------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/array_api_extra/testing.py b/src/array_api_extra/testing.py index e8ba27a0..855e7671 100644 --- a/src/array_api_extra/testing.py +++ b/src/array_api_extra/testing.py @@ -48,7 +48,7 @@ class Deprecated(enum.Enum): DEPRECATED = Deprecated.DEPRECATED -def _clone_function(f): +def _clone_function(f: Callable[..., Any]) -> Callable[..., Any]: """Returns a clone of an existing function.""" f_new = FunctionType( f.__code__, @@ -58,12 +58,11 @@ def _clone_function(f): closure=f.__closure__, ) f_new.__kwdefaults__ = f.__kwdefaults__ - update_wrapper(f_new, f) - return f_new + return update_wrapper(f_new, f) def lazy_xp_function( - func: Callable[..., Any] | Tuple[type, str], + func: Callable[..., Any] | tuple[type, str], *, allow_dask_compute: bool | int = False, jax_jit: bool = True, @@ -231,12 +230,14 @@ def test_myfunc(xp): cls, method_name = func method = getattr(cls, method_name) setattr(cls, method_name, _clone_function(method)) - func = getattr(cls, method_name) + f = getattr(cls, method_name) + else: + f = func try: - func._lazy_xp_function = tags # type: ignore[attr-defined] # pylint: disable=protected-access # pyright: ignore[reportFunctionMemberAccess] + f._lazy_xp_function = tags # pylint: disable=protected-access # pyright: ignore[reportFunctionMemberAccess] except AttributeError: # @cython.vectorize - _ufuncs_tags[func] = tags + _ufuncs_tags[f] = tags def patch_lazy_xp_functions( diff --git a/tests/test_testing.py b/tests/test_testing.py index 0763f468..a7f4747f 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,9 +1,10 @@ from collections.abc import Callable, Iterator from types import ModuleType -from typing import cast +from typing import Any, cast import numpy as np import pytest +from typing_extensions import override from array_api_extra._lib._backends import Backend from array_api_extra._lib._testing import ( @@ -322,32 +323,34 @@ def test_lazy_xp_function_cython_ufuncs(xp: ModuleType, library: Backend): class A: - def __init__(self, x): + def __init__(self, x: Array): xp = array_namespace(x) - self._xp = xp - self.x = np.asarray(x) + self._xp: ModuleType = xp + self.x: Any = np.asarray(x) - def f(self, y): - y = np.asarray(y) - return self._xp.asarray(np.matmul(self.x, y)) + def f(self, y: Array) -> Array: + return self._xp.asarray(np.matmul(self.x, np.asarray(y))) - def g(self, y, z): + def g(self, y: Array, z: Array) -> Array: return self.f(y) + self.f(z) class B(A): - def __init__(self, x): + @override + def __init__(self, x: Array): # pyright: ignore[reportMissingSuperCall] xp = array_namespace(x) - self._xp = xp - self.x = xp.asarray(x) + self._xp: ModuleType = xp + self.x: Any = xp.asarray(x) - def f(self, y): + @override + def f(self, y: Array) -> Array: return self._xp.matmul(self.x, y) lazy_xp_function((B, "g")) -def test_lazy_xp_function_class_inheritance(xp: ModuleType): + +def test_lazy_xp_function_class_inheritance(): assert hasattr(B.g, "_lazy_xp_function") assert not hasattr(A.g, "_lazy_xp_function") From 4ba6e3e8028e857914ed44467b7a404b49f68dcd Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:44:02 -0500 Subject: [PATCH 04/20] ENH: make patch_lazy_xp_functions check classes within modules --- src/array_api_extra/testing.py | 59 +++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/src/array_api_extra/testing.py b/src/array_api_extra/testing.py index 855e7671..74ff7ee7 100644 --- a/src/array_api_extra/testing.py +++ b/src/array_api_extra/testing.py @@ -249,10 +249,11 @@ def patch_lazy_xp_functions( """ Test lazy execution of functions tagged with :func:`lazy_xp_function`. - If ``xp==jax.numpy``, search for all functions which have been tagged with - :func:`lazy_xp_function` in the globals of the module that defines the current test, - as well as in the ``lazy_xp_modules`` list in the globals of the same module, - and wrap them with :func:`jax.jit`. Unwrap them at the end of the test. + If ``xp==jax.numpy``, search for all functions and classes which have been tagged + with :func:`lazy_xp_function` in the globals of the module that defines the current + test, as well as in the ``lazy_xp_modules`` list in the globals of the same module, + and wrap them with :func:`jax.jit`. + Unwrap them at the end of the test. If ``xp==dask.array``, wrap the functions with a decorator that disables ``compute()`` and ``persist()`` and ensures that exceptions and warnings are raised @@ -296,18 +297,32 @@ def xp(request): the example above. """ mod = cast(ModuleType, request.module) - mods = [mod, *cast(list[ModuleType], getattr(mod, "lazy_xp_modules", []))] - - to_revert: list[tuple[ModuleType, str, object]] = [] - - def temp_setattr(mod: ModuleType, name: str, func: object) -> None: + search_targets: list[ModuleType | type] = [ + mod, + *cast(list[ModuleType], getattr(mod, "lazy_xp_modules", [])), + ] + # Also search for classes within the above modules which have had lazy_xp_function + # applied to methods through ``lazy_xp_function((cls, method_name))`` syntax. + # We might end up adding classes incidentally imported into modules, so using a + # set here to cut down on potential redundancy. + classes: set[type] = set() + for target in search_targets: + for obj_name in dir(target): + obj = getattr(target, obj_name) + if isinstance(obj, type) and isinstance(obj, Exception): + classes.add(obj) + search_targets.extend(classes) + + to_revert: list[tuple[ModuleType | type, str, object]] = [] + + def temp_setattr(target: ModuleType | type, name: str, func: object) -> None: """ Variant of monkeypatch.setattr, which allows monkey-patching only selected parameters of a test so that pytest-run-parallel can run on the remainder. """ - assert hasattr(mod, name) - to_revert.append((mod, name, getattr(mod, name))) - setattr(mod, name, func) + assert hasattr(target, name) + to_revert.append((target, name, getattr(target, name))) + setattr(target, name, func) if monkeypatch is not None: warnings.warn( @@ -323,10 +338,10 @@ def temp_setattr(mod: ModuleType, name: str, func: object) -> None: temp_setattr = monkeypatch.setattr # type: ignore[assignment] # pyright: ignore[reportAssignmentType] def iter_tagged() -> Iterator[ - tuple[ModuleType, str, Callable[..., Any], dict[str, Any]] + tuple[ModuleType | type, str, Callable[..., Any], dict[str, Any]] ]: - for mod in mods: - for name, func in mod.__dict__.items(): + for target in search_targets: + for name, func in target.__dict__.items(): tags: dict[str, Any] | None = None with contextlib.suppress(AttributeError): tags = func._lazy_xp_function # pylint: disable=protected-access @@ -334,23 +349,23 @@ def iter_tagged() -> Iterator[ with contextlib.suppress(KeyError, TypeError): tags = _ufuncs_tags[func] if tags is not None: - yield mod, name, func, tags + yield target, name, func, tags if is_dask_namespace(xp): - for mod, name, func, tags in iter_tagged(): + for target, name, func, tags in iter_tagged(): n = tags["allow_dask_compute"] if n is True: n = 1_000_000 elif n is False: n = 0 wrapped = _dask_wrap(func, n) - temp_setattr(mod, name, wrapped) + temp_setattr(target, name, wrapped) elif is_jax_namespace(xp): - for mod, name, func, tags in iter_tagged(): + for target, name, func, tags in iter_tagged(): if tags["jax_jit"]: wrapped = jax_autojit(func) - temp_setattr(mod, name, wrapped) + temp_setattr(target, name, wrapped) # We can't just decorate patch_lazy_xp_functions with # @contextlib.contextmanager because it would not work with the @@ -360,8 +375,8 @@ def revert_on_exit() -> Generator[None]: try: yield finally: - for mod, name, orig_func in to_revert: - setattr(mod, name, orig_func) + for target, name, orig_func in to_revert: + setattr(target, name, orig_func) return revert_on_exit() From da76e148cf1e2f3a7761258eafe83474e350bcc3 Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:44:30 -0500 Subject: [PATCH 05/20] TST: Add test to lazy_xp_function works for class methods --- tests/test_testing.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/test_testing.py b/tests/test_testing.py index a7f4747f..1184fa61 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -350,9 +350,19 @@ def f(self, y: Array) -> Array: lazy_xp_function((B, "g")) -def test_lazy_xp_function_class_inheritance(): - assert hasattr(B.g, "_lazy_xp_function") - assert not hasattr(A.g, "_lazy_xp_function") +class TestLazyXpFunctionClasses: + def test_parent_method_not_tagged(self): + assert hasattr(B.g, "_lazy_xp_function") + assert not hasattr(A.g, "_lazy_xp_function") + + def test_lazy_xp_function_classes(self, xp): + x = xp.asarray([1.1, 2.2, 3.3]) + y = xp.asarray([1.0, 2.0, 3.0]) + z = xp.asarray([3.0, 4.0, 5.0]) + foo = B(x) + observed = foo.g(y, z) + expected = xp.asarray(44.0)[()] + xp_assert_close(observed, expected) def dask_raises(x: Array) -> Array: From 96f7eda2d1f7f29546e7ae587fba062d12654028 Mon Sep 17 00:00:00 2001 From: Albert Steppi <1953382+steppi@users.noreply.github.com> Date: Wed, 7 Jan 2026 08:28:08 -0600 Subject: [PATCH 06/20] Update src/array_api_extra/testing.py Co-authored-by: Guido Imperiale --- src/array_api_extra/testing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/array_api_extra/testing.py b/src/array_api_extra/testing.py index 74ff7ee7..0ca96cbd 100644 --- a/src/array_api_extra/testing.py +++ b/src/array_api_extra/testing.py @@ -227,6 +227,8 @@ def test_myfunc(xp): # Replace the method with a clone before adding tags # to avoid adding unwanted tags to a parent method when # the method was inherited from a parent class. + # Note: can't just accept an unbound method `cls.method_name` because in + # case of inheritance it would be impossible to attribute it to the child class. cls, method_name = func method = getattr(cls, method_name) setattr(cls, method_name, _clone_function(method)) From 368e019f2ab17dff91221a89e872796ed5ab07b5 Mon Sep 17 00:00:00 2001 From: Albert Steppi <1953382+steppi@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:11:35 -0600 Subject: [PATCH 07/20] Apply suggestions from code review Co-authored-by: Guido Imperiale --- src/array_api_extra/testing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/array_api_extra/testing.py b/src/array_api_extra/testing.py index 0ca96cbd..6256ca60 100644 --- a/src/array_api_extra/testing.py +++ b/src/array_api_extra/testing.py @@ -251,7 +251,7 @@ def patch_lazy_xp_functions( """ Test lazy execution of functions tagged with :func:`lazy_xp_function`. - If ``xp==jax.numpy``, search for all functions and classes which have been tagged + If ``xp==jax.numpy``, search for all functions and methods which have been tagged with :func:`lazy_xp_function` in the globals of the module that defines the current test, as well as in the ``lazy_xp_modules`` list in the globals of the same module, and wrap them with :func:`jax.jit`. @@ -311,7 +311,7 @@ def xp(request): for target in search_targets: for obj_name in dir(target): obj = getattr(target, obj_name) - if isinstance(obj, type) and isinstance(obj, Exception): + if isinstance(obj, type): classes.add(obj) search_targets.extend(classes) From 1e7997e46116cc14d5e65765ee89f49549150eb3 Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:19:17 -0500 Subject: [PATCH 08/20] TST: make test laxy xp classes actually test function is wrapped --- tests/test_testing.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/test_testing.py b/tests/test_testing.py index 1184fa61..fc4ad2de 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -334,6 +334,9 @@ def f(self, y: Array) -> Array: def g(self, y: Array, z: Array) -> Array: return self.f(y) + self.f(z) + def w(self, y: Array) -> bool: + return bool(self._xp.any(y)) + class B(A): @override @@ -348,6 +351,7 @@ def f(self, y: Array) -> Array: lazy_xp_function((B, "g")) +lazy_xp_function((B, "w")) class TestLazyXpFunctionClasses: @@ -355,14 +359,19 @@ def test_parent_method_not_tagged(self): assert hasattr(B.g, "_lazy_xp_function") assert not hasattr(A.g, "_lazy_xp_function") - def test_lazy_xp_function_classes(self, xp): + def test_lazy_xp_function_classes(self, xp: ModuleType, library: Backend): x = xp.asarray([1.1, 2.2, 3.3]) y = xp.asarray([1.0, 2.0, 3.0]) - z = xp.asarray([3.0, 4.0, 5.0]) - foo = B(x) - observed = foo.g(y, z) - expected = xp.asarray(44.0)[()] - xp_assert_close(observed, expected) + foo = A(x) + bar = B(x) + + if library.like(Backend.JAX): + with pytest.raises( + TypeError, match="Attempted boolean conversion of traced array" + ): + assert bar.w(y) + + assert foo.w(y) def dask_raises(x: Array) -> Array: From 86cdec539e6385d729257a4365a2b291bae6c7a4 Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Thu, 8 Jan 2026 12:59:55 -0500 Subject: [PATCH 09/20] TST: Add appropriate skips for lazy xp function classes test --- tests/test_testing.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_testing.py b/tests/test_testing.py index fc4ad2de..90d13861 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -359,6 +359,10 @@ def test_parent_method_not_tagged(self): assert hasattr(B.g, "_lazy_xp_function") assert not hasattr(A.g, "_lazy_xp_function") + @pytest.mark.skip_xp_backend(Backend.SPARSE, reason="converts to NumPy") + @pytest.mark.skip_xp_backend(Backend.CUPY, reason="converts to NumPy") + @pytest.mark.skip_xp_backend(Backend.JAX_GPU, reason="converts to NumPy") + @pytest.mark.skip_xp_backend(Backend.TORCH_GPU, reason="converts to NumPy") def test_lazy_xp_function_classes(self, xp: ModuleType, library: Backend): x = xp.asarray([1.1, 2.2, 3.3]) y = xp.asarray([1.0, 2.0, 3.0]) From 35ccdd2388d97c8314ab5f9467e72ba8a8252f2e Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Thu, 8 Jan 2026 21:13:27 -0500 Subject: [PATCH 10/20] TST: Ensure that classmethods and staticmethods stay those things --- src/array_api_extra/testing.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/array_api_extra/testing.py b/src/array_api_extra/testing.py index 6256ca60..9be60609 100644 --- a/src/array_api_extra/testing.py +++ b/src/array_api_extra/testing.py @@ -230,8 +230,21 @@ def test_myfunc(xp): # Note: can't just accept an unbound method `cls.method_name` because in # case of inheritance it would be impossible to attribute it to the child class. cls, method_name = func + # The method might be a staticmethod or classmethod so we need to do a dance + # to ensure that this is preserved. + raw_attr = cls.__dict__.get(method_name) method = getattr(cls, method_name) - setattr(cls, method_name, _clone_function(method)) + cloned_method = _clone_function(method) + + method_to_set: Any + if isinstance(raw_attr, staticmethod): + method_to_set = staticmethod(cloned_method) + elif isinstance(raw_attr, classmethod): + method_to_set = classmethod(cloned_method) + else: + method_to_set = cloned_method + + setattr(cls, method_name, method_to_set) f = getattr(cls, method_name) else: f = func From 34016130aa65f12719c72ca74227cfed84bea71b Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Thu, 8 Jan 2026 21:30:20 -0500 Subject: [PATCH 11/20] TST: Add test that lazy_xp_function preserves staticmethod --- tests/test_testing.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_testing.py b/tests/test_testing.py index 90d13861..0b05ad9b 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -349,9 +349,14 @@ def __init__(self, x: Array): # pyright: ignore[reportMissingSuperCall] def f(self, y: Array) -> Array: return self._xp.matmul(self.x, y) + @staticmethod + def k(y: Array) -> "B": + return B(2.0 * y) + lazy_xp_function((B, "g")) lazy_xp_function((B, "w")) +lazy_xp_function((B, "k")) class TestLazyXpFunctionClasses: @@ -377,6 +382,12 @@ def test_lazy_xp_function_classes(self, xp: ModuleType, library: Backend): assert foo.w(y) + def test_static_methods(self, xp: ModuleType): + x = xp.asarray([1.1, 2.2, 3.3]) + foo = B(x) + bar = foo.k(x) + xp_assert_equal(bar.x, 2.0 * foo.x) + def dask_raises(x: Array) -> Array: def _raises(x: Array) -> Array: From 9107fe2af281c25f549dc80cf4ce430527fdbfc6 Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Thu, 8 Jan 2026 22:55:14 -0500 Subject: [PATCH 12/20] TST: Add test that static methods actually get wrapped --- tests/test_testing.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/test_testing.py b/tests/test_testing.py index 0b05ad9b..e5df990d 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -353,10 +353,19 @@ def f(self, y: Array) -> Array: def k(y: Array) -> "B": return B(2.0 * y) + @staticmethod + def j(y: Array) -> "B": + xp = array_namespace(y) + y = xp.asarray(y) + if bool(xp.any(y)): + return B(y) + return B(y + 1.0) + lazy_xp_function((B, "g")) lazy_xp_function((B, "w")) lazy_xp_function((B, "k")) +lazy_xp_function((B, "j")) class TestLazyXpFunctionClasses: @@ -382,12 +391,25 @@ def test_lazy_xp_function_classes(self, xp: ModuleType, library: Backend): assert foo.w(y) - def test_static_methods(self, xp: ModuleType): + def test_static_methods_preserved(self, xp: ModuleType): + # Tests that static methods stay static methods when + # lazy_xp_function is applied. x = xp.asarray([1.1, 2.2, 3.3]) foo = B(x) bar = foo.k(x) xp_assert_equal(bar.x, 2.0 * foo.x) + def test_static_methods_wrapped(self, xp: ModuleType, library: Backend): + x = xp.asarray([1.1, 2.2, 3.3]) + foo = B(x) + + if library.like(Backend.JAX): + with pytest.raises( + TypeError, match="Attempted boolean conversion of traced array" + ): + assert isinstance(foo.j(x), B) + assert isinstance(foo.j(x), B) + def dask_raises(x: Array) -> Array: def _raises(x: Array) -> Array: From 21680096dcc13c7dafa6bcac1d2dee8e5f949401 Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:05:37 -0500 Subject: [PATCH 13/20] MAINT: fix monkeypatching of staticmethods and classmethods --- src/array_api_extra/testing.py | 47 +++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/src/array_api_extra/testing.py b/src/array_api_extra/testing.py index 9be60609..75bf1400 100644 --- a/src/array_api_extra/testing.py +++ b/src/array_api_extra/testing.py @@ -11,6 +11,7 @@ import warnings from collections.abc import Callable, Generator, Iterator, Sequence from functools import update_wrapper, wraps +from inspect import getattr_static from types import FunctionType, ModuleType from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast @@ -229,10 +230,12 @@ def test_myfunc(xp): # the method was inherited from a parent class. # Note: can't just accept an unbound method `cls.method_name` because in # case of inheritance it would be impossible to attribute it to the child class. + # This also makes it so tagged methods will appear in their class's ``__dict__`` + # and thus findable by ``iter_tagged_modules`` below. cls, method_name = func # The method might be a staticmethod or classmethod so we need to do a dance # to ensure that this is preserved. - raw_attr = cls.__dict__.get(method_name) + raw_attr = getattr_static(cls, method_name) method = getattr(cls, method_name) cloned_method = _clone_function(method) @@ -322,8 +325,7 @@ def xp(request): # set here to cut down on potential redundancy. classes: set[type] = set() for target in search_targets: - for obj_name in dir(target): - obj = getattr(target, obj_name) + for obj in target.__dict__.values(): if isinstance(obj, type): classes.add(obj) search_targets.extend(classes) @@ -336,7 +338,10 @@ def temp_setattr(target: ModuleType | type, name: str, func: object) -> None: parameters of a test so that pytest-run-parallel can run on the remainder. """ assert hasattr(target, name) - to_revert.append((target, name, getattr(target, name))) + # Need getattr_static because the attr could be a staticmethod or other + # descriptor and we don't want that to be stripped away. + original = getattr_static(target, name) + to_revert.append((target, name, original)) setattr(target, name, func) if monkeypatch is not None: @@ -353,10 +358,19 @@ def temp_setattr(target: ModuleType | type, name: str, func: object) -> None: temp_setattr = monkeypatch.setattr # type: ignore[assignment] # pyright: ignore[reportAssignmentType] def iter_tagged() -> Iterator[ - tuple[ModuleType | type, str, Callable[..., Any], dict[str, Any]] + tuple[ModuleType | type, str, Any, Callable[..., Any], dict[str, Any]] ]: for target in search_targets: - for name, func in target.__dict__.items(): + for name, attr in target.__dict__.items(): + # attr might be a staticmethod or classmethod. If so we need + # to peel it back and wrap the underlying function and later + # make sure not to accidentally replace it with a regular + # method. + func: Any = ( + attr.__func__ + if isinstance(attr, (staticmethod, classmethod)) + else attr + ) tags: dict[str, Any] | None = None with contextlib.suppress(AttributeError): tags = func._lazy_xp_function # pylint: disable=protected-access @@ -364,22 +378,37 @@ def iter_tagged() -> Iterator[ with contextlib.suppress(KeyError, TypeError): tags = _ufuncs_tags[func] if tags is not None: - yield target, name, func, tags + # put attr, and func in the outputs so we can later tell + # if this was a staticmethod or classmethod. + yield target, name, attr, func, tags + wrapped: Any if is_dask_namespace(xp): - for target, name, func, tags in iter_tagged(): + for target, name, attr, func, tags in iter_tagged(): n = tags["allow_dask_compute"] if n is True: n = 1_000_000 elif n is False: n = 0 wrapped = _dask_wrap(func, n) + # If we're dealing with a staticmethod or classmethod, make + # sure things stay that way. + if isinstance(attr, staticmethod): + wrapped = staticmethod(wrapped) + elif isinstance(attr, classmethod): + wrapped = classmethod(wrapped) temp_setattr(target, name, wrapped) elif is_jax_namespace(xp): - for target, name, func, tags in iter_tagged(): + for target, name, attr, func, tags in iter_tagged(): if tags["jax_jit"]: wrapped = jax_autojit(func) + # If we're dealing with a staticmethod or classmethod, make + # sure things stay that way. + if isinstance(attr, staticmethod): + wrapped = staticmethod(wrapped) + elif isinstance(attr, classmethod): + wrapped = classmethod(wrapped) temp_setattr(target, name, wrapped) # We can't just decorate patch_lazy_xp_functions with From fd7d86f8d08a84872be07efee1c203db98525166 Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:06:23 -0500 Subject: [PATCH 14/20] TST: Make sure test_static_tests_wrapped doesn't fail --- tests/test_testing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_testing.py b/tests/test_testing.py index e5df990d..7bd0ccb8 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -399,6 +399,7 @@ def test_static_methods_preserved(self, xp: ModuleType): bar = foo.k(x) xp_assert_equal(bar.x, 2.0 * foo.x) + @pytest.mark.skip_xp_backend(Backend.DASK, reason="calls dask.compute()") def test_static_methods_wrapped(self, xp: ModuleType, library: Backend): x = xp.asarray([1.1, 2.2, 3.3]) foo = B(x) @@ -408,7 +409,8 @@ def test_static_methods_wrapped(self, xp: ModuleType, library: Backend): TypeError, match="Attempted boolean conversion of traced array" ): assert isinstance(foo.j(x), B) - assert isinstance(foo.j(x), B) + else: + assert isinstance(foo.j(x), B) def dask_raises(x: Array) -> Array: From 6bdd40bd88973ca9c586eeb8a428223d8bd23ff5 Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:40:37 -0500 Subject: [PATCH 15/20] MAINT: re-enable monkeypatch circumvention --- src/array_api_extra/testing.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/array_api_extra/testing.py b/src/array_api_extra/testing.py index 75bf1400..23b522b1 100644 --- a/src/array_api_extra/testing.py +++ b/src/array_api_extra/testing.py @@ -238,6 +238,10 @@ def test_myfunc(xp): raw_attr = getattr_static(cls, method_name) method = getattr(cls, method_name) cloned_method = _clone_function(method) + # Update the ``__qualname__`` because this will be used later to check + # whether something is a method defined in the class of interest, or just + # a reference to a function that's stored in a class. + cloned_method.__qualname__ = f"{cls.__name__}.{method_name}" method_to_set: Any if isinstance(raw_attr, staticmethod): @@ -378,6 +382,19 @@ def iter_tagged() -> Iterator[ with contextlib.suppress(KeyError, TypeError): tags = _ufuncs_tags[func] if tags is not None: + if isinstance(target, type): + # There's a common pattern to wrap functions in namespace + # classes to bypass lazy_xp_function like this: + # + # class naked: + # myfunc = mymodule.myfunc + # + # To ensure this still works when checking for tags in + # attributes of classes, use ``__qualname__`` to check whether + # or not ``func`` was originally defined within ``target``. + qn = getattr(func, "__qualname__", "") + if not qn.startswith(f"{target.__name__}."): + continue # put attr, and func in the outputs so we can later tell # if this was a staticmethod or classmethod. yield target, name, attr, func, tags From 9cf78955016bc94800de550bed04c65e95b42d03 Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Fri, 9 Jan 2026 01:02:46 -0500 Subject: [PATCH 16/20] TST: add test that monkeypatching can be circumvented --- tests/test_testing.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test_testing.py b/tests/test_testing.py index 7bd0ccb8..de35e1ee 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,6 +1,6 @@ from collections.abc import Callable, Iterator from types import ModuleType -from typing import Any, cast +from typing import Any, cast, final import numpy as np import pytest @@ -362,6 +362,12 @@ def j(y: Array) -> "B": return B(y + 1.0) +@final +class eager: + # this needs to be a staticmethod to appease the type checker + non_materializable5 = staticmethod(non_materializable5) + + lazy_xp_function((B, "g")) lazy_xp_function((B, "w")) lazy_xp_function((B, "k")) @@ -412,6 +418,11 @@ def test_static_methods_wrapped(self, xp: ModuleType, library: Backend): else: assert isinstance(foo.j(x), B) + def test_circumvention(self, xp: ModuleType): + x = xp.asarray([1.0, 2.0]) + y = eager.non_materializable5(x) + xp_assert_equal(y, x) + def dask_raises(x: Array) -> Array: def _raises(x: Array) -> Array: From 40dfb29e171780a347a398210330eeb68922e9a3 Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Fri, 9 Jan 2026 01:41:16 -0500 Subject: [PATCH 17/20] MAINT: simplify check that target owns method --- src/array_api_extra/testing.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/array_api_extra/testing.py b/src/array_api_extra/testing.py index 23b522b1..6e4b5569 100644 --- a/src/array_api_extra/testing.py +++ b/src/array_api_extra/testing.py @@ -219,7 +219,7 @@ def test_myfunc(xp): DeprecationWarning, stacklevel=2, ) - tags = { + tags: dict[str, bool | int | type] = { "allow_dask_compute": allow_dask_compute, "jax_jit": jax_jit, } @@ -238,10 +238,6 @@ def test_myfunc(xp): raw_attr = getattr_static(cls, method_name) method = getattr(cls, method_name) cloned_method = _clone_function(method) - # Update the ``__qualname__`` because this will be used later to check - # whether something is a method defined in the class of interest, or just - # a reference to a function that's stored in a class. - cloned_method.__qualname__ = f"{cls.__name__}.{method_name}" method_to_set: Any if isinstance(raw_attr, staticmethod): @@ -253,6 +249,8 @@ def test_myfunc(xp): setattr(cls, method_name, method_to_set) f = getattr(cls, method_name) + # Annotate that cls owns this method so we can check that later. + tags["owner"] = cls else: f = func @@ -382,7 +380,7 @@ def iter_tagged() -> Iterator[ with contextlib.suppress(KeyError, TypeError): tags = _ufuncs_tags[func] if tags is not None: - if isinstance(target, type): + if isinstance(target, type) and tags.get("owner") is not target: # There's a common pattern to wrap functions in namespace # classes to bypass lazy_xp_function like this: # @@ -390,11 +388,9 @@ def iter_tagged() -> Iterator[ # myfunc = mymodule.myfunc # # To ensure this still works when checking for tags in - # attributes of classes, use ``__qualname__`` to check whether - # or not ``func`` was originally defined within ``target``. - qn = getattr(func, "__qualname__", "") - if not qn.startswith(f"{target.__name__}."): - continue + # attributes of classes, ensure that target is the actual + # owning class where func was defined. + continue # put attr, and func in the outputs so we can later tell # if this was a staticmethod or classmethod. yield target, name, attr, func, tags From 0c12809054512cc21341475fefd23d9b6cf2a9f3 Mon Sep 17 00:00:00 2001 From: Lucas Colley Date: Fri, 9 Jan 2026 20:58:39 +0000 Subject: [PATCH 18/20] add scipy to test deps for coverage --- pixi.lock | 248 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 2 files changed, 248 insertions(+), 1 deletion(-) diff --git a/pixi.lock b/pixi.lock index 3bdd8128..e9681410 100644 --- a/pixi.lock +++ b/pixi.lock @@ -756,6 +756,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/roman-numerals-4.1.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/roman-numerals-py-4.1.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/win-64/ruff-0.14.10-h37e10c4_0.conda + - conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py313he51e9a2_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/win-64/sleef-3.9.0-h67fd636_0.conda - conda: https://prefix.dev/conda-forge/noarch/snowballstemmer-3.0.1-pyhd8ed1ab_0.conda @@ -1531,6 +1532,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/roman-numerals-4.1.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/noarch/roman-numerals-py-4.1.0-pyhd8ed1ab_0.conda - conda: https://prefix.dev/conda-forge/win-64/ruff-0.14.10-h37e10c4_0.conda + - conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py313he51e9a2_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/win-64/sleef-3.9.0-h67fd636_0.conda - conda: https://prefix.dev/conda-forge/noarch/snowballstemmer-3.0.1-pyhd8ed1ab_0.conda @@ -2478,6 +2480,7 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/python-3.13.11-hc97d973_100_cp313.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://prefix.dev/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/scipy-1.16.3-py313h4b8bb8b_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda @@ -2525,6 +2528,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/python-3.13.11-h17c18a5_100_cp313.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://prefix.dev/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/scipy-1.16.3-py313hefbb9bc_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-hf689a15_3.conda @@ -2571,6 +2575,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-arm64/python-3.13.11-hfc2f54d_100_cp313.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://prefix.dev/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/scipy-1.16.3-py313h29d7d31_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda @@ -2616,6 +2621,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/pytest-cov-7.0.0-pyhcf101f3_1.conda - conda: https://prefix.dev/conda-forge/win-64/python-3.13.11-h09917c8_100_cp313.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py313he51e9a2_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/win-64/tbb-2022.3.0-hd094cb3_1.conda @@ -2992,6 +2998,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://prefix.dev/conda-forge/win-64/pytorch-2.9.1-cpu_mkl_py313_h4c75245_101.conda - conda: https://prefix.dev/conda-forge/win-64/pyyaml-6.0.3-py313hd650c13_0.conda + - conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py313he51e9a2_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/win-64/sleef-3.9.0-h67fd636_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda @@ -3374,6 +3381,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://prefix.dev/conda-forge/win-64/pytorch-2.9.1-cpu_mkl_py311_h668fc7c_101.conda - conda: https://prefix.dev/conda-forge/win-64/pyyaml-6.0.3-py311h3f79411_0.conda + - conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py311h9c22a71_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/win-64/sleef-3.9.0-h67fd636_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda @@ -3826,6 +3834,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://prefix.dev/conda-forge/win-64/pytorch-2.9.1-cuda128_mkl_py313_h7f80487_300.conda - conda: https://prefix.dev/conda-forge/win-64/pyyaml-6.0.3-py313hd650c13_0.conda + - conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py313he51e9a2_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/win-64/sleef-3.9.0-h67fd636_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda @@ -4276,6 +4285,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://prefix.dev/conda-forge/win-64/pytorch-2.9.1-cuda128_mkl_py311_h7c65ee9_300.conda - conda: https://prefix.dev/conda-forge/win-64/pyyaml-6.0.3-py311h3f79411_0.conda + - conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py311h9c22a71_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/win-64/sleef-3.9.0-h67fd636_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda @@ -4357,6 +4367,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313t.conda - conda: https://prefix.dev/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda - conda: https://prefix.dev/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/scipy-1.16.3-py313h0dc34c3_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda @@ -4418,6 +4429,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313t.conda - conda: https://prefix.dev/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda - conda: https://prefix.dev/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/scipy-1.16.3-py313h6f07835_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-hf689a15_3.conda @@ -4477,6 +4489,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313t.conda - conda: https://prefix.dev/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda - conda: https://prefix.dev/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/scipy-1.16.3-py313h0628c33_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda @@ -4535,6 +4548,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/python-freethreading-3.13.11-h92d6c8b_0.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313t.conda - conda: https://prefix.dev/conda-forge/noarch/pyyaml-6.0.3-pyh7db6752_0.conda + - conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py313hff732fb_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/win-64/tbb-2022.3.0-hd094cb3_1.conda @@ -4604,6 +4618,7 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/python-3.11.14-hd63d673_2_cpython.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://prefix.dev/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/scipy-1.15.2-py311h8f841c2_0.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda @@ -4651,6 +4666,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/python-3.11.14-h74c2667_2_cpython.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://prefix.dev/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/scipy-1.15.2-py311h0c91ca8_0.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-hf689a15_3.conda @@ -4696,6 +4712,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-arm64/python-3.11.14-h18782d2_2_cpython.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://prefix.dev/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/scipy-1.15.2-py311h0675101_0.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda @@ -4740,6 +4757,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/pytest-cov-7.0.0-pyhcf101f3_1.conda - conda: https://prefix.dev/conda-forge/win-64/python-3.11.14-h0159041_2_cpython.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.11-8_cp311.conda + - conda: https://prefix.dev/conda-forge/win-64/scipy-1.15.2-py311h99d06ae_0.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/win-64/tbb-2022.3.0-hd094cb3_1.conda @@ -4806,6 +4824,7 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/python-3.11.14-hd63d673_2_cpython.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://prefix.dev/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/scipy-1.16.3-py311hbe70eeb_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda @@ -4853,6 +4872,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/python-3.11.14-h74c2667_2_cpython.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://prefix.dev/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/scipy-1.16.3-py311hd77d3c2_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-hf689a15_3.conda @@ -4898,6 +4918,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-arm64/python-3.11.14-h18782d2_2_cpython.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.11-8_cp311.conda - conda: https://prefix.dev/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/scipy-1.16.3-py311ha71c161_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda @@ -4942,6 +4963,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/pytest-cov-7.0.0-pyhcf101f3_1.conda - conda: https://prefix.dev/conda-forge/win-64/python-3.11.14-h0159041_2_cpython.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.11-8_cp311.conda + - conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py311h9c22a71_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/win-64/tbb-2022.3.0-hd094cb3_1.conda @@ -5004,6 +5026,7 @@ environments: - conda: https://prefix.dev/conda-forge/linux-64/python-3.13.11-hc97d973_100_cp313.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://prefix.dev/conda-forge/linux-64/readline-8.3-h853b02a_0.conda + - conda: https://prefix.dev/conda-forge/linux-64/scipy-1.16.3-py313h4b8bb8b_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/linux-64/tk-8.6.13-noxft_ha0e22de_103.conda @@ -5051,6 +5074,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-64/python-3.13.11-h17c18a5_100_cp313.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://prefix.dev/conda-forge/osx-64/readline-8.3-h68b038d_0.conda + - conda: https://prefix.dev/conda-forge/osx-64/scipy-1.16.3-py313hefbb9bc_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/osx-64/tk-8.6.13-hf689a15_3.conda @@ -5097,6 +5121,7 @@ environments: - conda: https://prefix.dev/conda-forge/osx-arm64/python-3.13.11-hfc2f54d_100_cp313.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313.conda - conda: https://prefix.dev/conda-forge/osx-arm64/readline-8.3-h46df422_0.conda + - conda: https://prefix.dev/conda-forge/osx-arm64/scipy-1.16.3-py313h29d7d31_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/osx-arm64/tk-8.6.13-h892fb3f_3.conda @@ -5142,6 +5167,7 @@ environments: - conda: https://prefix.dev/conda-forge/noarch/pytest-cov-7.0.0-pyhcf101f3_1.conda - conda: https://prefix.dev/conda-forge/win-64/python-3.13.11-h09917c8_100_cp313.conda - conda: https://prefix.dev/conda-forge/noarch/python_abi-3.13-8_cp313.conda + - conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py313he51e9a2_2.conda - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda - conda: https://prefix.dev/conda-forge/noarch/sortedcontainers-2.4.0-pyhd8ed1ab_1.conda - conda: https://prefix.dev/conda-forge/win-64/tbb-2022.3.0-hd094cb3_1.conda @@ -5296,7 +5322,7 @@ packages: - pypi: ./ name: array-api-extra version: 0.10.0.dev0 - sha256: dbc3abd6bb4ce3b508394e29344b9fd75c70414529dcaede915511a9cb0607c5 + sha256: b7af66e75c59d1a6eaa343bc80d5227d257885c8b3aaf3e863062bb18c031972 requires_dist: - array-api-compat>=1.12.0,<2 requires_python: '>=3.11' @@ -12941,6 +12967,29 @@ packages: - pkg:pypi/ruff?source=compressed-mapping size: 11908812 timestamp: 1766095035171 +- conda: https://prefix.dev/conda-forge/linux-64/scipy-1.15.2-py311h8f841c2_0.conda + sha256: 6d0902775e3ff96dd1d36ac627e03fe6c0b3d2159bb71e115dd16a1f31693b25 + md5: 5ec0a1732a05376241e1e4c6d50e0e91 + depends: + - __glibc >=2.17,<3.0.a0 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - libgcc >=13 + - libgfortran + - libgfortran5 >=13.3.0 + - liblapack >=3.9.0,<4.0a0 + - libstdcxx >=13 + - numpy <2.5 + - numpy >=1.19,<3 + - numpy >=1.23.5 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/scipy?source=hash-mapping + size: 17193126 + timestamp: 1739791897768 - conda: https://prefix.dev/conda-forge/linux-64/scipy-1.16.3-py311hbe70eeb_2.conda sha256: a13084f1556674ea74de2ecbe50333d938dab8ef27f536408592ba312363c400 md5: 1f9587850322d7d77ea14d4fee3d16d8 @@ -12964,6 +13013,29 @@ packages: - pkg:pypi/scipy?source=hash-mapping size: 17026343 timestamp: 1766108701646 +- conda: https://prefix.dev/conda-forge/linux-64/scipy-1.16.3-py313h0dc34c3_2.conda + sha256: 9496a2040becb769af13d1f1709e23fae0c4c8d143f00ca6fdf2856e01007d8b + md5: 44c5b351e76d345ea2b86876dc517b59 + depends: + - __glibc >=2.17,<3.0.a0 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - libgcc >=14 + - libgfortran + - libgfortran5 >=14.3.0 + - liblapack >=3.9.0,<4.0a0 + - libstdcxx >=14 + - numpy <2.6 + - numpy >=1.23,<3 + - numpy >=1.25.2 + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313t + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/scipy?source=hash-mapping + size: 16836737 + timestamp: 1766108883881 - conda: https://prefix.dev/conda-forge/linux-64/scipy-1.16.3-py313h4b8bb8b_2.conda sha256: a5ddc728be0589e770f59e45e3c6c670c56d96a801ddf76a304cc0af7bcef5c4 md5: 0be9bd58abfb3e8f97260bd0176d5331 @@ -12987,6 +13059,28 @@ packages: - pkg:pypi/scipy?source=compressed-mapping size: 16785487 timestamp: 1766108773270 +- conda: https://prefix.dev/conda-forge/osx-64/scipy-1.15.2-py311h0c91ca8_0.conda + sha256: 796252d7772df42edd29a45ae70eb18843a7e476d42c96c273cd6e677ec148c8 + md5: 58c17d411ed0cd1220ed3e824a3efc82 + depends: + - __osx >=10.13 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - libcxx >=18 + - libgfortran >=5 + - libgfortran5 >=13.2.0 + - liblapack >=3.9.0,<4.0a0 + - numpy <2.5 + - numpy >=1.19,<3 + - numpy >=1.23.5 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/scipy?source=hash-mapping + size: 15759628 + timestamp: 1739792317052 - conda: https://prefix.dev/conda-forge/osx-64/scipy-1.16.3-py311hd77d3c2_2.conda sha256: dee00542bb0aed60d6160883365c19317fe63b34ea0f3237a8725fee297341f6 md5: 5153a584333b37a433b5e1244606ef3d @@ -13009,6 +13103,28 @@ packages: - pkg:pypi/scipy?source=hash-mapping size: 15290138 timestamp: 1766108637965 +- conda: https://prefix.dev/conda-forge/osx-64/scipy-1.16.3-py313h6f07835_2.conda + sha256: d5f3bcbdacfdd86569910717f8b98e43c1361105c894b2a75a431116292481d3 + md5: d7320342f63c804324931942facb178a + depends: + - __osx >=10.13 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - libcxx >=19 + - libgfortran + - libgfortran5 >=14.3.0 + - liblapack >=3.9.0,<4.0a0 + - numpy <2.6 + - numpy >=1.23,<3 + - numpy >=1.25.2 + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313t + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/scipy?source=hash-mapping + size: 15320844 + timestamp: 1766108736410 - conda: https://prefix.dev/conda-forge/osx-64/scipy-1.16.3-py313hefbb9bc_2.conda sha256: bb73a8bf8598537e25d6e81c05f607b4798597824c4fbfa876aeee3d2447d07e md5: 11104881493e37e12558eeb97193ad08 @@ -13031,6 +13147,29 @@ packages: - pkg:pypi/scipy?source=hash-mapping size: 15284100 timestamp: 1766108740047 +- conda: https://prefix.dev/conda-forge/osx-arm64/scipy-1.15.2-py311h0675101_0.conda + sha256: bc3e873e85c55deaaad446c410d9001d12a133c1b48fa2cb0050b4f46f926aa3 + md5: df904770f3fdb6c0265a09cdc22acf54 + depends: + - __osx >=11.0 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - libcxx >=18 + - libgfortran >=5 + - libgfortran5 >=13.2.0 + - liblapack >=3.9.0,<4.0a0 + - numpy <2.5 + - numpy >=1.19,<3 + - numpy >=1.23.5 + - python >=3.11,<3.12.0a0 + - python >=3.11,<3.12.0a0 *_cpython + - python_abi 3.11.* *_cp311 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/scipy?source=hash-mapping + size: 14569129 + timestamp: 1739792318601 - conda: https://prefix.dev/conda-forge/osx-arm64/scipy-1.16.3-py311ha71c161_2.conda sha256: 3a3bd525f126e7414c07bcf915060a36bfc3dac4cf31335585be942128d4337f md5: 1283a5d5d1c10e981638d2bd02c4eac6 @@ -13054,6 +13193,29 @@ packages: - pkg:pypi/scipy?source=hash-mapping size: 13815921 timestamp: 1766108875815 +- conda: https://prefix.dev/conda-forge/osx-arm64/scipy-1.16.3-py313h0628c33_2.conda + sha256: 042a5bbc6aad52cec0ae0b5a3372a6413aac563502534ec380ec6e11798ea385 + md5: 656cdfff488a825e6da6b214fa30ff4a + depends: + - __osx >=11.0 + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - libcxx >=19 + - libgfortran + - libgfortran5 >=14.3.0 + - liblapack >=3.9.0,<4.0a0 + - numpy <2.6 + - numpy >=1.23,<3 + - numpy >=1.25.2 + - python >=3.13,<3.14.0a0 + - python >=3.13,<3.14.0a0 *_cp313t + - python_abi 3.13.* *_cp313t + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/scipy?source=hash-mapping + size: 14133337 + timestamp: 1766109671329 - conda: https://prefix.dev/conda-forge/osx-arm64/scipy-1.16.3-py313h29d7d31_2.conda sha256: ee3cbddb7d598c78b592fafbfa3eaf8c89df353bbed56a1a9f32e9f7daa49bb4 md5: a3324bd937a39cbbf1cbe0940160e19e @@ -13077,6 +13239,90 @@ packages: - pkg:pypi/scipy?source=hash-mapping size: 13929516 timestamp: 1766109298759 +- conda: https://prefix.dev/conda-forge/win-64/scipy-1.15.2-py311h99d06ae_0.conda + sha256: 62ae1a1e02c919513213351474d1c72480fb70388a345fa81f1c95fa822d98bf + md5: c7ec15b5ea6a27bb71af2ea5f7c97cbb + depends: + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - liblapack >=3.9.0,<4.0a0 + - numpy <2.5 + - numpy >=1.19,<3 + - numpy >=1.23.5 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 + - ucrt >=10.0.20348.0 + - vc >=14.2,<15 + - vc14_runtime >=14.29.30139 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/scipy?source=hash-mapping + size: 15487645 + timestamp: 1739793313482 +- conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py311h9c22a71_2.conda + sha256: 49129601dc89d49742d342ace70f4ec0127a5eb24a50d66f95f91db01b3a23d5 + md5: 4b663de0f0c8ac0fbb4a4d9ee8536b0f + depends: + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - liblapack >=3.9.0,<4.0a0 + - numpy <2.6 + - numpy >=1.23,<3 + - numpy >=1.25.2 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/scipy?source=hash-mapping + size: 15129579 + timestamp: 1766109708812 +- conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py313he51e9a2_2.conda + sha256: 997a2202126425438a16de7ef1e5e924bd66feb43bda5b71326e281c7331489d + md5: a49556572438d5477f1eca06bb6d0770 + depends: + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - liblapack >=3.9.0,<4.0a0 + - numpy <2.6 + - numpy >=1.23,<3 + - numpy >=1.25.2 + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313 + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/scipy?source=hash-mapping + size: 15066293 + timestamp: 1766109539389 +- conda: https://prefix.dev/conda-forge/win-64/scipy-1.16.3-py313hff732fb_2.conda + sha256: 4fcd05087ff6081304dc64472b94e388ca945e88174c95429757b0cdc0b7b2b9 + md5: e6f67e36e4103657c984321eaff84064 + depends: + - libblas >=3.9.0,<4.0a0 + - libcblas >=3.9.0,<4.0a0 + - liblapack >=3.9.0,<4.0a0 + - numpy <2.6 + - numpy >=1.23,<3 + - numpy >=1.25.2 + - python >=3.13,<3.14.0a0 + - python_abi 3.13.* *_cp313t + - ucrt >=10.0.20348.0 + - vc >=14.3,<15 + - vc14_runtime >=14.44.35208 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/scipy?source=hash-mapping + size: 15263592 + timestamp: 1766109705205 - conda: https://prefix.dev/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda sha256: 972560fcf9657058e3e1f97186cc94389144b46dbdf58c807ce62e83f977e863 md5: 4de79c071274a53dcaf2a8c749d1499e diff --git a/pyproject.toml b/pyproject.toml index d6f02182..3d619f3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,7 @@ pytest-cov = ">=7.0.0" hypothesis = ">=6.148.7" array-api-strict = ">=2.4.1,<2.5" numpy = ">=1.22.0" +scipy = ">=1.15.2,<2" [tool.pixi.feature.tests.tasks] tests = { cmd = "pytest -v", description = "Run tests" } From a739cfe55d3e2e7f5fddd0c6e8c5b8000ade5af1 Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:11:36 -0500 Subject: [PATCH 19/20] TST: test that classmethods get wrapped --- tests/test_testing.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/tests/test_testing.py b/tests/test_testing.py index de35e1ee..b70de734 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -334,7 +334,7 @@ def f(self, y: Array) -> Array: def g(self, y: Array, z: Array) -> Array: return self.f(y) + self.f(z) - def w(self, y: Array) -> bool: + def h(self, y: Array) -> bool: return bool(self._xp.any(y)) @@ -361,6 +361,14 @@ def j(y: Array) -> "B": return B(y) return B(y + 1.0) + @classmethod + def w(cls, y: Array) -> "B": + xp = array_namespace(y) + y = xp.asarray(y) + if bool(xp.any(y)): + return B(y) + return B(y + 1.0) + @final class eager: @@ -369,9 +377,10 @@ class eager: lazy_xp_function((B, "g")) -lazy_xp_function((B, "w")) +lazy_xp_function((B, "h")) lazy_xp_function((B, "k")) lazy_xp_function((B, "j")) +lazy_xp_function((B, "w")) class TestLazyXpFunctionClasses: @@ -393,9 +402,9 @@ def test_lazy_xp_function_classes(self, xp: ModuleType, library: Backend): with pytest.raises( TypeError, match="Attempted boolean conversion of traced array" ): - assert bar.w(y) + assert bar.h(y) - assert foo.w(y) + assert foo.h(y) def test_static_methods_preserved(self, xp: ModuleType): # Tests that static methods stay static methods when @@ -418,6 +427,17 @@ def test_static_methods_wrapped(self, xp: ModuleType, library: Backend): else: assert isinstance(foo.j(x), B) + @pytest.mark.skip_xp_backend(Backend.DASK, reason="calls dask.compute()") + def test_class_methods_wrapped(self, xp: ModuleType, library: Backend): + x = xp.asarray([1.1, 2.2, 3.3]) + if library.like(Backend.JAX): + with pytest.raises( + TypeError, match="Attempted boolean conversion of traced array" + ): + assert isinstance(B.w(x), B) + else: + assert isinstance(B.w(x), B) + def test_circumvention(self, xp: ModuleType): x = xp.asarray([1.0, 2.0]) y = eager.non_materializable5(x) From 08b7e299fbe7d0d1abf505f56db418e315e0448a Mon Sep 17 00:00:00 2001 From: steppi <1953382+steppi@users.noreply.github.com> Date: Fri, 9 Jan 2026 17:11:56 -0500 Subject: [PATCH 20/20] MAINT: make sure classmethods get wrapped --- src/array_api_extra/testing.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/array_api_extra/testing.py b/src/array_api_extra/testing.py index 6e4b5569..9f2b0d38 100644 --- a/src/array_api_extra/testing.py +++ b/src/array_api_extra/testing.py @@ -237,6 +237,8 @@ def test_myfunc(xp): # to ensure that this is preserved. raw_attr = getattr_static(cls, method_name) method = getattr(cls, method_name) + if isinstance(raw_attr, classmethod): + method = method.__func__ cloned_method = _clone_function(method) method_to_set: Any @@ -249,6 +251,8 @@ def test_myfunc(xp): setattr(cls, method_name, method_to_set) f = getattr(cls, method_name) + if isinstance(raw_attr, classmethod): + f = f.__func__ # Annotate that cls owns this method so we can check that later. tags["owner"] = cls else: