Repository: astropenguin/xarray-dataclasses (python)

Issue:
Add bases module
Add bases module which provides `DataArrayClass`, a base class for dataclasses.

```python
from xarray_dataclasses import DataArray, DataArrayClass


class Image(DataArrayClass):
    data: DataArray[("x", "y"), float]
    x: DataArray["x", int] = 0
    y: DataArray["y", int] = 0
```

Pull request description:
Add bases module
This PR closes #5.

Patch:
diff --git a/xarray_dataclasses/__init__.py b/xarray_dataclasses/__init__.py
index 993a3bb..38e2c5f 100644
--- a/xarray_dataclasses/__init__.py
+++ b/xarray_dataclasses/__init__.py
@@ -4,8 +4,14 @@ __version__ = "0.1.1"
 
 
 # submodules
+from . import bases
 from . import typing
 
 
 # aliases
+from .bases import *
 from .typing import *
+
+
+# for sphinx docs
+__all__ = dir()
diff --git a/xarray_dataclasses/bases.py b/xarray_dataclasses/bases.py
new file mode 100644
index 0000000..512db61
--- /dev/null
+++ b/xarray_dataclasses/bases.py
@@ -0,0 +1,181 @@
+__all__ = [
+    "DataArrayClass",
+    "is_dataarrayclass",
+]
+
+
+# standard library
+from dataclasses import dataclass, Field, is_dataclass
+from typing import Any, Dict, Optional
+
+
+# dependencies
+import numpy as np
+import xarray as xr
+from .typing import DataArray
+
+
+# constants
+DATA = "data"
+
+
+# main features
+class DataArrayClassMeta(type):
+    """Metaclass of ``DataArrayClass``."""
+
+    def __instancecheck__(cls, inst: Any) -> bool:
+        if not isinstance(inst, get_data_field(cls).type):
+            return False
+
+        for name, field in get_coords_fields(cls).items():
+            if name not in inst.coords:
+                return False
+
+            if not isinstance(inst.coords[name], field.type):
+                return False
+
+        return True
+
+
+@dataclass
+class DataArrayClass(metaclass=DataArrayClassMeta):
+    """Base class for dataarrayclasses."""
+
+    data: DataArray  #: Values for a ``DataArray`` instance.
+
+    def to_dataarray(
+        self,
+        name: Optional[str] = None,
+        attrs: Optional[dict] = None,
+    ) -> xr.DataArray:
+        """Convert the instance to a ``DataArray`` instance.
+
+        Args:
+            name: Name of the ``DataArray`` instance.
+            attrs: Attributes of the ``DataArray`` instance.
+
+        Returns:
+            ``DataArray`` instance with fixed ``dims`` and ``dtype``.
+
+        """
+        da = get_data_field(self).type(self.data, name=name, attrs=attrs)
+
+        for name, field in get_coords_fields(self).items():
+            shape = [da.sizes[dim] for dim in field.type.dims]
+            value = getattr(self, name)
+
+            try:
+                coord = field.type(value)
+            except ValueError:
+                coord = field.type(np.full(shape, value))
+
+            da.coords[name] = coord
+
+        return da
+
+    def __init_subclass__(cls) -> None:
+        """Check if subclass is a valid dataarrayclass."""
+
+        init_error = ValueError(
+            "Failed to initialize a subclass. "
+            "Please check if the following conditions are fulfilled. "
+            "1. The subclass has a data field whose type is DataArray. "
+            "2. All ``dims`` of coords are subsets of data ``dims``."
+        )
+
+        dataclass(cls)
+
+        if not is_dataarrayclass(cls):
+            raise init_error
+
+
+def is_dataarrayclass(obj: Any) -> bool:
+    """Check if obj is a dataarrayclass or its instance.
+
+    It returns ``True`` if ``obj`` fulfills all the
+    following conditions or ``False`` otherwise.
+
+    1. ``obj`` is a Python's native dataclass.
+    2. ``obj`` has a data field whose type is DataArray.
+    3. All ``dims`` of coords are subsets of data ``dims``.
+
+    Args:
+        obj: Object to be checked.
+
+    Returns:
+        ``True`` if ``obj`` is a valid dataarrayclass
+        or its instance or ``False`` otherwise.
+
+    """
+    if not is_dataclass(obj):
+        return False
+
+    try:
+        data_field = get_data_field(obj)
+    except (KeyError, ValueError):
+        return False
+
+    data_dims = data_field.type.dims
+
+    if data_dims is None:
+        data_dims_set = set()
+    else:
+        data_dims_set = set(data_dims)
+
+    for field in get_coords_fields(obj).values():
+        coord_dims = field.type.dims
+
+        # Coord dims must be fixed.
+        if coord_dims is None:
+            return False
+
+        # Coord dims must be empty if data dims is not fixed.
+        # Otherwise, it must be a subset of data dims.
+        if not set(coord_dims).issubset(data_dims_set):
+            return False
+
+    return True
+
+
+# helper features
+def get_data_field(obj: DataArrayClass) -> Field:
+    """Return data field if it exists and is valid.
+
+    Args:
+        obj: Dataarrayclass or its instance.
+
+    Returns:
+        Data field if it exists and is valid.
+
+    Raises:
+        KeyError: Raised if data field does not exist.
+        ValueError: Raised if data field has an invalid type.
+
+    """
+    try:
+        data_field = obj.__dataclass_fields__[DATA]
+    except KeyError:
+        raise KeyError("Data field does not exist.")
+
+    if not issubclass(data_field.type, DataArray):
+        raise ValueError("Data field has an invalid type.")
+
+    return data_field
+
+
+def get_coords_fields(obj: DataArrayClass) -> Dict[str, Field]:
+    """Return fields of DataArray coordinates.
+
+    Args:
+        obj: Dataarrayclass or its instance.
+
+    Returns:
+        Fields of DataArray coordinates.
+
+    """
+
+    def condition(name, field):
+        return name != DATA and issubclass(field.type, DataArray)
+
+    fields = obj.__dataclass_fields__
+    return {k: v for k, v in fields.items() if condition(k, v)}
diff --git a/xarray_dataclasses/typing.py b/xarray_dataclasses/typing.py
index d8b1b54..0e4541d 100644
--- a/xarray_dataclasses/typing.py
+++ b/xarray_dataclasses/typing.py
@@ -2,7 +2,7 @@ __all__ = ["DataArray"]
 
 
 # standard library
