from __future__ import annotations import copy import sys from abc import abstractmethod from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Generic, cast, overload import numpy as np import pytest from packaging.version import Version from xarray.core.indexing import ExplicitlyIndexed from xarray.namedarray._typing import ( _arrayfunction_or_api, _default, _DType_co, _ShapeType_co, ) from xarray.namedarray.core import NamedArray, from_array if TYPE_CHECKING: from types import ModuleType from numpy.typing import ArrayLike, DTypeLike, NDArray from xarray.namedarray._typing import ( Default, _AttrsLike, _Dim, _DimsLike, _DType, _IndexKeyLike, _IntOrUnknown, _Shape, _ShapeLike, duckarray, ) class CustomArrayBase(Generic[_ShapeType_co, _DType_co]): def __init__(self, array: duckarray[Any, _DType_co]) -> None: self.array: duckarray[Any, _DType_co] = array @property def dtype(self) -> _DType_co: return self.array.dtype @property def shape(self) -> _Shape: return self.array.shape class CustomArray( CustomArrayBase[_ShapeType_co, _DType_co], Generic[_ShapeType_co, _DType_co] ): def __array__( self, dtype: np.typing.DTypeLike = None, /, *, copy: bool | None = None ) -> np.ndarray[Any, np.dtype[np.generic]]: if Version(np.__version__) >= Version("2.0.0"): return np.asarray(self.array, dtype=dtype, copy=copy) else: return np.asarray(self.array, dtype=dtype) class CustomArrayIndexable( CustomArrayBase[_ShapeType_co, _DType_co], ExplicitlyIndexed, Generic[_ShapeType_co, _DType_co], ): def __getitem__( self, key: _IndexKeyLike | CustomArrayIndexable[Any, Any], / ) -> CustomArrayIndexable[Any, _DType_co]: if isinstance(key, CustomArrayIndexable): if isinstance(key.array, type(self.array)): # TODO: key.array is duckarray here, can it be narrowed down further? # an _arrayapi cannot be used on a _arrayfunction for example. return type(self)(array=self.array[key.array]) # type: ignore[index] else: raise TypeError("key must have the same array type as self") else: return type(self)(array=self.array[key]) def __array_namespace__(self) -> ModuleType: return np def check_duck_array_typevar(a: duckarray[Any, _DType]) -> duckarray[Any, _DType]: # Mypy checks a is valid: b: duckarray[Any, _DType] = a # Runtime check if valid: if isinstance(b, _arrayfunction_or_api): return b else: missing_attrs = "" actual_attrs = set(dir(b)) for t in _arrayfunction_or_api: if sys.version_info >= (3, 13): # https://github.com/python/cpython/issues/104873 from typing import get_protocol_members expected_attrs = get_protocol_members(t) elif sys.version_info >= (3, 12): expected_attrs = t.__protocol_attrs__ else: from typing import _get_protocol_attrs # type: ignore[attr-defined] expected_attrs = _get_protocol_attrs(t) missing_attrs_ = expected_attrs - actual_attrs if missing_attrs_: missing_attrs += f"{t.__name__} - {missing_attrs_}\n" raise TypeError( f"a ({type(a)}) is not a valid _arrayfunction or _arrayapi. " "Missing following attrs:\n" f"{missing_attrs}" ) class NamedArraySubclassobjects: @pytest.fixture def target(self, data: np.ndarray[Any, Any]) -> Any: """Fixture that needs to be overridden""" raise NotImplementedError @abstractmethod def cls(self, *args: Any, **kwargs: Any) -> Any: """Method that needs to be overridden""" raise NotImplementedError @pytest.fixture def data(self) -> np.ndarray[Any, np.dtype[Any]]: return 0.5 * np.arange(10).reshape(2, 5) @pytest.fixture def random_inputs(self) -> np.ndarray[Any, np.dtype[np.float32]]: return np.arange(3 * 4 * 5, dtype=np.float32).reshape((3, 4, 5)) def test_properties(self, target: Any, data: Any) -> None: assert target.dims == ("x", "y") assert np.array_equal(target.data, data) assert target.dtype == float assert target.shape == (2, 5) assert target.ndim == 2 assert target.sizes == {"x": 2, "y": 5} assert target.size == 10 assert target.nbytes == 80 assert len(target) == 2 def test_attrs(self, target: Any) -> None: assert target.attrs == {} attrs = {"foo": "bar"} target.attrs = attrs assert target.attrs == attrs assert isinstance(target.attrs, dict) target.attrs["foo"] = "baz" assert target.attrs["foo"] == "baz" @pytest.mark.parametrize( "expected", [np.array([1, 2], dtype=np.dtype(np.int8)), [1, 2]] ) def test_init(self, expected: Any) -> None: actual = self.cls(("x",), expected) assert np.array_equal(np.asarray(actual.data), expected) actual = self.cls(("x",), expected) assert np.array_equal(np.asarray(actual.data), expected) def test_data(self, random_inputs: Any) -> None: expected = self.cls(["x", "y", "z"], random_inputs) assert np.array_equal(np.asarray(expected.data), random_inputs) with pytest.raises(ValueError): expected.data = np.random.random((3, 4)).astype(np.float64) d2 = np.arange(3 * 4 * 5, dtype=np.float32).reshape((3, 4, 5)) expected.data = d2 assert np.array_equal(np.asarray(expected.data), d2) class TestNamedArray(NamedArraySubclassobjects): def cls(self, *args: Any, **kwargs: Any) -> NamedArray[Any, Any]: return NamedArray(*args, **kwargs) @pytest.fixture def target(self, data: np.ndarray[Any, Any]) -> NamedArray[Any, Any]: return NamedArray(["x", "y"], data) @pytest.mark.parametrize( "expected", [ np.array([1, 2], dtype=np.dtype(np.int8)), pytest.param( [1, 2], marks=pytest.mark.xfail( reason="NamedArray only supports array-like objects" ), ), ], ) def test_init(self, expected: Any) -> None: super().test_init(expected) @pytest.mark.parametrize( "dims, data, expected, raise_error", [ (("x",), [1, 2, 3], np.array([1, 2, 3]), False), ((1,), np.array([4, 5, 6]), np.array([4, 5, 6]), False), ((), 2, np.array(2), False), # Fail: ( ("x",), NamedArray("time", np.array([1, 2, 3], dtype=np.dtype(np.int64))), np.array([1, 2, 3]), True, ), ], ) def test_from_array( self, dims: _DimsLike, data: ArrayLike, expected: np.ndarray[Any, Any], raise_error: bool, ) -> None: actual: NamedArray[Any, Any] if raise_error: with pytest.raises(TypeError, match="already a Named array"): actual = from_array(dims, data) # Named arrays are not allowed: from_array(actual) # type: ignore[call-overload] else: actual = from_array(dims, data) assert np.array_equal(np.asarray(actual.data), expected) def test_from_array_with_masked_array(self) -> None: masked_array: np.ndarray[Any, np.dtype[np.generic]] masked_array = np.ma.array([1, 2, 3], mask=[False, True, False]) # type: ignore[no-untyped-call] with pytest.raises(NotImplementedError): from_array(("x",), masked_array) def test_from_array_with_0d_object(self) -> None: data = np.empty((), dtype=object) data[()] = (10, 12, 12) narr = from_array((), data) np.array_equal(np.asarray(narr.data), data) # TODO: Make xr.core.indexing.ExplicitlyIndexed pass as a subclass of_arrayfunction_or_api # and remove this test. def test_from_array_with_explicitly_indexed( self, random_inputs: np.ndarray[Any, Any] ) -> None: array: CustomArray[Any, Any] array = CustomArray(random_inputs) output: NamedArray[Any, Any] output = from_array(("x", "y", "z"), array) assert isinstance(output.data, np.ndarray) array2: CustomArrayIndexable[Any, Any] array2 = CustomArrayIndexable(random_inputs) output2: NamedArray[Any, Any] output2 = from_array(("x", "y", "z"), array2) assert isinstance(output2.data, CustomArrayIndexable) def test_real_and_imag(self) -> None: expected_real: np.ndarray[Any, np.dtype[np.float64]] expected_real = np.arange(3, dtype=np.float64) expected_imag: np.ndarray[Any, np.dtype[np.float64]] expected_imag = -np.arange(3, dtype=np.float64) arr: np.ndarray[Any, np.dtype[np.complex128]] arr = expected_real + 1j * expected_imag named_array: NamedArray[Any, np.dtype[np.complex128]] named_array = NamedArray(["x"], arr) actual_real: duckarray[Any, np.dtype[np.float64]] = named_array.real.data assert np.array_equal(np.asarray(actual_real), expected_real) assert actual_real.dtype == expected_real.dtype actual_imag: duckarray[Any, np.dtype[np.float64]] = named_array.imag.data assert np.array_equal(np.asarray(actual_imag), expected_imag) assert actual_imag.dtype == expected_imag.dtype # Additional tests as per your original class-based code @pytest.mark.parametrize( "data, dtype", [ ("foo", np.dtype("U3")), (b"foo", np.dtype("S3")), ], ) def test_from_array_0d_string(self, data: Any, dtype: DTypeLike) -> None: named_array: NamedArray[Any, Any] named_array = from_array([], data) assert named_array.data == data assert named_array.dims == () assert named_array.sizes == {} assert named_array.attrs == {} assert named_array.ndim == 0 assert named_array.size == 1 assert named_array.dtype == dtype def test_from_array_0d_object(self) -> None: named_array: NamedArray[Any, Any] named_array = from_array([], (10, 12, 12)) expected_data = np.empty((), dtype=object) expected_data[()] = (10, 12, 12) assert np.array_equal(np.asarray(named_array.data), expected_data) assert named_array.dims == () assert named_array.sizes == {} assert named_array.attrs == {} assert named_array.ndim == 0 assert named_array.size == 1 assert named_array.dtype == np.dtype("O") def test_from_array_0d_datetime(self) -> None: named_array: NamedArray[Any, Any] named_array = from_array([], np.datetime64("2000-01-01")) assert named_array.dtype == np.dtype("datetime64[D]") @pytest.mark.parametrize( "timedelta, expected_dtype", [ (np.timedelta64(1, "D"), np.dtype("timedelta64[D]")), (np.timedelta64(1, "s"), np.dtype("timedelta64[s]")), (np.timedelta64(1, "m"), np.dtype("timedelta64[m]")), (np.timedelta64(1, "h"), np.dtype("timedelta64[h]")), (np.timedelta64(1, "us"), np.dtype("timedelta64[us]")), (np.timedelta64(1, "ns"), np.dtype("timedelta64[ns]")), (np.timedelta64(1, "ps"), np.dtype("timedelta64[ps]")), (np.timedelta64(1, "fs"), np.dtype("timedelta64[fs]")), (np.timedelta64(1, "as"), np.dtype("timedelta64[as]")), ], ) def test_from_array_0d_timedelta( self, timedelta: np.timedelta64, expected_dtype: np.dtype[np.timedelta64] ) -> None: named_array: NamedArray[Any, Any] named_array = from_array([], timedelta) assert named_array.dtype == expected_dtype assert named_array.data == timedelta @pytest.mark.parametrize( "dims, data_shape, new_dims, raises", [ (["x", "y", "z"], (2, 3, 4), ["a", "b", "c"], False), (["x", "y", "z"], (2, 3, 4), ["a", "b"], True), (["x", "y", "z"], (2, 4, 5), ["a", "b", "c", "d"], True), ([], [], (), False), ([], [], ("x",), True), ], ) def test_dims_setter( self, dims: Any, data_shape: Any, new_dims: Any, raises: bool ) -> None: named_array: NamedArray[Any, Any] named_array = NamedArray(dims, np.asarray(np.random.random(data_shape))) assert named_array.dims == tuple(dims) if raises: with pytest.raises(ValueError): named_array.dims = new_dims else: named_array.dims = new_dims assert named_array.dims == tuple(new_dims) def test_duck_array_class(self) -> None: numpy_a: NDArray[np.int64] numpy_a = np.array([2.1, 4], dtype=np.dtype(np.int64)) check_duck_array_typevar(numpy_a) masked_a: np.ma.MaskedArray[Any, np.dtype[np.int64]] masked_a = np.ma.asarray([2.1, 4], dtype=np.dtype(np.int64)) # type: ignore[no-untyped-call] check_duck_array_typevar(masked_a) custom_a: CustomArrayIndexable[Any, np.dtype[np.int64]] custom_a = CustomArrayIndexable(numpy_a) check_duck_array_typevar(custom_a) def test_duck_array_class_array_api(self) -> None: # Test numpy's array api: nxp = pytest.importorskip("array_api_strict", minversion="1.0") # TODO: nxp doesn't use dtype typevars, so can only use Any for the moment: arrayapi_a: duckarray[Any, Any] # duckarray[Any, np.dtype[np.int64]] arrayapi_a = nxp.asarray([2.1, 4], dtype=nxp.int64) check_duck_array_typevar(arrayapi_a) def test_new_namedarray(self) -> None: dtype_float = np.dtype(np.float32) narr_float: NamedArray[Any, np.dtype[np.float32]] narr_float = NamedArray(("x",), np.array([1.5, 3.2], dtype=dtype_float)) assert narr_float.dtype == dtype_float dtype_int = np.dtype(np.int8) narr_int: NamedArray[Any, np.dtype[np.int8]] narr_int = narr_float._new(("x",), np.array([1, 3], dtype=dtype_int)) assert narr_int.dtype == dtype_int class Variable( NamedArray[_ShapeType_co, _DType_co], Generic[_ShapeType_co, _DType_co] ): @overload def _new( self, dims: _DimsLike | Default = ..., data: duckarray[Any, _DType] = ..., attrs: _AttrsLike | Default = ..., ) -> Variable[Any, _DType]: ... @overload def _new( self, dims: _DimsLike | Default = ..., data: Default = ..., attrs: _AttrsLike | Default = ..., ) -> Variable[_ShapeType_co, _DType_co]: ... def _new( self, dims: _DimsLike | Default = _default, data: duckarray[Any, _DType] | Default = _default, attrs: _AttrsLike | Default = _default, ) -> Variable[Any, _DType] | Variable[_ShapeType_co, _DType_co]: dims_ = copy.copy(self._dims) if dims is _default else dims attrs_: Mapping[Any, Any] | None if attrs is _default: attrs_ = None if self._attrs is None else self._attrs.copy() else: attrs_ = attrs if data is _default: return type(self)(dims_, copy.copy(self._data), attrs_) cls_ = cast("type[Variable[Any, _DType]]", type(self)) return cls_(dims_, data, attrs_) var_float: Variable[Any, np.dtype[np.float32]] var_float = Variable(("x",), np.array([1.5, 3.2], dtype=dtype_float)) assert var_float.dtype == dtype_float var_int: Variable[Any, np.dtype[np.int8]] var_int = var_float._new(("x",), np.array([1, 3], dtype=dtype_int)) assert var_int.dtype == dtype_int def test_replace_namedarray(self) -> None: dtype_float = np.dtype(np.float32) np_val: np.ndarray[Any, np.dtype[np.float32]] np_val = np.array([1.5, 3.2], dtype=dtype_float) np_val2: np.ndarray[Any, np.dtype[np.float32]] np_val2 = 2 * np_val narr_float: NamedArray[Any, np.dtype[np.float32]] narr_float = NamedArray(("x",), np_val) assert narr_float.dtype == dtype_float narr_float2: NamedArray[Any, np.dtype[np.float32]] narr_float2 = NamedArray(("x",), np_val2) assert narr_float2.dtype == dtype_float class Variable( NamedArray[_ShapeType_co, _DType_co], Generic[_ShapeType_co, _DType_co] ): @overload def _new( self, dims: _DimsLike | Default = ..., data: duckarray[Any, _DType] = ..., attrs: _AttrsLike | Default = ..., ) -> Variable[Any, _DType]: ... @overload def _new( self, dims: _DimsLike | Default = ..., data: Default = ..., attrs: _AttrsLike | Default = ..., ) -> Variable[_ShapeType_co, _DType_co]: ... def _new( self, dims: _DimsLike | Default = _default, data: duckarray[Any, _DType] | Default = _default, attrs: _AttrsLike | Default = _default, ) -> Variable[Any, _DType] | Variable[_ShapeType_co, _DType_co]: dims_ = copy.copy(self._dims) if dims is _default else dims attrs_: Mapping[Any, Any] | None if attrs is _default: attrs_ = None if self._attrs is None else self._attrs.copy() else: attrs_ = attrs if data is _default: return type(self)(dims_, copy.copy(self._data), attrs_) cls_ = cast("type[Variable[Any, _DType]]", type(self)) return cls_(dims_, data, attrs_) var_float: Variable[Any, np.dtype[np.float32]] var_float = Variable(("x",), np_val) assert var_float.dtype == dtype_float var_float2: Variable[Any, np.dtype[np.float32]] var_float2 = var_float._replace(("x",), np_val2) assert var_float2.dtype == dtype_float @pytest.mark.parametrize( "dim,expected_ndim,expected_shape,expected_dims", [ (None, 3, (1, 2, 5), (None, "x", "y")), (_default, 3, (1, 2, 5), ("dim_2", "x", "y")), ("z", 3, (1, 2, 5), ("z", "x", "y")), ], ) def test_expand_dims( self, target: NamedArray[Any, np.dtype[np.float32]], dim: _Dim | Default, expected_ndim: int, expected_shape: _ShapeLike, expected_dims: _DimsLike, ) -> None: result = target.expand_dims(dim=dim) assert result.ndim == expected_ndim assert result.shape == expected_shape assert result.dims == expected_dims @pytest.mark.parametrize( "dims, expected_sizes", [ ((), {"y": 5, "x": 2}), (["y", "x"], {"y": 5, "x": 2}), (["y", ...], {"y": 5, "x": 2}), ], ) def test_permute_dims( self, target: NamedArray[Any, np.dtype[np.float32]], dims: _DimsLike, expected_sizes: dict[_Dim, _IntOrUnknown], ) -> None: actual = target.permute_dims(*dims) assert actual.sizes == expected_sizes def test_permute_dims_errors( self, target: NamedArray[Any, np.dtype[np.float32]], ) -> None: with pytest.raises(ValueError, match=r"'y'.*permuted list"): dims = ["y"] target.permute_dims(*dims) @pytest.mark.parametrize( "broadcast_dims,expected_ndim", [ ({"x": 2, "y": 5}, 2), ({"x": 2, "y": 5, "z": 2}, 3), ({"w": 1, "x": 2, "y": 5}, 3), ], ) def test_broadcast_to( self, target: NamedArray[Any, np.dtype[np.float32]], broadcast_dims: Mapping[_Dim, int], expected_ndim: int, ) -> None: expand_dims = set(broadcast_dims.keys()) - set(target.dims) # loop over expand_dims and call .expand_dims(dim=dim) in a loop for dim in expand_dims: target = target.expand_dims(dim=dim) result = target.broadcast_to(broadcast_dims) assert result.ndim == expected_ndim assert result.sizes == broadcast_dims def test_broadcast_to_errors( self, target: NamedArray[Any, np.dtype[np.float32]] ) -> None: with pytest.raises( ValueError, match=r"operands could not be broadcast together with remapped shapes", ): target.broadcast_to({"x": 2, "y": 2}) with pytest.raises(ValueError, match=r"Cannot add new dimensions"): target.broadcast_to({"x": 2, "y": 2, "z": 2}) def test_warn_on_repeated_dimension_names(self) -> None: with pytest.warns(UserWarning, match="Duplicate dimension names"): NamedArray(("x", "x"), np.arange(4).reshape(2, 2)) def test_aggregation(self) -> None: x: NamedArray[Any, np.dtype[np.int64]] x = NamedArray(("x", "y"), np.arange(4).reshape(2, 2)) result = x.sum() assert isinstance(result.data, np.ndarray) def test_repr() -> None: x: NamedArray[Any, np.dtype[np.uint64]] x = NamedArray(("x",), np.array([0], dtype=np.uint64)) # Reprs should not crash: r = x.__repr__() x._repr_html_() # Basic comparison: assert r == " Size: 8B\narray([0], dtype=uint64)"