Skip to content

Preserve exception instances and their context in ValidationError #753

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 11, 2023
32 changes: 26 additions & 6 deletions src/errors/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,10 @@ pub enum ErrorType {
// ---------------------
// python errors from functions
ValueError {
error: String,
error: Option<PyObject>, // Use Option because EnumIter requires Default to be implemented
},
AssertionError {
error: String,
error: Option<PyObject>, // Use Option because EnumIter requires Default to be implemented
},
// Note: strum message and serialize are not used here
CustomError {
Expand Down Expand Up @@ -419,8 +419,8 @@ impl ErrorType {
Self::MappingType { .. } => extract_context!(Cow::Owned, MappingType, ctx, error: String),
Self::BytesTooShort { .. } => extract_context!(BytesTooShort, ctx, min_length: usize),
Self::BytesTooLong { .. } => extract_context!(BytesTooLong, ctx, max_length: usize),
Self::ValueError { .. } => extract_context!(ValueError, ctx, error: String),
Self::AssertionError { .. } => extract_context!(AssertionError, ctx, error: String),
Self::ValueError { .. } => extract_context!(ValueError, ctx, error: Option<PyObject>),
Self::AssertionError { .. } => extract_context!(AssertionError, ctx, error: Option<PyObject>),
Self::LiteralError { .. } => extract_context!(LiteralError, ctx, expected: String),
Self::DateParsing { .. } => extract_context!(Cow::Owned, DateParsing, ctx, error: String),
Self::DateFromDatetimeParsing { .. } => extract_context!(DateFromDatetimeParsing, ctx, error: String),
Expand Down Expand Up @@ -631,8 +631,28 @@ impl ErrorType {
Self::MappingType { error } => render!(tmpl, error),
Self::BytesTooShort { min_length } => to_string_render!(tmpl, min_length),
Self::BytesTooLong { max_length } => to_string_render!(tmpl, max_length),
Self::ValueError { error } => render!(tmpl, error),
Self::AssertionError { error } => render!(tmpl, error),
Self::ValueError { error, .. } => {
let error = error.as_ref().map_or_else(
|| "None",
|v| {
v.as_ref(py)
.str()
.map_or_else(|_| "ValueError", |v| v.to_str().unwrap())
},
);
render!(tmpl, error)
}
Self::AssertionError { error, .. } => {
let error = error.as_ref().map_or_else(
|| "None",
|v| {
v.as_ref(py)
.str()
.map_or_else(|_| "ValueError", |v| v.to_str().unwrap())
},
);
render!(tmpl, error)
}
Self::CustomError {
custom_error: value_error,
} => value_error.message(py),
Expand Down
13 changes: 6 additions & 7 deletions src/validators/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,13 +477,12 @@ macro_rules! py_err_string {
($error_value:expr, $type_member:ident, $input:ident) => {
match $error_value.str() {
Ok(py_string) => match py_string.to_str() {
Ok(s) => {
let error = match s.is_empty() {
true => "Unknown error".to_string(),
false => s.to_string(),
};
ValError::new(ErrorType::$type_member { error }, $input)
}
Ok(_) => ValError::new(
ErrorType::$type_member {
error: Some($error_value.into()),
},
$input,
),
Err(e) => ValError::InternalErr(e),
},
Err(e) => ValError::InternalErr(e),
Expand Down
49 changes: 47 additions & 2 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import re
from decimal import Decimal
from typing import Any

import pytest
from dirty_equals import HasRepr, IsInstance, IsJson, IsStr
Expand Down Expand Up @@ -237,8 +238,8 @@ def f(input_value, info):
('bytes_type', 'Input should be a valid bytes', None),
('bytes_too_short', 'Data should have at least 42 bytes', {'min_length': 42}),
('bytes_too_long', 'Data should have at most 42 bytes', {'max_length': 42}),
('value_error', 'Value error, foobar', {'error': 'foobar'}),
('assertion_error', 'Assertion failed, foobar', {'error': 'foobar'}),
('value_error', 'Value error, foobar', {'error': ValueError('foobar')}),
('assertion_error', 'Assertion failed, foobar', {'error': AssertionError('foobar')}),
('literal_error', 'Input should be foo', {'expected': 'foo'}),
('literal_error', 'Input should be foo or bar', {'expected': 'foo or bar'}),
('date_type', 'Input should be a valid date', None),
Expand Down Expand Up @@ -472,6 +473,50 @@ def test_error_json():
assert exc_info.value.json(indent=2).startswith('[\n {\n "type": "string_too_short",')


def test_error_json_python_error():
def raise_py_error(v: Any) -> Any:
try:
assert False
except AssertionError as e:
raise ValueError('Oh no!') from e

s = SchemaValidator(core_schema.no_info_plain_validator_function(raise_py_error))
with pytest.raises(ValidationError) as exc_info:
s.validate_python('anything')

exc = exc_info.value.errors()[0]['ctx']['error'] # type: ignore
assert isinstance(exc, ValueError)
assert isinstance(exc.__context__, AssertionError)

# insert_assert(exc_info.value.errors(include_url=False))
assert exc_info.value.errors(include_url=False) == [
{
'type': 'value_error',
'loc': (),
'msg': 'Value error, Oh no!',
'input': 'anything',
'ctx': {'error': HasRepr(repr(ValueError('Oh no!')))},
}
]
assert exc_info.value.json() == IsJson(
[
{
'type': 'value_error',
'loc': [],
'msg': 'Value error, Oh no!',
'input': 'anything',
'ctx': {'error': 'Oh no!'},
'url': f'https://errors.pydantic.dev/{__version__}/v/value_error',
}
]
)
assert exc_info.value.json(include_url=False, include_context=False) == IsJson(
[{'type': 'value_error', 'loc': [], 'msg': 'Value error, Oh no!', 'input': 'anything'}]
)
assert exc_info.value.json().startswith('[{"type":"value_error",')
assert exc_info.value.json(indent=2).startswith('[\n {\n "type": "value_error",')


def test_error_json_cycle():
s = SchemaValidator({'type': 'str', 'min_length': 3})
cycle = []
Expand Down
11 changes: 6 additions & 5 deletions tests/validators/test_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Any, Dict, Type, Union

import pytest
from dirty_equals import HasRepr

from pydantic_core import SchemaError, SchemaValidator, ValidationError, core_schema

Expand Down Expand Up @@ -58,7 +59,7 @@ def f(input_value, info):
'loc': (),
'msg': 'Value error, foobar',
'input': 'input value',
'ctx': {'error': 'foobar'},
'ctx': {'error': HasRepr(repr(ValueError('foobar')))},
}
]

Expand Down Expand Up @@ -350,7 +351,7 @@ def f(input_value, info):
'loc': (),
'msg': 'Value error, foobar',
'input': 'input value',
'ctx': {'error': 'foobar'},
'ctx': {'error': HasRepr(repr(ValueError('foobar')))},
}
]

Expand Down Expand Up @@ -551,7 +552,7 @@ def f(input_value, info):
'loc': (),
'msg': 'Assertion failed, foobar',
'input': 'input value',
'ctx': {'error': 'foobar'},
'ctx': {'error': HasRepr(repr(AssertionError('foobar')))},
}
]

Expand All @@ -571,9 +572,9 @@ def f(input_value, info):
{
'type': 'assertion_error',
'loc': (),
'msg': 'Assertion failed, Unknown error',
'msg': 'Assertion failed, ',
'input': 'input value',
'ctx': {'error': 'Unknown error'},
'ctx': {'error': HasRepr(repr(AssertionError()))},
}
]

