Skip to content

Commit 77609ae

Browse files
CopilotborcheroCopilot
authored
feat: Add example rows to ValidationError for all rule failures (#286)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: borchero <22455425+borchero@users.noreply.github.com> Co-authored-by: Oliver Borchert <oliver.borchert@quantco.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 77bad09 commit 77609ae

13 files changed

Lines changed: 1979 additions & 205 deletions

File tree

Cargo.lock

Lines changed: 1606 additions & 174 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@ name = "dataframely"
1010
[dependencies]
1111
itertools = "*"
1212
num-format = "*"
13-
polars = { version = ">=0.50", default-features = false, features = [] }
13+
polars = { version = ">=0.50", default-features = false, features = [
14+
"dtype-full",
15+
] }
1416
polars-arrow = ">=0.50"
15-
polars-core = { version = ">=0.50", features = [
16-
"dtype-array",
17-
"dtype-struct",
18-
], default-features = false }
17+
polars-core = ">=0.50"
1918
pyo3 = { version = ">=0.25", features = ["abi3-py310", "extension-module"] }
2019
pyo3-polars = { version = ">=0.23.1", features = ["derive"] }
2120
rand = { version = "0.9", features = ["std_rng"] }

dataframely/_native.pyi

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,30 @@
11
from typing import overload
22

3-
def format_rule_failures(failures: list[tuple[str, int]]) -> str:
3+
import polars as pl
4+
5+
def format_rule_failures(
6+
failures: list[tuple[str, int]],
7+
*,
8+
failures_from: pl.DataFrame | None,
9+
examples_from: pl.DataFrame | None,
10+
primary_key_columns: list[str],
11+
max_examples: int,
12+
) -> str:
413
"""
514
Format rule failures with the same logic that produces validation errors from the
615
polars plugin.
716
817
Args:
918
failures: The name of the failures and their counts. This should only include
1019
failures with a count of at least 1.
20+
failures_from: The data frame containing the rule columns providing the
21+
failures.
22+
examples_from: The data frame containing the example rows for each failure.
23+
primary_key_columns: The primary key columns of the schema for which to format
24+
rule failures. This is only relevant if `failures_from` and `examples_from`
25+
are provided and allows for better error messages for the "primary_key" rule.
26+
max_examples: The maximum number of examples to include for each failure. This is
27+
only relevant if `failures_from` and `examples_from` are provided.
1128
1229
Returns:
1330
The formatted rule failures.

dataframely/_plugin.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import polars as pl
99
from polars.plugins import register_plugin_function
1010

11+
from dataframely.config import Config
12+
1113
PLUGIN_PATH = Path(__file__).parent
1214

1315
IntoExpr: TypeAlias = pl.Expr | str
@@ -57,8 +59,10 @@ def all_rules(rules: IntoExpr | Iterable[IntoExpr]) -> pl.Expr:
5759
def all_rules_required(
5860
rules: IntoExpr | Iterable[IntoExpr],
5961
*,
62+
data_columns: Iterable[IntoExpr],
6063
null_is_valid: bool = True,
6164
schema_name: str,
65+
primary_key_columns: list[str] | None = None,
6266
) -> pl.Expr:
6367
"""Execute :mod:`~polars.all_horizontal` and `.all` for a set of rules.
6468
@@ -73,18 +77,32 @@ def all_rules_required(
7377
7478
Args:
7579
rules: The rules to evaluate.
80+
data_columns: Data columns to include for generating example rows in error
81+
messages. If `max_failure_examples` is configured in `dy.Config`, values
82+
are extracted from here.
7683
schema_name: The name of the schema being validated. This is used to produce
7784
better error messages.
7885
null_is_valid: Whether to treat null values as valid (i.e., `true`).
86+
primary_key_columns: Optional list of primary key columns which are used for
87+
better error messages if data columns are provided.
7988
8089
Returns:
8190
A scalar boolean expression.
8291
"""
92+
rules_list = [rules] if isinstance(rules, pl.Expr) else list(rules)
93+
num_rule_columns = len(rules_list)
94+
data_columns_list = list(data_columns) if data_columns is not None else []
8395
return register_plugin_function(
8496
plugin_path=PLUGIN_PATH,
8597
function_name="all_rules_required",
86-
args=rules,
87-
kwargs={"null_is_valid": null_is_valid, "schema_name": schema_name},
98+
args=[*rules_list, *data_columns_list],
99+
kwargs={
100+
"null_is_valid": null_is_valid,
101+
"schema_name": schema_name,
102+
"num_rule_columns": num_rule_columns,
103+
"primary_key_columns": primary_key_columns or [],
104+
"max_failure_examples": Config.options["max_failure_examples"],
105+
},
88106
use_abs_path=True,
89107
# NOTE: Conceptually, we're reducing the input to a single boolean value here.
90108
# However, we set this option to ensure that the plugin does not become

dataframely/collection/collection.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from dataframely._storage.delta import DeltaStorageBackend
3434
from dataframely._storage.parquet import ParquetStorageBackend
3535
from dataframely._typing import DataFrame, LazyFrame, Validation
36+
from dataframely.config import Config
3637
from dataframely.exc import (
3738
DeserializationError,
3839
ValidationError,
@@ -409,11 +410,20 @@ def validate(
409410
# information to properly construct a useful error message.
410411
filtered, failures = cls.filter(data, cast=cast, eager=True)
411412
if any(len(failure) > 0 for failure in failures.values()):
412-
errors = {
413-
member: format_rule_failures(list(failure.counts().items()))
414-
for member, failure in failures.items()
415-
if len(failure) > 0
416-
}
413+
errors: dict[str, str] = {}
414+
for member, failure in failures.items():
415+
if len(failure) == 0:
416+
continue
417+
418+
counts = failure.counts()
419+
errors[member] = format_rule_failures(
420+
list(counts.items()),
421+
failures_from=failure._df.select(counts.keys()),
422+
examples_from=failure.invalid(),
423+
primary_key_columns=cls.member_schemas()[member].primary_key(),
424+
max_examples=Config.options["max_failure_examples"],
425+
)
426+
417427
details = [
418428
f" > Member '{member}' failed validation:\n"
419429
+ textwrap.indent(error, " ")
@@ -451,7 +461,11 @@ def validate(
451461
)
452462
.filter(
453463
all_rules_required(
454-
filter_names, null_is_valid=False, schema_name=name
464+
filter_names,
465+
null_is_valid=False,
466+
schema_name=name,
467+
data_columns=cls.common_primary_key(),
468+
primary_key_columns=cls.common_primary_key(),
455469
)
456470
)
457471
.drop(filter_names)

dataframely/config.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
from typing_extensions import Unpack
1414

1515

16-
class Options(TypedDict):
16+
class Options(TypedDict, total=False):
1717
#: The maximum number of iterations to use for "fuzzy" sampling.
1818
max_sampling_iterations: int
19+
#: The maximum number of examples to include in failure messages.
20+
max_failure_examples: int
1921

2022

2123
_ENV_PREFIX = "DATAFRAMELY_"
@@ -24,6 +26,7 @@ class Options(TypedDict):
2426
def _builtin_defaults() -> Options:
2527
return {
2628
"max_sampling_iterations": 10_000,
29+
"max_failure_examples": 0,
2730
}
2831

2932

@@ -56,6 +59,11 @@ def set_max_sampling_iterations(iterations: int) -> None:
5659
:meth:`Schema.sample`."""
5760
Config.options["max_sampling_iterations"] = iterations
5861

62+
@staticmethod
63+
def set_max_failure_examples(max_examples: int) -> None:
64+
"""Set the maximum number of examples to include in failure messages."""
65+
Config.options["max_failure_examples"] = max_examples
66+
5967
@staticmethod
6068
def restore_defaults() -> None:
6169
"""Restore the defaults of the configuration."""

dataframely/schema.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -576,8 +576,15 @@ def validate(
576576
if eager:
577577
out, failure = cls.filter(df, cast=cast, eager=True)
578578
if len(failure) > 0:
579+
counts = failure.counts()
579580
raise ValidationError(
580-
format_rule_failures(list(failure.counts().items()))
581+
format_rule_failures(
582+
list(counts.items()),
583+
failures_from=failure._df.select(counts.keys()),
584+
examples_from=failure.invalid(),
585+
primary_key_columns=cls.primary_key(),
586+
max_examples=Config.options["max_failure_examples"],
587+
)
581588
)
582589
return out
583590
else:
@@ -587,7 +594,14 @@ def validate(
587594
if rules := cls._validation_rules(with_cast=False):
588595
lf = (
589596
lf.pipe(with_evaluation_rules, rules)
590-
.filter(all_rules_required(rules.keys(), schema_name=cls.__name__))
597+
.filter(
598+
all_rules_required(
599+
rules.keys(),
600+
schema_name=cls.__name__,
601+
data_columns=cls.column_names(),
602+
primary_key_columns=cls.primary_key(),
603+
)
604+
)
591605
.drop(rules.keys())
592606
)
593607
return lf # type: ignore

docs/guides/features/lazy-validation.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,27 @@ For collections, the error message for `eager=False` is limited and non-determin
3939
information about a single member and, if multiple members fail validation, the member that the error message refers to
4040
may vary across executions.
4141
```
42+
43+
```{note}
44+
When the lazy frame is collected on the polars _streaming_ engine, lazy validation may not surface _all_ validation
45+
issues: validation is aborted as soon as the first failure is encountered. As a result, both the set of rules reported
46+
in the error message and the specific failure surfaced may be non-deterministic across executions.
47+
```
48+
49+
## Including failure examples in error messages
50+
51+
By default, validation error messages report only the name of each failing rule and the number of rows that violated it.
52+
For easier debugging, dataframely can additionally include a few example rows for each failing rule. This is configured
53+
via {meth}`~dataframely.Config.set_max_failure_examples` (or the `max_failure_examples` keyword on the
54+
{class}`~dataframely.Config` context manager) and applies to both `eager=True` and `eager=False`:
55+
56+
```python
57+
import dataframely as dy
58+
59+
with dy.Config(max_failure_examples=5):
60+
MySchema.validate(df)
61+
```
62+
63+
For column-level rules, examples include the value in the offending column. For schema-level rules, examples include all
64+
data columns of the schema, except for the `primary_key` rule where examples are limited to the primary key columns.
65+
The default value of `0` disables examples entirely.

src/polars_plugin/mod.rs

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,28 @@ pub fn all_rules(inputs: &[Series]) -> PolarsResult<Series> {
6262
struct RequiredValidationKwargs {
6363
schema_name: String,
6464
null_is_valid: bool,
65+
#[serde(default)]
66+
num_rule_columns: Option<usize>,
67+
primary_key_columns: Option<Vec<String>>,
68+
max_failure_examples: usize,
6569
}
6670

6771
/// Reduce a set of boolean columns into a single boolean scalar, AND-ing all values.
6872
/// Null values are treated as `true`.
6973
/// In contrast to `all_rules`, this function raises an error if the returned value would be
7074
/// `false`, including details about the `false` values (i.e. "rules" that failed).
75+
/// The first `num_rule_columns` inputs are boolean rule columns; any remaining inputs are
76+
/// data columns used to generate example rows in error messages.
7177
#[polars_expr(output_type=Boolean)]
7278
pub fn all_rules_required(
7379
inputs: &[Series],
7480
kwargs: RequiredValidationKwargs,
7581
) -> PolarsResult<Series> {
76-
let failures = compute_rule_failures(inputs, kwargs.null_is_valid)?;
82+
let num_rule = kwargs.num_rule_columns.unwrap_or(inputs.len());
83+
let rule_inputs = &inputs[..num_rule];
84+
let data_inputs = &inputs[num_rule..];
85+
86+
let failures = compute_rule_failures(rule_inputs, kwargs.null_is_valid)?;
7787

7888
// If there's any failure, we know that validation failed and use the failure object for an
7989
// informative error message. If no failure exists, we simply return a series with a single
@@ -85,7 +95,27 @@ pub fn all_rules_required(
8595
return Ok(column.take_materialized_series());
8696
}
8797

88-
// Aggregate failure counts into a validation error.
89-
let error = RuleValidationError::new(failures);
98+
// Aggregate failures into a validation error
99+
let failures_from = DataFrame::new(
100+
rule_inputs[0].len(),
101+
rule_inputs
102+
.iter()
103+
.map(|s| s.clone().into_column())
104+
.collect(),
105+
)?;
106+
let examples_from = DataFrame::new(
107+
data_inputs[0].len(),
108+
data_inputs
109+
.iter()
110+
.map(|s| s.clone().into_column())
111+
.collect(),
112+
)?;
113+
let error = RuleValidationError::new(
114+
failures,
115+
Some(failures_from),
116+
Some(examples_from),
117+
kwargs.primary_key_columns.unwrap_or_default(),
118+
kwargs.max_failure_examples,
119+
);
90120
Err(polars_err!(ComputeError: format!("\n{}", error.to_string(Some(&kwargs.schema_name)))))
91121
}

0 commit comments

Comments
 (0)