From bec6b4b0f00ab0f9fcf6d0a2db3d3155a0b436ea Mon Sep 17 00:00:00 2001 From: Youssef Date: Tue, 20 Aug 2024 16:29:14 +0100 Subject: [PATCH] Add ValidationError.__new__, Change from_exception_data to classmethod, remove @final --- python/pydantic_core/_pydantic_core.pyi | 8 +- src/errors/validation_exception.rs | 44 ++++--- src/errors/value_exception.rs | 2 +- tests/test_custom_errors.py | 149 ++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 24 deletions(-) create mode 100644 tests/test_custom_errors.py diff --git a/python/pydantic_core/_pydantic_core.pyi b/python/pydantic_core/_pydantic_core.pyi index aeec227f8..db890f3f3 100644 --- a/python/pydantic_core/_pydantic_core.pyi +++ b/python/pydantic_core/_pydantic_core.pyi @@ -736,20 +736,19 @@ class SchemaError(Exception): A list of [`ErrorDetails`][pydantic_core.ErrorDetails] for each error in the schema. """ -@final class ValidationError(ValueError): """ `ValidationError` is the exception raised by `pydantic-core` when validation fails, it contains a list of errors which detail why validation failed. """ - - @staticmethod + @classmethod def from_exception_data( + cls, title: str, line_errors: list[InitErrorDetails], input_type: Literal['python', 'json'] = 'python', hide_input: bool = False, - ) -> ValidationError: + ) -> Self: """ Python constructor for a Validation Error. @@ -820,7 +819,6 @@ class ValidationError(ValueError): before the first validation error is created. """ -@final class PydanticCustomError(ValueError): def __new__( cls, error_type: LiteralString, message_template: LiteralString, context: dict[str, Any] | None = None diff --git a/src/errors/validation_exception.rs b/src/errors/validation_exception.rs index e7861fa9b..914bba0ea 100644 --- a/src/errors/validation_exception.rs +++ b/src/errors/validation_exception.rs @@ -7,7 +7,7 @@ use pyo3::ffi; use pyo3::intern; use pyo3::prelude::*; use pyo3::sync::GILOnceCell; -use pyo3::types::{PyDict, PyList, PyString}; +use pyo3::types::{PyDict, PyList, PyString, PyType}; use serde::ser::{Error, SerializeMap, SerializeSeq}; use serde::{Serialize, Serializer}; @@ -26,7 +26,7 @@ use super::types::ErrorType; use super::value_exception::PydanticCustomError; use super::{InputValue, ValError}; -#[pyclass(extends=PyValueError, module="pydantic_core._pydantic_core")] +#[pyclass(extends=PyValueError, module="pydantic_core._pydantic_core", subclass)] #[derive(Clone)] #[cfg_attr(debug_assertions, derive(Debug))] pub struct ValidationError { @@ -248,27 +248,35 @@ impl ValidationError { #[pymethods] impl ValidationError { - #[staticmethod] + #[new] #[pyo3(signature = (title, line_errors, input_type="python", hide_input=false))] - fn from_exception_data( - py: Python, + fn py_new(title: PyObject, line_errors: Vec, input_type: &str, hide_input: bool) -> PyResult { + Ok(Self { + line_errors, + title, + input_type: InputType::try_from(input_type)?, + hide_input, + }) + } + + #[classmethod] + #[pyo3(signature = (title, line_errors, input_type="python", hide_input=false))] + fn from_exception_data<'py>( + cls: &Bound<'py, PyType>, title: PyObject, line_errors: Bound<'_, PyList>, input_type: &str, hide_input: bool, - ) -> PyResult> { - Py::new( - py, - Self { - line_errors: line_errors - .iter() - .map(|error| PyLineError::try_from(&error)) - .collect::>()?, - title, - input_type: InputType::try_from(input_type)?, - hide_input, - }, - ) + ) -> PyResult> { + cls.call1(( + title, + line_errors + .iter() + .map(|error| PyLineError::try_from(&error)) + .collect::>>()?, + InputType::try_from(input_type)?, + hide_input, + )) } #[getter] diff --git a/src/errors/value_exception.rs b/src/errors/value_exception.rs index 793bcd0b5..d428dc524 100644 --- a/src/errors/value_exception.rs +++ b/src/errors/value_exception.rs @@ -54,7 +54,7 @@ impl PydanticUseDefault { } } -#[pyclass(extends=PyValueError, module="pydantic_core._pydantic_core")] +#[pyclass(extends=PyValueError, module="pydantic_core._pydantic_core", subclass)] #[derive(Debug, Clone, Default)] pub struct PydanticCustomError { error_type: String, diff --git a/tests/test_custom_errors.py b/tests/test_custom_errors.py new file mode 100644 index 000000000..6a52fac6f --- /dev/null +++ b/tests/test_custom_errors.py @@ -0,0 +1,149 @@ +from typing import Any, LiteralString, Self, override +from unittest import TestCase +from unittest.mock import ANY + +import pytest + +from pydantic_core import ErrorDetails, InitErrorDetails, PydanticCustomError, ValidationError + + +def test_validation_error_subclassable(): + """Assert subclassable and inheritance hierarchy as expected""" + + class CustomValidationError(ValidationError): + pass + + with pytest.raises(ValidationError) as exception_info: + raise CustomValidationError.from_exception_data( + 'My CustomError', + [ + InitErrorDetails( + type='value_error', + loc=('myField',), + msg='This is my custom error.', + input='something invalid', + ctx={ + 'myField': 'something invalid', + 'error': "'something invalid' is not a valid value for 'myField'", + }, + ) + ], + ) + assert isinstance(exception_info.value, CustomValidationError) + + +def test_validation_error_loc_overrides(): + """Override methods in rust pyclass and assert change in behavior: ValidationError.errors""" + + class CustomLocOverridesError(ValidationError): + """Unnests some errors""" + + @override + def errors( + self, *, include_url: bool = True, include_context: bool = True, include_input: bool = True + ) -> list[ErrorDetails]: + errors = super().errors( + include_url=include_url, include_context=include_context, include_input=include_input + ) + return [error | {'loc': error['loc'][1:]} for error in errors] + + with pytest.raises(CustomLocOverridesError) as exception_info: + raise CustomLocOverridesError.from_exception_data( + 'My CustomError', + [ + InitErrorDetails( + type='value_error', + loc=( + 'hide_this', + 'myField', + ), + msg='This is my custom error.', + input='something invalid', + ctx={ + 'myField': 'something invalid', + 'error': "'something invalid' is not a valid value for 'myField'", + }, + ), + InitErrorDetails( + type='value_error', + loc=( + 'hide_this', + 'myFieldToo', + ), + msg='This is my custom error.', + input='something invalid', + ctx={ + 'myFieldToo': 'something invalid', + 'error': "'something invalid' is not a valid value for 'myFieldToo'", + }, + ), + ], + ) + + TestCase().assertCountEqual( + exception_info.value.errors(), + [ + { + 'type': 'value_error', + 'loc': ('myField',), + 'msg': "Value error, 'something invalid' is not a valid value for 'myField'", + 'input': 'something invalid', + 'ctx': { + 'error': "'something invalid' is not a valid value for 'myField'", + 'myField': 'something invalid', + }, + 'url': ANY, + }, + { + 'type': 'value_error', + 'loc': ('myFieldToo',), + 'msg': "Value error, 'something invalid' is not a valid value for 'myFieldToo'", + 'input': 'something invalid', + 'ctx': { + 'error': "'something invalid' is not a valid value for 'myFieldToo'", + 'myFieldToo': 'something invalid', + }, + 'url': ANY, + }, + ], + ) + + +def test_custom_pydantic_error_subclassable(): + """Assert subclassable and inheritance hierarchy as expected""" + + class MyCustomError(PydanticCustomError): + pass + + with pytest.raises(PydanticCustomError) as exception_info: + raise MyCustomError( + 'not_my_custom_thing', + "value is not compatible with my custom field, got '{wrong_value}'", + {'wrong_value': 'non compatible value'}, + ) + assert isinstance(exception_info.value, MyCustomError) + + +def test_custom_pydantic_error_overrides(): + """Override methods in rust pyclass and assert change in behavior: PydanticCustomError.__new__""" + + class CustomErrorWithCustomTemplate(PydanticCustomError): + @override + def __new__( + cls, error_type: LiteralString, my_custom_setting: str, context: dict[str, Any] | None = None + ) -> Self: + message_template = ( + "'{my_custom_value}' setting requires a specific my custom field value, got '{wrong_value}'" + ) + context = context | {'my_custom_value': my_custom_setting} + return super().__new__(cls, error_type, message_template, context) + + with pytest.raises(CustomErrorWithCustomTemplate) as exception_info: + raise CustomErrorWithCustomTemplate( + 'not_my_custom_thing', 'my_setting', {'wrong_value': 'non compatible value'} + ) + + assert ( + exception_info.value.message() + == "'my_setting' setting requires a specific my custom field value, got 'non compatible value'" + )