Skip to content

Commit 9b21b0f

Browse files
authored
Allow subclassing ValidationError and PydanticCustomError (#1413)
1 parent 7c70e3b commit 9b21b0f

File tree

4 files changed

+180
-24
lines changed

4 files changed

+180
-24
lines changed

python/pydantic_core/_pydantic_core.pyi

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -744,20 +744,19 @@ class SchemaError(Exception):
744744
A list of [`ErrorDetails`][pydantic_core.ErrorDetails] for each error in the schema.
745745
"""
746746

747-
@final
748747
class ValidationError(ValueError):
749748
"""
750749
`ValidationError` is the exception raised by `pydantic-core` when validation fails, it contains a list of errors
751750
which detail why validation failed.
752751
"""
753-
754-
@staticmethod
752+
@classmethod
755753
def from_exception_data(
754+
cls,
756755
title: str,
757756
line_errors: list[InitErrorDetails],
758757
input_type: Literal['python', 'json'] = 'python',
759758
hide_input: bool = False,
760-
) -> ValidationError:
759+
) -> Self:
761760
"""
762761
Python constructor for a Validation Error.
763762
@@ -828,7 +827,6 @@ class ValidationError(ValueError):
828827
before the first validation error is created.
829828
"""
830829

831-
@final
832830
class PydanticCustomError(ValueError):
833831
"""A custom exception providing flexible error handling for Pydantic validators.
834832

src/errors/validation_exception.rs

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use pyo3::ffi;
77
use pyo3::intern;
88
use pyo3::prelude::*;
99
use pyo3::sync::GILOnceCell;
10-
use pyo3::types::{PyDict, PyList, PyString};
10+
use pyo3::types::{PyDict, PyList, PyString, PyType};
1111
use serde::ser::{Error, SerializeMap, SerializeSeq};
1212
use serde::{Serialize, Serializer};
1313

@@ -26,7 +26,7 @@ use super::types::ErrorType;
2626
use super::value_exception::PydanticCustomError;
2727
use super::{InputValue, ValError};
2828

29-
#[pyclass(extends=PyValueError, module="pydantic_core._pydantic_core")]
29+
#[pyclass(extends=PyValueError, module="pydantic_core._pydantic_core", subclass)]
3030
#[derive(Clone)]
3131
#[cfg_attr(debug_assertions, derive(Debug))]
3232
pub struct ValidationError {
@@ -248,27 +248,35 @@ impl ValidationError {
248248

249249
#[pymethods]
250250
impl ValidationError {
251-
#[staticmethod]
251+
#[new]
252252
#[pyo3(signature = (title, line_errors, input_type="python", hide_input=false))]
253-
fn from_exception_data(
254-
py: Python,
253+
fn py_new(title: PyObject, line_errors: Vec<PyLineError>, input_type: &str, hide_input: bool) -> PyResult<Self> {
254+
Ok(Self {
255+
line_errors,
256+
title,
257+
input_type: InputType::try_from(input_type)?,
258+
hide_input,
259+
})
260+
}
261+
262+
#[classmethod]
263+
#[pyo3(signature = (title, line_errors, input_type="python", hide_input=false))]
264+
fn from_exception_data<'py>(
265+
cls: &Bound<'py, PyType>,
255266
title: PyObject,
256267
line_errors: Bound<'_, PyList>,
257268
input_type: &str,
258269
hide_input: bool,
259-
) -> PyResult<Py<Self>> {
260-
Py::new(
261-
py,
262-
Self {
263-
line_errors: line_errors
264-
.iter()
265-
.map(|error| PyLineError::try_from(&error))
266-
.collect::<PyResult<_>>()?,
267-
title,
268-
input_type: InputType::try_from(input_type)?,
269-
hide_input,
270-
},
271-
)
270+
) -> PyResult<Bound<'py, PyAny>> {
271+
cls.call1((
272+
title,
273+
line_errors
274+
.iter()
275+
.map(|error| PyLineError::try_from(&error))
276+
.collect::<PyResult<Vec<PyLineError>>>()?,
277+
InputType::try_from(input_type)?,
278+
hide_input,
279+
))
272280
}
273281

274282
#[getter]

src/errors/value_exception.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ impl PydanticUseDefault {
5454
}
5555
}
5656

57-
#[pyclass(extends=PyValueError, module="pydantic_core._pydantic_core")]
57+
#[pyclass(extends=PyValueError, module="pydantic_core._pydantic_core", subclass)]
5858
#[derive(Debug, Clone, Default)]
5959
pub struct PydanticCustomError {
6060
error_type: String,

tests/test_custom_errors.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from typing import Any, Dict, List, Optional
2+
from unittest import TestCase
3+
from unittest.mock import ANY
4+
5+
import pytest
6+
from typing_extensions import LiteralString, Self, override
7+
8+
from pydantic_core import ErrorDetails, InitErrorDetails, PydanticCustomError, ValidationError
9+
10+
11+
def test_validation_error_subclassable():
12+
"""Assert subclassable and inheritance hierarchy as expected"""
13+
14+
class CustomValidationError(ValidationError):
15+
pass
16+
17+
with pytest.raises(ValidationError) as exception_info:
18+
raise CustomValidationError.from_exception_data(
19+
'My CustomError',
20+
[
21+
InitErrorDetails(
22+
type='value_error',
23+
loc=('myField',),
24+
msg='This is my custom error.',
25+
input='something invalid',
26+
ctx={
27+
'myField': 'something invalid',
28+
'error': "'something invalid' is not a valid value for 'myField'",
29+
},
30+
)
31+
],
32+
)
33+
assert isinstance(exception_info.value, CustomValidationError)
34+
35+
36+
def test_validation_error_loc_overrides():
37+
"""Override methods in rust pyclass and assert change in behavior: ValidationError.errors"""
38+
39+
class CustomLocOverridesError(ValidationError):
40+
"""Unnests some errors"""
41+
42+
@override
43+
def errors(
44+
self, *, include_url: bool = True, include_context: bool = True, include_input: bool = True
45+
) -> List[ErrorDetails]:
46+
errors = super().errors(
47+
include_url=include_url, include_context=include_context, include_input=include_input
48+
)
49+
return [{**error, 'loc': error['loc'][1:]} for error in errors]
50+
51+
with pytest.raises(CustomLocOverridesError) as exception_info:
52+
raise CustomLocOverridesError.from_exception_data(
53+
'My CustomError',
54+
[
55+
InitErrorDetails(
56+
type='value_error',
57+
loc=(
58+
'hide_this',
59+
'myField',
60+
),
61+
msg='This is my custom error.',
62+
input='something invalid',
63+
ctx={
64+
'myField': 'something invalid',
65+
'error': "'something invalid' is not a valid value for 'myField'",
66+
},
67+
),
68+
InitErrorDetails(
69+
type='value_error',
70+
loc=(
71+
'hide_this',
72+
'myFieldToo',
73+
),
74+
msg='This is my custom error.',
75+
input='something invalid',
76+
ctx={
77+
'myFieldToo': 'something invalid',
78+
'error': "'something invalid' is not a valid value for 'myFieldToo'",
79+
},
80+
),
81+
],
82+
)
83+
84+
TestCase().assertCountEqual(
85+
exception_info.value.errors(),
86+
[
87+
{
88+
'type': 'value_error',
89+
'loc': ('myField',),
90+
'msg': "Value error, 'something invalid' is not a valid value for 'myField'",
91+
'input': 'something invalid',
92+
'ctx': {
93+
'error': "'something invalid' is not a valid value for 'myField'",
94+
'myField': 'something invalid',
95+
},
96+
'url': ANY,
97+
},
98+
{
99+
'type': 'value_error',
100+
'loc': ('myFieldToo',),
101+
'msg': "Value error, 'something invalid' is not a valid value for 'myFieldToo'",
102+
'input': 'something invalid',
103+
'ctx': {
104+
'error': "'something invalid' is not a valid value for 'myFieldToo'",
105+
'myFieldToo': 'something invalid',
106+
},
107+
'url': ANY,
108+
},
109+
],
110+
)
111+
112+
113+
def test_custom_pydantic_error_subclassable():
114+
"""Assert subclassable and inheritance hierarchy as expected"""
115+
116+
class MyCustomError(PydanticCustomError):
117+
pass
118+
119+
with pytest.raises(PydanticCustomError) as exception_info:
120+
raise MyCustomError(
121+
'not_my_custom_thing',
122+
"value is not compatible with my custom field, got '{wrong_value}'",
123+
{'wrong_value': 'non compatible value'},
124+
)
125+
assert isinstance(exception_info.value, MyCustomError)
126+
127+
128+
def test_custom_pydantic_error_overrides():
129+
"""Override methods in rust pyclass and assert change in behavior: PydanticCustomError.__new__"""
130+
131+
class CustomErrorWithCustomTemplate(PydanticCustomError):
132+
@override
133+
def __new__(
134+
cls, error_type: LiteralString, my_custom_setting: str, context: Optional[Dict[str, Any]] = None
135+
) -> Self:
136+
message_template = (
137+
"'{my_custom_value}' setting requires a specific my custom field value, got '{wrong_value}'"
138+
)
139+
context = {**context, 'my_custom_value': my_custom_setting}
140+
return super().__new__(cls, error_type, message_template, context)
141+
142+
with pytest.raises(CustomErrorWithCustomTemplate) as exception_info:
143+
raise CustomErrorWithCustomTemplate(
144+
'not_my_custom_thing', 'my_setting', {'wrong_value': 'non compatible value'}
145+
)
146+
147+
assert (
148+
exception_info.value.message()
149+
== "'my_setting' setting requires a specific my custom field value, got 'non compatible value'"
150+
)

0 commit comments

Comments
 (0)