-from typing import Any, Optional, Sequence, Tuple, Type, Union
+from typing import Any, Optional, Sequence, Tuple, Union
 
 
 # dependencies
@@ -12,14 +12,14 @@ import xarray as xr
 
 # type aliases
 Dims = Optional[Sequence[str]]
-Dtype = Optional[Union[Type, str]]
+Dtype = Optional[Union[type, str]]
 
 
 # main features
 class DataArrayMeta(type):
-    """Metaclass for the DataArray class."""
+    """Metaclass of ``DataArray``."""
 
-    def __getitem__(cls, options: Tuple[Dims, Dtype]) -> Type:
+    def __getitem__(cls, options: Tuple[Dims, Dtype]) -> type:
         try:
             dims, dtype = options
         except (ValueError, TypeError):
@@ -46,12 +46,12 @@ class DataArrayMeta(type):
             return False
 
         if cls.dims is None:
-            is_equal_dims = True  # do not evaluate
+            is_equal_dims = True  # Do not evaluate.
         else:
             is_equal_dims = inst.dims == cls.dims
 
         if cls.dtype is None:
-            is_equal_dtype = True  # do not evaluate
+            is_equal_dtype = True  # Do not evaluate.
         else:
             is_equal_dtype = inst.dtype == cls.dtype
 


Test patch:
diff --git a/tests/test_bases.py b/tests/test_bases.py
new file mode 100644
index 0000000..9773225
--- /dev/null
+++ b/tests/test_bases.py
@@ -0,0 +1,43 @@
+# standard library
+from dataclasses import dataclass
+
+
+# dependencies
+from pytest import mark
+from xarray_dataclasses.bases import is_dataarrayclass
+from xarray_dataclasses.typing import DataArray
+
+
+# test datasets
+@dataclass
+class Valid:
+    data: DataArray[("x", "y"), float]
+    x: DataArray["x", int] = 0
+    y: DataArray["y", int] = 0
+
+
+@dataclass
+class InvalidDataType:
+    data: DataArray
+    x: DataArray["x", int] = 0
+    y: DataArray["y", int] = 0
+
+
+@dataclass
+class InvalidCoordsType:
+    data: DataArray[("x", "y"), float]
+    x: DataArray["z", int] = 0  # noqa
+    y: DataArray["z", int] = 0  # noqa
+
+
+testdata_subclass = [
+    (Valid, True),
+    (InvalidDataType, False),
+    (InvalidCoordsType, False),
+]
+
+
+# test functions
+@mark.parametrize("cls, expected", testdata_subclass)
+def test_subclass(cls, expected):
+    assert is_dataarrayclass(cls) == expected