Repository: astropenguin/pandas-dataclasses

Issue:
Update specs module

Discussion:

Patch:
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index e1ac8b2..19e247c 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -5,22 +5,26 @@
         "dockerfile": "Dockerfile"
     },
     "postCreateCommand": "poetry install",
-    "extensions": [
-        "github.vscode-pull-request-github",
-        "mhutchie.git-graph",
-        "ms-python.python",
-        "streetsidesoftware.code-spell-checker",
-        "tamasfe.even-better-toml"
-    ],
-    "settings": {
-        "files.insertFinalNewline": true,
-        "files.trimTrailingWhitespace": true,
-        "python.formatting.provider": "black",
-        "python.languageServer": "Pylance",
-        "[python]": {
-            "editor.formatOnSave": true,
-            "editor.insertSpaces": true,
-            "editor.tabSize": 4
+    "customizations": {
+        "vscode": {
+            "extensions": [
+                "github.vscode-pull-request-github",
+                "mhutchie.git-graph",
+                "ms-python.python",
+                "streetsidesoftware.code-spell-checker",
+                "tamasfe.even-better-toml"
+            ],
+            "settings": {
+                "files.insertFinalNewline": true,
+                "files.trimTrailingWhitespace": true,
+                "python.formatting.provider": "black",
+                "python.languageServer": "Pylance",
+                "[python]": {
+                    "editor.formatOnSave": true,
+                    "editor.insertSpaces": true,
+                    "editor.tabSize": 4
+                }
+            }
         }
     }
 }
diff --git a/pandas_dataclasses/__init__.py b/pandas_dataclasses/__init__.py
index 3aabf4d..7095051 100644
--- a/pandas_dataclasses/__init__.py
+++ b/pandas_dataclasses/__init__.py
@@ -4,7 +4,6 @@ __all__ = [
     "AsFrame",
     "AsSeries",
     "Attr",
-    "Column",
     "Data",
     "Index",
     "Multiple",
diff --git a/pandas_dataclasses/core/api.py b/pandas_dataclasses/core/api.py
index 61e2c8b..33753cf 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, Optional, overload
+from typing import Any, Callable, Dict, Hashable, Iterable, Optional, Tuple, overload
 
 
 # dependencies
@@ -11,7 +11,7 @@ import numpy as np
 import pandas as pd
 from pandas.api.types import is_list_like
 from typing_extensions import get_origin
-from .specs import Spec
+from .specs import Field, Spec
 from .tagging import Tag
 from .typing import DataClass, DataClassOf, PAny, TFrame, TPandas, TSeries
 
@@ -209,25 +209,22 @@ def get_attrs(spec: Spec) -> Dict[Hashable, Any]:
     data: Dict[Hashable, Any] = {}
 
     for field in spec.fields.of(Tag.ATTR):
-        if field.has(Tag.MULTIPLE):
-            data.update(field.default)
-        else:
-            data[field.name] = field.default
+        data.update(items(field))
 
     return data
 
 
 def get_columns(spec: Spec) -> Optional[pd.Index]:
     """Derive columns from a specification."""
-    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:
+    if not (fields := spec.fields.of(Tag.DATA)):
         return None
-    if len(names) == 1:
-        return pd.Index(elems, name=names[0], tupleize_cols=False)
-    else:
-        return pd.MultiIndex.from_tuples(elems, names=names)
+
+    return squeeze(
+        pd.MultiIndex.from_tuples(
+            map(name, fields),
+            names=fields.names,
+        )
+    )
 
 
 def get_data(spec: Spec) -> Dict[Hashable, Any]:
@@ -235,39 +232,50 @@ def get_data(spec: Spec) -> Dict[Hashable, Any]:
     data: Dict[Hashable, Any] = {}
 
     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)
+        for key, val in items(field):
+            data[key] = ensure(val, field.dtype)
 
     return data
 
 
 def get_index(spec: Spec) -> Optional[pd.Index]:
     """Derive index from a specification."""
-    data: Dict[Hashable, Any] = {}
+    if not (fields := spec.fields.of(Tag.INDEX)):
+        return None
 
-    for field in spec.fields.of(Tag.INDEX):
-        if field.has(Tag.MULTIPLE):
-            items = field.default.items()
-        else:
-            items = {field.name: field.default}.items()
+    data: Dict[Hashable, Any] = {}
 
-        for name, default in items:
-            data[name] = ensure(default, field.dtype)
+    for field in fields:
+        for key, val in items(field):
+            data[key] = ensure(val, field.dtype)
 
-    if len(data) == 0:
-        return None
-    if len(data) == 1:
-        return pd.Index(
-            list(data.values())[0],
-            name=list(data.keys())[0],
-        )
-    else:
-        return pd.MultiIndex.from_arrays(
+    return squeeze(
+        pd.MultiIndex.from_arrays(
             np.broadcast_arrays(*data.values()),
-            names=list(data.keys()),
+            names=data.keys(),
         )
+    )
+
+
+def items(field: Field) -> Iterable[Tuple[Hashable, Any]]:
+    """Generate default(s) of a field specification."""
+    if field.has(Tag.MULTIPLE):
+        yield from field.default.items()
+    else:
+        yield (name(field), field.default)
+
+
+def name(field: Field) -> Hashable:
+    """Derive the name of a field specification."""
+    if isinstance(name := field.name, dict):
+        return tuple(name.values())
+    else:
+        return name
+
+
+def squeeze(index: pd.Index) -> pd.Index:
+    """Convert a MultiIndex to an Index if possible."""
+    if index.nlevels == 1:
+        return index.get_level_values(0)
+    else:
+        return index
diff --git a/pandas_dataclasses/core/specs.py b/pandas_dataclasses/core/specs.py
index a05e680..41684e4 100644
--- a/pandas_dataclasses/core/specs.py
+++ b/pandas_dataclasses/core/specs.py
@@ -2,7 +2,6 @@ __all__ = ["Spec"]
 
 
 # standard library
-import types
 from dataclasses import (
     Field as Field_,
     dataclass,
@@ -12,25 +11,14 @@ from dataclasses import (
 )
 from functools import lru_cache
 from itertools import repeat
-from typing import (
-    Any,
-    Callable,
-    Collection,
-    Hashable,
-    List,
-    Literal,
-    Mapping,
-    Optional,
-    Union,
-    cast,
-)
+from typing import Any, Callable, Hashable, Literal, Optional, Tuple, Union
 
 
 # dependencies
 from pandas.api.types import pandas_dtype
 from typing_extensions import get_args, get_origin, get_type_hints
 from .tagging import Tag, get_nontags, get_tagged, get_tags
-from .typing import TAny, Pandas
+from .typing import HashDict, Pandas, TAny, is_union
 
 
 @dataclass(frozen=True)
@@ -40,10 +28,10 @@ class Field:
     id: str
     """Identifier of the field."""
 
-    name: Hashable
+    name: Union[Hashable, HashDict]
     """Name of the field data."""
 
-    tags: List[Tag] = field_(default_factory=list)
+    tags: Tuple[Tag, ...] = ()
     """Tags of the field."""
 
     type: Optional[Any] = None
@@ -63,14 +51,21 @@ class Field:
         """Update the specification by an object."""
         return replace(
             self,
-            name=format_(self.name, obj),
+            name=format(self.name, obj),
             default=getattr(obj, self.id, self.default),
         )
 
 
-class Fields(List[Field]):
+class Fields(Tuple[Field, ...]):
     """List of field specifications with selectors."""
 
+    @property
+    def names(self) -> Optional[Tuple[Hashable, ...]]:
+        """Optional names of the field specifications."""
+        for field in self:
+            if isinstance(name := field.name, dict):
+                return tuple(name.keys())
+
     def of(self, tag: Tag) -> "Fields":
         """Select only fields that have a tag."""
         return type(self)(filter(lambda field: field.has(tag), self))
@@ -110,10 +105,11 @@ class Spec:
 
     def update(self, obj: Any) -> "Spec":
         """Update the specification by an object."""
-        if self.origin is None or isinstance(obj, self.origin):
-            return replace(self, fields=self.fields.update(obj))
-        else:
-            return self.update(self.origin(obj))
+        if self.origin is not None:
+            if not isinstance(obj, self.origin):
+                obj = self.origin(obj)
+
+        return replace(self, fields=self.fields.update(obj))
 
     def __matmul__(self, obj: Any) -> "Spec":
         """Alias of the update method."""
@@ -125,7 +121,7 @@ def convert_field(field_: "Field_[Any]") -> Field:
     """Convert a dataclass field to a field specification."""
     return Field(
         id=field_.name,
-        name=get_name(field_.type, field_.name),
+        name=get_first(field_.type, field_.name),
         tags=get_tags(field_.type, Tag.FIELD),
         type=field_.type,
         dtype=get_dtype(field_.type),
@@ -142,18 +138,16 @@ def eval_field_types(dataclass: type) -> None:
         field_.type = types[field_.name]
 
 
-def format_(obj: TAny, by: Any) -> TAny:
+def format(obj: TAny, by: Any) -> TAny:
     """Format a string or nested strings in an object."""
-    tp = type(obj)
-
     if isinstance(obj, str):
-        return tp(obj.format(by))  # type: ignore
+        return type(obj)(obj.format(by))  # type: ignore
 
-    if isinstance(obj, Mapping):
-        return tp(map(format_, obj.items(), repeat(by)))  # type: ignore
+    if isinstance(obj, (list, tuple)):
+        return type(obj)(map(format, obj, repeat(by)))  # type: ignore
 
-    if isinstance(obj, Collection):
-        return tp(map(format_, obj, repeat(by)))  # type: ignore
+    if isinstance(obj, dict):
+        return type(obj)(map(format, obj.items(), repeat(by)))  # type: ignore
 
     return obj
 
@@ -178,21 +172,12 @@ def get_dtype(tp: Any) -> Optional[str]:
     return pandas_dtype(dtype).name
 
 
-def get_name(tp: Any, default: Hashable = None) -> Hashable:
-    """Extract the first hashable as a name from a type hint."""
+def get_first(tp: Any, default: Any = None) -> Optional[Any]:
+    """Extract the first nontag annotation from a type hint."""
     if not (nontags := get_nontags(tp, Tag.FIELD)):
         return default
 
-    if (name := nontags[0]) is Ellipsis:
+    if (first := nontags[0]) is Ellipsis:
         return default
 
-    hash(name)
-    return cast(Hashable, name)
-
-
-def is_union(tp: Any) -> bool:
-    """Check if a type hint is a union of types."""
-    if UnionType := getattr(types, "UnionType", None):
-        return get_origin(tp) is Union or isinstance(tp, UnionType)
-    else:
-        return get_origin(tp) is Union
+    return first
diff --git a/pandas_dataclasses/core/tagging.py b/pandas_dataclasses/core/tagging.py
index f28e47a..a18eab3 100644
--- a/pandas_dataclasses/core/tagging.py
+++ b/pandas_dataclasses/core/tagging.py
@@ -6,7 +6,7 @@ from enum import Flag, auto
 from functools import reduce
 from itertools import chain, filterfalse
 from operator import or_
-from typing import Any, Iterable, List, Optional
+from typing import Any, Iterable, Optional, Tuple
 
 
 # dependencies
@@ -19,9 +19,6 @@ class Tag(Flag):
     ATTR = auto()
     """Tag for a type specifying an attribute field."""
 
-    COLUMN = auto()
-    """Tag for a type specifying a column field."""
-
     DATA = auto()
     """Tag for a type specifying a data field."""
 
@@ -34,7 +31,7 @@ class Tag(Flag):
     MULTIPLE = auto()
     """Tag for a type specifying a multiple-item field."""
 
-    FIELD = ATTR | COLUMN | DATA | INDEX
+    FIELD = ATTR | DATA | INDEX
     """Union of field-related tags."""
 
     ANY = FIELD | DTYPE | MULTIPLE
@@ -83,13 +80,13 @@ def get_tagged(
         return tagged if keep_annotations else get_args(tagged)[0]
 
 
-def get_tags(tp: Any, bound: Tag = Tag.ANY) -> List[Tag]:
+def get_tags(tp: Any, bound: Tag = Tag.ANY) -> Tuple[Tag, ...]:
     """Extract all tags from the first tagged type."""
     tagged = get_tagged(tp, bound, True)
-    return list(filter(Tag.creates, get_args(tagged)[1:]))
+    return tuple(filter(Tag.creates, get_args(tagged)[1:]))
 
 
-def get_nontags(tp: Any, bound: Tag = Tag.ANY) -> List[Any]:
+def get_nontags(tp: Any, bound: Tag = Tag.ANY) -> Tuple[Any, ...]:
     """Extract all except tags from the first tagged type."""
     tagged = get_tagged(tp, bound, True)
-    return list(filterfalse(Tag.creates, get_args(tagged)[1:]))
+    return tuple(filterfalse(Tag.creates, get_args(tagged)[1:]))
diff --git a/pandas_dataclasses/core/typing.py b/pandas_dataclasses/core/typing.py
index 344149e..cacde73 100644
--- a/pandas_dataclasses/core/typing.py
+++ b/pandas_dataclasses/core/typing.py
@@ -1,26 +1,31 @@
 __all__ = [
     "DataClass",
     "DataClassOf",
+    "HashDict",
     "Pandas",
     "PAny",
     "TAny",
     "TFrame",
     "TPandas",
     "TSeries",
+    "is_union",
 ]
 
 
 # standard library
+import types
 from dataclasses import Field
-from typing import Any, Callable, Dict, Protocol, TypeVar, Union
+from typing import Any, Callable, Dict, Hashable, Protocol, TypeVar, Union
 
 
 # dependencies
 from pandas import DataFrame, Series
-from typing_extensions import ParamSpec
+from typing_extensions import ParamSpec, get_origin
 
 
-# type hints
+HashDict = Dict[Hashable, Hashable]
+"""Type hint for dictionary of hashable keys and values."""
+
 Pandas = Union[DataFrame, "Series[Any]"]
 """Type hint for any pandas object."""
 
@@ -57,3 +62,11 @@ class DataClassOf(Protocol[TPandas, PAny]):
 
     def __init__(self, *args: PAny.args, **kwargs: PAny.kwargs) -> None:
         ...
+
+
+def is_union(tp: Any) -> bool:
+    """Check if a type hint is a union of types."""
+    if UnionType := getattr(types, "UnionType", None):
+        return get_origin(tp) is Union or isinstance(tp, UnionType)
+    else:
+        return get_origin(tp) is Union
diff --git a/pandas_dataclasses/extras/hints.py b/pandas_dataclasses/extras/hints.py
index ed2a445..a637ca3 100644
--- a/pandas_dataclasses/extras/hints.py
+++ b/pandas_dataclasses/extras/hints.py
@@ -1,4 +1,4 @@
-__all__ = ["Attr", "Column", "Data", "Index", "Multiple"]
+__all__ = ["Attr", "Data", "Index", "Multiple"]
 
 
 # standard library
@@ -15,9 +15,6 @@ from ..core.typing import TAny
 Attr = Annotated[TAny, Tag.ATTR]
 """Type hint for attribute fields (``Attr[TAny]``)."""
 
-Column = Annotated[TAny, Tag.COLUMN]
-"""Type hint for column fields (``Column[TAny]``)."""
-
 Data = Annotated[Collection[Annotated[TAny, Tag.DTYPE]], Tag.DATA]
 """Type hint for data fields (``Data[TAny]``)."""
 


Test patch:
diff --git a/tests/data.py b/tests/data.py
index 97a1722..146749d 100644
--- a/tests/data.py
+++ b/tests/data.py
@@ -3,16 +3,20 @@ __all__ = ["Weather", "weather", "df_weather_true", "ser_weather_true"]
 
 # standard library
 from dataclasses import dataclass, field
-from typing import Any
+from typing import Any, Dict
 
 
 # dependencies
 import pandas as pd
-from pandas_dataclasses import Attr, Column, Data, Index, Multiple
+from pandas_dataclasses import Attr, Data, Index, Multiple
 from typing_extensions import Annotated as Ann
 
 
 # test dataclass and object
+def name(meas: str, stat: str) -> Dict[str, str]:
+    return {"Measurement": meas, "Statistic": stat}
+
+
 @dataclass
 class Weather:
     """Weather information."""
@@ -23,22 +27,16 @@ class Weather:
     month: Ann[Index[int], "Month"]
     """Month of the measured time."""
 
-    meas: Ann[Column[None], "Measurement"] = field(init=False, repr=False)
-    """Name of the measurement."""
-
-    stat: Ann[Column[None], "Statistic"] = field(init=False, repr=False)
-    """Name of the statistic."""
-
-    temp_avg: Ann[Data[float], ("Temperature ({.temp_unit})", "Average")]
+    temp_avg: Ann[Data[float], name("Temperature ({.temp_unit})", "Average")]
     """Monthly average temperature with given units."""
 
-    temp_max: Ann[Data[float], ("Temperature ({.temp_unit})", "Maximum")]
+    temp_max: Ann[Data[float], name("Temperature ({.temp_unit})", "Maximum")]
     """Monthly maximum temperature with given units."""
 
-    wind_avg: Ann[Data[float], ("Wind speed ({.wind_unit})", "Average")]
+    wind_avg: Ann[Data[float], name("Wind speed ({.wind_unit})", "Average")]
     """Monthly average wind speed with given units."""
 
-    wind_max: Ann[Data[float], ("Wind speed ({.wind_unit})", "Maximum")]
+    wind_max: Ann[Data[float], name("Wind speed ({.wind_unit})", "Maximum")]
     """Monthly maximum wind speed with given units."""
 
     loc: Ann[Attr[str], "Location"] = "Tokyo"
diff --git a/tests/test_core_api.py b/tests/test_core_api.py
index b0ef09e..31e4ad8 100644
--- a/tests/test_core_api.py
+++ b/tests/test_core_api.py
@@ -7,7 +7,13 @@ 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, Tag, asframe, asseries
-from pandas_dataclasses.core.api import get_attrs, get_columns, get_data, get_index
+from pandas_dataclasses.core.api import (
+    get_attrs,
+    get_columns,
+    get_data,
+    get_index,
+    name,
+)
 
 
 # test data
@@ -35,17 +41,16 @@ def test_get_columns() -> None:
     columns = cast(pd.Index, get_columns(spec))
 
     for i in range(len(columns)):
-        assert columns[i] == spec.fields.of(Tag.DATA)[i].name
+        assert columns[i] == name(spec.fields.of(Tag.DATA)[i])
 
-    for i in range(columns.nlevels):
-        assert columns.names[i] == spec.fields.of(Tag.COLUMN)[i].name
+    assert columns.names == spec.fields.of(Tag.DATA).names  # type: ignore
 
 
 def test_get_data() -> None:
     data = get_data(spec)
 
     for i, (key, val) in enumerate(data.items()):
-        assert key == spec.fields.of(Tag.DATA)[i].name
+        assert key == name(spec.fields.of(Tag.DATA)[i])
         assert val.dtype.name == spec.fields.of(Tag.DATA)[i].dtype
         assert (val == spec.fields.of(Tag.DATA)[i].default).all()
 
diff --git a/tests/test_core_specs.py b/tests/test_core_specs.py
index 077dcd7..6d1eddf 100644
--- a/tests/test_core_specs.py
+++ b/tests/test_core_specs.py
@@ -3,7 +3,7 @@ from dataclasses import MISSING
 
 
 # dependencies
-from data import Weather, weather
+from data import Weather, name, weather
 from pandas_dataclasses import Spec, Tag
 
 
@@ -17,7 +17,7 @@ def test_year() -> None:
     field = spec.fields.of(Tag.INDEX)[0]
 
     assert field.id == "year"
-    assert field.tags == [Tag.INDEX]
+    assert field.tags == (Tag.INDEX,)
     assert field.name == "Year"
     assert field.dtype == "int64"
     assert field.default is MISSING
@@ -27,7 +27,7 @@ def test_year_updated() -> None:
     field = spec_updated.fields.of(Tag.INDEX)[0]
 
     assert field.id == "year"
-    assert field.tags == [Tag.INDEX]
+    assert field.tags == (Tag.INDEX,)
     assert field.name == "Year"
     assert field.dtype == "int64"
     assert field.default == weather.year
@@ -37,7 +37,7 @@ def test_month() -> None:
     field = spec.fields.of(Tag.INDEX)[1]
 
     assert field.id == "month"
-    assert field.tags == [Tag.INDEX]
+    assert field.tags == (Tag.INDEX,)
     assert field.name == "Month"
     assert field.dtype == "int64"
     assert field.default is MISSING
@@ -47,54 +47,18 @@ def test_month_updated() -> None:
     field = spec_updated.fields.of(Tag.INDEX)[1]
 
     assert field.id == "month"
-    assert field.tags == [Tag.INDEX]
+    assert field.tags == (Tag.INDEX,)
     assert field.name == "Month"
     assert field.dtype == "int64"
     assert field.default == weather.month
 
 
-def test_meas() -> None:
-    field = spec.fields.of(Tag.COLUMN)[0]
-
-    assert field.id == "meas"
-    assert field.tags == [Tag.COLUMN]
-    assert field.name == "Measurement"
-    assert field.default is MISSING
-
-
-def test_meas_updated() -> None:
-    field = spec_updated.fields.of(Tag.COLUMN)[0]
-
-    assert field.id == "meas"
-    assert field.tags == [Tag.COLUMN]
-    assert field.name == "Measurement"
-    assert field.default is MISSING
-
-
-def test_stat() -> None:
-    field = spec.fields.of(Tag.COLUMN)[1]
-
-    assert field.id == "stat"
-    assert field.tags == [Tag.COLUMN]
-    assert field.name == "Statistic"
-    assert field.default is MISSING
-
-
-def test_stat_updated() -> None:
-    field = spec_updated.fields.of(Tag.COLUMN)[1]
-
-    assert field.id == "stat"
-    assert field.tags == [Tag.COLUMN]
-    assert field.name == "Statistic"
-    assert field.default is MISSING
-
-
 def test_temp_avg() -> None:
     field = spec.fields.of(Tag.DATA)[0]
 
     assert field.id == "temp_avg"
-    assert field.tags == [Tag.DATA]
-    assert field.name == ("Temperature ({.temp_unit})", "Average")
+    assert field.tags == (Tag.DATA,)
+    assert field.name == name("Temperature ({.temp_unit})", "Average")
     assert field.dtype == "float64"
     assert field.default is MISSING
 
@@ -103,8 +67,8 @@ def test_temp_avg_updated() -> None:
     field = spec_updated.fields.of(Tag.DATA)[0]
 
     assert field.id == "temp_avg"
-    assert field.tags == [Tag.DATA]
-    assert field.name == ("Temperature (deg C)", "Average")
+    assert field.tags == (Tag.DATA,)
+    assert field.name == name("Temperature (deg C)", "Average")
     assert field.dtype == "float64"
     assert field.default == weather.temp_avg
 
@@ -113,8 +77,8 @@ def test_temp_max() -> None:
     field = spec.fields.of(Tag.DATA)[1]
 
     assert field.id == "temp_max"
-    assert field.tags == [Tag.DATA]
-    assert field.name == ("Temperature ({.temp_unit})", "Maximum")
+    assert field.tags == (Tag.DATA,)
+    assert field.name == name("Temperature ({.temp_unit})", "Maximum")
     assert field.dtype == "float64"
     assert field.default is MISSING
 
@@ -123,8 +87,8 @@ def test_temp_max_updated() -> None:
     field = spec_updated.fields.of(Tag.DATA)[1]
 
     assert field.id == "temp_max"
-    assert field.tags == [Tag.DATA]
-    assert field.name == ("Temperature (deg C)", "Maximum")
+    assert field.tags == (Tag.DATA,)
+    assert field.name == name("Temperature (deg C)", "Maximum")
     assert field.dtype == "float64"
     assert field.default == weather.temp_max
 
@@ -133,8 +97,8 @@ def test_wind_avg() -> None:
     field = spec.fields.of(Tag.DATA)[2]
 
     assert field.id == "wind_avg"
-    assert field.tags == [Tag.DATA]
-    assert field.name == ("Wind speed ({.wind_unit})", "Average")
+    assert field.tags == (Tag.DATA,)
+    assert field.name == name("Wind speed ({.wind_unit})", "Average")
     assert field.dtype == "float64"
     assert field.default is MISSING
 
@@ -143,8 +107,8 @@ def test_wind_avg_updated() -> None:
     field = spec_updated.fields.of(Tag.DATA)[2]
 
     assert field.id == "wind_avg"
-    assert field.tags == [Tag.DATA]
-    assert field.name == ("Wind speed (m/s)", "Average")
+    assert field.tags == (Tag.DATA,)
+    assert field.name == name("Wind speed (m/s)", "Average")
     assert field.dtype == "float64"
     assert field.default == weather.wind_avg
 
@@ -153,8 +117,8 @@ def test_wind_max() -> None:
     field = spec.fields.of(Tag.DATA)[3]
 
     assert field.id == "wind_max"
-    assert field.tags == [Tag.DATA]
-    assert field.name == ("Wind speed ({.wind_unit})", "Maximum")
+    assert field.tags == (Tag.DATA,)
+    assert field.name == name("Wind speed ({.wind_unit})", "Maximum")
     assert field.dtype == "float64"
     assert field.default is MISSING
 
@@ -163,8 +127,8 @@ def test_wind_max_updated() -> None:
     field = spec_updated.fields.of(Tag.DATA)[3]
 
     assert field.id == "wind_max"
-    assert field.tags == [Tag.DATA]
-    assert field.name == ("Wind speed (m/s)", "Maximum")
+    assert field.tags == (Tag.DATA,)
+    assert field.name == name("Wind speed (m/s)", "Maximum")
     assert field.dtype == "float64"
     assert field.default == weather.wind_max
 
@@ -173,7 +137,7 @@ def test_loc() -> None:
     field = spec.fields.of(Tag.ATTR)[0]
 
     assert field.id == "loc"
-    assert field.tags == [Tag.ATTR]
+    assert field.tags == (Tag.ATTR,)
     assert field.name == "Location"
     assert field.default == Weather.loc
 
@@ -182,7 +146,7 @@ def test_loc_updated() -> None:
     field = spec_updated.fields.of(Tag.ATTR)[0]
 
     assert field.id == "loc"
-    assert field.tags == [Tag.ATTR]
+    assert field.tags == (Tag.ATTR,)
     assert field.name == "Location"
     assert field.default == weather.loc
 
@@ -191,7 +155,7 @@ def test_lon() -> None:
     field = spec.fields.of(Tag.ATTR)[1]
 
     assert field.id == "lon"
-    assert field.tags == [Tag.ATTR]
+    assert field.tags == (Tag.ATTR,)
     assert field.name == "Longitude ({.lon_unit})"
     assert field.default == Weather.lon
 
@@ -200,7 +164,7 @@ def test_lon_updated() -> None:
     field = spec_updated.fields.of(Tag.ATTR)[1]
 
     assert field.id == "lon"
-    assert field.tags == [Tag.ATTR]
+    assert field.tags == (Tag.ATTR,)
     assert field.name == "Longitude (deg)"
     assert field.default == weather.lon
 
@@ -209,7 +173,7 @@ def test_lat() -> None:
     field = spec.fields.of(Tag.ATTR)[2]
 
     assert field.id == "lat"
-    assert field.tags == [Tag.ATTR]
+    assert field.tags == (Tag.ATTR,)
     assert field.name == "Latitude ({.lat_unit})"
     assert field.default == Weather.lat
 
@@ -218,7 +182,7 @@ def test_lat_updated() -> None:
     field = spec_updated.fields.of(Tag.ATTR)[2]
 
     assert field.id == "lat"
-    assert field.tags == [Tag.ATTR]
+    assert field.tags == (Tag.ATTR,)
     assert field.name == "Latitude (deg)"
     assert field.default == weather.lat
 
@@ -227,7 +191,7 @@ def test_attrs() -> None:
     field = spec.fields.of(Tag.ATTR)[3]
 
     assert field.id == "attrs"
-    assert field.tags == [Tag.ATTR, Tag.MULTIPLE]
+    assert field.tags == (Tag.ATTR, Tag.MULTIPLE)
     assert field.name == "attrs"
     assert field.default is MISSING
 
@@ -236,7 +200,7 @@ 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.tags == (Tag.ATTR, Tag.MULTIPLE)
     assert field.name == "attrs"
     assert field.default == weather.attrs
 
diff --git a/tests/test_core_tagging.py b/tests/test_core_tagging.py
index 776dae5..185021c 100644
--- a/tests/test_core_tagging.py
+++ b/tests/test_core_tagging.py
@@ -3,33 +3,30 @@ from typing import Any, List, Tuple, Union
 
 
 # dependencies
-from pandas_dataclasses import Attr, Column, Data, Index, Tag
+from pandas_dataclasses import Attr, Data, Index, Tag
 from pandas_dataclasses.core.tagging import get_tags
 from pytest import mark
 from typing_extensions import Annotated as Ann
 
 
 # test data
-testdata: List[Tuple[Any, List[Tag]]] = [
-    (Attr[Any], [Tag.ATTR]),  # type: ignore
-    (Column[Any], [Tag.COLUMN]),  # type: ignore
-    (Data[Any], [Tag.DATA]),
-    (Index[Any], [Tag.INDEX]),
-    (Any, []),
-    (Ann[Attr[Any], "attr"], [Tag.ATTR]),  # type: ignore
-    (Ann[Column[Any], "attr"], [Tag.COLUMN]),  # type: ignore
-    (Ann[Data[Any], "data"], [Tag.DATA]),
-    (Ann[Index[Any], "index"], [Tag.INDEX]),
-    (Ann[Any, "other"], []),
-    (Union[Ann[Attr[Any], "attr"], Ann[Any, "any"]], [Tag.ATTR]),  # type: ignore
-    (Union[Ann[Column[Any], "attr"], Ann[Any, "any"]], [Tag.COLUMN]),  # type: ignore
-    (Union[Ann[Data[Any], "data"], Ann[Any, "any"]], [Tag.DATA]),
-    (Union[Ann[Index[Any], "index"], Ann[Any, "any"]], [Tag.INDEX]),
-    (Union[Ann[Any, "other"], Ann[Any, "any"]], []),
+testdata: List[Tuple[Any, Tuple[Tag, ...]]] = [
+    (Attr[Any], (Tag.ATTR,)),  # type: ignore
+    (Data[Any], (Tag.DATA,)),
+    (Index[Any], (Tag.INDEX,)),
+    (Any, ()),
+    (Ann[Attr[Any], "attr"], (Tag.ATTR,)),  # type: ignore
+    (Ann[Data[Any], "data"], (Tag.DATA,)),
+    (Ann[Index[Any], "index"], (Tag.INDEX,)),
+    (Ann[Any, "other"], ()),
+    (Union[Ann[Attr[Any], "attr"], Ann[Any, "any"]], (Tag.ATTR,)),  # type: ignore
+    (Union[Ann[Data[Any], "data"], Ann[Any, "any"]], (Tag.DATA,)),
+    (Union[Ann[Index[Any], "index"], Ann[Any, "any"]], (Tag.INDEX,)),
+    (Union[Ann[Any, "other"], Ann[Any, "any"]], ()),
 ]
 
 
 # test functions
 @mark.parametrize("tp, tags", testdata)
-def test_get_tags(tp: Any, tags: List[Tag]) -> None:
+def test_get_tags(tp: Any, tags: Tuple[Tag, ...]) -> None:
     assert get_tags(tp) == tags
diff --git a/tests/test_core_typing.py b/tests/test_core_typing.py
index f551e8a..68d6a69 100644
--- a/tests/test_core_typing.py
+++ b/tests/test_core_typing.py
@@ -5,8 +5,8 @@ from typing import Any, Hashable, List, Literal as L, Optional, Tuple, Union
 # dependencies
 import numpy as np
 import pandas as pd
-from pandas_dataclasses import Attr, Column, Data, Index
-from pandas_dataclasses.core.specs import get_dtype, get_name
+from pandas_dataclasses import Attr, Data, Index
+from pandas_dataclasses.core.specs import get_dtype, get_first
 from pytest import mark
 from typing_extensions import Annotated as Ann
 
@@ -33,24 +33,20 @@ testdata_dtype: List[Tuple[Any, Any]] = [
     (Union[Ann[Index[float], "index"], Ann[Any, "any"]], np.dtype("f8")),
 ]
 
-testdata_name: List[Tuple[Any, Optional[Hashable]]] = [
+testdata_first: List[Tuple[Any, Optional[Hashable]]] = [
     (Attr[Any], None),  # type: ignore
-    (Column[Any], None),  # type: ignore
     (Data[Any], None),
     (Index[Any], None),
     (Any, None),
     (Ann[Attr[Any], "attr"], "attr"),  # type: ignore
-    (Ann[Column[Any], "column"], "column"),  # type: ignore
     (Ann[Data[Any], "data"], "data"),
     (Ann[Index[Any], "index"], "index"),
     (Ann[Any, "other"], None),
     (Ann[Attr[Any], ..., "attr"], None),  # type: ignore
-    (Ann[Column[Any], ..., "column"], None),  # type: ignore
     (Ann[Data[Any], ..., "data"], None),
     (Ann[Index[Any], ..., "index"], None),
     (Ann[Any, ..., "other"], None),
     (Union[Ann[Attr[Any], "attr"], Ann[Any, "any"]], "attr"),  # type: ignore
-    (Union[Ann[Column[Any], "column"], Ann[Any, "any"]], "column"),  # type: ignore
     (Union[Ann[Data[Any], "data"], Ann[Any, "any"]], "data"),
     (Union[Ann[Index[Any], "index"], Ann[Any, "any"]], "index"),
     (Union[Ann[Any, "other"], Ann[Any, "any"]], None),
@@ -63,6 +59,6 @@ def test_get_dtype(tp: Any, dtype: Optional[str]) -> None:
     assert get_dtype(tp) == dtype
 
 
-@mark.parametrize("tp, name", testdata_name)
-def test_get_name(tp: Any, name: Optional[Hashable]) -> None:
-    assert get_name(tp) == name
+@mark.parametrize("tp, first", testdata_first)
+def test_get_first(tp: Any, first: Optional[Any]) -> None:
+    assert get_first(tp, None) == first