diff --git a/requirements.txt b/requirements.txt index fbe8c997..8773b0e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ pytest -hypothesis>=6.30.0 +hypothesis>=6.31.1 regex removestar diff --git a/xptests/pytest_helpers.py b/xptests/pytest_helpers.py index b10647dc..2cb78cb0 100644 --- a/xptests/pytest_helpers.py +++ b/xptests/pytest_helpers.py @@ -23,6 +23,7 @@ "assert_result_shape", "assert_keepdimable_shape", "assert_fill", + "assert_array", ] @@ -226,3 +227,20 @@ def assert_fill( assert ah.all(ah.isnan(out)), msg else: assert ah.all(ah.equal(out, ah.asarray(fill_value, dtype=dtype))), msg + + +def assert_array(func_name: str, out: Array, expected: Array, /, **kw): + assert_dtype(func_name, out.dtype, expected.dtype, **kw) + assert_shape(func_name, out.shape, expected.shape, **kw) + msg = f"out not as expected [{func_name}({fmt_kw(kw)})]\n{out=}\n{expected=}" + if dh.is_float_dtype(out.dtype): + neg_zeros = expected == -0.0 + assert xp.all((out == -0.0) == neg_zeros), msg + pos_zeros = expected == +0.0 + assert xp.all((out == +0.0) == pos_zeros), msg + nans = xp.isnan(expected) + assert xp.all(xp.isnan(out) == nans), msg + mask = ~(neg_zeros | pos_zeros | nans) + assert xp.all(out[mask] == expected[mask]), msg + else: + assert xp.all(out == expected), msg diff --git a/xptests/test_array2scalar.py b/xptests/test_array2scalar.py deleted file mode 100644 index 162d2da3..00000000 --- a/xptests/test_array2scalar.py +++ /dev/null @@ -1,47 +0,0 @@ -import pytest -from hypothesis import given -from hypothesis import strategies as st - -from . import _array_module as xp -from . import dtype_helpers as dh -from . import xps -from .typing import DataType, Param - -method_stype = { - "__bool__": bool, - "__int__": int, - "__index__": int, - "__float__": float, -} - - -def make_param(method_name: str, dtype: DataType) -> Param: - stype = method_stype[method_name] - if isinstance(dtype, xp._UndefinedStub): - marks = pytest.mark.skip(reason=f"xp.{dtype.name} not defined") - else: - marks = () - return pytest.param( - method_name, - dtype, - stype, - id=f"{method_name}({dh.dtype_to_name[dtype]})", - marks=marks, - ) - - -@pytest.mark.parametrize( - "method_name, dtype, stype", - [make_param("__bool__", xp.bool)] - + [make_param("__int__", d) for d in dh.all_int_dtypes] - + [make_param("__index__", d) for d in dh.all_int_dtypes] - + [make_param("__float__", d) for d in dh.float_dtypes], -) -@given(data=st.data()) -def test_0d_array_can_convert_to_scalar(method_name, dtype, stype, data): - x = data.draw(xps.arrays(dtype, shape=()), label="x") - method = getattr(x, method_name) - out = method() - assert isinstance( - out, stype - ), f"{method_name}({x})={out}, which is not a {stype.__name__} scalar" diff --git a/xptests/test_array_object.py b/xptests/test_array_object.py new file mode 100644 index 00000000..78385cbe --- /dev/null +++ b/xptests/test_array_object.py @@ -0,0 +1,133 @@ +import math +from itertools import product +from typing import Sequence, Union, get_args + +import pytest +from hypothesis import assume, given, note +from hypothesis import strategies as st + +from . import _array_module as xp +from . import dtype_helpers as dh +from . import hypothesis_helpers as hh +from . import pytest_helpers as ph +from . import xps +from .typing import DataType, Param, Scalar, ScalarType, Shape + + +def reshape( + flat_seq: Sequence[Scalar], shape: Shape +) -> Union[Scalar, Sequence[Scalar]]: + """Reshape a flat sequence""" + if len(shape) == 0: + assert len(flat_seq) == 1 # sanity check + return flat_seq[0] + elif len(shape) == 1: + return flat_seq + size = len(flat_seq) + n = math.prod(shape[1:]) + return [reshape(flat_seq[i * n : (i + 1) * n], shape[1:]) for i in range(size // n)] + + +@given(hh.shapes(min_side=1), st.data()) # TODO: test 0-sided arrays +def test_getitem(shape, data): + size = math.prod(shape) + dtype = data.draw(xps.scalar_dtypes(), label="dtype") + obj = data.draw( + st.lists(xps.from_dtype(dtype), min_size=size, max_size=size).map( + lambda l: reshape(l, shape) + ), + label="obj", + ) + x = xp.asarray(obj, dtype=dtype) + note(f"{x=}") + key = data.draw(xps.indices(shape=shape), label="key") + + out = x[key] + + ph.assert_dtype("__getitem__", x.dtype, out.dtype) + _key = tuple(key) if isinstance(key, tuple) else (key,) + if Ellipsis in _key: + start_a = _key.index(Ellipsis) + stop_a = start_a + (len(shape) - (len(_key) - 1)) + slices = tuple(slice(None, None) for _ in range(start_a, stop_a)) + _key = _key[:start_a] + slices + _key[start_a + 1 :] + axes_indices = [] + out_shape = [] + for a, i in enumerate(_key): + if isinstance(i, int): + axes_indices.append([i]) + else: + side = shape[a] + indices = range(side)[i] + axes_indices.append(indices) + out_shape.append(len(indices)) + out_shape = tuple(out_shape) + ph.assert_shape("__getitem__", out.shape, out_shape) + assume(all(len(indices) > 0 for indices in axes_indices)) + out_obj = [] + for idx in product(*axes_indices): + val = obj + for i in idx: + val = val[i] + out_obj.append(val) + out_obj = reshape(out_obj, out_shape) + expected = xp.asarray(out_obj, dtype=dtype) + ph.assert_array("__getitem__", out, expected) + + +@given(hh.shapes(min_side=1), st.data()) # TODO: test 0-sided arrays +def test_setitem(shape, data): + size = math.prod(shape) + dtype = data.draw(xps.scalar_dtypes(), label="dtype") + obj = data.draw( + st.lists(xps.from_dtype(dtype), min_size=size, max_size=size).map( + lambda l: reshape(l, shape) + ), + label="obj", + ) + x = xp.asarray(obj, dtype=dtype) + note(f"{x=}") + key = data.draw(xps.indices(shape=shape, max_dims=0), label="key") + value = data.draw( + xps.from_dtype(dtype) | xps.arrays(dtype=dtype, shape=()), label="value" + ) + + res = xp.asarray(x, copy=True) + res[key] = value + + ph.assert_dtype("__setitem__", x.dtype, res.dtype, repr_name="x.dtype") + ph.assert_shape("__setitem__", res.shape, x.shape, repr_name="x.shape") + if isinstance(value, get_args(Scalar)): + msg = f"x[{key}]={res[key]!r}, but should be {value=} [__setitem__()]" + if math.isnan(value): + assert xp.isnan(res[key]), msg + else: + assert res[key] == value, msg + else: + ph.assert_0d_equals("__setitem__", "value", value, f"x[{key}]", res[key]) + + +# TODO: test boolean indexing + + +def make_param(method_name: str, dtype: DataType, stype: ScalarType) -> Param: + return pytest.param( + method_name, dtype, stype, id=f"{method_name}({dh.dtype_to_name[dtype]})" + ) + + +@pytest.mark.parametrize( + "method_name, dtype, stype", + [make_param("__bool__", xp.bool, bool)] + + [make_param("__int__", d, int) for d in dh.all_int_dtypes] + + [make_param("__index__", d, int) for d in dh.all_int_dtypes] + + [make_param("__float__", d, float) for d in dh.float_dtypes], +) +@given(data=st.data()) +def test_duck_typing(method_name, dtype, stype, data): + x = data.draw(xps.arrays(dtype, shape=()), label="x") + method = getattr(x, method_name) + out = method() + assert isinstance( + out, stype + ), f"{method_name}({x})={out}, which is not a {stype.__name__} scalar" diff --git a/xptests/test_indexing.py b/xptests/test_indexing.py deleted file mode 100644 index 4441581e..00000000 --- a/xptests/test_indexing.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -https://data-apis.github.io/array-api/latest/API_specification/indexing.html - -For these tests, we only need arrays where each element is distinct, so we use -arange(). -""" - -from hypothesis import given -from hypothesis.strategies import shared - -from .array_helpers import assert_exactly_equal -from .hypothesis_helpers import (slices, sizes, integer_indices, shapes, prod, - multiaxis_indices) -from .pytest_helpers import raises -from ._array_module import arange, reshape - -# TODO: Add tests for __setitem__ - -@given(shared(sizes, key='array_sizes'), integer_indices(shared(sizes, key='array_sizes'))) -def test_integer_indexing(size, idx): - # Test that indices on single dimensional arrays give the same result as - # Python lists. idx may be a Python integer or a 0-D array with integer - # dtype. - - # Sanity check that the strategies are working properly - assert -size <= int(idx) <= max(0, size - 1), "Sanity check failed. This indicates a bug in the test suite" - - # The spec only requires support for a single integer index on dimension 1 - # arrays. - a = arange(size) - l = list(range(size)) - # TODO: We can remove int() here if we add __index__ to the spec. See - # https://github.com/data-apis/array-api/issues/231. - sliced_list = l[int(idx)] - sliced_array = a[idx] - - assert sliced_array.shape == (), "Integer indices should reduce the dimension by 1" - assert sliced_array.dtype == a.dtype, "Integer indices should not change the dtype" - assert sliced_array == sliced_list, "Integer index did not give the correct entry" - -@given(shared(sizes, key='array_sizes'), slices(shared(sizes, key='array_sizes'))) -def test_slicing(size, s): - # Test that slices on arrays give the same result as Python lists. - - # Sanity check that the strategies are working properly - if s.start is not None: - assert -size <= s.start <= size, "Sanity check failed. This indicates a bug in the test suite" - if s.stop is not None: - if s.step is None or s.step > 0: - assert -size <= s.stop <= size, "Sanity check failed. This indicates a bug in the test suite" - else: - assert -size - 1 <= s.stop <= size - 1, "Sanity check failed. This indicates a bug in the test suite" - - # The spec only requires support for a single slice index on dimension 1 - # arrays. - a = arange(size) - l = list(range(size)) - sliced_list = l[s] - sliced_array = a[s] - - assert len(sliced_list) == sliced_array.size, "Slice index did not give the same number of elements as slicing an equivalent Python list" - assert sliced_array.shape == (sliced_array.size,), "Slice index did not give the correct shape" - assert sliced_array.dtype == a.dtype, "Slice indices should not change the dtype" - for i in range(len(sliced_list)): - assert sliced_array[i] == sliced_list[i], "Slice index did not give the same elements as slicing an equivalent Python list" - -@given(shared(shapes(), key='array_shapes'), - multiaxis_indices(shapes=shared(shapes(), key='array_shapes'))) -def test_multiaxis_indexing(shape, idx): - # NOTE: Out of bounds indices (both integer and slices) are out of scope - # for the spec. If you get a (valid) out of bounds error, it indicates a - # bug in the multiaxis_indices strategy, which should only generate - # indices that are not out of bounds. - size = prod(shape) - a = reshape(arange(size), shape) - - n_ellipses = len([i for i in idx if i is ...]) - if n_ellipses > 1: - raises(IndexError, lambda: a[idx], - "Indices with more than one ellipsis should raise IndexError") - return - elif len(idx) - n_ellipses > len(shape): - raises(IndexError, lambda: a[idx], - "Tuple indices with more single axis expressions than the shape should raise IndexError") - return - - sliced_array = a[idx] - equiv_idx = idx - if n_ellipses or len(idx) < len(shape): - # Would be - # - # ellipsis_i = idx.index(...) if n_ellipses else len(idx) - # - # except we have to be careful to not use == to compare array elements - # of idx. - ellipsis_i = len(idx) - for i in range(len(idx)): - if idx[i] is ...: - ellipsis_i = i - break - - equiv_idx = (idx[:ellipsis_i] - + (slice(None, None, None),)*(len(shape) - len(idx) + n_ellipses) - + idx[ellipsis_i + 1:]) - # Sanity check - assert len(equiv_idx) == len(shape), "Sanity check failed. This indicates a bug in the test suite" - sliced_array2 = a[equiv_idx] - assert_exactly_equal(sliced_array, sliced_array2) - - # TODO: We don't check that the exact entries are correct. Instead we - # check the shape and other properties, and assume the single dimension - # tests above are sufficient for testing the exact behavior of integer - # indices and slices. - - # Check that the new shape is what it should be - newshape = [] - for i, s in enumerate(equiv_idx): - # Slices should retain the dimension. Integers remove a dimension. - if isinstance(s, slice): - newshape.append(len(range(shape[i])[s])) - assert sliced_array.shape == tuple(newshape), "Index did not give the correct resulting array shape" - - # Check that integer indices i chose the same elements as the slice i:i+1 - equiv_idx2 = [] - for i, size in zip(equiv_idx, shape): - if isinstance(i, int): - if i >= 0: - i = slice(i, i + 1) - else: - i = slice(size + i, size + i + 1) - equiv_idx2.append(i) - equiv_idx2 = tuple(equiv_idx2) - - sliced_array2 = a[equiv_idx2] - assert sliced_array2.size == sliced_array.size, "Integer index not choosing the same elements as an equivalent slice" - assert_exactly_equal(reshape(sliced_array2, sliced_array.shape), sliced_array)