Skip to content

Commit

Permalink
Add ValidationError.__new__, Change from_exception_data to classmetho…
Browse files Browse the repository at this point in the history
…d, remove @Final
  • Loading branch information
Youssefares committed Aug 20, 2024
1 parent 4113638 commit bec6b4b
Show file tree
Hide file tree
Showing 4 changed files with 179 additions and 24 deletions.
8 changes: 3 additions & 5 deletions python/pydantic_core/_pydantic_core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
44 changes: 26 additions & 18 deletions src/errors/validation_exception.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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 {
Expand Down Expand Up @@ -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<PyLineError>, input_type: &str, hide_input: bool) -> PyResult<Self> {
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<Self>> {
Py::new(
py,
Self {
line_errors: line_errors
.iter()
.map(|error| PyLineError::try_from(&error))
.collect::<PyResult<_>>()?,
title,
input_type: InputType::try_from(input_type)?,
hide_input,
},
)
) -> PyResult<Bound<'py, PyAny>> {
cls.call1((
title,
line_errors
.iter()
.map(|error| PyLineError::try_from(&error))
.collect::<PyResult<Vec<PyLineError>>>()?,
InputType::try_from(input_type)?,
hide_input,
))
}

#[getter]
Expand Down
2 changes: 1 addition & 1 deletion src/errors/value_exception.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
149 changes: 149 additions & 0 deletions tests/test_custom_errors.py
Original file line number Diff line number Diff line change
@@ -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'"
)

0 comments on commit bec6b4b

Please sign in to comment.