Repository: astropenguin/pandas-dataclasses

Issue:
Update the tagging method for type hints

Discussion:

Patch:
diff --git a/pandas_dataclasses/__init__.py b/pandas_dataclasses/__init__.py
index 925a8c1..590fca2 100644
--- a/pandas_dataclasses/__init__.py
+++ b/pandas_dataclasses/__init__.py
@@ -7,8 +7,8 @@ __all__ = [
     "Column",
     "Data",
     "Index",
-    "Other",
     "Spec",
+    "Tag",
     "asdataframe",
     "asframe",
     "aspandas",
diff --git a/pandas_dataclasses/core/specs.py b/pandas_dataclasses/core/specs.py
index 6a46f08..4a85b17 100644
--- a/pandas_dataclasses/core/specs.py
+++ b/pandas_dataclasses/core/specs.py
@@ -10,12 +10,13 @@ from dataclasses import (
     replace,
 )
 from functools import lru_cache
-from typing import Any, Callable, Hashable, List, Literal, Optional, Type
+from itertools import repeat
+from typing import Any, Callable, Collection, Hashable, List, Mapping, Optional
 
 
 # dependencies
 from typing_extensions import get_type_hints
-from .typing import T, Pandas, Tag, get_dtype, get_name, get_tag
+from .typing import T, Pandas, Tag, get_dtype, get_name, get_tags
 
 
 # runtime classes
@@ -26,14 +27,11 @@ class Field:
     id: str
     """Identifier of the field."""
 
-    tag: Literal["attr", "column", "data", "index"]
-    """Tag of the field."""
-
-    name: Hashable = None
+    name: Hashable
     """Name of the field data."""
 
-    default: Any = None
-    """Default value of the field data."""
+    tags: List[Tag] = field_(default_factory=list)
+    """Tags of the field."""
 
     type: Optional[Any] = None
     """Type or type hint of the field data."""
@@ -41,6 +39,9 @@ class Field:
     dtype: Optional[str] = None
     """Data type of the field data."""
 
+    default: Any = None
+    """Default value of the field data."""
+
     def update(self, obj: Any) -> "Field":
         """Update the specification by an object."""
         return replace(
@@ -56,26 +57,30 @@ class Fields(List[Field]):
     @property
     def of_attr(self) -> "Fields":
         """Select only attribute field specifications."""
-        return Fields(field for field in self if field.tag == "attr")
+        return self.filter(lambda f: Tag.ATTR in Tag.union(f.tags))
 
     @property
     def of_column(self) -> "Fields":
         """Select only column field specifications."""
-        return Fields(field for field in self if field.tag == "column")
+        return self.filter(lambda f: Tag.COLUMN in Tag.union(f.tags))
 
     @property
     def of_data(self) -> "Fields":
         """Select only data field specifications."""
-        return Fields(field for field in self if field.tag == "data")
+        return self.filter(lambda f: Tag.DATA in Tag.union(f.tags))
 
     @property
     def of_index(self) -> "Fields":
         """Select only index field specifications."""
-        return Fields(field for field in self if field.tag == "index")
+        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))
 
     def update(self, obj: Any) -> "Fields":
         """Update the specifications by an object."""
-        return Fields(field.update(obj) for field in self)
+        return type(self)(field.update(obj) for field in self)
 
 
 @dataclass(frozen=True)
@@ -97,14 +102,14 @@ class Spec:
     @classmethod
     def from_dataclass(cls, dataclass: type) -> "Spec":
         """Create a specification from a data class."""
-        fields = Fields()
+        eval_field_types(dataclass)
 
-        for field_ in fields_(eval_types(dataclass)):
-            if (field := get_field(field_)) is not None:
-                fields.append(field)
-
-        factory = getattr(dataclass, "__pandas_factory__", None)
-        return cls(dataclass.__name__, dataclass, factory, fields)
+        return cls(
+            name=dataclass.__name__,
+            origin=dataclass,
+            factory=getattr(dataclass, "__pandas_factory__", None),
+            fields=Fields(map(convert_field, fields_(dataclass))),
+        )
 
     def update(self, obj: Any) -> "Spec":
         """Update the specification by an object."""
