Skip to content

Commit 4557c70

Browse files
authored
feat: add schema generation (lazy and eager) (#11)
* initial schema generation * return repr object for easier io * rename optional dependency to "pyperclip" * support lazy schema generation * make __repr__ return the schema string * comments * tests
1 parent ddf0cfe commit 4557c70

5 files changed

Lines changed: 226 additions & 43 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ classifiers = [
1111
]
1212
dependencies = ["narwhals>=1.0.0"]
1313

14+
[project.optional-dependencies]
15+
pyperclip = ["pyperclip>=1.0.0"]
16+
17+
1418
[build-system]
1519
requires = ["setuptools>=42", "wheel"]
1620
build-backend = "setuptools.build_meta"

src/checkedframe/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@
3131
UInt128,
3232
Unknown,
3333
)
34+
from ._schema_generation import generate_schema_repr

src/checkedframe/_dtypes.py

Lines changed: 59 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from abc import abstractmethod
44
from collections.abc import Mapping
5-
from typing import TYPE_CHECKING, Literal, Optional, Union
5+
from typing import TYPE_CHECKING, Literal, Optional, TypedDict, Union
66

77
import narwhals.stable.v1 as nw
88
from narwhals.stable.v1.dtypes import DType as NarwhalsDType
@@ -30,6 +30,14 @@ class _BoundedDType(_DType):
3030
_max: int | float
3131

3232