Expand Down
16 changes: 14 additions & 2 deletions tests/validators/test_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,20 @@ def f(input_value, info):
with pytest.raises(ValidationError) as exc_info:
v.validate_python([1, 2])
assert exc_info.value.errors(include_url=False) == [
{'type': 'value_error', 'loc': (0,), 'msg': 'Value error, error 1', 'input': 1, 'ctx': {'error': 'error 1'}},
{'type': 'value_error', 'loc': (1,), 'msg': 'Value error, error 2', 'input': 2, 'ctx': {'error': 'error 2'}},
{
'type': 'value_error',
'loc': (0,),
'msg': 'Value error, error 1',
'input': 1,
'ctx': {'error': HasRepr(repr(ValueError('error 1')))},
},
{
'type': 'value_error',
'loc': (1,),
'msg': 'Value error, error 2',
'input': 2,
'ctx': {'error': HasRepr(repr(ValueError('error 2')))},
},
]


Expand Down
10 changes: 5 additions & 5 deletions tests/validators/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Any, Callable, Dict, List, Set, Tuple

import pytest
from dirty_equals import IsInstance
from dirty_equals import HasRepr, IsInstance

from pydantic_core import SchemaError, SchemaValidator, ValidationError, core_schema

Expand Down Expand Up @@ -139,7 +139,7 @@ def f(
'loc': (),
'msg': 'Assertion failed, assert 456 == 123',
'input': {'field_a': 456},
'ctx': {'error': 'assert 456 == 123'},
'ctx': {'error': HasRepr(repr(AssertionError('assert 456 == 123')))},
}
]

Expand Down Expand Up @@ -173,7 +173,7 @@ def f(input_value: Dict[str, Any], info: core_schema.ValidationInfo):
'loc': (),
'msg': 'Assertion failed, assert 456 == 123',
'input': {'field_a': 456},
'ctx': {'error': 'assert 456 == 123'},
'ctx': {'error': HasRepr(repr(AssertionError('assert 456 == 123')))},
}
]

Expand Down Expand Up @@ -208,7 +208,7 @@ def f(input_value_and_fields_set: Tuple[Dict[str, Any], Set[str]]):
'loc': (),
'msg': 'Assertion failed, assert 456 == 123',
'input': {'field_a': 456},
'ctx': {'error': 'assert 456 == 123'},
'ctx': {'error': HasRepr(repr(AssertionError('assert 456 == 123')))},
}
]

Expand Down Expand Up @@ -876,7 +876,7 @@ def call_me_maybe(self, context, **kwargs):
'loc': (),
'msg': 'Value error, this is broken: test',
'input': {'field_a': 'test'},
'ctx': {'error': 'this is broken: test'},
'ctx': {'error': HasRepr(repr(ValueError('this is broken: test')))},
}
]

Expand Down