Skip to content

Commit 0036afc

Browse files
authored
Merge branch 'main' into limit_log_length_on_schema_error
2 parents 4c111a5 + ce6422e commit 0036afc

24 files changed

Lines changed: 1488 additions & 178 deletions

.github/CONTRIBUTING.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ uv pip install polars-lts-cpu
5353
#### Run Tests
5454

5555
```bash
56-
pytest tests/core
56+
pytest tests/core tests/pandas
5757
```
5858

5959
#### Build Documentation Locally
@@ -142,9 +142,9 @@ You should see an output like this:
142142

143143
```bash
144144
...
145-
* tests(extra='core', pydantic='1.10.11', python='3.9', pandas='2.1.1') -> Run the test suite.
146-
* tests(extra='strategies', pydantic='1.10.11', python='3.9', pandas='2.1.1') -> Run the test suite.
147-
* tests(extra='hypotheses', pydantic='1.10.11', python='3.9', pandas='2.1.1') -> Run the test suite.
145+
* tests-3.9(extra='pandas', pandas='2.1.1', pydantic='1.10.11') -> Run the test suite.
146+
* tests-3.9(extra='strategies', pandas='2.2.3', pydantic='2.10.6') -> Run the test suite.
147+
* tests-3.9(extra='hypotheses', pandas='2.2.3', pydantic='2.10.6') -> Run the test suite.
148148
...
149149
```
150150

.github/workflows/ci-tests.yml

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ jobs:
8787
# test base functionality
8888
unit-tests-base:
8989
name: >
90-
Unit Tests Base: python-${{ matrix.python-version }} ${{ matrix.os }} (pydantic-${{ matrix.pydantic-version }})
90+
Unit Tests Base: python-${{ matrix.python-version }} ${{ matrix.os }}
9191
runs-on: ${{ matrix.os }}
9292
defaults:
9393
run:
@@ -113,7 +113,7 @@ jobs:
113113
pip list
114114
printenv | sort
115115
- name: Unit Tests Base
116-
run: nox -v -db uv --non-interactive --session "tests-${{ matrix.python-version }}(extra=None, pandas=None, pydantic=None)"
116+
run: nox -v -db uv --non-interactive --session "tests-${{ matrix.python-version }}(extra=None, pandas=None, pydantic=None, polars=None)"
117117
- name: Upload coverage to Codecov
118118
uses: codecov/codecov-action@v4
119119

@@ -150,7 +150,7 @@ jobs:
150150
pip list
151151
printenv | sort
152152
- name: Unit Tests - pandas
153-
run: nox -v -db uv --non-interactive --session "tests-${{ matrix.python-version }}(extra='pandas', pandas='${{ matrix.pandas-version }}', pydantic='${{ matrix.pydantic-version }}')"
153+
run: nox -v -db uv --non-interactive --session "tests-${{ matrix.python-version }}(extra='pandas', pandas='${{ matrix.pandas-version }}', pydantic='${{ matrix.pydantic-version }}', polars=None)"
154154
- name: Upload coverage to Codecov
155155
uses: codecov/codecov-action@v4
156156

@@ -194,14 +194,14 @@ jobs:
194194
pip list
195195
printenv | sort
196196
- name: Unit Tests - ${{ matrix.extra }}
197-
run: nox -v -db uv --non-interactive --session "tests-${{ matrix.python-version }}(extra='${{ matrix.extra }}', pandas='${{ matrix.pandas-version }}', pydantic='${{ matrix.pydantic-version }}')"
197+
run: nox -v -db uv --non-interactive --session "tests-${{ matrix.python-version }}(extra='${{ matrix.extra }}', pandas='${{ matrix.pandas-version }}', pydantic='${{ matrix.pydantic-version }}', polars=None)"
198198
- name: Upload coverage to Codecov
199199
uses: codecov/codecov-action@v4
200200

