Repository: unionai-oss/pandera

Issue:
Concise typing.List type annotation throws error in DataFrameModel.to_schema()
**Describe the bug**

The [Using Data Types directly for Column Type Annotations](https://pandera.readthedocs.io/en/stable/dataframe_models.html#using-data-types-directly-for-column-type-annotations) feature doesn't work for a `typing.List` type as described in [Support for the python typing module](https://pandera.readthedocs.io/en/latest/dtype_validation.html?highlight=typing.List#support-for-the-python-typing-module)

#### Code Sample, a copy-pastable example

```python
from typing import List
import pandera as pa
from pandera.typing import Series

class TodoList(pa.DataFrameModel):
    items: List[str] = pa.Field(description="The scan IDs")

TodoList.to_schema() # throws error
```

This throws the error:
```py
Invalid annotation 'items: typing.List[str]'
```

#### Expected behavior
Should behave exactly the same as having an explicit `Series` data type:
```py
class TodoList(pa.DataFrameModel):
    # here, I wrap it in pa.typing.Series and it works
    items: pa.typing.Series[List[str]] = pa.Field(description="The scan IDs")
```

#### Desktop (please complete the following information):

 - OS: MacOS
 - Version: 14.1




Discussion:

Resolving patch:
diff --git a/pandera/api/pandas/model.py b/pandera/api/pandas/model.py
--- a/pandera/api/pandas/model.py
+++ b/pandera/api/pandas/model.py
@@ -20,6 +20,7 @@
 from pandera.api.pandas.container import DataFrameSchema
 from pandera.api.pandas.components import Column, Index, MultiIndex
 from pandera.api.pandas.model_config import BaseConfig
+from pandera.engines.pandas_engine import Engine
 from pandera.errors import SchemaInitError
 from pandera.typing import AnnotationInfo, INDEX_TYPES, SERIES_TYPES
 
@@ -78,6 +79,7 @@ def _build_columns_index(  # pylint:disable=too-many-locals
             field_name = field.name
             check_name = getattr(field, "check_name", None)
 
+            use_raw_annotation = False
             if annotation.metadata:
                 if field.dtype_kwargs:
                     raise TypeError(
@@ -90,13 +92,21 @@ def _build_columns_index(  # pylint:disable=too-many-locals
             elif annotation.default_dtype:
                 dtype = annotation.default_dtype
             else:
-                dtype = annotation.arg
+                try:
+                    # if the raw annotation is accepted by the engine, use it as
+                    # the dtype
+                    Engine.dtype(annotation.raw_annotation)
+                    dtype = annotation.raw_annotation
+                    use_raw_annotation = True
+                except TypeError:
+                    dtype = annotation.arg
 
             dtype = None if dtype is Any else dtype
 
             if (
                 annotation.is_annotated_type
                 or annotation.origin is None
+                or use_raw_annotation
                 or annotation.origin in SERIES_TYPES
                 or annotation.raw_annotation in SERIES_TYPES
             ):


Test patch:
diff --git a/tests/core/test_dtypes.py b/tests/core/test_dtypes.py
--- a/tests/core/test_dtypes.py
+++ b/tests/core/test_dtypes.py
@@ -749,6 +749,13 @@ class PointTuple(NamedTuple):
         },
     )
 
+    class Model(pa.DataFrameModel):
+        Dict_column: Dict[str, int]
+        List_column: List[float]
+        Tuple_column: Tuple[int, str, float]
+        typeddict_column: PointDict
+        namedtuple_column: PointTuple
+
     data = pd.DataFrame(
         {
             "Dict_column": [{"foo": 1, "bar": 2}],
@@ -760,6 +767,7 @@ class PointTuple(NamedTuple):
     )
 
     schema.validate(data)
+    Model.validate(data)
 
 
 @pytest.mark.skipif(
@@ -775,6 +783,12 @@ def test_python_std_list_dict_generics():
             "tuple_column": pa.Column(tuple[int, str, float]),
         },
     )
+
+    class Model(pa.DataFrameModel):
+        dict_column: dict[str, int]
+        list_column: list[float]
+        tuple_column: tuple[int, str, float]
+
     data = pd.DataFrame(
         {
             "dict_column": [{"foo": 1, "bar": 2}],
@@ -783,6 +797,7 @@ def test_python_std_list_dict_generics():
         }
     )
     schema.validate(data)
+    Model.validate(data)
 
 
 @pytest.mark.parametrize("nullable", [True, False])
@@ -812,6 +827,13 @@ def test_python_typing_handle_empty_list_dict_and_none(nullable, data_dict):
         coerce=True,
     )
 
+    class Model(pa.DataFrameModel):
+        dict_column: Dict[str, int] = pa.Field(nullable=nullable)
+        list_column: List[float] = pa.Field(nullable=nullable)
+
+        class Config:
+            coerce = True
+
     data = pd.DataFrame(data_dict)
 
     expected = pd.DataFrame(
@@ -822,11 +844,13 @@ def test_python_typing_handle_empty_list_dict_and_none(nullable, data_dict):
     )
 
     if nullable:
-        validated_data = schema.validate(data)
-        assert validated_data.equals(expected)
+        assert schema.validate(data).equals(expected)
+        assert Model.validate(data).equals(expected)
     else:
         with pytest.raises(pa.errors.SchemaError):
             schema.validate(data)
+        with pytest.raises(pa.errors.SchemaError):
+            Model.validate(data)
 
 
 @pytest.mark.skipif(
@@ -861,6 +885,13 @@ def test_python_std_list_dict_empty_and_none(nullable, data_dict):
         coerce=True,
     )
 
+    class Model(pa.DataFrameModel):
+        dict_column: dict[str, int] = pa.Field(nullable=nullable)
+        list_column: list[float] = pa.Field(nullable=nullable)
+
+        class Config:
+            coerce = True
+
     data = pd.DataFrame(data_dict)
 
     expected = pd.DataFrame(
@@ -871,11 +902,13 @@ def test_python_std_list_dict_empty_and_none(nullable, data_dict):
     )
 
     if nullable:
-        validated_data = schema.validate(data)
-        assert validated_data.equals(expected)
+        assert schema.validate(data).equals(expected)
+        assert Model.validate(data).equals(expected)
     else:
         with pytest.raises(pa.errors.SchemaError):
             schema.validate(data)
+        with pytest.raises(pa.errors.SchemaError):
+            Model.validate(data)
 
 
 def test_python_std_list_dict_error():
@@ -887,6 +920,10 @@ def test_python_std_list_dict_error():
         },
     )
 
+    class Model(pa.DataFrameModel):
+        dict_column: Dict[str, int]
+        list_column: List[float]
+
     data = pd.DataFrame(
         {
             "dict_column": [{"foo": 1}, {"foo": 1, "bar": "2"}, {}],
@@ -894,11 +931,12 @@ def test_python_std_list_dict_error():
         }
     )
 
-    try:
-        schema.validate(data, lazy=True)
-    except pa.errors.SchemaErrors as exc:
-        assert exc.failure_cases["failure_case"].iloc[0] == {
-            "foo": 1,
-            "bar": "2",
-        }
-        assert exc.failure_cases["failure_case"].iloc[1] == ["1.0", 2.0]
+    for validator in (schema, Model):
+        try:
+            validator.validate(data, lazy=True)
+        except pa.errors.SchemaErrors as exc:
+            assert exc.failure_cases["failure_case"].iloc[0] == {
+                "foo": 1,
+                "bar": "2",
+            }
+            assert exc.failure_cases["failure_case"].iloc[1] == ["1.0", 2.0]