Repository: data-apis/array-api-strict
Pull request #145: ENH: disallow numpy generics
Author: login: crusaderky
Created: 2025-04-02T13:10:03Z | Merged: 2025-04-23T20:12:29Z

In #135, binary operators vs. numpy.int64 became (correctly) disallowed.
Add a test for it.
Also test binary operators vs. numpy.float64 and numpy.complex128, which must be ~allowed as~ rejected even if they are subclasses of float and complex respectively.

The same treatment for functions is in a separate PR: https://github.com/data-apis/array-api-strict/pull/148.

- XREF https://github.com/scipy/scipy/pull/22783



Diff:
diff --git a/array_api_strict/_array_object.py b/array_api_strict/_array_object.py
index 483952e..579da90 100644
--- a/array_api_strict/_array_object.py
+++ b/array_api_strict/_array_object.py
@@ -233,15 +233,16 @@ def _check_allowed_dtypes(
 
         return other
 
-    def _check_device(self, other: Array | bool | int | float | complex) -> None:
-        """Check that other is on a device compatible with the current array"""
-        if isinstance(other, (bool, int, float, complex)):
-            return
-        elif isinstance(other, Array):
+    def _check_type_device(self, other: Array | bool | int | float | complex) -> None:
+        """Check that other is either a Python scalar or an array on a device
+        compatible with the current array.
+        """
+        if isinstance(other, Array):
             if self.device != other.device:
                 raise ValueError(f"Arrays from two different devices ({self.device} and {other.device}) can not be combined.")
-        else:
-            raise TypeError(f"Expected Array | python scalar; got {type(other)}")
+        # Disallow subclasses of Python scalars, such as np.float64 and np.complex128
+        elif type(other) not in (bool, int, float, complex):
+            raise TypeError(f"Expected Array or Python scalar; got {type(other)}")
 
     # Helper function to match the type promotion rules in the spec
     def _promote_scalar(self, scalar: bool | int | float | complex) -> Array:
@@ -542,7 +543,7 @@ def __add__(self, other: Array | int | float | complex, /) -> Array:
         """
         Performs the operation __add__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "numeric", "__add__")
         if other is NotImplemented:
             return other
@@ -554,7 +555,7 @@ def __and__(self, other: Array | bool | int, /) -> Array:
         """
         Performs the operation __and__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "integer or boolean", "__and__")
         if other is NotImplemented:
             return other
@@ -651,7 +652,7 @@ def __eq__(self, other: Array | bool | int | float | complex, /) -> Array:  # ty
         """
         Performs the operation __eq__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         # Even though "all" dtypes are allowed, we still require them to be
         # promotable with each other.
         other = self._check_allowed_dtypes(other, "all", "__eq__")
@@ -677,7 +678,7 @@ def __floordiv__(self, other: Array | int | float, /) -> Array:
         """
         Performs the operation __floordiv__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "real numeric", "__floordiv__")
         if other is NotImplemented:
             return other
@@ -689,7 +690,7 @@ def __ge__(self, other: Array | int | float, /) -> Array:
         """
         Performs the operation __ge__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "real numeric", "__ge__")
         if other is NotImplemented:
             return other
@@ -741,7 +742,7 @@ def __gt__(self, other: Array | int | float, /) -> Array:
         """
         Performs the operation __gt__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "real numeric", "__gt__")
         if other is NotImplemented:
             return other
@@ -796,7 +797,7 @@ def __le__(self, other: Array | int | float, /) -> Array:
         """
         Performs the operation __le__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "real numeric", "__le__")
         if other is NotImplemented:
             return other
@@ -808,7 +809,7 @@ def __lshift__(self, other: Array | int, /) -> Array:
         """
         Performs the operation __lshift__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "integer", "__lshift__")
         if other is NotImplemented:
             return other
@@ -820,7 +821,7 @@ def __lt__(self, other: Array | int | float, /) -> Array:
         """
         Performs the operation __lt__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "real numeric", "__lt__")
         if other is NotImplemented:
             return other
@@ -832,7 +833,7 @@ def __matmul__(self, other: Array, /) -> Array:
         """
         Performs the operation __matmul__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         # matmul is not defined for scalars, but without this, we may get
         # the wrong error message from asarray.
         other = self._check_allowed_dtypes(other, "numeric", "__matmul__")
@@ -845,7 +846,7 @@ def __mod__(self, other: Array | int | float, /) -> Array:
         """
         Performs the operation __mod__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "real numeric", "__mod__")
         if other is NotImplemented:
             return other
@@ -857,7 +858,7 @@ def __mul__(self, other: Array | int | float | complex, /) -> Array:
         """
         Performs the operation __mul__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "numeric", "__mul__")
         if other is NotImplemented:
             return other
