Skip to content

Commit ad2a385

Browse files
authored
Merge pull request #13 from CangyuanLi/check-inference
fix: completely refactor check type inference
2 parents b7a85f3 + 0f8b2ff commit ad2a385

4 files changed

Lines changed: 217 additions & 29 deletions

File tree

requirements_test.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
pytest
22
narwhals
3-
polars
3+
polars
4+
pandas

src/checkedframe/_checks.py

Lines changed: 129 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,93 @@
33
import functools
44
import inspect
55
from collections.abc import Collection, Sequence
6-
from typing import Any, Callable, Literal, Optional
6+
from typing import Any, Callable, Literal, Optional, get_type_hints
77

88
import narwhals.stable.v1 as nw
9+
from narwhals.stable.v1.dependencies import (
10+
get_cudf,
11+
get_modin,
12+
get_pandas,
13+
get_polars,
14+
get_pyarrow,
15+
)
916

1017
from .selectors import Selector
1118

1219
col = nw.col
1320
lit = nw.lit
1421

1522

23+
def _is_polars_series(ser: Any) -> bool:
24+
return (pl := get_polars()) is not None and issubclass(ser, pl.Series)
25+
26+
27+
def _is_polars_expr(expr: Any) -> bool:
28+
return (pl := get_polars()) is not None and issubclass(expr, pl.Expr)
29+
30+
31+
def _is_polars_dataframe(df: Any) -> bool:
32+
return (pl := get_polars()) is not None and issubclass(df, pl.DataFrame)
33+
34+
35+
def _is_pandas_series(ser: Any) -> bool:
36+
return (pd := get_pandas()) is not None and issubclass(ser, pd.Series)
37+
38+
39+
def _is_pandas_dataframe(df: Any) -> bool:
40+
return (pd := get_pandas()) is not None and issubclass(df, pd.DataFrame)
41+
42+
43+
def _is_modin_dataframe(df: Any) -> bool:
44+
return (mpd := get_modin()) is not None and issubclass(df, mpd.DataFrame)
45+
46+
47+
def _is_modin_series(ser: Any) -> bool:
48+
return (mpd := get_modin()) is not None and issubclass(ser, mpd.Series)
49+
50+
51+
def _is_cudf_dataframe(df: Any) -> bool:
52+
return (cudf := get_cudf()) is not None and issubclass(df, cudf.DataFrame)
53+
54+
55+
def _is_cudf_series(ser: Any) -> bool:
56+
return (cudf := get_cudf()) is not None and issubclass(ser, cudf.Series)
57+
58+
59+
def _is_pyarrow_chunked_array(ser: Any) -> bool:
60+
return (pa := get_pyarrow()) is not None and issubclass(ser, pa.ChunkedArray)
61+
62+
63+
def _is_pyarrow_table(df: Any) -> bool:
64+
return (pa := get_pyarrow()) is not None and issubclass(df, pa.Table)
65+
66+
67+
def _is_series(x: Any) -> bool:
68+
return (
69+
issubclass(x, nw.Series)
70+
or _is_pandas_series(x)
71+
or _is_modin_series(x)
72+
or _is_cudf_series(x)
73+
or _is_polars_series(x)
74+
or _is_pyarrow_chunked_array(x)
75+
)
76+
77+
78+
def _is_expr(x: Any) -> bool:
79+
return issubclass(x, nw.Expr) or _is_polars_expr(x)
80+
81+
82+
def _is_dataframe(x: Any) -> bool:
83+
return (
84+
isinstance(x, nw.DataFrame)
85+
or _is_polars_dataframe(x)
86+
or _is_pandas_dataframe(x)
87+
or _is_modin_dataframe(x)
88+
or _is_cudf_dataframe(x)
89+
or _is_pyarrow_table(x)
90+
)
91+
92+
1693
class staticproperty:
1794
"""
1895
A decorator that allows defining a read-only, class-level attribute
@@ -38,21 +115,47 @@ def __delete__(self, obj):
38115
raise AttributeError(f"can't delete attribute '{self.__name__}'")
39116

40117

41-
def _resolve_return_type_from_annotation(func: Callable):
118+
def _infer_input_type(
119+
type_hints: dict[str, Any], signature: inspect.Signature
120+
) -> CheckInputType:
121+
params = signature.parameters
122+
if len(params) == 0:
123+
return None
124+
125+
first_param_name = list(params.keys())[0]
42126
try:
43-
dtype = str(func.__annotations__["return"])
127+
type_hint = type_hints[first_param_name]
44128
except KeyError:
45129
return "auto"
46130

47-
if dtype == "bool":
48-
return "bool"
131+
if issubclass(type_hint, str):
132+
return "str"
133+
elif _is_dataframe(type_hint):
134+
return "Frame"
135+
elif _is_series(type_hint):
136+
return "Series"
49137

50-
if len(inspect.signature(func).parameters) == 0:
51-
return "Expr"
138+
return "auto"
52139

53-
if "Series" in dtype:
54-
return "Series"
55-
elif "Expr" in dtype:
140+
141+
def _infer_return_type(
142+
type_hints: dict[str, Any], input_type: CheckInputType
143+
) -> CheckReturnType:
144+
try:
145+
# Try to get it from the type hints first
146+
type_hint = type_hints["return"]
147+
148+
if issubclass(type_hint, bool):
149+
return "bool"
150+
elif _is_expr(type_hint):
151+
return "Expr"
152+
elif _is_series(type_hint):
153+
return "Series"
154+
except KeyError:
155+
# If type hints don't exist, we try to infer from the input_type
156+
pass
157+
158+
if input_type == "str" or input_type is None:
56159
return "Expr"
57160

58161
return "auto"
@@ -272,7 +375,7 @@ def contains(pattern: str, literal: bool = False) -> Check:
272375
)
273376

274377

275-
CheckInputType = Optional[Literal["auto", "Frame", "Expr", "Series"]]
378+
CheckInputType = Optional[Literal["auto", "Frame", "str", "Series"]]
276379
CheckReturnType = Literal["auto", "bool", "Expr", "Series"]
277380

278381

@@ -324,22 +427,21 @@ def __init__(
324427

325428
def _set_params(self) -> None:
326429
assert self.func is not None
327-
self._func_n_params = len(inspect.signature(self.func).parameters)
328-
329-
if self.input_type == "auto":
330-
if self._func_n_params == 0:
331-
self.input_type = None
332-
333-
if self.return_type == "auto" and self.func is not None:
334-
if self.input_type is None:
335-
self.return_type = "Expr"
336-
else:
337-
self.return_type = _resolve_return_type_from_annotation(
338-
self.func,
339-
)
340-
341-
if self.return_type == "Expr":
342-
self.input_type = None
430+
auto_input_type = self.input_type == "auto"
431+
auto_return_type = self.return_type == "auto"
432+
433+
if auto_input_type or auto_return_type:
434+
signature = inspect.signature(self.func)
435+
type_hints = get_type_hints(self.func)
436+
437+
if auto_input_type:
438+
self.input_type = _infer_input_type(type_hints, signature)
439+
440+
if auto_return_type:
441+
self.return_type = _infer_return_type(
442+
type_hints,
443+
self.input_type,
444+
)
343445

344446
if self.name is None:
345447
self.name = None if self.func.__name__ == "<lambda>" else self.func.__name__

src/checkedframe/_core.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,9 +221,11 @@ def _validate(schema: Schema, df: nwt.IntoDataFrameT, cast: bool) -> nwt.IntoDat
221221
for i, check in enumerate(schema.checks):
222222
check_name = f"frame_check_{i}" if check.name is None else check.name
223223

224+
# As a last best-effort guess, if the check is running in a DataFrame context,
225+
# we infer the input type to be a dataframe
224226
check_input_type: CheckInputType
225227
if check.input_type == "auto":
226-
check_input_type = "Expr" if check._func_n_params == 0 else "Frame" # type: ignore[assignment]
228+
check_input_type = "Frame" # type: ignore[assignment]
227229
else:
228230
check_input_type = check.input_type # type: ignore[assignment]
229231

tests/test_checks.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,92 @@
1+
import pandas as pd
12
import polars as pl
23
import pytest
34

45
import checkedframe as cf
56

67

8+
def test_type_inference_polars():
9+
class A(cf.Schema):
10+
a = cf.String()
11+
12+
@cf.Check
13+
def frame_check(df: pl.DataFrame) -> bool:
14+
return df.height == 2
15+
16+
@cf.Check(columns="a")
17+
def a_check1(name: str) -> pl.Expr:
18+
return pl.col(name).is_not_null()
19+
20+
@cf.Check(columns="a")
21+
def a_check2(s: pl.Series) -> pl.Series:
22+
return s.is_not_null()
23+
24+
@cf.Check(columns="a")
25+
def a_check3(s: pl.Series) -> bool:
26+
return s.is_not_null().all()
27+
28+
@cf.Check(columns="a")
29+
def a_check4() -> pl.Expr:
30+
return pl.col("a").is_not_null()
31+
32+
schema = A._parse_into_schema()
33+
34+
frame_checks = {check.name: check for check in schema.checks}
35+
col_checks = {}
36+
for k, v in schema.expected_schema.items():
37+
for c in v.checks:
38+
col_checks[c.name] = c
39+
40+
assert frame_checks["frame_check"].input_type == "Frame"
41+
assert frame_checks["frame_check"].return_type == "bool"
42+
43+
assert col_checks["a_check1"].input_type == "str"
44+
assert col_checks["a_check1"].return_type == "Expr"
45+
46+
assert col_checks["a_check2"].input_type == "Series"
47+
assert col_checks["a_check2"].return_type == "Series"
48+
49+
assert col_checks["a_check3"].input_type == "Series"
50+
assert col_checks["a_check3"].return_type == "bool"
51+
52+
assert col_checks["a_check4"].input_type is None
53+
assert col_checks["a_check4"].return_type == "Expr"
54+
55+
56+
def test_type_inference_pandas():
57+
class A(cf.Schema):
58+
a = cf.String()
59+
60+
@cf.Check
61+
def frame_check(df: pd.DataFrame) -> bool:
62+
return df.shape[0] == 2
63+
64+
@cf.Check(columns="a")
65+
def a_check2(s: pd.Series) -> pd.Series:
66+
return s.notnull()
67+
68+
@cf.Check(columns="a")
69+
def a_check3(s: pd.Series) -> bool:
70+
return s.notnull().all()
71+
72+
schema = A._parse_into_schema()
73+
74+
frame_checks = {check.name: check for check in schema.checks}
75+
col_checks = {}
76+
for k, v in schema.expected_schema.items():
77+
for c in v.checks:
78+
col_checks[c.name] = c
79+
80+
assert frame_checks["frame_check"].input_type == "Frame"
81+
assert frame_checks["frame_check"].return_type == "bool"
82+
83+
assert col_checks["a_check2"].input_type == "Series"
84+
assert col_checks["a_check2"].return_type == "Series"
85+
86+
assert col_checks["a_check3"].input_type == "Series"
87+
assert col_checks["a_check3"].return_type == "bool"
88+
89+
790
def test_is_between():
891
df = pl.DataFrame({"a": [1, 2, 3]})
992

0 commit comments

Comments
 (0)