@@ -120,15 +125,26 @@ class Spec:
 
 # runtime functions
 @lru_cache(maxsize=None)
-def eval_types(dataclass: Type[T]) -> Type[T]:
+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),
+        tags=get_tags(field_.type, Tag.FIELD),
+        type=field_.type,
+        dtype=get_dtype(field_.type),
+        default=field_.default,
+    )
+
+
+@lru_cache(maxsize=None)
+def eval_field_types(dataclass: type) -> None:
     """Evaluate field types of a dataclass."""
     types = get_type_hints(dataclass, include_extras=True)
 
     for field_ in fields_(dataclass):
         field_.type = types[field_.name]
 
-    return dataclass
-
 
 def format_(obj: T, by: Any) -> T:
     """Format a string or nested strings in an object."""
@@ -136,32 +152,11 @@ def format_(obj: T, by: Any) -> T:
 
     if isinstance(obj, str):
         return tp(obj.format(by))  # type: ignore
-    elif isinstance(obj, (list, tuple, set)):
-        return tp(format_(item, by) for item in obj)  # type: ignore
-    elif isinstance(obj, dict):
-        return tp(format_(item, by) for item in obj.items())  # type: ignore
-    else:
-        return obj
-
-
-@lru_cache(maxsize=None)
-def get_field(field_: "Field_[Any]") -> Optional[Field]:
-    """Create a field specification from a dataclass field."""
-    tag = get_tag(field_.type)
 
-    if tag is Tag.OTHER:
-        return None
+    if isinstance(obj, Mapping):
+        return tp(map(format_, obj.items(), repeat(by)))  # type: ignore
 
-    if tag in Tag.DATA | Tag.INDEX:
-        dtype = get_dtype(field_.type)
-    else:
-        dtype = None
+    if isinstance(obj, Collection):
+        return tp(map(format_, obj, repeat(by)))  # type: ignore
 
-    return Field(
-        id=field_.name,
-        tag=tag.name.lower(),  # type: ignore
-        name=get_name(field_.type, field_.name),
-        default=field_.default,
-        type=field_.type,
-        dtype=dtype,
-    )
+    return obj
diff --git a/pandas_dataclasses/core/typing.py b/pandas_dataclasses/core/typing.py
index e45ea5f..4769684 100644
--- a/pandas_dataclasses/core/typing.py
+++ b/pandas_dataclasses/core/typing.py
@@ -1,11 +1,13 @@
-__all__ = ["Attr", "Column", "Data", "Index", "Other"]
+__all__ = ["Attr", "Column", "Data", "Index", "Tag"]
 
 
 # standard library
 import types
 from dataclasses import Field
 from enum import Flag, auto
-from itertools import chain
+from functools import reduce
+from itertools import chain, filterfalse
+from operator import or_
 from typing import (
     Any,
     Callable,
@@ -13,19 +15,20 @@ from typing import (
     Dict,
     Hashable,
     Iterable,
+    List,
     Literal,
     Optional,
     Protocol,
-    Tuple,
     TypeVar,
     Union,
+    cast,
 )
 
 
 # dependencies
 import pandas as pd
 from pandas.api.types import pandas_dtype
-from typing_extensions import Annotated, ParamSpec, get_args, get_origin, get_type_hints
+from typing_extensions import Annotated, ParamSpec, get_args, get_origin
 
 
 # type hints (private)
@@ -57,27 +60,54 @@ class PandasClass(Protocol[P, TPandas]):
 
 
 class Tag(Flag):
-    """Annotations for typing dataclass fields."""
+    """Collection of tags for annotating types."""
 
     ATTR = auto()
-    """Annotation for attribute fields."""
+    """Tag for a type specifying an attribute field."""
 
     COLUMN = auto()
-    """Annotation for column fields."""
+    """Tag for a type specifying a column field."""
 
     DATA = auto()
-    """Annotation for data fields."""
+    """Tag for a type specifying a data field."""
 
     INDEX = auto()
-    """Annotation for index fields."""
+    """Tag for a type specifying an index field."""
 
-    OTHER = auto()
-    """Annotation for other fields."""
+    DTYPE = auto()
+    """Tag for a type specifying a data type."""
+
+    FIELD = ATTR | COLUMN | DATA | INDEX
+    """Union of field-related tags."""
+
+    ANY = FIELD | DTYPE
+    """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
+
+    @classmethod
+    def creates(cls, obj: Any) -> bool:
+        """Check if Tag is the type of an object."""
+        return isinstance(obj, cls)
 
     @classmethod
-    def annotates(cls, tp: Any) -> bool:
-        """Check if any tag annotates a type hint."""
-        return any(isinstance(arg, cls) for arg in get_args(tp))
+    def union(cls, tags: Iterable["Tag"]) -> "Tag":
+        """Create a tag as an union of tags."""
+        return reduce(or_, tags, Tag(0))
+
+    def __repr__(self) -> str:
+        """Return the hashtag-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()}"
 
 
 # type hints (public)
