Skip to content

Commit 2a0ee70

Browse files
authored
Enh/future annotations py3.13 (#1980)
* fix typing unit test for py3.13 Signed-off-by: cosmicBboy <[email protected]> * support __future__.annotations, including forwardref types Signed-off-by: cosmicBboy <[email protected]> * fix weird issue with DataFrameBase.__setattr__: use dict.update Signed-off-by: cosmicBboy <[email protected]> * add python 3.13 to ci Signed-off-by: cosmicBboy <[email protected]> * fix up tests Signed-off-by: cosmicBboy <[email protected]> * update ci matrix Signed-off-by: cosmicBboy <[email protected]> --------- Signed-off-by: cosmicBboy <[email protected]>
1 parent c49b18f commit 2a0ee70

File tree

12 files changed

+207
-59
lines changed

12 files changed

+207
-59
lines changed

.github/workflows/ci-tests.yml

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
strategy:
3131
fail-fast: true
3232
matrix:
33-
python-version: ["3.9", "3.10", "3.11", "3.12"]
33+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
3434
defaults:
3535
run:
3636
shell: bash -l {0}
@@ -102,7 +102,7 @@ jobs:
102102
fail-fast: false
103103
matrix:
104104
os: [ubuntu-latest, windows-latest, macos-latest]
105-
python-version: ["3.9", "3.10", "3.11", "3.12"]
105+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
106106
pydantic-version: ["2.10.6"]
107107
steps:
108108
- uses: actions/checkout@v4
@@ -135,9 +135,12 @@ jobs:
135135
fail-fast: false
136136
matrix:
137137
os: [ubuntu-latest, windows-latest, macos-latest]
138-
python-version: ["3.9", "3.10", "3.11", "3.12"]
138+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
139139
pandas-version: ["2.1.1", "2.2.3"]
140140
pydantic-version: ["1.10.11", "2.10.6"]
141+
exclude:
142+
- pandas-version: "2.1.1"
143+
python-version: "3.13"
141144
steps:
142145
- uses: actions/checkout@v4
143146
- name: Set up Python ${{ matrix.python-version }}
@@ -169,7 +172,7 @@ jobs:
169172
fail-fast: false
170173
matrix:
171174
os: [ubuntu-latest, windows-latest, macos-latest]
172-
python-version: ["3.9", "3.10", "3.11", "3.12"]
175+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
173176
pandas-version: ["2.2.3"]
174177
pydantic-version: ["2.10.6"]
175178
extra:
@@ -215,7 +218,7 @@ jobs:
215218
fail-fast: false
216219
matrix:
217220
os: [ubuntu-latest, windows-latest, macos-latest]
218-
python-version: ["3.9", "3.10", "3.11", "3.12"]
221+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
219222
pandas-version: ["2.2.3"]
220223
pydantic-version: ["2.10.6"]
221224
extra:
@@ -235,7 +238,13 @@ jobs:
235238
- extra: pyspark
236239
os: windows-latest
237240
- extra: pyspark
238-
python-version: 3.12
241+
python-version: "3.12"
242+
- extra: pyspark
243+
python-version: "3.13"
244+
- extra: modin-dask
245+
python-version: "3.13"
246+
- extra: modin-ray
247+
python-version: "3.13"
239248

240249
steps:
241250
- uses: actions/checkout@v4

noxfile.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"docs",
2222
)
2323

24-
PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12"]
24+
PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12", "3.13"]
2525
PANDAS_VERSIONS = ["2.1.1", "2.2.3"]
2626
PYDANTIC_VERSIONS = ["1.10.11", "2.10.6"]
2727
PACKAGE = "pandera"
@@ -174,7 +174,7 @@ def _testing_requirements(
174174

175175
return [
176176
*_updated_requirements,
177-
*nox.project.dependency_groups(PYPROJECT, *["dev", "testing", "docs"]),
177+
*nox.project.dependency_groups(PYPROJECT, *["dev", "testing"]),
178178
]
179179

180180

pandera/api/dataframe/model.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
Union,
2121
cast,
2222
)
23-
23+
from typing_extensions import get_type_hints
2424
from pandera.api.base.model import BaseModel
2525
from pandera.api.base.schema import BaseSchema
2626
from pandera.api.checks import Check
@@ -49,11 +49,6 @@
4949
from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
5050
from pydantic_core import core_schema
5151

52-
try:
53-
from typing_extensions import get_type_hints
54-
except ImportError: # pragma: no cover
55-
from typing import get_type_hints # type: ignore
56-
5752

