Repository: astropenguin/pandas-dataclasses

Issue:
Update parsers module

Discussion:

Patch:
diff --git a/pandas_dataclasses/core/parsers.py b/pandas_dataclasses/core/parsers.py
index 1ba26ec..4b3ebe8 100644
--- a/pandas_dataclasses/core/parsers.py
+++ b/pandas_dataclasses/core/parsers.py
@@ -2,7 +2,7 @@ __all__ = ["asdataframe", "asseries"]
 
 
 # standard library
-from typing import Any, Dict, Hashable, Iterable, Optional, Type, TypeVar, overload
+from typing import Any, Hashable, List, Optional, Type, TypeVar, overload
 
 
 # dependencies
@@ -12,11 +12,10 @@ import pandas as pd
 
 # submodules
 from .specs import Spec
-from .typing import P, T, AnyDType, AnyPandas, DataClass, PandasClass
+from .typing import P, AnyDType, DataClass, PandasClass
 
 
 # type hints
-AnyDict = Dict[Hashable, Any]
 TDataFrame = TypeVar("TDataFrame", bound=pd.DataFrame)
 TSeries = TypeVar("TSeries", bound=pd.Series)
 
@@ -39,16 +38,17 @@ def asdataframe(obj: DataClass[P], *, factory: None = None) -> pd.DataFrame:
 
 def asdataframe(obj: Any, *, factory: Any = None) -> Any:
     """Create a DataFrame object from a dataclass object."""
-    attrs = get_attrs(obj)
-    data = get_data(obj)
-    index = get_index(obj)
-    columns = get_columns(obj)
+    spec = Spec.from_dataclass(type(obj)) @ obj
+    attrs = get_attrs(spec)
+    data = get_data(spec)
+    index = get_index(spec)
+    columns = get_columns(spec)
 
     if factory is None:
-        factory = get_factory(obj) or pd.DataFrame
+        factory = spec.factory or pd.DataFrame
 
     if not issubclass(factory, pd.DataFrame):
-        raise TypeError("Factory was not a subclass of DataFrame.")
+        raise TypeError("Factory must be a subclass of DataFrame.")
 
     dataframe = factory(data, index, columns)
     dataframe.attrs.update(attrs)
@@ -72,122 +72,100 @@ def asseries(obj: DataClass[P], *, factory: None = None) -> pd.Series:
 
 def asseries(obj: Any, *, factory: Any = None) -> Any:
     """Create a Series object from a dataclass object."""
-    attrs = get_attrs(obj)
-    data = get_data(obj)
-    index = get_index(obj)
+    spec = Spec.from_dataclass(type(obj)) @ obj
+    attrs = get_attrs(spec)
+    data = get_data(spec)
+    index = get_index(spec)
 
     if data is None:
         name = None
     else:
-        name = first(data.keys())
-        data = first(data.values())
+        name, data = list(data.items())[0]
 
     if factory is None:
-        factory = get_factory(obj) or pd.Series
+        factory = spec.factory or pd.Series
 
     if not issubclass(factory, pd.Series):
-        raise TypeError("Factory was not a subclass of Series.")
+        raise TypeError("Factory must be a subclass of Series.")
 
     series = factory(data, index, name=name)
     series.attrs.update(attrs)
     return series
 
 
-def astype(data: Any, dtype: Optional[AnyDType]) -> Any:
-    """Convert data to have given data type."""
+def ensure(data: Any, dtype: Optional[AnyDType]) -> Any:
+    """Ensure data to be 1D and have given data type."""
+    if not isinstance(data, (pd.Index, pd.Series)):
+        data = np.atleast_1d(data)
+
     if dtype is None:
         return data
     else:
         return data.astype(dtype, copy=False)
 
 
-def atleast_1d(data: Any) -> Any:
-    """Convert data to be at least one dimensional."""
-    if isinstance(data, pd.Series):
-        return data
-    else:
-        return np.atleast_1d(data)
-
-
-def first(obj: Iterable[T]) -> T:
-    """Return the first item of an iterable."""
-    return next(iter(obj))
-
-
-def get_attrs(obj: DataClass[P]) -> AnyDict:
-    """Derive attributes from a dataclass object."""
-    spec = Spec.from_dataclass(type(obj)).update(obj)
-    attrs: AnyDict = {}
+def get_attrs(spec: Spec) -> "dict[Hashable, Any]":
+    """Derive attributes from a specification."""
+    attrs: "dict[Hashable, Any]" = {}
 
     for field in spec.fields.of_attr:
-        attrs[field.hashable_name] = getattr(obj, field.id)
+        attrs[field.hashable_name] = field.default
 
     return attrs
 
 
-def get_columns(obj: DataClass[P]) -> Optional[pd.Index]:
-    """Derive columns from a dataclass object."""
-    spec = Spec.from_dataclass(type(obj)).update(obj)
-    names: Any = [field.name for field in spec.fields.of_data]
+def get_columns(spec: Spec) -> Optional[pd.Index]:
+    """Derive columns from a specification."""
+    if not spec.fields.of_data:
+        return
 
-    if all(isinstance(name, Hashable) for name in names):
+    names_ = [field.name for field in spec.fields.of_data]
+
+    if all(isinstance(name, Hashable) for name in names_):
         return
 
-    if (
-        all(isinstance(name, dict) for name in names)
-        and len(set(map(tuple, names))) == 1
-    ):
-        return pd.MultiIndex.from_tuples(
-            [tuple(name.values()) for name in names],
-            names=first(names).keys(),
-        )
+    if not all(isinstance(name, dict) for name in names_):
+        raise ValueError("All names must be dictionaries.")
+
+    names = [tuple(name.keys()) for name in names_]  # type: ignore
+    indexes = [tuple(name.values()) for name in names_]  # type: ignore
 