@@ -87,64 +117,57 @@ Attr = Annotated[T, Tag.ATTR]
 Column = Annotated[T, Tag.COLUMN]
 """Type hint for column fields (``Column[T]``)."""
 
-Data = Annotated[Collection[T], Tag.DATA]
+Data = Annotated[Collection[Annotated[T, Tag.DTYPE]], Tag.DATA]
 """Type hint for data fields (``Data[T]``)."""
 
-Index = Annotated[Collection[T], Tag.INDEX]
+Index = Annotated[Collection[Annotated[T, Tag.DTYPE]], Tag.INDEX]
 """Type hint for index fields (``Index[T]``)."""
 
-Other = Annotated[T, Tag.OTHER]
-"""Type hint for other fields (``Other[T]``)."""
-
 
 # runtime functions
-def deannotate(tp: Any) -> Any:
-    """Recursively remove annotations in a type hint."""
-
-    class Temporary:
-        __annotations__ = dict(tp=tp)
-
-    return get_type_hints(Temporary)["tp"]
-
-
-def find_annotated(tp: Any) -> Iterable[Any]:
+def gen_annotated(tp: Any) -> Iterable[Any]:
     """Generate all annotated types in a type hint."""
-    args = get_args(tp)
-
     if get_origin(tp) is Annotated:
         yield tp
-        yield from find_annotated(args[0])
+        yield from gen_annotated(get_args(tp)[0])
     else:
-        yield from chain(*map(find_annotated, args))
+        yield from chain(*map(gen_annotated, get_args(tp)))
 
 
-def get_annotated(tp: Any) -> Any:
-    """Extract the first tag-annotated type."""
-    for annotated in filter(Tag.annotates, find_annotated(tp)):
-        return deannotate(annotated)
+def get_tagged(
+    tp: Any,
+    bound: Tag = Tag.ANY,
+    keep_annotations: bool = False,
+) -> Optional[Any]:
+    """Extract the first tagged type from a type hint."""
+    for tagged in filter(bound.annotates, gen_annotated(tp)):
+        return tagged if keep_annotations else get_args(tagged)[0]
 
-    raise TypeError("Could not find any tag-annotated type.")
 
+def get_tags(tp: Any, bound: Tag = Tag.ANY) -> List[Tag]:
+    """Extract all tags from the first tagged type."""
+    tagged = get_tagged(tp, bound, True)
+    return list(filter(Tag.creates, get_args(tagged)[1:]))
 
-def get_annotations(tp: Any) -> Tuple[Any, ...]:
-    """Extract annotations of the first tag-annotated type."""
-    for annotated in filter(Tag.annotates, find_annotated(tp)):
-        return get_args(annotated)[1:]
 
-    raise TypeError("Could not find any tag-annotated type.")
+def get_nontags(tp: Any, bound: Tag = Tag.ANY) -> List[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:]))
 
 
 def get_dtype(tp: Any) -> Optional[str]:
