Repository: astropenguin/pandas-dataclasses

Issue:
Update specification of name

Discussion:

Patch:
diff --git a/pandas_dataclasses/specs.py b/pandas_dataclasses/specs.py
index 7e35111..4c52020 100644
--- a/pandas_dataclasses/specs.py
+++ b/pandas_dataclasses/specs.py
@@ -4,7 +4,7 @@ __all__ = ["DataSpec"]
 # standard library
 from dataclasses import dataclass, field, fields
 from functools import lru_cache
-from typing import Any, Dict, Hashable, Optional, Type
+from typing import Any, Dict, Optional, Type
 
 
 # dependencies
@@ -15,6 +15,7 @@ from typing_extensions import Literal, TypeAlias, get_type_hints
 from .typing import (
     AnyDType,
     AnyField,
+    AnyName,
     AnyPandas,
     DataClass,
     P,
@@ -35,7 +36,7 @@ AnySpec: TypeAlias = "ArraySpec | ScalarSpec"
 class ArraySpec:
     """Specification of an array."""
 
-    name: Hashable
+    name: AnyName
     """Name of the array."""
 
     role: Literal["data", "index"]
@@ -52,7 +53,7 @@ class ArraySpec:
 class ScalarSpec:
     """Specification of a scalar."""
 
-    name: Hashable
+    name: AnyName
     """Name of the scalar."""
 
     role: Literal["attr", "name"]
diff --git a/pandas_dataclasses/typing.py b/pandas_dataclasses/typing.py
index 15fa0b3..cd97350 100644
--- a/pandas_dataclasses/typing.py
+++ b/pandas_dataclasses/typing.py
@@ -12,6 +12,7 @@ from typing import (
     Dict,
     Hashable,
     Iterable,
+    Mapping,
     Optional,
     Tuple,
     Type,
@@ -30,6 +31,7 @@ from typing_extensions import (
     ParamSpec,
     Protocol,
     TypeAlias,
+    TypeGuard,
     get_args,
     get_origin,
     get_type_hints,
@@ -39,7 +41,9 @@ from typing_extensions import (
 # type hints (private)
 AnyDType: TypeAlias = "dtype[Any] | ExtensionDtype"
 AnyField: TypeAlias = "Field[Any]"
+AnyName: TypeAlias = "HashableMapping | Hashable"
 AnyPandas: TypeAlias = "DataFrame | Series"
+HashableMapping = Mapping[Hashable, Hashable]
 P = ParamSpec("P")
 T = TypeVar("T")
 THashable = TypeVar("THashable", bound=Hashable)
@@ -158,7 +162,7 @@ def get_dtype(tp: Any) -> Optional[AnyDType]:
     return pandas_dtype(dtype)  # type: ignore
 
 
-def get_name(tp: Any, default: Hashable = None) -> Hashable:
+def get_name(tp: Any, default: AnyName = None) -> AnyName:
     """Extract a name if found or return given default."""
     try:
         annotations = get_annotations(tp)[1:]
@@ -169,6 +173,9 @@ def get_name(tp: Any, default: Hashable = None) -> Hashable:
         if isinstance(annotation, Hashable):
             return annotation
 
+        if is_hashable_mapping(annotation):
+            return annotation
+
     return default
 
 
@@ -178,3 +185,17 @@ def get_role(tp: Any, default: Role = Role.OTHER) -> Role:
         return get_annotations(tp)[0]
     except TypeError:
         return default
+
+
+def is_hashable_mapping(obj: Any) -> TypeGuard[HashableMapping]:
+    """Check if an object is a mapping with only hashable values."""
+    if not isinstance(obj, Mapping):
+        return False
+
+    if any(not isinstance(key, Hashable) for key in obj.keys()):
+        return False
+
+    if any(not isinstance(val, Hashable) for val in obj.values()):
+        return False
+
+    return True


Test patch:
diff --git a/tests/test_specs.py b/tests/test_specs.py
index ba2302d..60d42e9 100644
--- a/tests/test_specs.py
+++ b/tests/test_specs.py
@@ -1,5 +1,6 @@
 # standard library
 from dataclasses import MISSING, dataclass
+from typing import Dict
 
 
 # dependencies
@@ -11,15 +12,19 @@ from typing_extensions import Literal as L
 
 
 # test datasets
+def name(stat: str, cat: str) -> Dict[str, str]:
+    return {"Statistic": stat, "Category": cat}
+
+
 @dataclass
 class Weather:
     """Weather information at a location."""
 
     time: Ann[Index[L["M8[ns]"]], "Time in UTC"]
-    temperature: Ann[Data[float], "Temperature (degC)"]
-    humidity: Ann[Data[float], "Humidity (percent)"]
-    wind_speed: Ann[Data[float], "Speed (m/s)"]
-    wind_direction: Ann[Data[float], "Direction (deg)"]
+    temp_avg: Ann[Data[float], name("Temperature (degC)", "Average")]
+    temp_max: Ann[Data[float], name("Temperature (degC)", "Maximum")]
+    wind_avg: Ann[Data[float], name("Wind speed (m/s)", "Average")]
+    wind_max: Ann[Data[float], name("Wind speed (m/s)", "Maximum")]
     location: Attr[str] = "Tokyo"
     longitude: Attr[float] = 139.69167
     latitude: Attr[float] = 35.68944
@@ -36,37 +41,37 @@ def test_time() -> None:
     assert spec.default is MISSING
 
 
-def test_temperature() -> None:
-    spec = DataSpec.from_dataclass(Weather).specs.of_data["temperature"]
+def test_temp_avg() -> None:
+    spec = DataSpec.from_dataclass(Weather).specs.of_data["temp_avg"]
 
-    assert spec.name == "Temperature (degC)"
+    assert spec.name == name("Temperature (degC)", "Average")
     assert spec.role == "data"
     assert spec.dtype == np.float64
     assert spec.default is MISSING
 
 
-def test_humidity() -> None:
-    spec = DataSpec.from_dataclass(Weather).specs.of_data["humidity"]
+def test_temp_max() -> None:
+    spec = DataSpec.from_dataclass(Weather).specs.of_data["temp_max"]
 
-    assert spec.name == "Humidity (percent)"
+    assert spec.name == name("Temperature (degC)", "Maximum")
     assert spec.role == "data"
     assert spec.dtype == np.float64
     assert spec.default is MISSING
 
 
-def test_wind_speed() -> None:
-    spec = DataSpec.from_dataclass(Weather).specs.of_data["wind_speed"]
+def test_wind_avg() -> None:
+    spec = DataSpec.from_dataclass(Weather).specs.of_data["wind_avg"]
 
-    assert spec.name == "Speed (m/s)"
+    assert spec.name == name("Wind speed (m/s)", "Average")
     assert spec.role == "data"
     assert spec.dtype == np.float64
     assert spec.default is MISSING
 
 
-def test_wind_direction() -> None:
-    spec = DataSpec.from_dataclass(Weather).specs.of_data["wind_direction"]
+def test_wind_max() -> None:
+    spec = DataSpec.from_dataclass(Weather).specs.of_data["wind_max"]
 
-    assert spec.name == "Direction (deg)"
+    assert spec.name == name("Wind speed (m/s)", "Maximum")
     assert spec.role == "data"
     assert spec.dtype == np.float64
     assert spec.default is MISSING
diff --git a/tests/test_typing.py b/tests/test_typing.py
index 5c657b6..1fc91ac 100644
--- a/tests/test_typing.py
+++ b/tests/test_typing.py
@@ -50,6 +50,10 @@ testdata_name = [
     (Ann[Data[Any], "data"], "data"),
     (Ann[Index[Any], "index"], "index"),
     (Ann[Name[Any], "name"], "name"),
+    (Ann[Attr[Any], dict(name="attr")], dict(name="attr")),
+    (Ann[Data[Any], dict(name="data")], dict(name="data")),
+    (Ann[Index[Any], dict(name="index")], dict(name="index")),
+    (Ann[Name[Any], dict(name="name")], dict(name="name")),
     (Ann[Any, "other"], None),
     (Union[Ann[Attr[Any], "attr"], Ann[Any, "any"]], "attr"),
     (Union[Ann[Data[Any], "data"], Ann[Any, "any"]], "data"),