-
-
Notifications
You must be signed in to change notification settings - Fork 5.5k
TST: array types: enforce namespace in tests #22132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
markers = | ||
slow: Tests that are very slow | ||
xslow: mark test as extremely slow (not run unless explicitly requested) | ||
xfail_on_32bit: mark test as failing on 32-bit platforms | ||
array_api_backends: test iterates on all array API backends | ||
array_api_compatible: test is compatible with array API | ||
skip_xp_backends(backends, reason=None, np_only=False, cpu_only=False, exceptions=None): mark the desired skip configuration for the `skip_xp_backends` fixture | ||
xfail_xp_backends(backends, reason=None, np_only=False, cpu_only=False, exceptions=None): mark the desired xfail configuration for the `xfail_xp_backends` fixture |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pytest would complain with the previous method of adding custom markers to conftest.py
, because I added uses of pytest.mark.array_api_backends
in conftest.py
before conftest.py::pytest_configure()
could be executed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This sounds like a circular import of sorts, and those typically signal that something is wrong with the design.
I actually don't know enough about pytest: what is the recommended way, conftest.py or pytest.ini?
If the former, the next question is what is so special about array_api_backends
that it breaks the recommended pattern; if the latter, then moving markers to pytest.ini
is a clean-up?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This sounds like a circular import of sorts, and those typically signal that something is wrong with the design.
I agree, I doubt that pytest markers were ever thought by pytest designers to be added at runtime. You can, in some cases, but I don't think you should.
I actually don't know enough about pytest: what is the recommended way, conftest.py or pytest.ini?
This is the very first time, across all projects I've worked on, that I see this much pytest configuration defined at runtime in conftest.py - and unnecessarily so. I believe that the current industry standard is to put as much as possible in pyproject.toml (or specific config files missing pyproject.toml).
This is akin to the shift from setup.py to setup.cfg to pyproject.toml we have witnessed in the last few years.
__tracebackhide__ = True # Hide traceback for py.test | ||
actual = actual if isinstance(actual, tuple) else (actual,) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could not find a use case for this
@@ -29,12 +29,6 @@ | |||
|
|||
|
|||
def pytest_configure(config): | |||
config.addinivalue_line("markers", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Read comment in pytest.ini
-t scipy.integrate.tests.test_quadrature | ||
-t scipy.signal.tests.test_signaltools | ||
# Only run tests that use the `xp` fixture | ||
XP_TESTS: -m array_api_backends |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are there any tests that don't use the xp
fixture that would be important to run with SCIPY_ARRAY_API=1
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
probably fine to just run those with xp
for now. We can always expand coverage in the future.
@@ -963,7 +969,7 @@ def gen_oa_shapes_eq(sizes): | |||
class TestOAConvolve: | |||
@pytest.mark.slow() | |||
@pytest.mark.parametrize('shape_a_0, shape_b_0', | |||
gen_oa_shapes_eq(list(range(100)) + | |||
gen_oa_shapes_eq(list(range(1, 100, 1)) + |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
size 0 is covered by test_empty (and fails on pytorch)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Stats and optimize changes look fine. Thanks!
@@ -179,7 +179,7 @@ def f(*args, **kwargs): | |||
ref_attr = [xp.asarray(getattr(ref, attr)) for ref in refs] | |||
res_attr = getattr(res, attr) | |||
xp_assert_close(xp_ravel(res_attr, xp=xp), xp.stack(ref_attr)) | |||
xp_assert_equal(res_attr.shape, shape) | |||
assert res_attr.shape == shape |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you know why this one was passing before? When converting shape tests like this, I know we typically use plain assert
, and I thought that was because xp_assert_equal
would fail due to neither of the results being arrays.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It was passing because the scipy variant of array_namespace()
applied to a tuple returns numpy:
scipy/scipy/_lib/_array_api.py
Lines 87 to 89 in e1a841b
elif not is_array_api_obj(array): | |
try: | |
array = np.asanyarray(array) |
So _assert_matching_namespace
was observing that numpy is the namespace for both for the actual and the desired array.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW, I played a bit with removing this np.asanyarray
because in array-api-compat==1.10
, array_namespace
accepts and ignores python scalars and None
s. This turned out to be an unpleasant exercise, and this asanyarray
is being relied on in several scenarios.
scipy/stats/tests/test_variation.py
Outdated
@@ -32,9 +34,13 @@ def test_sign(self, sgn, xp): | |||
expected = xp.asarray(sgn*math.sqrt(2)/3) | |||
xp_assert_close(v, expected, rtol=1e-10) | |||
|
|||
@xfail_xp_backends(np_only=True, reason="IndexError: tuple index out of range") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not skip other backends? Also, I think the intent here (for better or for worse) was to check for a Python scalar input to variation
, which produces a NumPy scalar output.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you suggesting there is value in explicitly testing an input that is an actual pure-python scalar instead of a scalar numpy array?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't investigate the code, but the fact that array-api-strict fails too strongly hints at a bug in the scipy.stats code itself
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are you suggesting there is value in explicitly testing an input that is an actual pure-python scalar instead of a scalar numpy array?
I don't think there's value in the behavior to begin with. This is a reducing function. If the input is 0d and you reduce along axis
0 (which doesn't exist), what should happen?
I commented because I was trying to understand the motivation for the change in what was being tested. We try to preserve existing behavior with Python floats, but I don't think unusual behavior needs to be extended to Array API 0d arrays. So maybe yes, it seems just a tiny bit better to leave the test with Python floats and NumPy only.
I didn't investigate the code, but the fact that array-api-strict fails too strongly hints at a bug in the scipy.stats code itself
Maybe take that up with NumPy and JAX first:
import numpy as np
np.mean(np.asarray(1.), axis=0)
# numpy.exceptions.AxisError: axis 0 is out of bounds for array of dimension 0
import jax.numpy as xp
xp.mean(xp.asarray(1.), axis=0)
# IndexError: tuple index out of range
LMK what they say. But I don't think reducing functions should need to be able to reduce along an axis that doesn't exist. I wonder if it was intentional in torch
.
import torch
torch.mean(torch.asarray(1.), axis=0)
# tensor(1.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think reducing functions should need to be able to reduce along an axis that doesn't exist.
Definitely they shouldn't
Maybe take that up with NumPy and JAX first:
Why does the test pass with a 0d numpy array though?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Anyway, I've reverted the test
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left a few requests for clarifications below. This is certainly quite ingenious and is most likely correct, but as of yet it does not pass my internal "will I be able to debug it in the future" test.
xp = array_namespace(desired) | ||
else: | ||
# Wrap namespace if needed | ||
xp = array_namespace(xp.asarray(0)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To make sure I understand what's going on here (am being dense, please bear with me):
- the first
if xp is None
, line 253, is decided by whetherxp_assert_close
was called with an explicitxp
or the defaultxp=None
? - the inner
if xp is None
is decided by a module-level contextvar, which, in turn, gets the value from thexp
fixture. The fixture may invoke the context manager, which, in turn, is decided by whether the pytest invocation hasSCIPY_ARRAY_API
env variable set or not. Like this?
If the above is somewhat correct:
-
Is there a way to move entring the context manager closer to
_strict_check
? This would both make the fixture a little less magical, and make it easier to reason about what's going on in general. If not --- and I suspect the problem is that we want bothdef test_function(xp)
andxp_assert_close(x, y)
without an explicitxp
argument --- please add copious code comments to explain what comes from where and how. -
It seems that the namespace is always wrapped: if SCIPY_ARRAY_API is not set, we still get
np_compat
as the namespace? If yes, is this a change of behavior or it was always like this? My impression is that's the former, and I'd like to understand why it's harmless. Or maybe if it is, maybe we want to wrap earlier, and for the duration of thexp_assert_*
body? -
xp
namespace is now derived fromdesired
, while previously it was derived fromactual
(line 296 below). Why this change, is it necessary here and is this what we actually want? Or maybe it's not relevant because namespaces must match in via_assert_matching_namespace
. I'm on record of wantingactual
anddesired
to be as symmeric as possible, so preserving or improving symmetry would be a nice bonus.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the first if xp is None, line 253, is decided by whether xp_assert_close was called with an explicit xp or the default xp=None?
the inner if xp is None is decided by a module-level contextvar, which, in turn, gets the value from the xp fixture. The fixture may invoke the context manager, which, in turn, is decided by whether the pytest invocation has SCIPY_ARRAY_API env variable set or not. Like this?
All correct. I've changed the inner test for none for a test for LookupError, to make it easier to understand that we're testing for the default state of the contextvar.
Is there a way to move entering the context manager closer to _strict_check?
No. That's the whole use case for contextvars. We're saying, "within the context of this test, it's unacceptable to observe an array whose namespace differs from xp". The context manager is set by the xp fixture and the observation for the arrays is done every time you call *assert_*
.
It seems that the namespace is always wrapped: if SCIPY_ARRAY_API is not set, we still get np_compat as the namespace?
Correct, you always get np_compat.
If yes, is this a change of behavior or it was always like this?
It was always like this:
scipy/scipy/_lib/_array_api.py
Line 256 in a4b8af0
desired_space = array_namespace(desired) |
xp namespace is now derived from desired, while previously it was derived from actual
It was derived from actual in the individual check function, and then immediately tested to be identical to the one derived from desired on line 259. By the time _assert_matching_namespace
exits, it does not matter anymore where you derived it from. I simply cleaned the confusion up. At the end of the day I don't care which of the two arrays we derive it from, as long as we only derive it once.
I'm unsure what the point of having the option to pass check_namespace=False
, as from my understanding the array API spec is quite adamant that the various libraries are not meant to be interoperable. There is only one place in which it's called, test_signaltools.py (#20772). I've now removed the flag from the test and it's still passing. should I also remove the flag from the assert functions too?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
check_namespace=False
I would keep it for now, even if we don't end up using it. The xp_assert
functions are an improvement over the offerings from np.testing
, and even if we never turn off check_namespace
in SciPy, maybe some other library would find it useful. That said, that seems like a niche use-case, so I don't feel too strongly about it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is there a way to move entering the context manager closer to _strict_check?
No. That's the whole use case for contextvars. We're saying, "within the context of this test, it's unacceptable to observe an array whose namespace differs from xp". The context manager is set by the xp fixture and the observation for the arrays is done every time you call assert_.
Meant this: applying
$ git diff
diff --git a/scipy/_lib/_array_api.py b/scipy/_lib/_array_api.py
index cb9fb05eda..212ba8e3be 100644
--- a/scipy/_lib/_array_api.py
+++ b/scipy/_lib/_array_api.py
@@ -257,7 +257,8 @@ def _strict_check(actual, desired, xp, *,
xp = array_namespace(xp.asarray(0))
if check_namespace:
- _assert_matching_namespace(actual, desired, xp)
+ with default_xp(xp):
+ _assert_matching_namespace(actual, desired, xp)
# only NumPy distinguishes between scalars and arrays; we do if check_0d=True.
# do this first so we can then cast to array (and thus use the array API) below.
diff --git a/scipy/conftest.py b/scipy/conftest.py
index a0891e7ff0..9d3f7556e1 100644
--- a/scipy/conftest.py
+++ b/scipy/conftest.py
@@ -213,8 +213,8 @@ def xp(request):
# xp_assert_* functions, test that the array namespace is xp in both the
# expected and actual arrays. This is to detect the case where both arrays are
# erroneously just plain numpy while xp is something else.
- with default_xp(request.param):
- yield request.param
+ # with default_xp(request.param):
+ yield request.param
else:
yield request.param
diff --git a/scipy/signal/tests/test_signaltools.py b/scipy/signal/tests/test_signaltools.py
index a472d763d1..d6b4b1b4f9 100644
--- a/scipy/signal/tests/test_signaltools.py
+++ b/scipy/signal/tests/test_signaltools.py
@@ -4429,3 +4429,7 @@ class TestUniqueRoots:
unique, multiplicity = unique_roots(p, 2)
assert_almost_equal(unique, [np.min(p)], decimal=15)
xp_assert_equal(multiplicity, [100])
+
+
+def test_namespace(xp):
+ xp_assert_close(xp.arange(3), np.arange(3)) # should fail the namespace check
and running
$ python dev.py test -t scipy/signal/tests/test_signaltools.py -b all -v -- -k'namespace'
💻 ninja -C /home/br/repos/scipy/scipy/build -j4
ninja: Entering directory `/home/br/repos/scipy/scipy/build'
[2/338] Generating subprojects/highs/src/HConfig.h with a custom command
Build OK
... snip...
collected 36975 items / 36972 deselected / 3 selected
scipy/signal/tests/test_signaltools.py::test_namespace[numpy] PASSED [ 33%]
scipy/signal/tests/test_signaltools.py::test_namespace[array_api_strict] FAILED [ 66%]
scipy/signal/tests/test_signaltools.py::test_namespace[torch] FAILED [100%]
============================================= FAILURES ==============================================
_________________________________ test_namespace[array_api_strict] __________________________________
scipy/signal/tests/test_signaltools.py:4435: in test_namespace
xp_assert_close(xp.arange(3), np.arange(3)) # should fail the namespace check
E AssertionError: Namespace of actual and desired arrays do not match.
E Actual: array_api_strict
E Desired: scipy._lib.array_api_compat.numpy
xp = <module 'array_api_strict' from '/home/br/repos/array-api-strict/array_api_strict/__init__.py'>
_______________________________________ test_namespace[torch] _______________________________________
scipy/signal/tests/test_signaltools.py:4435: in test_namespace
xp_assert_close(xp.arange(3), np.arange(3)) # should fail the namespace check
E AssertionError: Namespace of actual and desired arrays do not match.
E Actual: scipy._lib.array_api_compat.torch
E Desired: scipy._lib.array_api_compat.numpy
xp = <module 'torch' from '/home/br/miniforge3/envs/scipy-dev/lib/python3.12/site-packages/torch/__init__.py'>
====================================== short test summary info ======================================
FAILED scipy/signal/tests/test_signaltools.py::test_namespace[array_api_strict] - AssertionError: Namespace of actual and desired arrays do not match.
FAILED scipy/signal/tests/test_signaltools.py::test_namespace[torch] - AssertionError: Namespace of actual and desired arrays do not match.
=========================== 2 failed, 1 passed, 36972 deselected in 3.39s ===========================
produces the desired result: two out of three tests fail, as they should.
Unless I'm missing something, so this seems to imply that
- the
xp
fixture inconftest.py
can flip back to be apytest.mark.parametrize
- the
default_xp
context manager can be contained to_lib/_array_api.py
The goal is to avoid increasing our use of pytest black magic (what on earth is request.param
), avoid the coupling of conftest.py
and _lib/_array_api.py
, and making it generally a little easier to trace what comes from where.
There will still be a bit of bend-over-backwards passing the xp
module variable to assertions via the contextvar, but this indeed seems not avoidable. Let's at least contain the magic and spooky action on a distance.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The patch you propose reverts to what you already have in main, nothing more, nothing less. There is no point in setting a context variable two lines ahead of testing it; you might as well continue passing xp=xp
as a parameter.
def test_namespace(xp):
xp_assert_close(xp.arange(3), np.arange(3)) # should fail the namespace check
This is not what this PR aims to fix. This already works in main.
This PR aims to fix these use cases:
def test1(xp):
xp_assert_close(np.arange(3), [0, 1, 2]) # completely forgot to use xp
def test2(xp):
x = xp.asarray([1,2,3])
actual = foo(x) # incorrectly returns a numpy array
desired = bar(x) # ALSO incorrectly returns a numpy array
xp_assert_close(actual, desired)
The goal is to avoid increasing our use of pytest black magic (what on earth is
request.param
)
It's a feature of pytest that is extensively documented in the pytest documentation. I strongly disagree on calling it "black magic".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, thanks. In my patch prototype the context manager is entered too late indeed. Then the right thing is probably to do something like
with default_xp(xp):
... = _strict_check(....)
Like I said, what I'm trying to achieve is to avoid coupling between conftest.py
and _lib/_array_api.py
.
It's a feature of pytest that is extensively documented
Sure it is. Does not make it less magic (magick) from where I stand :-). Anyhow, this is about tastes which differ, let's not let it distract from the PR itself.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Then the right thing is probably to do something like
with default_xp(xp):
... = _strict_check(....)
I'm sorry, but this still makes no sense. What's the value of the xp
parameter to default_xp
if you call it one line before _strict_check
? And why are you not just calling _strict_check(..., xp=xp)
(which is exactly what main
does)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, I'm going to stop trying to offer half-solutions and circle back to my main point then: I suspect the code will be simpler if we decouple conftest.py
from _lib/_array_api.py
. Is there a way to have that?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see any.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's too bad. I'll keep thinking about it , but my thinking does not need to block this PR.
markers = | ||
slow: Tests that are very slow | ||
xslow: mark test as extremely slow (not run unless explicitly requested) | ||
xfail_on_32bit: mark test as failing on 32-bit platforms | ||
array_api_backends: test iterates on all array API backends | ||
array_api_compatible: test is compatible with array API | ||
skip_xp_backends(backends, reason=None, np_only=False, cpu_only=False, exceptions=None): mark the desired skip configuration for the `skip_xp_backends` fixture | ||
xfail_xp_backends(backends, reason=None, np_only=False, cpu_only=False, exceptions=None): mark the desired xfail configuration for the `xfail_xp_backends` fixture |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This sounds like a circular import of sorts, and those typically signal that something is wrong with the design.
I actually don't know enough about pytest: what is the recommended way, conftest.py or pytest.ini?
If the former, the next question is what is so special about array_api_backends
that it breaks the recommended pattern; if the latter, then moving markers to pytest.ini
is a clean-up?
@@ -179,7 +179,7 @@ def f(*args, **kwargs): | |||
ref_attr = [xp.asarray(getattr(ref, attr)) for ref in refs] | |||
res_attr = getattr(res, attr) | |||
xp_assert_close(xp_ravel(res_attr, xp=xp), xp.stack(ref_attr)) | |||
xp_assert_equal(res_attr.shape, shape) | |||
assert res_attr.shape == shape |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW, I played a bit with removing this np.asanyarray
because in array-api-compat==1.10
, array_namespace
accepts and ignores python scalars and None
s. This turned out to be an unpleasant exercise, and this asanyarray
is being relied on in several scenarios.
9cb32b8
to
ca4ec44
Compare
I've added documentation and addressed all of @ev-br's comments; please see my latest commit |
22df9f3
to
ededaec
Compare
Gave it a spin. Applying $ git diff
diff --git a/scipy/signal/tests/test_signaltools.py b/scipy/signal/tests/test_signaltools.py
index a472d763d1..235bf1e33b 100644
--- a/scipy/signal/tests/test_signaltools.py
+++ b/scipy/signal/tests/test_signaltools.py
@@ -4357,6 +4357,10 @@ class TestDetrend:
@skip_xp_backends(np_only=True)
class TestUniqueRoots:
+
+ def test_no_xp(self):
+ raise ValueError()
+
def test_real_no_repeat(self, xp):
p = [-1.0, -0.5, 0.3, 1.2, 10.0]
unique, multiplicity = unique_roots(p)
@@ -4429,3 +4433,7 @@ class TestUniqueRoots:
unique, multiplicity = unique_roots(p, 2)
assert_almost_equal(unique, [np.min(p)], decimal=15)
xp_assert_equal(multiplicity, [100])
+
+
+def test_free_no_xp():
+ pass
and running
produces
So something is missing it seems? |
It's working as intended. |
617196e
to
e994652
Compare
Whoa, thanks. We than need to find a way to be able to both use Or actually turn it on globally for all tests and remove |
I can move the contents of the For the sake of clarity however, I suggest leaving this to a later PR. |
This would be nice actually!
Agreed. The later PR though should come through soon after this one lands, because |
e994652
to
bab5174
Compare
bab5174
to
4b0ab51
Compare
Okay, let's land this as is. Thanks @crusaderky , all |
I found several tests for the array API backends that were
xp
and just running in plain numpy, orxp
, but then the tested function would return a plain numpy array and there was no error because the desired outcome was also a plain numpy arrayThis PR causes all calls to
assert_array_almost_equal
assert_almost_equal
xp_assert_equal
xp_assert_close
xp_assert_less
to raise if either the actual or the desired array don't match the
xp
defined by the test fixture.Additionally, this PR makes the
@array_api_compatible
decorator obsolete. I have not removed it yet to facilitate code review. I plan to trivially clean it up in a later PR.Finally, this PR introduces a new pytest mark,
array_api_backends
, which automatically selects all and only the tests that use thexp
parameter. This means that we no longer need to keep an inventory of array API tests in the github CI script. To figure out which tests are involved in array API, just runor
Variations of the same commands could be used e.g. to figure out which tests have been missed for whatever reason.