@@ -869,7 +870,7 @@ def __ne__(self, other: Array | bool | int | float | complex, /) -> Array:  # ty
         """
         Performs the operation __ne__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "all", "__ne__")
         if other is NotImplemented:
             return other
@@ -890,7 +891,7 @@ def __or__(self, other: Array | bool | int, /) -> Array:
         """
         Performs the operation __or__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "integer or boolean", "__or__")
         if other is NotImplemented:
             return other
@@ -913,7 +914,7 @@ def __pow__(self, other: Array | int | float | complex, /) -> Array:
         """
         from ._elementwise_functions import pow  # type: ignore[attr-defined]
 
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "numeric", "__pow__")
         if other is NotImplemented:
             return other
@@ -925,7 +926,7 @@ def __rshift__(self, other: Array | int, /) -> Array:
         """
         Performs the operation __rshift__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "integer", "__rshift__")
         if other is NotImplemented:
             return other
@@ -961,7 +962,7 @@ def __sub__(self, other: Array | int | float | complex, /) -> Array:
         """
         Performs the operation __sub__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "numeric", "__sub__")
         if other is NotImplemented:
             return other
@@ -975,7 +976,7 @@ def __truediv__(self, other: Array | int | float | complex, /) -> Array:
         """
         Performs the operation __truediv__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "floating-point", "__truediv__")
         if other is NotImplemented:
             return other
@@ -987,7 +988,7 @@ def __xor__(self, other: Array | bool | int, /) -> Array:
         """
         Performs the operation __xor__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "integer or boolean", "__xor__")
         if other is NotImplemented:
             return other
@@ -999,7 +1000,7 @@ def __iadd__(self, other: Array | int | float | complex, /) -> Array:
         """
         Performs the operation __iadd__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "numeric", "__iadd__")
         if other is NotImplemented:
             return other
@@ -1010,7 +1011,7 @@ def __radd__(self, other: Array | int | float | complex, /) -> Array:
         """
         Performs the operation __radd__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "numeric", "__radd__")
         if other is NotImplemented:
             return other
@@ -1022,7 +1023,7 @@ def __iand__(self, other: Array | bool | int, /) -> Array:
         """
         Performs the operation __iand__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "integer or boolean", "__iand__")
         if other is NotImplemented:
             return other
@@ -1033,7 +1034,7 @@ def __rand__(self, other: Array | bool | int, /) -> Array:
         """
         Performs the operation __rand__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "integer or boolean", "__rand__")
         if other is NotImplemented:
             return other
@@ -1045,7 +1046,7 @@ def __ifloordiv__(self, other: Array | int | float, /) -> Array:
         """
         Performs the operation __ifloordiv__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "real numeric", "__ifloordiv__")
         if other is NotImplemented:
             return other
@@ -1056,7 +1057,7 @@ def __rfloordiv__(self, other: Array | int | float, /) -> Array:
         """
         Performs the operation __rfloordiv__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "real numeric", "__rfloordiv__")
         if other is NotImplemented:
             return other
@@ -1068,7 +1069,7 @@ def __ilshift__(self, other: Array | int, /) -> Array:
         """
         Performs the operation __ilshift__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "integer", "__ilshift__")
         if other is NotImplemented:
             return other