-    """Extract a NumPy or pandas data type."""
-    try:
-        dtype = get_args(get_annotated(tp))[0]
-    except (IndexError, TypeError):
+    """Extract a data type of NumPy or pandas from a type hint."""
+    if (tagged := get_tagged(tp, Tag.DATA | Tag.INDEX)) is None:
+        return None
+
+    if (dtype := get_tagged(tagged, Tag.DTYPE)) is None:
         return None
 
     if dtype is Any or dtype is type(None):
         return None
 
-    if is_union_type(dtype):
+    if is_union(dtype):
         dtype = get_args(dtype)[0]
 
     if get_origin(dtype) is Literal:
@@ -154,35 +177,20 @@ def get_dtype(tp: Any) -> Optional[str]:
 
 
 def get_name(tp: Any, default: Hashable = None) -> Hashable:
-    """Extract a name if found or return given default."""
-    try:
-        name = get_annotations(tp)[1]
-    except (IndexError, TypeError):
+    """Extract the first hashable as a name from a type hint."""
+    if not (nontags := get_nontags(tp, Tag.FIELD)):
         return default
 
-    if name is Ellipsis:
+    if (name := nontags[0]) is Ellipsis:
         return default
 
-    try:
-        hash(name)
-    except TypeError:
-        raise ValueError("Could not find any valid name.")
-
-    return name  # type: ignore
+    hash(name)
+    return cast(Hashable, name)
 
 
-def get_tag(tp: Any, default: Tag = Tag.OTHER) -> Tag:
-    """Extract a tag if found or return given default."""
-    try:
-        return get_annotations(tp)[0]  # type: ignore
-    except (IndexError, TypeError):
-        return default
-
-
-def is_union_type(tp: Any) -> bool:
-    """Check if a type hint is a union type."""
-    if get_origin(tp) is Union:
-        return True
-
-    UnionType = getattr(types, "UnionType", None)
-    return UnionType is not None and isinstance(tp, UnionType)
+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/pyproject.toml b/pyproject.toml
index 06e9c5b..301d108 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,7 @@ sphinx = "^5.3"
 
 [tool.mypy]
 strict = true
+warn_no_return = false
 warn_unused_ignores = false
 
 [tool.pyright]


Test patch:
diff --git a/tests/test_core_specs.py b/tests/test_core_specs.py
index dba5fa8..c942d71 100644
--- a/tests/test_core_specs.py
+++ b/tests/test_core_specs.py
@@ -4,7 +4,7 @@ from dataclasses import MISSING
 
 # dependencies
 from data import Weather, weather
-from pandas_dataclasses import Spec
+from pandas_dataclasses import Spec, Tag
 
 
 # test data
@@ -17,7 +17,7 @@ def test_year() -> None:
     field = spec.fields.of_index[0]
 
     assert field.id == "year"
-    assert field.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_index[0]
 
     assert field.id == "year"
-    assert field.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_index[1]
 
     assert field.id == "month"
-    assert field.tag == "index"
+    assert field.tags == [Tag.INDEX]
     assert field.name == "Month"
     assert field.dtype == "int64"
     assert field.default is MISSING
@@ -47,7 +47,7 @@ def test_month_updated() -> None:
     field = spec_updated.fields.of_index[1]
 
     assert field.id == "month"
-    assert field.tag == "index"
+    assert field.tags == [Tag.INDEX]
     assert field.name == "Month"
     assert field.dtype == "int64"
     assert field.default == weather.month
@@ -57,7 +57,7 @@ def test_meas() -> None:
     field = spec.fields.of_column[0]
 
     assert field.id == "meas"
-    assert field.tag == "column"
+    assert field.tags == [Tag.COLUMN]
     assert field.name == "Measurement"
     assert field.default is MISSING
 
@@ -66,7 +66,7 @@ def test_meas_updated() -> None:
     field = spec_updated.fields.of_column[0]
 
     assert field.id == "meas"
-    assert field.tag == "column"
+    assert field.tags == [Tag.COLUMN]
     assert field.name == "Measurement"
     assert field.default is MISSING
 