201201
# test extras for popular dataframe libraries
202202
unit-tests-dataframe-extras:
203203
name: >
204-
Unit Tests DataFrame Extras: python-${{ matrix.python-version }} ${{ matrix.os }} (extra-${{ matrix.extra }}, pandas-${{ matrix.pandas-version }}, pydantic-${{ matrix.pydantic-version }})
204+
Unit Tests DataFrame Extras: python-${{ matrix.python-version }} ${{ matrix.os }} (extra-${{ matrix.extra }})
205205
runs-on: ${{ matrix.os }}
206206
defaults:
207207
run:
@@ -215,19 +215,22 @@ jobs:
215215
matrix:
216216
os: [ubuntu-latest, windows-latest, macos-latest]
217217
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
218-
pandas-version: ["2.2.3"]
219-
pydantic-version: ["2.10.6"]
220218
extra:
221219
- dask
222220
- polars
223221
- pyspark
224222
- modin-dask
225223
- modin-ray
226224
- ibis
225+
include:
226+
- extra: polars
227+
polars-version: "0.20.0"
228+
- extra: polars
229+
polars-version: "1.32.2"
230+
- extra: polars
231+
pandas-version: "2.2.3"
232+
pydantic-version: "2.10.6"
227233
exclude:
228-
# mypy tests hang on windows
229-
- extra: mypy
230-
os: windows-latest
231234
- extra: modin-ray
232235
os: windows-latest
233236
# pyspark serialization issue on windows: "pandas" module not found
@@ -264,7 +267,12 @@ jobs:
264267
printenv | sort
265268
266269
- name: Unit Tests - ${{ matrix.extra }}
267-
run: nox -v -db uv --non-interactive --session "tests-${{ matrix.python-version }}(extra='${{ matrix.extra }}', pandas='${{ matrix.pandas-version }}', pydantic='${{ matrix.pydantic-version }}')"
270+
if: matrix.extra == 'polars'
271+
run: nox -v -db uv --non-interactive --session "tests-${{ matrix.python-version }}(extra='${{ matrix.extra }}', pandas='${{ matrix.pandas-version }}', pydantic='${{ matrix.pydantic-version }}', polars='${{ matrix.polars-version }}')"
272+
273+
- name: Unit Tests - ${{ matrix.extra }}
274+
if: matrix.extra != 'polars'
275+
run: nox -v -db uv --non-interactive --session "tests-${{ matrix.python-version }}(extra='${{ matrix.extra }}', pandas=None, pydantic=None, polars=None)"
268276

269277
- name: Upload coverage to Codecov
270278
uses: codecov/codecov-action@v4

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ deps-from-environment.yml:
4646
python scripts/generate_pip_deps_from_conda.py
4747

4848
unit-tests:
49-
pytest tests/core
49+
pytest tests/core tests/pandas
5050