@@ -1079,7 +1080,7 @@ def __rlshift__(self, other: Array | int, /) -> Array:
         """
         Performs the operation __rlshift__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "integer", "__rlshift__")
         if other is NotImplemented:
             return other
@@ -1096,7 +1097,7 @@ def __imatmul__(self, other: Array, /) -> Array:
         other = self._check_allowed_dtypes(other, "numeric", "__imatmul__")
         if other is NotImplemented:
             return other
-        self._check_device(other)
+        self._check_type_device(other)
         res = self._array.__imatmul__(other._array)
         return self.__class__._new(res, device=self.device)
 
@@ -1109,7 +1110,7 @@ def __rmatmul__(self, other: Array, /) -> Array:
         other = self._check_allowed_dtypes(other, "numeric", "__rmatmul__")
         if other is NotImplemented:
             return other
-        self._check_device(other)
+        self._check_type_device(other)
         res = self._array.__rmatmul__(other._array)
         return self.__class__._new(res, device=self.device)
 
@@ -1130,7 +1131,7 @@ def __rmod__(self, other: Array | int | float, /) -> Array:
         other = self._check_allowed_dtypes(other, "real numeric", "__rmod__")
         if other is NotImplemented:
             return other
-        self._check_device(other)
+        self._check_type_device(other)
         self, other = self._normalize_two_args(self, other)
         res = self._array.__rmod__(other._array)
         return self.__class__._new(res, device=self.device)
@@ -1152,7 +1153,7 @@ def __rmul__(self, other: Array | int | float | complex, /) -> Array:
         other = self._check_allowed_dtypes(other, "numeric", "__rmul__")
         if other is NotImplemented:
             return other
-        self._check_device(other)
+        self._check_type_device(other)
         self, other = self._normalize_two_args(self, other)
         res = self._array.__rmul__(other._array)
         return self.__class__._new(res, device=self.device)
@@ -1171,7 +1172,7 @@ def __ror__(self, other: Array | bool | int, /) -> Array:
         """
         Performs the operation __ror__.
         """
-        self._check_device(other)
+        self._check_type_device(other)
         other = self._check_allowed_dtypes(other, "integer or boolean", "__ror__")
         if other is NotImplemented:
             return other
@@ -1219,7 +1220,7 @@ def __rrshift__(self, other: Array | int, /) -> Array:
         other = self._check_allowed_dtypes(other, "integer", "__rrshift__")
         if other is NotImplemented:
             return other
-        self._check_device(other)
+        self._check_type_device(other)
         self, other = self._normalize_two_args(self, other)
         res = self._array.__rrshift__(other._array)
         return self.__class__._new(res, device=self.device)
@@ -1241,7 +1242,7 @@ def __rsub__(self, other: Array | int | float | complex, /) -> Array:
         other = self._check_allowed_dtypes(other, "numeric", "__rsub__")
         if other is NotImplemented:
             return other
-        self._check_device(other)
+        self._check_type_device(other)
         self, other = self._normalize_two_args(self, other)
         res = self._array.__rsub__(other._array)
         return self.__class__._new(res, device=self.device)
@@ -1263,7 +1264,7 @@ def __rtruediv__(self, other: Array | int | float | complex, /) -> Array:
         other = self._check_allowed_dtypes(other, "floating-point", "__rtruediv__")
         if other is NotImplemented:
             return other
-        self._check_device(other)
+        self._check_type_device(other)
         self, other = self._normalize_two_args(self, other)
         res = self._array.__rtruediv__(other._array)
         return self.__class__._new(res, device=self.device)
@@ -1285,7 +1286,7 @@ def __rxor__(self, other: Array | bool | int, /) -> Array:
         other = self._check_allowed_dtypes(other, "integer or boolean", "__rxor__")
         if other is NotImplemented:
             return other
-        self._check_device(other)
+        self._check_type_device(other)
         self, other = self._normalize_two_args(self, other)
         res = self._array.__rxor__(other._array)
         return self.__class__._new(res, device=self.device)
diff --git a/array_api_strict/tests/test_array_object.py b/array_api_strict/tests/test_array_object.py
index dbab1af..e950be5 100644
--- a/array_api_strict/tests/test_array_object.py
+++ b/array_api_strict/tests/test_array_object.py
@@ -255,30 +255,37 @@ def _check_op_array_scalar(dtypes, a, s, func, func_name, BIG_INT=BIG_INT):
             func(s)
         return False
 
+binary_op_dtypes = {
+    "__add__": "numeric",
+    "__and__": "integer or boolean",
+    "__eq__": "all",
+    "__floordiv__": "real numeric",
+    "__ge__": "real numeric",
+    "__gt__": "real numeric",
+    "__le__": "real numeric",
+    "__lshift__": "integer",
+    "__lt__": "real numeric",
+    "__mod__": "real numeric",
+    "__mul__": "numeric",
+    "__ne__": "all",
+    "__or__": "integer or boolean",
+    "__pow__": "numeric",
+    "__rshift__": "integer",
+    "__sub__": "numeric",
+    "__truediv__": "floating-point",
+    "__xor__": "integer or boolean",
+}
+unary_op_dtypes = {
+    "__abs__": "numeric",
+    "__invert__": "integer or boolean",
+    "__neg__": "numeric",
+    "__pos__": "numeric",
+}
 
 def test_operators():
     # For every operator, we test that it works for the required type
     # combinations and raises TypeError otherwise
-    binary_op_dtypes = {
-        "__add__": "numeric",
-        "__and__": "integer or boolean",
-        "__eq__": "all",
-        "__floordiv__": "real numeric",
-        "__ge__": "real numeric",
-        "__gt__": "real numeric",
-        "__le__": "real numeric",
-        "__lshift__": "integer",
-        "__lt__": "real numeric",
-        "__mod__": "real numeric",
-        "__mul__": "numeric",
-        "__ne__": "all",
-        "__or__": "integer or boolean",
-        "__pow__": "numeric",
-        "__rshift__": "integer",
-        "__sub__": "numeric",
-        "__truediv__": "floating-point",
-        "__xor__": "integer or boolean",
-    }
+
     # Recompute each time because of in-place ops
     def _array_vals():
         for d in _integer_dtypes:
@@ -337,12 +344,6 @@ def _array_vals():
                             else:
                                 assert_raises(TypeError, lambda: getattr(x, _op)(y))
 
-    unary_op_dtypes = {
-        "__abs__": "numeric",
-        "__invert__": "integer or boolean",
-        "__neg__": "numeric",
-        "__pos__": "numeric",
-    }
     for op, dtypes in unary_op_dtypes.items():
         for a in _array_vals():
             if (
@@ -410,6 +411,53 @@ def _matmul_array_vals():
                     x.__imatmul__(y)
 
 
+@pytest.mark.parametrize("op,dtypes", binary_op_dtypes.items())
+def test_binary_operators_vs_numpy_generics(op, dtypes):
+    """Test that np.bool_, np.int64, np.float32, np.float64, np.complex64, np.complex128
+    are disallowed in binary operators.
+    np.float64 and np.complex128 are subclasses of float and complex, so they need
+    special treatment in order to be rejected.
+    """
+    match = "Expected Array or Python scalar"
+
+    if dtypes not in ("numeric", "integer", "real numeric", "floating-point"):
+        a = asarray(True)
+        func = getattr(a, op)
+        with pytest.raises(TypeError, match=match):
+            func(np.bool_(True))
+
+    if dtypes != "floating-point":
+        a = asarray(1)
+        func = getattr(a, op)
+        with pytest.raises(TypeError, match=match):
+            func(np.int64(1))
+
+    if dtypes not in ("integer", "integer or boolean"):
+        a = asarray(1.,)
+        func = getattr(a, op)
+        with pytest.raises(TypeError, match=match):
+            func(np.float32(1.))
+        with pytest.raises(TypeError, match=match):
+            func(np.float64(1.))
+
+    if dtypes not in ("integer", "integer or boolean", "real numeric"):
+        a = asarray(1.,)
+        func = getattr(a, op)
+        with pytest.raises(TypeError, match=match):
+            func(np.complex64(1.))
+        with pytest.raises(TypeError, match=match):
+            func(np.complex128(1.))
+
+
+@pytest.mark.parametrize("op,dtypes", binary_op_dtypes.items())
+def test_binary_operators_device_mismatch(op, dtypes):
+    dtype = float64 if dtypes == "floating-point" else int64
+    a = asarray(1, dtype=dtype, device=CPU_DEVICE)
+    b = asarray(1, dtype=dtype, device=Device("device1"))
+    with pytest.raises(ValueError, match="different devices"):
+        getattr(a, op)(b)
+
+
 def test_python_scalar_construtors():
     b = asarray(False)
     i = asarray(0)


Reviews:
user: login: ev-br
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T12:26:45Z
user: login: crusaderky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T13:26:40Z
user: login: ev-br
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T13:33:44Z
user: login: crusaderky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T13:41:07Z
user: login: crusaderky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T13:42:45Z
user: login: ev-br
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-08T13:50:38Z
user: login: ev-br
body: So this PR does two things:

- adds a test for binary dunders reject arrays on different devices
- adds tests which codify the behavior on binary dunders with numpy scalars.

The former is great IMO, the latter is less so. Essentially, I'd consider it a bug that `np.float32(1.)` behaves differently from `np.float64(1.)`. Either they both should duck-type as the built-in `float`  or they both should duck-type as a 0D array of the corresponding dtype.

To tell which behavior is better, consider how a numpy scalar might come from to array-api-strict. I see two possible scenarios:

- a user explicitly creates an `np.int64(python_int)` or `np.float32(python_float)`. Old code does that sometimes. IMO the user code should decide if they need a builtin object or a 0D array, and change accordingly.
- some numpy operation (indexing or a ufunc, for instance) creates a numpy scalar. In those cases any other backend would have created a 0D array. Therefore, `array-api-strict` can have two possible behaviors: reject all numpy scalars or convert them into 0D arrays.

Rejecting numpy scalars is probably not very useful: it would require that all user code explicitly calls `asarray` just make user tests pass on both numpy and `array-api-strict`. 
Converting them all sounds more useful for downstream testing, which is the main purpose of `array-api-strict`.

Therefore, I suggest that instead of codifying the existing bug, we add a test or several that all instances of `np.generic` duck-type with arrays in both binary functions and binary dunders, and actually make it happen: I think it will require something like `if isinstance(x, np.generic): x = xp.asarray(x)` before checking `isinstance(x, bool | int| float | complex)`.


state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-22T14:55:23Z
user: login: crusaderky
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-23T12:44:45Z
user: login: ev-br
body: LGTM now.

There is a non-zero chance that this causes issues downstream. However I believe that the change is correct and useful and it's downstream that should adjust to better align with the spec.
If push comes to shove, we'll temporarily revert. So let's do EAFP not LBYL and land this. Thanks @crusaderky 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T20:12:21Z

Review comments:
diff_hunk: @@ -410,6 +411,78 @@ def _matmul_array_vals():
                     x.__imatmul__(y)
 
 
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items()
+        if dtypes not in ("real numeric", "floating-point")
+    ],
+)
+def test_binary_operators_vs_numpy_int(op):
+    """np.int64 is not a subclass of int and must be disallowed"""
+    a = asarray(1)
+    i64 = np.int64(1)
+    with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+        getattr(a, op)(i64)
+
+
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items() 
+        if dtypes not in ("integer", "integer or boolean")
+    ],
+)
+def test_binary_operators_vs_numpy_float(op):
+    """
+    np.float64 is a subclass of float and must be allowed.
path: array_api_strict/tests/test_array_object.py
user: login: ev-br
body: Have to admit I'm not a fan of this. This is technically correct, it is a subclass. That said, it is an implementation detail IMO. 

