Skip to content

Commit 7030c9f

Browse files
committed
changes based on review
Signed-off-by: Sajid Alam <sajid_alam@mckinsey.com>
1 parent e2a0e8a commit 7030c9f

10 files changed

Lines changed: 941 additions & 876 deletions

File tree

kedro/validation/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22

33
from .core import (
44
CallableValidator,
5-
CheckFailure,
6-
DataValidationError,
7-
ValidationConfigurationError,
85
Validator,
96
ValidatorSpec,
107
preflight_check,
118
resolve_validator,
129
)
13-
from .exceptions import ModelInstantiationError, ParameterValidationError
10+
from .exceptions import (
11+
CheckFailure,
12+
DataValidationError,
13+
ModelInstantiationError,
14+
ParameterValidationError,
15+
ValidationConfigurationError,
16+
)
1417
from .model_factory import instantiate_model
1518
from .pandera_validator import PanderaValidator
1619
from .parameter_validator import ParameterValidator

kedro/validation/core.py

Lines changed: 33 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
1919

2020
from kedro.utils import load_obj
21+
from kedro.validation.exceptions import (
22+
DataValidationError, # noqa: F401 (re-exported for backwards-compatible imports)
23+
ValidationConfigurationError,
24+
)
2125

2226
if TYPE_CHECKING:
2327
from collections.abc import Callable
@@ -33,20 +37,10 @@
3337
_VALID_MODES = ("load", "save")
3438
_VALID_SEVERITIES = ("error", "warn")
3539

36-
# Bounds that keep a rendered validation error readable no matter how large the
37-
# validated data is; the full backend report stays available on `__cause__`.
38-
#: Maximum number of failure examples captured per check.
39-
_MAX_FAILURE_EXAMPLES = 5
40-
#: Maximum number of failed checks rendered by `DataValidationError.__str__`.
41-
_MAX_RENDERED_FAILURES = 10
42-
#: Maximum length of a rendered failure example.
43-
_MAX_EXAMPLE_REPR_LEN = 40
44-
#: Maximum length of a rendered check name or fallback message.
45-
_MAX_LABEL_LEN = 200
46-
#: Maximum length of a rendered column name.
47-
_MAX_COLUMN_LEN = 60
48-
#: Maximum length of a rendered fallback message.
49-
_MAX_MESSAGE_LEN = 500
40+
_PANDERA_INSTALL_HINT = (
41+
"Install it with: pip install 'kedro[pandera-pandas]' "
42+
"(or the pandera-polars extra for the polars backend)."
43+
)
5044

5145