5151
nox-tests:
5252
nox -db uv -s tests ${NOX_FLAGS}

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
[![PyPI pyversions](https://img.shields.io/pypi/pyversions/pandera.svg?style=for-the-badge)](https://pypi.python.org/pypi/pandera/)
2828
[![DOI](https://img.shields.io/badge/DOI-10.5281/zenodo.3385265-blue?style=for-the-badge)](https://doi.org/10.5281/zenodo.3385265)
2929
[![asv](http://img.shields.io/badge/benchmarked%20by-asv-green.svg?style=for-the-badge)](https://pandera-dev.github.io/pandera-asv-logs/)
30-
[![Monthly Downloads](https://img.shields.io/pypi/dm/pandera?style=for-the-badge&color=blue)](https://pepy.tech/project/pandera)
3130
[![Total Downloads](https://img.shields.io/pepy/dt/pandera?style=for-the-badge&color=blue)](https://pepy.tech/project/pandera)
3231
[![Conda Downloads](https://img.shields.io/conda/dn/conda-forge/pandera?style=for-the-badge)](https://anaconda.org/conda-forge/pandera)
3332
[![Slack](https://img.shields.io/badge/Slack-4A154B?logo=slack&logoColor=fff&style=for-the-badge)](https://flyte-org.slack.com/archives/C08FDTY2X3L)

docs/source/data_synthesis_strategies.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ check = pa.Check(lambda x: x.between(0, 100))
210210
You can then define a strategy for this check with:
211211

212212
```{code-cell} python
213+
import pandera.strategies.pandas_strategies as st
214+
213215
def in_range_strategy(pandera_dtype, strategy=None):
214216
if strategy is None:
215217
# handle base strategy case

docs/source/index.md

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,6 @@ file_format: mystnb
6464
:target: https://pandera-dev.github.io/pandera-asv-logs/
6565
```
6666

67-
```{image} https://img.shields.io/pypi/dm/pandera?style=for-the-badge&color=blue
68-
:alt: Monthly Downloads
69-
:target: https://pepy.tech/project/pandera
70-
```
71-
7267
```{image} https://img.shields.io/pepy/dt/pandera?style=for-the-badge&color=blue
7368
:alt: Total Downloads
7469
:target: https://pepy.tech/badge/pandera

environment.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dependencies:
2121

2222
# mypy extra
2323
- pandas-stubs
24+
- scipy-stubs
2425

2526
# pyspark extra
2627
- pyspark[connect] >= 3.2.0, < 4.0.0

noxfile.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,6 @@ def _testing_requirements(
160160
req = "ibis-framework[duckdb,polars]"
161161
if req == "polars":
162162
req = f"polars=={polars}"
163-
if sys.platform == "darwin":
164-
# On macOS, add polars-lts-cpu in addition to polars (which tends to get pulled in as a transitive dependency)
165-
_updated_requirements.append(f"polars-lts-cpu=={polars}")
166163

167164
# for some reason uv will try to install an old version of dask,
168165
# have to specifically pin dask[dataframe] to a higher version

pandera/api/checks.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,16 @@ def __init__(
3939
"""Apply a validation function to a data object.
4040
4141
:param check_fn: A function to check data object. For Column
42-
or SeriesSchema checks, if element_wise is True, this function
42+
or SeriesSchema checks, if element_wise is False, this function
4343
should have the signature: ``Callable[[pd.Series],
4444
Union[pd.Series, bool]]``, where the output series is a boolean
4545
vector.
4646
47-
If element_wise is False, this function should have the signature:
47+
If element_wise is True, this function should have the signature:
4848
``Callable[[Any], bool]``, where ``Any`` is an element in the
4949
column.
5050
51-
For DataFrameSchema checks, if element_wise=True, fn
51+
For DataFrameSchema checks, if element_wise=False, fn
5252
should have the signature: ``Callable[[pd.DataFrame],
5353
Union[pd.DataFrame, pd.Series, bool]]``, where the output dataframe
5454
or series contains booleans.

pandera/backends/ibis/container.py

Lines changed: 124 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,20 @@
44

55
import copy
66
import traceback
7-
from typing import TYPE_CHECKING, Any, Optional
7+
from typing import TYPE_CHECKING, Any, Callable, Optional
88
from collections.abc import Iterable
99

1010
import ibis
11-
import ibis.selectors as s
11+
from ibis import _, selectors as s
1212
from ibis.common.exceptions import IbisError
1313

1414
from pandera.api.base.error_handler import ErrorHandler
1515
from pandera.config import ValidationScope
1616
from pandera.backends.base import CoreCheckResult, ColumnInfo
17+
from pandera.backends.utils import convert_uniquesettings
1718
from pandera.backends.ibis.base import IbisSchemaBackend
1819
from pandera.errors import (
20+
ParserError,
1921
SchemaDefinitionError,
2022
SchemaError,
2123
SchemaErrorReason,
@@ -51,6 +53,22 @@ def validate(
5153

5254
column_info = self.collect_column_info(check_obj, schema)
5355

56+
core_parsers: list[tuple[Callable[..., Any], tuple[Any, ...]]] = [
57+
(self.strict_filter_columns, (schema, column_info)),
58+
]
59+
60+
for parser, args in core_parsers:
61+
try:
62+
check_obj = parser(check_obj, *args)
63+
except SchemaError as exc:
64+
error_handler.collect_error(
65+
validation_type(exc.reason_code),
66+
exc.reason_code,
67+
exc,
68+
)
69+
except SchemaErrors as exc:
70+
error_handler.collect_errors(exc.schema_errors)
71+
5472
# collect schema components
5573
components = self.collect_schema_components(
5674
check_obj, schema, column_info
@@ -62,6 +80,7 @@ def validate(
6280
# run the checks
6381
core_checks = [
6482
(self.check_column_presence, (check_obj, schema, column_info)),
83+
(self.check_column_values_are_unique, (check_obj, schema)),
6584
(
6685
self.run_schema_component_checks,
6786
(sample, schema, components, lazy),
@@ -213,7 +232,7 @@ def collect_column_info(
213232
regex_match_patterns.append(col_schema.name)
214233
except SchemaError:
215234
pass
216-
elif col_name in check_obj.columns:
235+
elif col_name in check_obj:
217236
column_names.append(col_name)
218237

219238
# Ibis tables cannot have duplicated column names
@@ -265,6 +284,60 @@ def collect_schema_components(
265284

266285
return schema_components
267286

287+
###########
288+
# Parsers #
289+
###########
290+
291+
def strict_filter_columns(
292+
self,
293+
check_obj: ibis.Table,
294+
schema: DataFrameSchema,
295+
column_info: ColumnInfo,
296+
) -> ibis.Table:
297+
"""Filter columns that aren't specified in the schema."""
298+
# dataframe strictness check makes sure all columns in the dataframe
299+
# are specified in the dataframe schema
300+
if not (schema.strict or schema.ordered):
301+
return check_obj
302+
303+
filter_out_columns = []
304+
sorted_column_names = iter(column_info.sorted_column_names)
305+
for column in column_info.destuttered_column_names:
306+
is_schema_col = column in column_info.expanded_column_names
307+
if schema.strict is True and not is_schema_col:
308+
raise SchemaError(
309+
schema=schema,
310+
data=check_obj,
311+
message=(
312+
f"column '{column}' not in {schema.__class__.__name__}"
313+
f" {schema.columns}"
314+
),
315+
failure_cases=column,
316+
check="column_in_schema",
317+
reason_code=SchemaErrorReason.COLUMN_NOT_IN_SCHEMA,
318+
)
319+
if schema.strict == "filter" and not is_schema_col:
320+
filter_out_columns.append(column)
321+
if schema.ordered and is_schema_col:
322+
try:
323+
next_ordered_col = next(sorted_column_names)
324+
except StopIteration:
325+
pass
326+
if next_ordered_col != column:
327+
raise SchemaError(
328+
schema=schema,
329+
data=check_obj,
330+
message=f"column '{column}' out-of-order",
331+
failure_cases=column,
332+
check="column_ordered",
333+
reason_code=SchemaErrorReason.COLUMN_NOT_ORDERED,
334+
)
335+
336+
if schema.strict == "filter":
337+
check_obj = check_obj.drop(filter_out_columns)
338+
339+
return check_obj
340+
268341
##########
269342
# Checks #
270343
##########
@@ -312,3 +385,51 @@ def check_column_presence(
312385
)
313386
)
314387
return results
388+
389+
@validate_scope(scope=ValidationScope.DATA)
390+
def check_column_values_are_unique(
391+
self,
392+
check_obj: ibis.Table,
393+
schema: DataFrameSchema,
394+
) -> CoreCheckResult:
395+
"""Check that column values are unique."""
396+
397+
passed = True
398+
message = None
399+
failure_cases = None
400+
401+
if not schema.unique:
402+
return CoreCheckResult(
403+
passed=passed,
404+
check="multiple_fields_uniqueness",
405+
)
406+
407+
keep_setting = convert_uniquesettings(schema.report_duplicates)
408+
temp_unique: list[list] = (
409+
[schema.unique]
410+
if all(isinstance(x, str) for x in schema.unique)
411+
else schema.unique
412+
)
413+
for lst in temp_unique:
414+
subset = [x for x in lst if x in check_obj]
415+
if keep_setting == "first":
416+
duplicated = ibis.row_number().over(group_by=subset) > 0
417+
elif keep_setting == "last":
418+
duplicated = (_.count() - ibis.row_number()).over(
419+
group_by=subset
420+
) > 1
421+
else:
422+
duplicated = _.count().over(group_by=subset) > 1
423+
duplicates = check_obj.select(duplicated=duplicated).duplicated
424+
if duplicates.any().execute():
425+
failure_cases = check_obj.filter(duplicated)
426+
passed = False
427+
message = f"columns '{*subset,}' not unique:\n{failure_cases}"
428+
break
429+
return CoreCheckResult(
430+
passed=passed,
431+
check="multiple_fields_uniqueness",
432+
reason_code=SchemaErrorReason.DUPLICATES,
433+
message=message,
434+
failure_cases=failure_cases,
435+
)

0 commit comments

Comments
 (0)