I'd think that all instances where numpy returns a numpy scalar, all other libraries return 0D arrays, so I'd think we better always handle them together with arrays not python scalars.
created_at: 2025-04-08T12:26:45Z
author_association: MEMBER
diff_hunk: @@ -410,6 +411,78 @@ def _matmul_array_vals():
                     x.__imatmul__(y)
 
 
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items()
+        if dtypes not in ("real numeric", "floating-point")
+    ],
+)
+def test_binary_operators_vs_numpy_int(op):
+    """np.int64 is not a subclass of int and must be disallowed"""
+    a = asarray(1)
+    i64 = np.int64(1)
+    with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+        getattr(a, op)(i64)
+
+
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items() 
+        if dtypes not in ("integer", "integer or boolean")
+    ],
+)
+def test_binary_operators_vs_numpy_float(op):
+    """
+    np.float64 is a subclass of float and must be allowed.
path: array_api_strict/tests/test_array_object.py
user: login: crusaderky
body: Are you saying that
```python
x = xp.asarray(1, dtype=xp.float32)
y = np.float64(2)
z = xp.maximum(x, y)
```
`maximum` should crash instead of returning an `Array[float32]`?
created_at: 2025-04-08T13:26:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -410,6 +411,78 @@ def _matmul_array_vals():
                     x.__imatmul__(y)
 
 
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items()
+        if dtypes not in ("real numeric", "floating-point")
+    ],
+)
+def test_binary_operators_vs_numpy_int(op):
+    """np.int64 is not a subclass of int and must be disallowed"""
+    a = asarray(1)
+    i64 = np.int64(1)
+    with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+        getattr(a, op)(i64)
+
+
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items() 
+        if dtypes not in ("integer", "integer or boolean")
+    ],
+)
+def test_binary_operators_vs_numpy_float(op):
+    """
+    np.float64 is a subclass of float and must be allowed.
path: array_api_strict/tests/test_array_object.py
user: login: ev-br
body: Definitely not crash :-).