@@ -75,7 +75,7 @@ def test_stat() -> None:
     field = spec.fields.of_column[1]
 
     assert field.id == "stat"
-    assert field.tag == "column"
+    assert field.tags == [Tag.COLUMN]
     assert field.name == "Statistic"
     assert field.default is MISSING
 
@@ -84,7 +84,7 @@ def test_stat_updated() -> None:
     field = spec_updated.fields.of_column[1]
 
     assert field.id == "stat"
-    assert field.tag == "column"
+    assert field.tags == [Tag.COLUMN]
     assert field.name == "Statistic"
     assert field.default is MISSING
 
@@ -93,7 +93,7 @@ def test_temp_avg() -> None:
     field = spec.fields.of_data[0]
 
     assert field.id == "temp_avg"
-    assert field.tag == "data"
+    assert field.tags == [Tag.DATA]
     assert field.name == ("Temperature ({.temp_unit})", "Average")
     assert field.dtype == "float64"
     assert field.default is MISSING
@@ -103,7 +103,7 @@ def test_temp_avg_updated() -> None:
     field = spec_updated.fields.of_data[0]
 
     assert field.id == "temp_avg"
-    assert field.tag == "data"
+    assert field.tags == [Tag.DATA]
     assert field.name == ("Temperature (deg C)", "Average")
     assert field.dtype == "float64"
     assert field.default == weather.temp_avg
@@ -113,7 +113,7 @@ def test_temp_max() -> None:
     field = spec.fields.of_data[1]
 
     assert field.id == "temp_max"
-    assert field.tag == "data"
+    assert field.tags == [Tag.DATA]
     assert field.name == ("Temperature ({.temp_unit})", "Maximum")
     assert field.dtype == "float64"
     assert field.default is MISSING
@@ -123,7 +123,7 @@ def test_temp_max_updated() -> None:
     field = spec_updated.fields.of_data[1]
 
     assert field.id == "temp_max"
-    assert field.tag == "data"
+    assert field.tags == [Tag.DATA]
     assert field.name == ("Temperature (deg C)", "Maximum")
     assert field.dtype == "float64"
     assert field.default == weather.temp_max
@@ -133,7 +133,7 @@ def test_wind_avg() -> None:
     field = spec.fields.of_data[2]
 
     assert field.id == "wind_avg"
-    assert field.tag == "data"
+    assert field.tags == [Tag.DATA]
     assert field.name == ("Wind speed ({.wind_unit})", "Average")
     assert field.dtype == "float64"
     assert field.default is MISSING
@@ -143,7 +143,7 @@ def test_wind_avg_updated() -> None:
     field = spec_updated.fields.of_data[2]
 
     assert field.id == "wind_avg"
-    assert field.tag == "data"
+    assert field.tags == [Tag.DATA]
     assert field.name == ("Wind speed (m/s)", "Average")
     assert field.dtype == "float64"
     assert field.default == weather.wind_avg
@@ -153,7 +153,7 @@ def test_wind_max() -> None:
     field = spec.fields.of_data[3]
 
     assert field.id == "wind_max"
-    assert field.tag == "data"
+    assert field.tags == [Tag.DATA]
     assert field.name == ("Wind speed ({.wind_unit})", "Maximum")
     assert field.dtype == "float64"
     assert field.default is MISSING
@@ -163,7 +163,7 @@ def test_wind_max_updated() -> None:
     field = spec_updated.fields.of_data[3]
 
     assert field.id == "wind_max"
-    assert field.tag == "data"
+    assert field.tags == [Tag.DATA]
     assert field.name == ("Wind speed (m/s)", "Maximum")
     assert field.dtype == "float64"
     assert field.default == weather.wind_max
@@ -173,7 +173,7 @@ def test_loc() -> None:
     field = spec.fields.of_attr[0]
 
     assert field.id == "loc"
-    assert field.tag == "attr"
+    assert field.tags == [Tag.ATTR]
     assert field.name == "Location"
     assert field.default == Weather.loc
 