5246
@runtime_checkable
@@ -66,121 +60,6 @@ def validate(self, data: Any) -> Any:
6660
"""
6761

6862

69-
@dataclass(frozen=True)
70-
class CheckFailure:
71-
"""A single failed check, optionally grouped over multiple failure cases.
72-
73-
Attributes:
74-
message: Human-readable description of the failure.
75-
check: Name of the failed check (e.g. `greater_than_or_equal_to(0)`).
76-
column: Column the check applies to, if column-scoped.
77-
failure_count: Number of failure cases grouped under this check.
78-
failure_examples: Sample of failing values (capped, default cap 5).
79-
index: Index/location of the first failure case, if available.
80-
"""
81-
82-
message: str
83-
check: str | None = None
84-
column: str | None = None
85-
failure_count: int = 1
86-
failure_examples: list[Any] = field(default_factory=list)
87-
index: Any | None = None
88-
89-
90-
def _truncate(value: Any, limit: int) -> str:
91-
"""Render `value` as a string of at most `limit` characters."""
92-
text = str(value)
93-
if len(text) > limit:
94-
text = text[: limit - 3] + "..."
95-
return text
96-
97-
98-
class DataValidationError(Exception):
99-
"""Raised when dataset validation fails.
100-
101-
Attributes:
102-
message: The base error message.
103-
dataset_name: Name of the dataset that failed validation.
104-
mode: The operation during which validation failed
105-
(`"load"`, `"save"` or `"api"`).
106-
validator: Class path of the validator that raised the failure.
107-
failures: Structured list of :class:`CheckFailure` objects.
108-
109-
The rendered message (`str(exc)`) is bounded regardless of the size of
110-
the validated data; the full backend report remains available on
111-
`__cause__`.
112-
"""
113-
114-
def __init__(
115-
self,
116-
message: str,
117-
*,
118-
dataset_name: str | None = None,
119-
mode: str | None = None,
120-
validator: str | None = None,
121-
failures: list[CheckFailure] | None = None,
122-
) -> None:
123-
super().__init__(message)
124-
self.message = message
125-
self.dataset_name = dataset_name
126-
self.mode = mode
127-
self.validator = validator
128-
self.failures: list[CheckFailure] = list(failures) if failures else []
129-
130-
@staticmethod
131-
def _render_failure(failure: CheckFailure) -> str:
132-
"""Render one grouped check failure as a single bounded line.
133-
134-
Each part is capped separately so that a long column name still leaves
135-
room for the check that failed.
136-
"""
137-
detail = _truncate(failure.check or failure.message, _MAX_LABEL_LEN)
138-
if failure.column:
139-
label = f"{_truncate(failure.column, _MAX_COLUMN_LEN)}: {detail}"
140-
else:
141-
label = detail
142-
cases = "case" if failure.failure_count == 1 else "cases"
143-
line = f"{label}{failure.failure_count} {cases}"
144-
if failure.failure_examples:
145-
examples = ", ".join(
146-
_truncate(example, _MAX_EXAMPLE_REPR_LEN)
147-
for example in failure.failure_examples[:_MAX_FAILURE_EXAMPLES]
148-
)
149-
line += f" (e.g. {examples})"
150-
return line
151-
152-
def __str__(self) -> str:
153-
if self.dataset_name:
154-
header = f"Validation failed for dataset '{self.dataset_name}'"
155-
if self.mode:
156-
header += f" on {self.mode}"
157-
else:
158-
header = self.message or "Validation failed"
159-
lines = [_truncate(header, _MAX_MESSAGE_LEN)]
160-
if self.validator:
161-
lines.append(f"(validator: {self.validator})")
162-
if self.failures:
163-
total_cases = sum(failure.failure_count for failure in self.failures)
164-
lines.append(
165-
f"{len(self.failures)} check(s) failed — "
166-
f"{total_cases} failure case(s):"
167-
)
168-
for failure in self.failures[:_MAX_RENDERED_FAILURES]:
169-
lines.append(f" - {self._render_failure(failure)}")
170-
hidden = len(self.failures) - _MAX_RENDERED_FAILURES
171-
if hidden > 0:
172-
lines.append(f" ... and {hidden} more check(s)")
173-
elif self.dataset_name and self.message:
174-
lines.append(_truncate(self.message, _MAX_MESSAGE_LEN))
175-
return "\n".join(lines)
176-
177-
178-
class ValidationConfigurationError(Exception):
179-
"""Raised when a `validator:` declaration is invalid or unresolvable."""
180-
181-
pass
182-
183-
18463
def _parse_on_modes(value: Any, ds_name: str) -> tuple[str, ...]:
18564
"""Normalise and validate the `on` field of a validator declaration."""
18665
if isinstance(value, str):
@@ -195,7 +74,9 @@ def _parse_on_modes(value: Any, ds_name: str) -> tuple[str, ...]:
19574
f"'on' must be a non-empty subset of {list(_VALID_MODES)}, "
19675
f"got {value!r}."
19776
)
198-
return on
77+
# Canonicalise so every declaration has one representation: deduplicated,
78+
# in ("load", "save") order.
79+
return tuple(mode for mode in _VALID_MODES if mode in on)
19980

20081

20182
@dataclass(frozen=True)
@@ -210,6 +91,9 @@ class ValidatorSpec:
21091
skip_load_after_save: Skip load-validation when the same catalog
21192
instance already validated the dataset on save in this process.
21293
options: Keyword options forwarded to the validator/adapter.
94+
95+
Attributes are read-only after construction. Instances are not hashable,
96+
and `options` is a plain dict, copied at parse time.
21397
"""
21498

