1818from typing import TYPE_CHECKING , Any , Protocol , runtime_checkable
1919
2020from 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
2226if TYPE_CHECKING :
2327 from collections .abc import Callable
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-
18463def _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