Repository: astropenguin/pandas-dataclasses

Issue:
Support for multiple items received in a single field
This issue will introduce a new type hint, `Multiple[T]`, an alias of `dict[str, T]` with a dedicated tag, `Tag.MULTIPLE`. Combined with other type hints (e.g. `Multiple[Data[Any]]`), it will enable the corresponding field receive a dictionary of data, indexes, or attributes. For example, the `attrs` field in the following code will accept a dictionary of any values which will be stored in `attrs` in a DataFrame.

```python
from dataclasses import dataclass, field
from pandas_dataclasses import AsFrame, Attr, Data, Index, Multiple


@dataclass
class Weather(AsFrame):
    """Weather information."""

    year: Index[int]
    month: Index[int]
    temp: Data[float]
    wind: Data[float]
    attrs: Multiple[Attr[Any]] = field(default_factory=dict)
```

Discussion:

Patch:
diff --git a/pandas_dataclasses/__init__.py b/pandas_dataclasses/__init__.py
index 590fca2..a5d2e9d 100644
--- a/pandas_dataclasses/__init__.py
+++ b/pandas_dataclasses/__init__.py
@@ -7,6 +7,7 @@ __all__ = [
     "Column",
     "Data",
     "Index",
+    "Multiple",
     "Spec",
     "Tag",
     "asdataframe",
diff --git a/pandas_dataclasses/core/api.py b/pandas_dataclasses/core/api.py
index 5a7d400..a449087 100644
--- a/pandas_dataclasses/core/api.py
+++ b/pandas_dataclasses/core/api.py
@@ -3,7 +3,7 @@ __all__ = ["asframe", "aspandas", "asseries"]
 
 # standard library
 from types import FunctionType
-from typing import Any, Callable, Dict, Hashable, List, Optional, overload
+from typing import Any, Callable, Dict, Hashable, Optional, overload
 
 
 # dependencies
@@ -12,7 +12,7 @@ import pandas as pd
 from pandas.api.types import is_list_like
 from typing_extensions import get_origin
 from .specs import Spec
-from .typing import P, DataClass, PandasClass, TFrame, TPandas, TSeries
+from .typing import P, DataClass, PandasClass, TFrame, TPandas, TSeries, Tag
 
 
 # runtime functions
@@ -206,18 +206,21 @@ def ensure(data: Any, dtype: Optional[str]) -> Any:
 
 def get_attrs(spec: Spec) -> Dict[Hashable, Any]:
     """Derive attributes from a specification."""
-    attrs: Dict[Hashable, Any] = {}
+    data: Dict[Hashable, Any] = {}
 
-    for field in spec.fields.of_attr:
-        attrs[field.name] = field.default
+    for field in spec.fields.of(Tag.ATTR):
+        if field.has(Tag.MULTIPLE):
+            data.update(field.default)
+        else:
+            data[field.name] = field.default
 
-    return attrs
+    return data
 
 
 def get_columns(spec: Spec) -> Optional[pd.Index]:
     """Derive columns from a specification."""
-    names = [field.name for field in spec.fields.of_column]
-    elems = [field.name for field in spec.fields.of_data]
+    names = [field.name for field in spec.fields.of(Tag.COLUMN)]
+    elems = [field.name for field in spec.fields.of(Tag.DATA)]
 
     if len(names) == 0:
         return None
@@ -231,25 +234,40 @@ def get_data(spec: Spec) -> Dict[Hashable, Any]:
     """Derive data from a specification."""
     data: Dict[Hashable, Any] = {}
 
-    for field in spec.fields.of_data:
-        data[field.name] = ensure(field.default, field.dtype)
+    for field in spec.fields.of(Tag.DATA):
+        if field.has(Tag.MULTIPLE):
+            items = field.default.items()
+        else:
+            items = {field.name: field.default}.items()
+
+        for name, default in items:
+            data[name] = ensure(default, field.dtype)
 
     return data
 
 
 def get_index(spec: Spec) -> Optional[pd.Index]:
     """Derive index from a specification."""
-    names: List[Hashable] = []
-    elems: List[Any] = []
+    data: Dict[Hashable, Any] = {}
 
-    for field in spec.fields.of_index:
-        names.append(field.name)
-        elems.append(ensure(field.default, field.dtype))
+    for field in spec.fields.of(Tag.INDEX):
+        if field.has(Tag.MULTIPLE):
+            items = field.default.items()
+        else:
+            items = {field.name: field.default}.items()
 
-    if len(names) == 0:
+        for name, default in items:
+            data[name] = ensure(default, field.dtype)
+
+    if len(data) == 0:
         return None
-    if len(names) == 1:
-        return pd.Index(elems[0], name=names[0])
+    if len(data) == 1:
+        return pd.Index(
+            list(data.values())[0],
+            name=list(data.keys())[0],
+        )
     else:
-        elems = np.broadcast_arrays(*elems)
-        return pd.MultiIndex.from_arrays(elems, names=names)
+        return pd.MultiIndex.from_arrays(
+            np.broadcast_arrays(*data.values()),
+            names=list(data.keys()),
+        )
diff --git a/pandas_dataclasses/core/specs.py b/pandas_dataclasses/core/specs.py
index 4a85b17..09c0285 100644
--- a/pandas_dataclasses/core/specs.py
+++ b/pandas_dataclasses/core/specs.py
@@ -42,6 +42,10 @@ class Field:
     default: Any = None
     """Default value of the field data."""
 
+    def has(self, tag: Tag) -> bool:
+        """Check if the specification has a tag."""
+        return bool(tag & Tag.union(self.tags))
+
     def update(self, obj: Any) -> "Field":
         """Update the specification by an object."""
         return replace(
@@ -52,31 +56,11 @@ class Field:
 
 
 class Fields(List[Field]):
-    """List of field specifications (with selectors)."""
-
-    @property
-    def of_attr(self) -> "Fields":
-        """Select only attribute field specifications."""
-        return self.filter(lambda f: Tag.ATTR in Tag.union(f.tags))
-
-    @property
-    def of_column(self) -> "Fields":
-        """Select only column field specifications."""
-        return self.filter(lambda f: Tag.COLUMN in Tag.union(f.tags))
-
-    @property
-    def of_data(self) -> "Fields":
-        """Select only data field specifications."""
-        return self.filter(lambda f: Tag.DATA in Tag.union(f.tags))
-
-    @property
-    def of_index(self) -> "Fields":
-        """Select only index field specifications."""
-        return self.filter(lambda f: Tag.INDEX in Tag.union(f.tags))
-
-    def filter(self, condition: Callable[[Field], bool]) -> "Fields":
-        """Select only fields that make a condition True."""
-        return type(self)(filter(condition, self))
+    """List of field specifications with selectors."""
+
+    def of(self, tag: Tag) -> "Fields":
+        """Select only fields that have a tag."""
+        return type(self)(filter(lambda field: field.has(tag), self))
 
     def update(self, obj: Any) -> "Fields":
         """Update the specifications by an object."""
diff --git a/pandas_dataclasses/core/typing.py b/pandas_dataclasses/core/typing.py
index 4769684..7ec0ba5 100644
--- a/pandas_dataclasses/core/typing.py
+++ b/pandas_dataclasses/core/typing.py
@@ -1,4 +1,4 @@
-__all__ = ["Attr", "Column", "Data", "Index", "Tag"]
+__all__ = ["Attr", "Column", "Data", "Index", "Multiple", "Tag"]
 
 
 # standard library
@@ -28,7 +28,7 @@ from typing import (
 # dependencies
 import pandas as pd
 from pandas.api.types import pandas_dtype
-from typing_extensions import Annotated, ParamSpec, get_args, get_origin
+from typing_extensions import Annotated, ParamSpec, TypeGuard, get_args, get_origin
 
 
 # type hints (private)
@@ -77,22 +77,22 @@ class Tag(Flag):
     DTYPE = auto()
     """Tag for a type specifying a data type."""
 
+    MULTIPLE = auto()
+    """Tag for a type specifying a multiple-item field."""
+
     FIELD = ATTR | COLUMN | DATA | INDEX
     """Union of field-related tags."""
 
-    ANY = FIELD | DTYPE
+    ANY = FIELD | DTYPE | MULTIPLE
     """Union of all tags."""
 
     def annotates(self, tp: Any) -> bool:
         """Check if the tag annotates a type hint."""
-        return any(map(self.covers, get_args(tp)))
-
-    def covers(self, obj: Any) -> bool:
-        """Check if the tag is superset of an object."""
-        return type(self).creates(obj) and obj in self
+        tags = filter(type(self).creates, get_args(tp))
+        return bool(self & type(self).union(tags))
 
     @classmethod
-    def creates(cls, obj: Any) -> bool:
+    def creates(cls, obj: Any) -> TypeGuard["Tag"]:
         """Check if Tag is the type of an object."""
         return isinstance(obj, cls)
 
@@ -102,12 +102,12 @@ class Tag(Flag):
         return reduce(or_, tags, Tag(0))
 
     def __repr__(self) -> str:
-        """Return the hashtag-style string of the tag."""
+        """Return the bracket-style string of the tag."""
         return str(self)
 
     def __str__(self) -> str:
-        """Return the hashtag-style string of the tag."""
-        return f"#{str(self.name).lower()}"
+        """Return the bracket-style string of the tag."""
+        return f"<{str(self.name).lower()}>"
 
 
 # type hints (public)
@@ -123,6 +123,9 @@ Data = Annotated[Collection[Annotated[T, Tag.DTYPE]], Tag.DATA]
 Index = Annotated[Collection[Annotated[T, Tag.DTYPE]], Tag.INDEX]
 """Type hint for index fields (``Index[T]``)."""
 
+Multiple = Dict[str, Annotated[T, Tag.MULTIPLE]]
+"""Type hint for multiple-item fields (``Multiple[T]``)."""
+
 
 # runtime functions
 def gen_annotated(tp: Any) -> Iterable[Any]:
@@ -158,10 +161,10 @@ def get_nontags(tp: Any, bound: Tag = Tag.ANY) -> List[Any]:
 
 def get_dtype(tp: Any) -> Optional[str]:
     """Extract a data type of NumPy or pandas from a type hint."""
-    if (tagged := get_tagged(tp, Tag.DATA | Tag.INDEX)) is None:
+    if (tp := get_tagged(tp, Tag.DATA | Tag.INDEX, True)) is None:
         return None
 
-    if (dtype := get_tagged(tagged, Tag.DTYPE)) is None:
+    if (dtype := get_tagged(tp, Tag.DTYPE)) is None:
         return None
 
     if dtype is Any or dtype is type(None):


Test patch:
diff --git a/tests/data.py b/tests/data.py
index c860371..97a1722 100644
--- a/tests/data.py
+++ b/tests/data.py
@@ -8,7 +8,7 @@ from typing import Any
 
 # dependencies
 import pandas as pd
-from pandas_dataclasses import Attr, Column, Data, Index
+from pandas_dataclasses import Attr, Column, Data, Index, Multiple
 from typing_extensions import Annotated as Ann
 
 
@@ -62,6 +62,9 @@ class Weather:
     lat_unit: str = "deg"
     """Units of the latitude."""
 
+    attrs: Multiple[Attr[Any]] = field(default_factory=dict)
+    """Other attributes."""
+
 
 weather = Weather(
     [2020, 2020, 2021, 2021, 2022],
diff --git a/tests/test_core_api.py b/tests/test_core_api.py
index f977da1..b0ef09e 100644
--- a/tests/test_core_api.py
+++ b/tests/test_core_api.py
@@ -6,7 +6,7 @@ from typing import cast
 import pandas as pd
 from pandas.testing import assert_frame_equal, assert_series_equal
 from data import Weather, weather, df_weather_true, ser_weather_true
-from pandas_dataclasses import Spec, asframe, asseries
+from pandas_dataclasses import Spec, Tag, asframe, asseries
 from pandas_dataclasses.core.api import get_attrs, get_columns, get_data, get_index
 
 
@@ -27,27 +27,27 @@ def test_get_attrs() -> None:
     attrs = get_attrs(spec)
 
     for i, (key, val) in enumerate(attrs.items()):
-        assert key == spec.fields.of_attr[i].name
-        assert val == spec.fields.of_attr[i].default
+        assert key == spec.fields.of(Tag.ATTR)[i].name
+        assert val == spec.fields.of(Tag.ATTR)[i].default
 
 
 def test_get_columns() -> None:
     columns = cast(pd.Index, get_columns(spec))
 
     for i in range(len(columns)):
-        assert columns[i] == spec.fields.of_data[i].name
+        assert columns[i] == spec.fields.of(Tag.DATA)[i].name
 
     for i in range(columns.nlevels):
-        assert columns.names[i] == spec.fields.of_column[i].name
+        assert columns.names[i] == spec.fields.of(Tag.COLUMN)[i].name
 
 
 def test_get_data() -> None:
     data = get_data(spec)
 
     for i, (key, val) in enumerate(data.items()):
-        assert key == spec.fields.of_data[i].name
-        assert val.dtype.name == spec.fields.of_data[i].dtype
-        assert (val == spec.fields.of_data[i].default).all()
+        assert key == spec.fields.of(Tag.DATA)[i].name
+        assert val.dtype.name == spec.fields.of(Tag.DATA)[i].dtype
+        assert (val == spec.fields.of(Tag.DATA)[i].default).all()
 
 
 def test_get_index() -> None:
@@ -55,6 +55,6 @@ def test_get_index() -> None:
 
     for i in range(index.nlevels):
         level = index.get_level_values(i)
-        assert level.name == spec.fields.of_index[i].name
-        assert level.dtype.name == spec.fields.of_index[i].dtype
-        assert (level == spec.fields.of_index[i].default).all()
+        assert level.name == spec.fields.of(Tag.INDEX)[i].name
+        assert level.dtype.name == spec.fields.of(Tag.INDEX)[i].dtype
+        assert (level == spec.fields.of(Tag.INDEX)[i].default).all()
diff --git a/tests/test_core_specs.py b/tests/test_core_specs.py
index c942d71..077dcd7 100644
--- a/tests/test_core_specs.py
+++ b/tests/test_core_specs.py
@@ -14,7 +14,7 @@ spec_updated = spec @ weather
 
 # test functions
 def test_year() -> None:
-    field = spec.fields.of_index[0]
+    field = spec.fields.of(Tag.INDEX)[0]
 
     assert field.id == "year"
     assert field.tags == [Tag.INDEX]
@@ -24,7 +24,7 @@ def test_year() -> None:
 
 
 def test_year_updated() -> None:
-    field = spec_updated.fields.of_index[0]
+    field = spec_updated.fields.of(Tag.INDEX)[0]
 
     assert field.id == "year"
     assert field.tags == [Tag.INDEX]
@@ -34,7 +34,7 @@ def test_year_updated() -> None:
 
 
 def test_month() -> None:
-    field = spec.fields.of_index[1]
+    field = spec.fields.of(Tag.INDEX)[1]
 
     assert field.id == "month"
     assert field.tags == [Tag.INDEX]
@@ -44,7 +44,7 @@ def test_month() -> None:
 
 
 def test_month_updated() -> None:
-    field = spec_updated.fields.of_index[1]
+    field = spec_updated.fields.of(Tag.INDEX)[1]
 
     assert field.id == "month"
     assert field.tags == [Tag.INDEX]
@@ -54,7 +54,7 @@ def test_month_updated() -> None:
 
 
 def test_meas() -> None:
-    field = spec.fields.of_column[0]
+    field = spec.fields.of(Tag.COLUMN)[0]
 
     assert field.id == "meas"
     assert field.tags == [Tag.COLUMN]
@@ -63,7 +63,7 @@ def test_meas() -> None:
 
 
 def test_meas_updated() -> None:
-    field = spec_updated.fields.of_column[0]
+    field = spec_updated.fields.of(Tag.COLUMN)[0]
 
     assert field.id == "meas"
     assert field.tags == [Tag.COLUMN]
@@ -72,7 +72,7 @@ def test_meas_updated() -> None:
 
 
 def test_stat() -> None:
-    field = spec.fields.of_column[1]
+    field = spec.fields.of(Tag.COLUMN)[1]
 
     assert field.id == "stat"
     assert field.tags == [Tag.COLUMN]
@@ -81,7 +81,7 @@ def test_stat() -> None:
 
 
 def test_stat_updated() -> None:
-    field = spec_updated.fields.of_column[1]
+    field = spec_updated.fields.of(Tag.COLUMN)[1]
 
     assert field.id == "stat"
     assert field.tags == [Tag.COLUMN]
@@ -90,7 +90,7 @@ def test_stat_updated() -> None:
 
 
 def test_temp_avg() -> None:
-    field = spec.fields.of_data[0]
+    field = spec.fields.of(Tag.DATA)[0]
 
     assert field.id == "temp_avg"
     assert field.tags == [Tag.DATA]
@@ -100,7 +100,7 @@ def test_temp_avg() -> None:
 
 
 def test_temp_avg_updated() -> None:
-    field = spec_updated.fields.of_data[0]
+    field = spec_updated.fields.of(Tag.DATA)[0]
 
     assert field.id == "temp_avg"
     assert field.tags == [Tag.DATA]
@@ -110,7 +110,7 @@ def test_temp_avg_updated() -> None:
 
 
 def test_temp_max() -> None:
-    field = spec.fields.of_data[1]
+    field = spec.fields.of(Tag.DATA)[1]
 
     assert field.id == "temp_max"
     assert field.tags == [Tag.DATA]
@@ -120,7 +120,7 @@ def test_temp_max() -> None:
 
 
 def test_temp_max_updated() -> None:
-    field = spec_updated.fields.of_data[1]
+    field = spec_updated.fields.of(Tag.DATA)[1]
 
     assert field.id == "temp_max"
     assert field.tags == [Tag.DATA]
@@ -130,7 +130,7 @@ def test_temp_max_updated() -> None:
 
 
 def test_wind_avg() -> None:
-    field = spec.fields.of_data[2]
+    field = spec.fields.of(Tag.DATA)[2]
 
     assert field.id == "wind_avg"
     assert field.tags == [Tag.DATA]
@@ -140,7 +140,7 @@ def test_wind_avg() -> None:
 
 
 def test_wind_avg_updated() -> None:
-    field = spec_updated.fields.of_data[2]
+    field = spec_updated.fields.of(Tag.DATA)[2]
 
     assert field.id == "wind_avg"
     assert field.tags == [Tag.DATA]
@@ -150,7 +150,7 @@ def test_wind_avg_updated() -> None:
 
 
 def test_wind_max() -> None:
-    field = spec.fields.of_data[3]
+    field = spec.fields.of(Tag.DATA)[3]
 
     assert field.id == "wind_max"
     assert field.tags == [Tag.DATA]
@@ -160,7 +160,7 @@ def test_wind_max() -> None:
 
 
 def test_wind_max_updated() -> None:
-    field = spec_updated.fields.of_data[3]
+    field = spec_updated.fields.of(Tag.DATA)[3]
 
     assert field.id == "wind_max"
     assert field.tags == [Tag.DATA]
@@ -170,7 +170,7 @@ def test_wind_max_updated() -> None:
 
 
 def test_loc() -> None:
-    field = spec.fields.of_attr[0]
+    field = spec.fields.of(Tag.ATTR)[0]
 
     assert field.id == "loc"
     assert field.tags == [Tag.ATTR]
@@ -179,7 +179,7 @@ def test_loc() -> None:
 
 
 def test_loc_updated() -> None:
-    field = spec_updated.fields.of_attr[0]
+    field = spec_updated.fields.of(Tag.ATTR)[0]
 
     assert field.id == "loc"
     assert field.tags == [Tag.ATTR]
@@ -188,7 +188,7 @@ def test_loc_updated() -> None:
 
 
 def test_lon() -> None:
-    field = spec.fields.of_attr[1]
+    field = spec.fields.of(Tag.ATTR)[1]
 
     assert field.id == "lon"
     assert field.tags == [Tag.ATTR]
@@ -197,7 +197,7 @@ def test_lon() -> None:
 
 
 def test_lon_updated() -> None:
-    field = spec_updated.fields.of_attr[1]
+    field = spec_updated.fields.of(Tag.ATTR)[1]
 
     assert field.id == "lon"
     assert field.tags == [Tag.ATTR]
@@ -206,7 +206,7 @@ def test_lon_updated() -> None:
 
 
 def test_lat() -> None:
-    field = spec.fields.of_attr[2]
+    field = spec.fields.of(Tag.ATTR)[2]
 
     assert field.id == "lat"
     assert field.tags == [Tag.ATTR]
@@ -215,7 +215,7 @@ def test_lat() -> None:
 
 
 def test_lat_updated() -> None:
-    field = spec_updated.fields.of_attr[2]
+    field = spec_updated.fields.of(Tag.ATTR)[2]
 
     assert field.id == "lat"
     assert field.tags == [Tag.ATTR]
@@ -223,6 +223,24 @@ def test_lat_updated() -> None:
     assert field.default == weather.lat
 
 
+def test_attrs() -> None:
+    field = spec.fields.of(Tag.ATTR)[3]
+
+    assert field.id == "attrs"
+    assert field.tags == [Tag.ATTR, Tag.MULTIPLE]
+    assert field.name == "attrs"
+    assert field.default is MISSING
+
+
+def test_attrs_updated() -> None:
+    field = spec_updated.fields.of(Tag.ATTR)[3]
+
+    assert field.id == "attrs"
+    assert field.tags == [Tag.ATTR, Tag.MULTIPLE]
+    assert field.name == "attrs"
+    assert field.default == weather.attrs
+
+
 def test_factory() -> None:
     assert spec.factory is None