21599
class_path: str
@@ -379,11 +263,7 @@ def _import_error_hint(missing: str) -> str:
379263
if missing == "pandera" or (
380264
missing.startswith("pandera.") and importlib.util.find_spec("pandera") is None
381265
):
382-
return (
383-
"The 'pandera' package is not installed. "
384-
"Install it with: pip install 'kedro[pandera-pandas]' "
385-
"(or the pandera-polars / pandera-pyspark extra for your backend)"
386-
)
266+
return f"The 'pandera' package is not installed. {_PANDERA_INSTALL_HINT}"
387267
if missing.startswith("pandera."):
388268
return (
389269
f"pandera is installed but '{missing}' could not be imported; "
@@ -438,13 +318,23 @@ def resolve_validator(spec: ValidatorSpec) -> Validator:
438318
return result
439319

440320
if inspect.isclass(obj):
321+
# Reject before constructing anything: instantiating an arbitrary
322+
# class just to discover it is not a validator can have side effects.
323+
if not hasattr(obj, "validate"):
324+
raise ValidationConfigurationError(
325+
f"Validator '{spec.class_path}' resolved to class "
326+
f"{obj.__name__}, which does not provide a 'validate(data)' "
327+
f"method."
328+
)
441329
# NEVER isinstance-check the class object itself against the
442330
# runtime-checkable Protocol: it matches any class merely defining
443331
# a `validate` method and would return the uninstantiated class,
444332
# silently dropping options.
445333
try:
446334
instance = obj(**spec.options)
447-
except TypeError as exc:
335+
except Exception as exc:
336+
# Constructors are free to validate their own arguments with any
337+
# exception type; all of them are configuration errors here.
448338
raise ValidationConfigurationError(
449339
f"Could not instantiate validator '{spec.class_path}' with "
450340
f"options {spec.options!r}: {exc}"
@@ -493,13 +383,14 @@ def preflight_check(specs: dict[str, ValidatorSpec]) -> list[str]:
493383
494384
Returns:
495385
A list of warning strings, one per dataset whose validator's
496-
top-level package cannot be found.
386+
top-level package cannot be found. Nothing is logged here; the
387+
caller decides how to emit them.
497388
"""
498-
warnings: list[str] = []
389+
messages: list[str] = []
499390
for ds_name, spec in specs.items():
500391
top_level = spec.class_path.split(".")[0]
501392
if not top_level:
502-
warnings.append(
393+
messages.append(
503394
f"Validator for dataset '{ds_name}' has an invalid class "
504395
f"path {spec.class_path!r}."
505396
)
@@ -509,16 +400,10 @@ def preflight_check(specs: dict[str, ValidatorSpec]) -> list[str]:
509400
except (ImportError, ValueError):
510401
found = None
511402
if found is None:
512-
hint = ""
513-
if top_level == "pandera":
514-
hint = (
515-
" Install it with: pip install 'kedro[pandera-pandas]' "
516-
"(or the pandera-polars / pandera-pyspark extra "
517-
"for your backend)."
518-
)
519-
warnings.append(
403+
hint = f" {_PANDERA_INSTALL_HINT}" if top_level == "pandera" else ""
404+
messages.append(
520405
f"Validator for dataset '{ds_name}' requires package "
521406
f"'{top_level}' which is not installed "
522407
f"(declared: {spec.class_path}).{hint}"
523408
)
524-
return warnings
409+
return messages

0 commit comments

Comments
 (0)