-    raise ValueError("Could not create columns.")
+    if not len(set(names)) == 1:
+        raise ValueError("All name keys must be same.")
 
+    return pd.MultiIndex.from_tuples(indexes, names=names[0])
 
-def get_data(obj: DataClass[P]) -> Optional[AnyDict]:
-    """Derive data from a dataclass object."""
-    spec = Spec.from_dataclass(type(obj)).update(obj)
-    data: AnyDict = {}
 
+def get_data(spec: Spec) -> Optional["dict[Hashable, Any]"]:
+    """Derive data from a specification."""
     if not spec.fields.of_data:
         return
 
-    for field in spec.fields.of_data:
-        data[field.hashable_name] = astype(
-            atleast_1d(getattr(obj, field.id)),
-            field.dtype,
-        )
-
-    return data
+    names: List[Hashable] = []
+    data: List[Any] = []
 
+    for field in spec.fields.of_data:
+        names.append(field.hashable_name)
+        data.append(ensure(field.default, field.dtype))
 
-def get_factory(obj: DataClass[P]) -> Optional[Type[AnyPandas]]:
-    """Derive pandas factory from a dataclass object."""
-    return Spec.from_dataclass(type(obj)).factory
-
+    return dict(zip(names, data))
 
-def get_index(obj: DataClass[P]) -> Optional[pd.Index]:
-    """Derive index from a dataclass object."""
-    spec = Spec.from_dataclass(type(obj)).update(obj)
-    indexes: AnyDict = {}
 
+def get_index(spec: Spec) -> Optional[pd.Index]:
+    """Derive index from a specification."""
     if not spec.fields.of_index:
         return
 
+    names: List[Hashable] = []
+    indexes: List[Any] = []
+
     for field in spec.fields.of_index:
-        indexes[field.hashable_name] = astype(
-            atleast_1d(getattr(obj, field.id)),
-            field.dtype,
-        )
+        names.append(field.hashable_name)
+        indexes.append(ensure(field.default, field.dtype))
+
+    indexes = np.broadcast_arrays(*indexes)
 
     if len(indexes) == 1:
-        return pd.Index(
-            first(indexes.values()),
-            name=first(indexes.keys()),
-        )
+        return pd.Index(indexes[0], name=names[0])
     else:
-        return pd.MultiIndex.from_arrays(
-            np.broadcast_arrays(*indexes.values()),
-            names=indexes.keys(),
-        )
+        return pd.MultiIndex.from_arrays(indexes, names=names)
diff --git a/pandas_dataclasses/core/specs.py b/pandas_dataclasses/core/specs.py
index 8d2fd83..a1c5532 100644
--- a/pandas_dataclasses/core/specs.py
+++ b/pandas_dataclasses/core/specs.py
@@ -59,7 +59,11 @@ class Field:
 
     def update(self, obj: DataClass[P]) -> "Field":
         """Update the specification by a dataclass object."""
-        return replace(self, name=format_name(self.name, obj))
+        return replace(
+            self,
+            name=format_name(self.name, obj),
+            default=getattr(obj, self.id),
+        )
 
 
 class Fields(List[Field]):
@@ -113,6 +117,10 @@ class Spec:
         """Update the specification by a dataclass object."""
         return replace(self, fields=self.fields.update(obj))
 
+    def __matmul__(self, obj: DataClass[P]) -> "Spec":
+        """Alias of the update method."""
+        return self.update(obj)
+
 
 # runtime functions
 @lru_cache(maxsize=None)


Test patch:
diff --git a/tests/test_parsers.py b/tests/test_parsers.py
index b714858..d87b697 100644
--- a/tests/test_parsers.py
+++ b/tests/test_parsers.py
@@ -5,7 +5,8 @@ from dataclasses import dataclass
 # dependencies
 import numpy as np
 import pandas as pd
-from pandas_dataclasses.core.parsers import get_attrs, get_data, get_factory, get_index
+from pandas_dataclasses.core.parsers import get_attrs, get_data, get_index
+from pandas_dataclasses.core.specs import Spec
 from pandas_dataclasses.core.typing import Attr, Data, Index
 from typing_extensions import Annotated as Ann
 
@@ -34,7 +35,9 @@ weather = Weather(
 
 # test functions
 def test_attrs() -> None:
-    assert get_attrs(weather) == {
+    spec = Spec.from_dataclass(type(weather)) @ weather
+
+    assert get_attrs(spec) == {
         "Location": "Tokyo",
         "Longitude (deg)": 139.69167,
         "Latitude (deg)": 35.68944,
@@ -42,7 +45,8 @@ def test_attrs() -> None:
 
 
 def test_data() -> None:
-    data = get_data(weather)
+    spec = Spec.from_dataclass(type(weather)) @ weather
+    data = get_data(spec)
     data_temp = data.get("Average temperature (deg C)")  # type: ignore
     data_humid = data.get("Average humidity (%)")  # type: ignore
     expected_temp = np.array(weather.temp, float)
@@ -55,7 +59,8 @@ def test_data() -> None:
 
 
 def test_index() -> None:
-    index = get_index(weather)
+    spec = Spec.from_dataclass(type(weather)) @ weather
+    index = get_index(spec)
     expected = pd.MultiIndex.from_arrays(
         [weather.year, weather.month],
         names=["Year", "Month"],
@@ -64,7 +69,3 @@ def test_index() -> None:
     assert (index == expected).all()
     assert (index.dtypes == expected.dtypes).all()  # type: ignore
     assert index.names == expected.names  # type: ignore
-
-
-def test_factory() -> None:
-    assert get_factory(weather) is None