5853
TDataFrame = TypeVar("TDataFrame")
5954
TDataFrameModel = TypeVar("TDataFrameModel", bound="DataFrameModel")
@@ -337,10 +332,7 @@ def _get_model_attrs(cls) -> Dict[str, Any]:
337332
def _collect_fields(cls) -> Dict[str, Tuple[AnnotationInfo, FieldInfo]]:
338333
"""Centralize publicly named fields and their corresponding annotations."""
339334
# pylint: disable=unexpected-keyword-arg
340-
annotations = get_type_hints( # type: ignore[call-arg]
341-
cls,
342-
include_extras=True,
343-
)
335+
annotations = get_type_hints(cls, include_extras=True)
344336
# pylint: enable=unexpected-keyword-arg
345337
attrs = cls._get_model_attrs()
346338

@@ -358,6 +350,8 @@ def _collect_fields(cls) -> Dict[str, Tuple[AnnotationInfo, FieldInfo]]:
358350

359351
fields = {}
360352
for field_name, annotation in annotations.items():
353+
if not _is_field(field_name):
354+
continue
361355
field = attrs[field_name] # __init_subclass__ guarantees existence
362356
if not isinstance(field, FieldInfo):
363357
raise SchemaInitError(

pandera/api/pandas/model.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def _build_columns_index( # pylint:disable=too-many-locals,too-many-branches
8383

8484
columns: Dict[str, Column] = {}
8585
indices: List[Index] = []
86+
8687
for field_name, (annotation, field) in fields.items():
8788
field_checks = checks.get(field_name, [])
8889
field_parsers = parsers.get(field_name, [])

pandera/api/pyspark/model.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
import pyspark.sql as ps
2626
from pyspark.sql.types import StructType
27+
from typing_extensions import get_type_hints
2728

2829
from pandera.api.base.model import BaseModel
2930
from pandera.api.checks import Check
@@ -43,11 +44,6 @@
4344
from pandera.typing.common import DataFrameBase
4445
from pandera.typing.pyspark import DataFrame
4546

46-
try:
47-
from typing_extensions import get_type_hints
48-
except ImportError: # pragma: no cover
49-
from typing import get_type_hints # type: ignore
50-
5147

5248
_CONFIG_KEY = "Config"
5349

pandera/decorators.py

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import inspect
55
import sys
66
import types
7-
import typing
87
from typing import (
98
Any,
109
Callable,
@@ -21,6 +20,7 @@
2120
)
2221

2322
from pydantic import validate_arguments
23+
from typing_extensions import get_type_hints
2424

2525
from pandera import errors
2626
from pandera.api.base.error_handler import ErrorHandler
@@ -600,42 +600,55 @@ def check_types(
600600
)
601601

602602
# Front-load annotation parsing
603-
annotated_schema_models: Dict[
603+
# @functools.lru_cache
604+
def _get_annotated_schema_models(
605+
wrapped: Callable,
606+
) -> Dict[
604607
str,
605608
Iterable[
606609
Tuple[Union[DataFrameModel, None], Union[AnnotationInfo, None]]
607610
],
608-
] = {}
609-
for arg_name_, annotation in typing.get_type_hints(wrapped).items():
610-
annotation_info = AnnotationInfo(annotation)
611-
if not annotation_info.is_generic_df:
612-
# pylint: disable=comparison-with-callable
613-
if annotation_info.origin == Union:
614-
annotation_model_pairs = []
615-
for annot in annotation_info.args: # type: ignore[union-attr]
616-
sub_annotation_info = AnnotationInfo(annot)
617-
if not sub_annotation_info.is_generic_df:
618-
continue
619-
620-
schema_model = cast(
621-
DataFrameModel, sub_annotation_info.arg
622-
)
623-
annotation_model_pairs.append(
624-
(schema_model, sub_annotation_info)
625-
)
611+
]:
612+
annotated_schema_models: Dict[
613+
str,
614+
Iterable[
615+
Tuple[Union[DataFrameModel, None], Union[AnnotationInfo, None]]
616+
],
617+
] = {}
618+
for arg_name_, annotation in get_type_hints(
619+
wrapped, include_extras=True
620+
).items():
621+
annotation_info = AnnotationInfo(annotation)
622+
if not annotation_info.is_generic_df:
623+
# pylint: disable=comparison-with-callable
624+
if annotation_info.origin == Union:
625+
annotation_model_pairs = []
626+
for annot in annotation_info.args: # type: ignore[union-attr]
627+
sub_annotation_info = AnnotationInfo(annot)
628+
if not sub_annotation_info.is_generic_df:
629+
continue
630+
631+
schema_model = cast(
632+
DataFrameModel, sub_annotation_info.arg
633+
)
634+
annotation_model_pairs.append(
635+
(schema_model, sub_annotation_info)
636+
)
637+
else:
638+
continue
626639
else:
627-
continue
628-
else:
629-
schema_model = cast(DataFrameModel, annotation_info.arg)
630-
annotation_model_pairs = [(schema_model, annotation_info)]
640+
schema_model = cast(DataFrameModel, annotation_info.arg)
641+
annotation_model_pairs = [(schema_model, annotation_info)]
631642

632-
annotated_schema_models[arg_name_] = annotation_model_pairs
643+
annotated_schema_models[arg_name_] = annotation_model_pairs
644+
return annotated_schema_models
633645

634646
def _check_arg(arg_name: str, arg_value: Any) -> Any:
635647
"""
636648
Validate function's argument if annotated with a schema, else
637649
pass-through.
638650
"""
651+
annotated_schema_models = _get_annotated_schema_models(wrapped)
639652
annotation_model_pairs = annotated_schema_models.get(
640653
arg_name, [(None, None)]
641654
)

pandera/engines/engine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@
1919
Tuple,
2020
Type,
2121
TypeVar,
22-
get_type_hints,
2322
)
2423

2524
import typing_inspect
25+
from typing_extensions import get_type_hints
2626

2727
from pandera.dtypes import DataType
2828

pandera/typing/common.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,13 +130,14 @@ def __setattr__(self, name: str, value: Any) -> None:
130130
# pylint: disable=no-member
131131
object.__setattr__(self, name, value)
132132
if name == "__orig_class__":
133-
orig_class = getattr(self, "__orig_class__")
133+
orig_class = value
134134
class_args = getattr(orig_class, "__args__", None)
135135
if class_args is not None and any(
136136
x.__name__ == "DataFrameModel"
137137
for x in inspect.getmro(class_args[0])
138138
):
139139
schema_model = value.__args__[0]
140+
schema = schema_model.to_schema()
140141
else:
141142
raise TypeError("Could not find DataFrameModel in class args")
142143

@@ -147,12 +148,12 @@ def __setattr__(self, name: str, value: Any) -> None:
147148
if (
148149
pandera_accessor is None
149150
or pandera_accessor.schema is None
150-
or pandera_accessor.schema != schema_model.to_schema()
151+
or pandera_accessor.schema != schema
151152
):
152-
self.__dict__ = schema_model.validate(self).__dict__
153+
self.__dict__.update(schema.validate(self).__dict__)
153154
if pandera_accessor is None:
154155
pandera_accessor = getattr(self, "pandera")
155-
pandera_accessor.add_schema(schema_model.to_schema())
156+
pandera_accessor.add_schema(schema)
156157

157158

158159
# pylint:disable=too-few-public-methods
@@ -233,8 +234,14 @@ def _parse_annotation(self, raw_annotation: Type) -> None:
233234
self.arg = args[0] if args else args
234235

235236
metadata = getattr(raw_annotation, "__metadata__", None)
237+
236238
if metadata:
237239
self.is_annotated_type = True
240+
try:
241+
inspect.signature(self.arg)
242+
except ValueError:
243+
metadata = None
244+
238245
elif metadata := getattr(self.arg, "__metadata__", None):
239246
self.arg = typing_inspect.get_args(self.arg)[0]
240247

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ classifiers = [
2727
"Programming Language :: Python :: 3.10",
2828
"Programming Language :: Python :: 3.11",
2929
"Programming Language :: Python :: 3.12",
30+
"Programming Language :: Python :: 3.13",
3031
"Topic :: Scientific/Engineering",
3132
]
3233
dependencies = [
@@ -129,6 +130,7 @@ testing = [
129130
"pytest-cov",
130131
"pytest-xdist",
131132
"pytest-asyncio",
133+
"sphinx",
132134
"ibis-framework[duckdb,sqlite] >= 9.0.0",
133135
]
134136
docs = [

tests/pandas/test_model.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import numpy as np
1212
import pandas as pd
1313
import pytest
14+
from pandas._testing import assert_frame_equal
1415

1516
import pandera.pandas as pa
1617
import pandera.api.extensions as pax
@@ -1118,7 +1119,7 @@ class Config:
11181119
}
11191120
pandera_validated_df = DataFrame[Schema](raw_data)
11201121
pandas_df = pd.DataFrame(raw_data)
1121-
assert pandera_validated_df.equals(Schema.validate(pandas_df))
1122+
assert_frame_equal(pandera_validated_df, Schema.validate(pandas_df))
11221123
assert isinstance(pandera_validated_df, DataFrame)
11231124
assert isinstance(pandas_df, pd.DataFrame)
11241125

0 commit comments

Comments
 (0)