I think the following is correct[ because `np.float64(2)` is conceptually a `np.asarray(2.)` not python scalar 2.)

```
In [4]: xp = array_namespace(np.empty(2))

In [5]: xp.maximum(xp.asarray(1, dtype=xp.float32), 2)
Out[5]: np.float32(2.0)

In [6]: xp.maximum(xp.asarray(1, dtype=xp.float32), np.float64(2))
Out[6]: np.float64(2.0)
```

Likewise, the following is also correct, for the same reason:

```
In [12]: xpt = array_namespace(torch.empty(2))

In [13]: xpt.maximum(xpt.asarray(1, dtype=xpt.float32), np.float64(2))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[13], line 1
----> 1 xpt.maximum(xpt.asarray(1, dtype=xpt.float32), np.float64(2))

File ~/repos/array-api-compat/array_api_compat/torch/_aliases.py:91, in _two_arg.<locals>._f(x1, x2, **kwargs)
     88 @_wraps(f)
     89 def _f(x1, x2, /, **kwargs):
     90     x1, x2 = _fix_promotion(x1, x2)
---> 91     return f(x1, x2, **kwargs)

TypeError: maximum(): argument 'other' (position 2) must be Tensor, not numpy.float64
```
created_at: 2025-04-08T13:33:44Z
author_association: MEMBER
diff_hunk: @@ -410,6 +411,78 @@ def _matmul_array_vals():
                     x.__imatmul__(y)
 
 
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items()
+        if dtypes not in ("real numeric", "floating-point")
+    ],
+)
+def test_binary_operators_vs_numpy_int(op):
+    """np.int64 is not a subclass of int and must be disallowed"""
+    a = asarray(1)
+    i64 = np.int64(1)
+    with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+        getattr(a, op)(i64)
+
+
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items() 
+        if dtypes not in ("integer", "integer or boolean")
+    ],
+)
+def test_binary_operators_vs_numpy_float(op):
+    """
+    np.float64 is a subclass of float and must be allowed.
path: array_api_strict/tests/test_array_object.py
user: login: crusaderky
body: > In [6]: xp.maximum(xp.asarray(1, dtype=xp.float32), np.float64(2))
> Out[6]: np.float64(2.0)

This looks definitely wrong to me. array-api-strict shouldn't quietly accept a numpy array.
Right now the output is the expected one without explicitly disallowing inheritance:

```python
>>> xp.maximum(xp.asarray(1, dtype=xp.float32), np.float64(2))
Array(2., dtype=array_api_strict.float32)
>>> xp.maximum(xp.asarray(1, dtype=xp.float32), np.asarray(2, dtype=np.float64))
TypeError: Only real numeric dtypes are allowed in maximum(...). Got array_api_strict.float32 and float64.
>>> xp.maximum(xp.asarray(1, dtype=xp.int32), np.int64(2))
TypeError: Only real numeric dtypes are allowed in maximum(...). Got array_api_strict.int32 and int64.
```
created_at: 2025-04-08T13:41:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -410,6 +411,78 @@ def _matmul_array_vals():
                     x.__imatmul__(y)
 
 
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items()
+        if dtypes not in ("real numeric", "floating-point")
+    ],
+)
+def test_binary_operators_vs_numpy_int(op):
+    """np.int64 is not a subclass of int and must be disallowed"""
+    a = asarray(1)
+    i64 = np.int64(1)
+    with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+        getattr(a, op)(i64)
+
+
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items() 
+        if dtypes not in ("integer", "integer or boolean")
+    ],
+)
+def test_binary_operators_vs_numpy_float(op):
+    """
+    np.float64 is a subclass of float and must be allowed.
path: array_api_strict/tests/test_array_object.py
user: login: crusaderky
body: nit: the message of the TypeError is a bit misleading; it should call out explicitly something like `"only Array and python scalars are accepted; got numpy.ndarray`
created_at: 2025-04-08T13:42:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -410,6 +411,78 @@ def _matmul_array_vals():
                     x.__imatmul__(y)
 
 
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items()
+        if dtypes not in ("real numeric", "floating-point")
+    ],
+)
+def test_binary_operators_vs_numpy_int(op):
+    """np.int64 is not a subclass of int and must be disallowed"""
+    a = asarray(1)
+    i64 = np.int64(1)
+    with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+        getattr(a, op)(i64)
+
+
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items() 
+        if dtypes not in ("integer", "integer or boolean")
+    ],
+)
+def test_binary_operators_vs_numpy_float(op):
+    """
+    np.float64 is a subclass of float and must be allowed.
path: array_api_strict/tests/test_array_object.py
user: login: ev-br
body: > This looks definitely wrong to me. array-api-strict shouldn't quietly accept a numpy array.
> Right now the output is the expected one without explicitly disallowing inheritance:

Yes, in my example `xp` is `array_api_compat.numpy`. As your example shows, array-api-strict does the right thing in main, too. So what is this PR fixing?
created_at: 2025-04-08T13:50:38Z
author_association: MEMBER
diff_hunk: @@ -233,15 +233,15 @@ def _check_allowed_dtypes(
 
         return other
 
-    def _check_device(self, other: Array | bool | int | float | complex) -> None:
-        """Check that other is on a device compatible with the current array"""
-        if isinstance(other, (bool, int, float, complex)):
-            return
-        elif isinstance(other, Array):
+    def _check_type_device(self, other: Array | bool | int | float | complex) -> None:
+        """Check that other is either a Python scalar or an array on a device
+        compatible with the current array.
+        """
+        if isinstance(other, Array):
             if self.device != other.device:
                 raise ValueError(f"Arrays from two different devices ({self.device} and {other.device}) can not be combined.")
-        else:
-            raise TypeError(f"Expected Array | python scalar; got {type(other)}")
+        elif not isinstance(other, bool | int | float | complex):
+            raise TypeError(f"Expected Array or Python scalar; got {type(other)}")
path: array_api_strict/_array_object.py
user: login: ev-br
body: NOte to self: the changes to `_check_device` are just cosmetics plus a rename, the functionality is the same.
created_at: 2025-04-22T13:22:44Z
author_association: MEMBER
diff_hunk: @@ -255,30 +255,37 @@ def _check_op_array_scalar(dtypes, a, s, func, func_name, BIG_INT=BIG_INT):
             func(s)
         return False
 
+binary_op_dtypes = {
+    "__add__": "numeric",
+    "__and__": "integer or boolean",
+    "__eq__": "all",
+    "__floordiv__": "real numeric",
+    "__ge__": "real numeric",
+    "__gt__": "real numeric",
+    "__le__": "real numeric",
+    "__lshift__": "integer",
+    "__lt__": "real numeric",
+    "__mod__": "real numeric",
+    "__mul__": "numeric",
+    "__ne__": "all",
+    "__or__": "integer or boolean",
+    "__pow__": "numeric",
+    "__rshift__": "integer",
+    "__sub__": "numeric",
+    "__truediv__": "floating-point",
+    "__xor__": "integer or boolean",
path: array_api_strict/tests/test_array_object.py
user: login: ev-br
body: Note to self: the values here and in `unary_op_types` below correspond to dtype categories from `_dtypes.py`, so these two dicts are similar to those in https://github.com/data-apis/array-api-strict/blob/main/array_api_strict/tests/test_elementwise_functions.py#L36
created_at: 2025-04-22T14:08:58Z
author_association: MEMBER
diff_hunk: @@ -410,6 +411,78 @@ def _matmul_array_vals():
                     x.__imatmul__(y)
 
 
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items()
+        if dtypes not in ("real numeric", "floating-point")
+    ],
+)
+def test_binary_operators_vs_numpy_int(op):
+    """np.int64 is not a subclass of int and must be disallowed"""
+    a = asarray(1)
+    i64 = np.int64(1)
+    with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+        getattr(a, op)(i64)
path: array_api_strict/tests/test_array_object.py
user: login: ev-br
body: Note to self: this test basically tests the status quo, the behavior is the same on main.


created_at: 2025-04-22T14:16:10Z
author_association: MEMBER
diff_hunk: @@ -410,6 +411,78 @@ def _matmul_array_vals():
                     x.__imatmul__(y)
 
 
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items()
+        if dtypes not in ("real numeric", "floating-point")
+    ],
+)
+def test_binary_operators_vs_numpy_int(op):
+    """np.int64 is not a subclass of int and must be disallowed"""
+    a = asarray(1)
+    i64 = np.int64(1)
+    with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+        getattr(a, op)(i64)
+
+
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items() 
+        if dtypes not in ("integer", "integer or boolean")
+    ],
+)
+def test_binary_operators_vs_numpy_float(op):
+    """
+    np.float64 is a subclass of float and must be allowed.
+    np.float32 is not and must be rejected.
+    """
+    a = asarray(1.)
+    f64 = np.float64(1.)
+    f32 = np.float32(1.)
+    func = getattr(a, op)
+    for op in binary_op_dtypes:
+        assert isinstance(func(f64), Array)
+        with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+            func(f32)
path: array_api_strict/tests/test_array_object.py
user: login: ev-br
body: This test also reflects the status quo: the behavior is exactly the same on main.
created_at: 2025-04-22T14:33:13Z
author_association: MEMBER
diff_hunk: @@ -410,6 +411,78 @@ def _matmul_array_vals():
                     x.__imatmul__(y)
 
 
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items()
+        if dtypes not in ("real numeric", "floating-point")
+    ],
+)
+def test_binary_operators_vs_numpy_int(op):
+    """np.int64 is not a subclass of int and must be disallowed"""
+    a = asarray(1)
+    i64 = np.int64(1)
+    with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+        getattr(a, op)(i64)
+
+
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items() 
+        if dtypes not in ("integer", "integer or boolean")
+    ],
+)
+def test_binary_operators_vs_numpy_float(op):
+    """
+    np.float64 is a subclass of float and must be allowed.
+    np.float32 is not and must be rejected.
+    """
+    a = asarray(1.)
+    f64 = np.float64(1.)
+    f32 = np.float32(1.)
+    func = getattr(a, op)
+    for op in binary_op_dtypes:
+        assert isinstance(func(f64), Array)
+        with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+            func(f32)
+
+
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items()
+        if dtypes not in ("integer", "integer or boolean", "real numeric")
+    ],
+)
+def test_binary_operators_vs_numpy_complex(op):
+    """
+    np.complex128 is a subclass of complex and must be allowed.
+    np.complex64 is not and must be rejected.
+    """
+    a = asarray(1.)
+    c64 = np.complex64(1.)
+    c128 = np.complex128(1.)
+    func = getattr(a, op)
+    for op in binary_op_dtypes:
+        assert isinstance(func(c128), Array)
+        with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+            func(c64)
+
+
+@pytest.mark.parametrize("op,dtypes", binary_op_dtypes.items())
+def test_binary_operators_device_mismatch(op, dtypes):
+    if dtypes in ("real numeric", "floating-point"):
+        dtype = float64
+    else:
+        dtype = int64
+    
+    a = asarray(1, dtype=dtype, device=CPU_DEVICE)
+    b = asarray(1, dtype=dtype, device=Device("device1"))
+    with pytest.raises(ValueError, match="different devices"):
+        getattr(a, op)(b)
path: array_api_strict/tests/test_array_object.py
user: login: ev-br
body: This test would be great to extend to binary functions, too. I'd expect them to work just the same, because they reuse the `._promote_scalar` logic. If `xp.add(a, b)` differs from `a.__add__(b)`, it's a bug, so would be great to test for.
created_at: 2025-04-22T14:35:26Z
author_association: MEMBER
diff_hunk: @@ -410,6 +411,78 @@ def _matmul_array_vals():
                     x.__imatmul__(y)
 
 
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items()
+        if dtypes not in ("real numeric", "floating-point")
+    ],
+)
+def test_binary_operators_vs_numpy_int(op):
+    """np.int64 is not a subclass of int and must be disallowed"""
+    a = asarray(1)
+    i64 = np.int64(1)
+    with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+        getattr(a, op)(i64)
+
+
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items() 
+        if dtypes not in ("integer", "integer or boolean")
+    ],
+)
+def test_binary_operators_vs_numpy_float(op):
+    """
+    np.float64 is a subclass of float and must be allowed.
+    np.float32 is not and must be rejected.
+    """
+    a = asarray(1.)
+    f64 = np.float64(1.)
+    f32 = np.float32(1.)
+    func = getattr(a, op)
+    for op in binary_op_dtypes:
+        assert isinstance(func(f64), Array)
+        with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+            func(f32)
+
+
+@pytest.mark.parametrize(
+    "op",
+    [
+        op for op, dtypes in binary_op_dtypes.items()
+        if dtypes not in ("integer", "integer or boolean", "real numeric")
+    ],
+)
+def test_binary_operators_vs_numpy_complex(op):
+    """
+    np.complex128 is a subclass of complex and must be allowed.
+    np.complex64 is not and must be rejected.
+    """
+    a = asarray(1.)
+    c64 = np.complex64(1.)
+    c128 = np.complex128(1.)
+    func = getattr(a, op)
+    for op in binary_op_dtypes:
+        assert isinstance(func(c128), Array)
+        with pytest.raises(TypeError, match="Expected Array or Python scalar"):
+            func(c64)
+
+
+@pytest.mark.parametrize("op,dtypes", binary_op_dtypes.items())
+def test_binary_operators_device_mismatch(op, dtypes):
+    if dtypes in ("real numeric", "floating-point"):
+        dtype = float64
+    else:
+        dtype = int64
+    
+    a = asarray(1, dtype=dtype, device=CPU_DEVICE)
+    b = asarray(1, dtype=dtype, device=Device("device1"))
+    with pytest.raises(ValueError, match="different devices"):
+        getattr(a, op)(b)
path: array_api_strict/tests/test_array_object.py
user: login: crusaderky
body: It turns out extending to binary functions caused the scope blow up, so I moved it to a separate PR: #148
created_at: 2025-04-23T12:44:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: crusaderky
created_at: 2025-04-23T12:47:33Z
body: I've reworked this PR to disallow np.float64 and np.complex128 from being treated as float and complex in Array methods.
The same treatment for functions is in #148.
This is ready for a second round of review.

author_association: CONTRIBUTOR
user: login: crusaderky
created_at: 2025-04-24T08:52:05Z
body: > There is a non-zero chance that this causes issues downstream. However I believe that the change is correct and useful and it's downstream that should adjust to better align with the spec.

Well, the whole purpose of array-api-strict is to cause issues early during testing.
author_association: CONTRIBUTOR
user: login: ev-br
created_at: 2025-04-24T09:07:27Z
body: > Well, the whole purpose of array-api-strict is to cause issues early during testing.

Exactly. My only point is that if the patch proves too disruptive downstream, we can temporarily revert to give upstream time to adapt. Happened with `__array__` already.
author_association: MEMBER
user: login: mdhaber
created_at: 2025-08-03T22:12:45Z
body: Haven't worked on `marray` for a while, but I think this broke all reflected operations. I'm not seeing a way around it because, for instance, `__add__` raises before an `marray` `__radd__` could be called. Is that supposed to happen?
author_association: NONE
user: login: crusaderky
created_at: 2025-08-04T11:03:50Z
body: > `__add__` raises before an `marray` `__radd__` could be called. Is that supposed to happen?

Yes.

array-api-strict, by design, explicitly disallows everything that in the Array API standard is left as undefined.
This includes interoperability between different Array API-compatible libraries outside of the two APIs that explicitly allow for it, which are `__buffer__` -> `asarray` and `__dlpack__` -> `from_dlpack`.

Adding a naked array-api-strict object to a marray that wraps around array-api-strict falls neatly in the "undefined in Array API, therefore forbidden in array-api-strict" category. If marray supports this operation, great, but it should not expect another arbitrary Array API library to do the same.

The expectation in libraries such as scipy is that all your inputs use the same Array API library.

author_association: CONTRIBUTOR