Skip to content

Commit bec6b4b

Browse files
committed
Add ValidationError.__new__, Change from_exception_data to classmethod, remove @Final
1 parent 4113638 commit bec6b4b

File tree

4 files changed

+179
-24
lines changed

4 files changed

+179
-24
lines changed

python/pydantic_core/_pydantic_core.pyi

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

739-
@final
740739
class ValidationError(ValueError):
741740
"""
742741
`ValidationError` is the exception raised by `pydantic-core` when validation fails, it contains a list of errors
743742
which detail why validation failed.
744743
"""
745-
746-
@staticmethod
744+
@classmethod
747745
def from_exception_data(
746+
cls,
748747
title: str,
749748
line_errors: list[InitErrorDetails],
750749
input_type: Literal['python', 'json'] = 'python',
751750
hide_input: bool = False,
752-
) -> ValidationError:
751+
) -> Self:
753752
"""
754753
Python constructor for a Validation Error.
755754
@@ -820,7 +819,6 @@ class ValidationError(ValueError):
820819
before the first validation error is created.
821820
"""
822821

823-
@final
824822
class PydanticCustomError(ValueError):
825823
def __new__(
826824
cls, error_type: LiteralString, message_template: LiteralString, context: dict[str, Any] | None = None

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

0 commit comments

Comments
 (0)