33+
class _ColumnKwargs(TypedDict):
34+
name: Optional[str]
35+
nullable: bool
36+
required: bool
37+
cast: bool
38+
checks: Optional[list[Check]]
39+
40+
3341
class _Column:
3442
"""Represents a column in a DataFrame.
3543
@@ -945,11 +953,15 @@ def __to_narwhals(self):
945953
return nw.Datetime(time_unit=self.time_unit, time_zone=self.time_zone)
946954

947955
@staticmethod
948-
def from_narwhals(nw_dtype: nw.Datetime) -> Datetime:
956+
def from_narwhals(nw_dtype: nw.Datetime, **column_kwargs) -> Datetime:
949957
if hasattr(nw_dtype, "time_unit"):
950-
return Datetime(time_unit=nw_dtype.time_unit, time_zone=nw_dtype.time_zone)
958+
return Datetime(
959+
time_unit=nw_dtype.time_unit,
960+
time_zone=nw_dtype.time_zone,
961+
**column_kwargs,
962+
)
951963

952-
return Datetime()
964+
return Datetime(**column_kwargs)
953965

954966
@staticmethod
955967
def _safe_cast(s: nw.Series, to_dtype: _DType) -> nw.Series:
@@ -987,11 +999,11 @@ def __to_narwhals(self):
987999
return nw.Duration(time_unit=self.time_unit)
9881000

9891001
@staticmethod
990-
def from_narwhals(nw_dtype: nw.Duration) -> Duration:
1002+
def from_narwhals(nw_dtype: nw.Duration, **column_kwargs) -> Duration:
9911003
if hasattr(nw_dtype, "time_unit"):
992-
return Duration(nw_dtype.time_unit)
1004+
return Duration(nw_dtype.time_unit, **column_kwargs)
9931005

994-
return Duration()
1006+
return Duration(**column_kwargs)
9951007

9961008
@staticmethod
9971009
def _safe_cast(s: nw.Series, to_dtype: _DType) -> nw.Series:
@@ -1113,8 +1125,10 @@ def to_narwhals(self): # type: ignore
11131125
return nw.Array(self.inner.to_narwhals(), self.shape)
11141126

11151127
@staticmethod
1116-
def from_narwhals(nw_dtype: nw.Array) -> Array:
1117-
return Array(_nw_type_to_cf_type(nw_dtype.inner), shape=nw_dtype.shape)
1128+
def from_narwhals(nw_dtype: nw.Array, **column_kwargs) -> Array:
1129+
return Array(
1130+
_nw_type_to_cf_type(nw_dtype.inner), shape=nw_dtype.shape, **column_kwargs
1131+
)
11181132

11191133
@staticmethod
11201134
def _safe_cast(s: nw.Series, to_dtype: _DType) -> nw.Series:
@@ -1148,8 +1162,8 @@ def to_narwhals(self): # type: ignore[override]
11481162
return nw.List(self.inner.to_narwhals())
11491163

11501164
@staticmethod
1151-
def from_narwhals(nw_dtype: nw.List) -> List:
1152-
return List(_nw_type_to_cf_type(nw_dtype.inner))
1165+
def from_narwhals(nw_dtype: nw.List, **column_kwargs) -> List:
1166+
return List(_nw_type_to_cf_type(nw_dtype.inner), **column_kwargs)
11531167

11541168
@staticmethod
11551169
def _safe_cast(s: nw.Series, to_dtype: _DType) -> nw.Series:
@@ -1196,56 +1210,58 @@ def to_narwhals(self) -> nw.Struct: # type: ignore
11961210
return nw.Struct(dct)
11971211

11981212
@staticmethod
1199-
def from_narwhals(nw_dtype: nw.Struct) -> Struct:
1213+
def from_narwhals(nw_dtype: nw.Struct, **column_kwargs) -> Struct:
12001214
dct = {}
12011215
for field in nw_dtype.fields:
12021216
dct[field.name] = _nw_type_to_cf_type(field.dtype)
12031217

1204-
return Struct(dct)
1218+
return Struct(dct, **column_kwargs)
12051219

12061220
@staticmethod
12071221
def _safe_cast(s: nw.Series, to_dtype: _DType) -> nw.Series:
12081222
return _checked_cast(s, to_dtype)
12091223

12101224

1211-
_NARWHALS_DTYPE_TO_CHECKEDFRAME_DTYPE_MAPPER: dict[type[NarwhalsDType], _DType] = {
1212-
nw.Binary: Binary(),
1213-
nw.Boolean: Boolean(),
1214-
nw.Categorical: Categorical(),
1215-
nw.Date: Date(),
1216-
nw.Datetime: Datetime(),
1217-
nw.Decimal: Decimal(),
1218-
nw.Enum: Enum(),
1219-
nw.Float32: Float32(),
1220-
nw.Float64: Float64(),
1221-
nw.Int8: Int8(),
1222-
nw.Int16: Int16(),
1223-
nw.Int32: Int32(),
1224-
nw.Int64: Int64(),
1225-
nw.Int128: Int128(),
1226-
nw.Object: Object(),
1227-
nw.String: String(),
1228-
nw.UInt8: UInt8(),
1229-
nw.UInt16: UInt16(),
1230-
nw.UInt32: UInt32(),
1231-
nw.UInt64: UInt64(),
1232-
nw.UInt128: UInt128(),
1233-
nw.Unknown: Unknown(),
1225+
_NARWHALS_DTYPE_TO_CHECKEDFRAME_DTYPE_MAPPER: dict[
1226+
type[NarwhalsDType], type[_DType]
1227+
] = {
1228+
nw.Binary: Binary,
1229+
nw.Boolean: Boolean,
1230+
nw.Categorical: Categorical,
1231+
nw.Date: Date,
1232+
nw.Datetime: Datetime,
1233+
nw.Decimal: Decimal,
1234+
nw.Enum: Enum,
1235+
nw.Float32: Float32,
1236+
nw.Float64: Float64,
1237+
nw.Int8: Int8,
1238+
nw.Int16: Int16,
1239+
nw.Int32: Int32,
1240+
nw.Int64: Int64,
1241+
nw.Int128: Int128,
1242+
nw.Object: Object,
1243+
nw.String: String,
1244+
nw.UInt8: UInt8,
1245+
nw.UInt16: UInt16,
1246+
nw.UInt32: UInt32,
1247+
nw.UInt64: UInt64,
1248+
nw.UInt128: UInt128,
1249+
nw.Unknown: Unknown,
12341250
}
12351251

12361252

12371253
def _nw_type_to_cf_type(
1238-
nw_dtype: Union[NarwhalsDType, type[NarwhalsDType]],
1254+
nw_dtype: Union[NarwhalsDType, type[NarwhalsDType]], **column_kwargs
12391255
) -> _DType:
12401256
if isinstance(nw_dtype, nw.Array):
1241-
return Array.from_narwhals(nw_dtype)
1257+
return Array.from_narwhals(nw_dtype, **column_kwargs)
12421258
elif isinstance(nw_dtype, nw.List):
1243-
return List.from_narwhals(nw_dtype)
1259+
return List.from_narwhals(nw_dtype, **column_kwargs)
12441260
elif isinstance(nw_dtype, nw.Struct):
1245-
return Struct.from_narwhals(nw_dtype)
1261+
return Struct.from_narwhals(nw_dtype, **column_kwargs)
12461262
elif isinstance(nw_dtype, nw.Datetime):
1247-
return Datetime.from_narwhals(nw_dtype)
1263+
return Datetime.from_narwhals(nw_dtype, **column_kwargs)
12481264
elif isinstance(nw_dtype, nw.Duration):
1249-
return Duration.from_narwhals(nw_dtype)
1265+
return Duration.from_narwhals(nw_dtype, **column_kwargs)
12501266

1251-
return _NARWHALS_DTYPE_TO_CHECKEDFRAME_DTYPE_MAPPER[nw_dtype] # type: ignore
1267+
return _NARWHALS_DTYPE_TO_CHECKEDFRAME_DTYPE_MAPPER[nw_dtype](**column_kwargs) # type: ignore
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from __future__ import annotations
2+
3+
import keyword
4+
from pathlib import Path
5+
from typing import Optional
6+
7+
import narwhals.stable.v1 as nw
8+
import narwhals.stable.v1.selectors as nws
9+
import narwhals.stable.v1.typing as nwt
10+
11+
from ._dtypes import _nw_type_to_cf_type
12+
13+
INF = float("inf")
14+
NEG_INF = float("-inf")
15+
16+
17+
class SchemaRepr:
18+
def __init__(self, schema_repr: str):
19+
self.schema_repr = schema_repr
20+
21+
def __repr__(self) -> str:
22+
# This is useful for usage in notebooks, where the user may want to just copy
23+
# the output from the notebook cell.
24+
return self.schema_repr
25+
26+
def write_clipboard(self):
27+
import pyperclip # type: ignore
28+
29+
pyperclip.copy(self.schema_repr)
30+
31+
def write_text(self, file: str | Path):
32+
Path(file).write_text(self.schema_repr)
33+
34+
35+
def generate_schema_repr(
36+
df: nwt.IntoFrame,
37+
lazy: bool = False,
38+
class_name: str = "MySchema",
39+
header: Optional[str] = "import checkedframe as cf",
40+
import_alias: str = "cf.",
41+
) -> SchemaRepr:
42+
nw_df = nw.from_native(df)
43+
44+
if isinstance(nw_df, nw.LazyFrame):
45+
lazy = True
46+
47+
if not lazy:
48+
null_df = nw_df.select(nws.all().is_null().any())
49+
50+
float_selector = nws.by_dtype(nw.Float32, nw.Float64)
51+
nan_df = nw_df.select(float_selector.is_nan().any())
52+
inf_df = nw_df.select(float_selector.is_in((INF, NEG_INF)).any())
53+
54+
float_cols = set(nan_df.columns)
55+
56+
# Build string representation of schema
57+
columns = []
58+
i = 0
59+
for col, nw_dtype in nw_df.collect_schema().items():
60+
column_kwargs: dict[str, bool | str] = {}
61+
if not col.isidentifier() or keyword.iskeyword(col):
62+
column_kwargs["name"] = f'"{col}"'
63+
64+
sanitized_col = f"column_{i}"
65+
i += 1
66+
else:
67+
sanitized_col = col
68+
69+
if not lazy:
70+
if null_df[col].item():
71+
column_kwargs["nullable"] = True
72+
73+
if col in float_cols:
74+
if nan_df[col].item():
75+
column_kwargs["allow_nan"] = True
76+
77+
if inf_df[col].item():
78+
column_kwargs["allow_inf"] = True
79+
80+
cf_dtype = _nw_type_to_cf_type(nw_dtype, **column_kwargs)
81+
82+
kwargs_to_show = []
83+
for k, v in column_kwargs.items():
84+
kwargs_to_show.append(f"{k}={v}")
85+
86+
display_kwargs = ", ".join(kwargs_to_show)
87+
display_dtype = str(cf_dtype).replace("(", f"({import_alias}")
88+
89+
columns.append(
90+
f" {sanitized_col} = {import_alias}{display_dtype}({display_kwargs})".replace(
91+
")(", ", " if len(kwargs_to_show) > 0 else ""
92+
)
93+
)
94+
95+
if header is not None:
96+
header = f"{header}\n\n"
97+
else:
98+
header = ""
99+
100+
col_repr = "\n".join(columns)
101+
102+
schema_repr = f"{header}class {class_name}({import_alias}Schema):\n{col_repr}"
103+
104+
return SchemaRepr(schema_repr)

tests/test_schema_parsing.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import tempfile
2+
3+
import polars as pl
14
import pytest
25

36
import checkedframe as cf
@@ -91,3 +94,58 @@ def a_check() -> bool:
9194
assert name in schema_dict
9295

9396
assert len(schema_dict[name].checks) == 1
97+
98+
99+
def test_schema_generation():
100+
correct_repr = (
101+
"import checkedframe as cf\n"
102+
"\n"
103+
"class AASchema(cf.Schema):\n"
104+
' column_0 = cf.Float64(name="reason code", nullable=True, allow_nan=True)\n'
105+
" y = cf.List(cf.Int64, nullable=True)\n"
106+
" z = cf.List(cf.List(cf.Int64), nullable=True)"
107+
)
108+
109+
schema_repr = cf.generate_schema_repr(
110+
pl.DataFrame(
111+
{
112+
"reason code": [1.0, float("nan"), None],
113+
"y": [[1], [2], None],
114+
"z": [[[1]], None, [[3]]],
115+
}
116+
),
117+
class_name="AASchema",
118+
)
119+
120+
assert schema_repr.schema_repr == correct_repr
121+
122+
123+
def test_schema_generation_lazy():
124+
correct_repr = (
125+
"import checkedframe as cf\n"
126+
"\n"
127+
"class AASchema(cf.Schema):\n"
128+
' column_0 = cf.Float64(name="reason code")\n'
129+
" y = cf.List(cf.Int64)\n"
130+
" z = cf.List(cf.List(cf.Int64))"
131+
)
132+
133+
df = pl.DataFrame(
134+
{
135+
"reason code": [1.0, float("nan"), None],
136+
"y": [[1], [2], None],
137+
"z": [[[1]], None, [[3]]],
138+
}
139+
)
140+
141+
schema_repr = cf.generate_schema_repr(
142+
df,
143+
lazy=True,
144+
class_name="AASchema",
145+
)
146+
147+
assert schema_repr.schema_repr == correct_repr
148+
149+
schema_repr = cf.generate_schema_repr(df.lazy(), class_name="AASchema")
150+
151+
assert schema_repr.schema_repr == correct_repr

0 commit comments

Comments
 (0)