@@ -182,7 +182,7 @@ def test_loc_updated() -> None:
     field = spec_updated.fields.of_attr[0]
 
     assert field.id == "loc"
-    assert field.tag == "attr"
+    assert field.tags == [Tag.ATTR]
     assert field.name == "Location"
     assert field.default == weather.loc
 
@@ -191,7 +191,7 @@ def test_lon() -> None:
     field = spec.fields.of_attr[1]
 
     assert field.id == "lon"
-    assert field.tag == "attr"
+    assert field.tags == [Tag.ATTR]
     assert field.name == "Longitude ({.lon_unit})"
     assert field.default == Weather.lon
 
@@ -200,7 +200,7 @@ def test_lon_updated() -> None:
     field = spec_updated.fields.of_attr[1]
 
     assert field.id == "lon"
-    assert field.tag == "attr"
+    assert field.tags == [Tag.ATTR]
     assert field.name == "Longitude (deg)"
     assert field.default == weather.lon
 
@@ -209,7 +209,7 @@ def test_lat() -> None:
     field = spec.fields.of_attr[2]
 
     assert field.id == "lat"
-    assert field.tag == "attr"
+    assert field.tags == [Tag.ATTR]
     assert field.name == "Latitude ({.lat_unit})"
     assert field.default == Weather.lat
 
@@ -218,7 +218,7 @@ def test_lat_updated() -> None:
     field = spec_updated.fields.of_attr[2]
 
     assert field.id == "lat"
-    assert field.tag == "attr"
+    assert field.tags == [Tag.ATTR]
     assert field.name == "Latitude (deg)"
     assert field.default == weather.lat
 
diff --git a/tests/test_core_typing.py b/tests/test_core_typing.py
index 4712094..1a970bd 100644
--- a/tests/test_core_typing.py
+++ b/tests/test_core_typing.py
@@ -1,18 +1,18 @@
 # standard library
-from typing import Any, Literal as L, Union
+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.typing import Tag, get_dtype, get_name, get_tag
+from pandas_dataclasses.core.typing import Tag, get_dtype, get_name, get_tags
 from pytest import mark
 from typing_extensions import Annotated as Ann
 
 
 # test data
-testdata_dtype = [
+testdata_dtype: List[Tuple[Any, Any]] = [
     (Data[Any], None),
     (Data[None], None),
     (Data[int], np.dtype("i8")),
@@ -33,7 +33,7 @@ testdata_dtype = [
     (Union[Ann[Index[float], "index"], Ann[Any, "any"]], np.dtype("f8")),
 ]
 
-testdata_name = [
+testdata_name: List[Tuple[Any, Optional[Hashable]]] = [
     (Attr[Any], None),  # type: ignore
     (Column[Any], None),  # type: ignore
     (Data[Any], None),
@@ -56,36 +56,36 @@ testdata_name = [
     (Union[Ann[Any, "other"], Ann[Any, "any"]], None),
 ]
 
-testdata_tag = [
-    (Attr[Any], Tag.ATTR),  # type: ignore
-    (Column[Any], Tag.COLUMN),  # type: ignore
-    (Data[Any], Tag.DATA),
-    (Index[Any], Tag.INDEX),
-    (Any, Tag.OTHER),
-    (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"], Tag.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"]], Tag.OTHER),
+testdata_tags: 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"]], []),
 ]
 
 
 # test functions
 @mark.parametrize("tp, dtype", testdata_dtype)
-def test_get_dtype(tp: Any, dtype: Any) -> None:
+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: Any) -> None:
+def test_get_name(tp: Any, name: Optional[Hashable]) -> None:
     assert get_name(tp) == name
 
 
-@mark.parametrize("tp, tag", testdata_tag)
-def test_get_tag(tp: Any, tag: Any) -> None:
-    assert get_tag(tp) is tag
+@mark.parametrize("tp, tags", testdata_tags)
+def test_get_tags(tp: Any, tags: List[Tag]) -> None:
+    assert get_tags(tp) == tags