diff --git a/HISTORY.rst b/HISTORY.rst index e5672ed..9bebbbf 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -6,6 +6,9 @@ History v0.9.2 (2018-XX-XX) ................... * add ``Config.allow_population_by_alias`` #160, thanks @bendemaree +* **breaking change**: new errors format #179 +* **breaking change**: removed ``Config.min_number_size`` and ``Config.max_number_size`` #183 +* added error context and ability to redefine error message templates using ``Config.error_msg_templates`` #183 v0.9.1 (2018-05-10) ................... diff --git a/pydantic/__init__.py b/pydantic/__init__.py index b3052f8..52365d5 100644 --- a/pydantic/__init__.py +++ b/pydantic/__init__.py @@ -1,6 +1,7 @@ # flake8: noqa from .env_settings import BaseSettings -from .exceptions import * +from .error_wrappers import ValidationError +from .errors import * from .fields import Required from .main import BaseConfig, BaseModel, create_model, validator from .parse import Protocol diff --git a/pydantic/datetime_parse.py b/pydantic/datetime_parse.py index 02ddfe2..e32d3d2 100644 --- a/pydantic/datetime_parse.py +++ b/pydantic/datetime_parse.py @@ -18,6 +18,8 @@ import re from datetime import date, datetime, time, timedelta, timezone from typing import Union +from . import errors + date_re = re.compile(r'(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})$') time_re = re.compile( @@ -96,10 +98,14 @@ def parse_date(value: Union[date, StrIntFloat]) -> date: match = date_re.match(value) if not match: - raise ValueError('Invalid date format') + raise errors.DateError() kw = {k: int(v) for k, v in match.groupdict().items()} - return date(**kw) + + try: + return date(**kw) + except ValueError as e: + raise errors.DateError() from e def parse_time(value: Union[time, str]) -> time: @@ -116,13 +122,18 @@ def parse_time(value: Union[time, str]) -> time: match = time_re.match(value) if not match: - raise ValueError('Invalid time format') + raise errors.TimeError() kw = match.groupdict() if kw['microsecond']: kw['microsecond'] = kw['microsecond'].ljust(6, '0') + kw = {k: int(v) for k, v in kw.items() if v is not None} - return time(**kw) + + try: + return time(**kw) + except ValueError as e: + raise errors.TimeError() from e def parse_datetime(value: Union[datetime, StrIntFloat]) -> datetime: @@ -144,11 +155,12 @@ def parse_datetime(value: Union[datetime, StrIntFloat]) -> datetime: match = datetime_re.match(value) if not match: - raise ValueError('Invalid datetime format') + raise errors.DateTimeError() kw = match.groupdict() if kw['microsecond']: kw['microsecond'] = kw['microsecond'].ljust(6, '0') + tzinfo = kw.pop('tzinfo') if tzinfo == 'Z': tzinfo = timezone.utc @@ -158,9 +170,14 @@ def parse_datetime(value: Union[datetime, StrIntFloat]) -> datetime: if tzinfo[0] == '-': offset = -offset tzinfo = timezone(timedelta(minutes=offset)) + kw = {k: int(v) for k, v in kw.items() if v is not None} kw['tzinfo'] = tzinfo - return datetime(**kw) + + try: + return datetime(**kw) + except ValueError as e: + raise errors.DateTimeError() from e def parse_duration(value: StrIntFloat) -> timedelta: @@ -177,13 +194,16 @@ def parse_duration(value: StrIntFloat) -> timedelta: match = standard_duration_re.match(value) or iso8601_duration_re.match(value) if not match: - raise ValueError('Invalid duration format') + raise errors.DurationError() kw = match.groupdict() sign = -1 if kw.pop('sign', '+') == '-' else 1 if kw.get('microseconds'): kw['microseconds'] = kw['microseconds'].ljust(6, '0') + if kw.get('seconds') and kw.get('microseconds') and kw['seconds'].startswith('-'): kw['microseconds'] = '-' + kw['microseconds'] + kw = {k: float(v) for k, v in kw.items() if v is not None} + return sign * timedelta(**kw) diff --git a/pydantic/error_wrappers.py b/pydantic/error_wrappers.py new file mode 100644 index 0000000..a5d2400 --- /dev/null +++ b/pydantic/error_wrappers.py @@ -0,0 +1,140 @@ +import json +from functools import lru_cache +from typing import Iterable, Type + +from .errors import PydanticErrorMixin +from .utils import to_snake_case + +__all__ = ( + 'ErrorWrapper', + 'ValidationError', +) + + +class ErrorWrapper: + __slots__ = 'exc', 'loc', 'msg_template' + + def __init__(self, exc, *, loc, config=None): + self.exc = exc + self.loc = loc if isinstance(loc, tuple) else (loc,) + self.msg_template = config.error_msg_templates.get(self.type_) if config else None + + @property + def ctx(self): + return getattr(self.exc, 'ctx', None) + + @property + def msg(self): + default_msg_template = getattr(self.exc, 'msg_template', None) + msg_template = self.msg_template or default_msg_template + if msg_template: + return msg_template.format(**self.ctx or {}) + + return str(self.exc) + + @property + def type_(self): + return get_exc_type(self.exc) + + def dict(self, *, loc_prefix=None): + loc = self.loc if loc_prefix is None else loc_prefix + self.loc + + d = { + 'loc': loc, + 'msg': self.msg, + 'type': self.type_, + } + + if self.ctx is not None: + d['ctx'] = self.ctx + + return d + + +class ValidationError(ValueError): + __slots__ = 'errors', 'message' + + def __init__(self, errors): + self.errors = errors + self.message = 'validation errors' + + super().__init__(self.message) + + @property + def display_errors(self): + return display_errors(self.flatten_errors()) + + def __str__(self): + return f'{self.message}\n{self.display_errors}' + + def flatten_errors(self): + return list(flatten_errors(self.errors)) + + def json(self, *, indent=2): + return json.dumps(self.flatten_errors(), indent=indent, sort_keys=True) + + +def display_errors(errors): + return '\n'.join( + f'{_display_error_loc(e)}\n {e["msg"]} ({_display_error_type_and_ctx(e)})' + for e in errors + ) + + +def _display_error_loc(error): + return ' -> '.join(str(l) for l in error['loc']) + + +def _display_error_type_and_ctx(error): + display = f'type={error["type"]}' + + ctx = error.get('ctx') + if ctx: + ctx = '; '.join(f'{k}={v}' for k, v in ctx.items()) + display = f'{display}; {ctx}' + + return display + + +def flatten_errors(errors, *, loc=None): + for error in errors: + if isinstance(error, ErrorWrapper): + if isinstance(error.exc, ValidationError): + yield from flatten_errors(error.exc.errors, loc=error.loc) + else: + yield error.dict(loc_prefix=loc) + elif isinstance(error, list): + yield from flatten_errors(error) + else: + raise RuntimeError(f'Unknown error object: {error}') + + +@lru_cache() +def get_exc_type(exc: Exception) -> str: + cls = type(exc) + + if issubclass(cls, PydanticErrorMixin): + return _get_pydantic_exc_type(cls) + + bases = tuple(_get_exc_bases(cls)) + bases = bases[::-1] + + return to_snake_case('.'.join(bases)) + + +def _get_pydantic_exc_type(exc: Type[PydanticErrorMixin]) -> str: + if issubclass(exc, TypeError): + type_ = 'type_error' + else: + type_ = 'value_error' + + return f'{type_}.{exc.code}' + + +def _get_exc_bases(exc: Type[Exception]) -> Iterable[str]: + for b in exc.__mro__: # pragma: no branch + if b in (TypeError, ValueError): + yield b.__name__ + break + + yield b.__name__.replace('Error', '') diff --git a/pydantic/errors.py b/pydantic/errors.py new file mode 100644 index 0000000..369799a --- /dev/null +++ b/pydantic/errors.py @@ -0,0 +1,218 @@ +from decimal import Decimal +from typing import Union + + +class PydanticErrorMixin: + code: str + msg_template: str + + def __init__(self, **ctx) -> None: + self.ctx = ctx or None + super().__init__() + + def __str__(self) -> str: + return self.msg_template.format(**self.ctx or {}) + + +class PydanticTypeError(PydanticErrorMixin, TypeError): + pass + + +class PydanticValueError(PydanticErrorMixin, ValueError): + pass + + +class ConfigError(RuntimeError): + pass + + +class MissingError(PydanticValueError): + code = 'missing' + msg_template = 'field required' + + +class ExtraError(PydanticValueError): + code = 'extra' + msg_template = 'extra fields not permitted' + + +class NoneIsNotAllowedError(PydanticTypeError): + code = 'none.not_allowed' + msg_template = 'none is not an allow value' + + +class BytesError(PydanticTypeError): + code = 'bytes' + msg_template = 'byte type expected' + + +class DictError(PydanticTypeError): + code = 'dict' + msg_template = 'value is not a valid dict' + + +class DSNDriverIsEmptyError(PydanticValueError): + code = 'dsn.driver_is_empty' + msg_template = '"driver" field may not be empty' + + +class EmailError(PydanticValueError): + code = 'email' + msg_template = 'value is not a valid email address' + + +class EnumError(PydanticTypeError): + code = 'enum' + msg_template = 'value is not a valid enumeration member' + + +class IntegerError(PydanticTypeError): + code = 'integer' + msg_template = 'value is not a valid integer' + + +class FloatError(PydanticTypeError): + code = 'float' + msg_template = 'value is not a valid float' + + +class ListError(PydanticTypeError): + code = 'list' + msg_template = 'value is not a valid list' + + +class PathError(PydanticTypeError): + code = 'path' + msg_template = 'value is not a valid path' + + +class PyObjectError(PydanticTypeError): + code = 'py_object' + msg_template = 'ensure this value contains valid import path' + + +class SequenceError(PydanticTypeError): + code = 'sequence' + msg_template = 'value is not a valid sequence' + + +class SetError(PydanticTypeError): + code = 'set' + msg_template = 'value is not a valid set' + + +class TupleError(PydanticTypeError): + code = 'tuple' + msg_template = 'value is not a valid tuple' + + +class AnyStrMinLengthError(PydanticValueError): + code = 'any_str.min_length' + msg_template = 'ensure this value has at least {limit_value} characters' + + def __init__(self, *, limit_value: int) -> None: + super().__init__(limit_value=limit_value) + + +class AnyStrMaxLengthError(PydanticValueError): + code = 'any_str.max_length' + msg_template = 'ensure this value has at most {limit_value} characters' + + def __init__(self, *, limit_value: int) -> None: + super().__init__(limit_value=limit_value) + + +class StrError(PydanticTypeError): + code = 'str' + msg_template = 'str type expected' + + +class StrRegexError(PydanticValueError): + code = 'str.regex' + msg_template = 'string does not match regex "{pattern}"' + + def __init__(self, *, pattern: str) -> None: + super().__init__(pattern=pattern) + + +class NumberMinSizeError(PydanticValueError): + code = 'number.min_size' + msg_template = 'ensure this value is greater than {limit_value}' + + def __init__(self, *, limit_value: Union[int, float, Decimal]) -> None: + super().__init__(limit_value=limit_value) + + +class NumberMaxSizeError(PydanticValueError): + code = 'number.max_size' + msg_template = 'ensure this value is less than {limit_value}' + + def __init__(self, *, limit_value: Union[int, float, Decimal]) -> None: + super().__init__(limit_value=limit_value) + + +class DecimalError(PydanticTypeError): + code = 'decimal' + msg_template = 'value is not a valid decimal' + + +class DecimalIsNotFiniteError(PydanticValueError): + code = 'decimal.not_finite' + msg_template = 'value is not a valid decimal' + + +class DecimalMaxDigitsError(PydanticValueError): + code = 'decimal.max_digits' + msg_template = 'ensure that there are no more than {max_digits} digits in total' + + def __init__(self, *, max_digits: int) -> None: + super().__init__(max_digits=max_digits) + + +class DecimalMaxPlacesError(PydanticValueError): + code = 'decimal.max_places' + msg_template = 'ensure that there are no more than {decimal_places} decimal places' + + def __init__(self, *, decimal_places: int) -> None: + super().__init__(decimal_places=decimal_places) + + +class DecimalWholeDigitsError(PydanticValueError): + code = 'decimal.whole_digits' + msg_template = 'ensure that there are no more than {whole_digits} digits before the decimal point' + + def __init__(self, *, whole_digits: int) -> None: + super().__init__(whole_digits=whole_digits) + + +class DateTimeError(PydanticTypeError): + code = 'datetime' + msg_template = 'invalid datetime format' + + +class DateError(PydanticTypeError): + code = 'date' + msg_template = 'invalid date format' + + +class TimeError(PydanticTypeError): + code = 'time' + msg_template = 'invalid time format' + + +class DurationError(PydanticTypeError): + code = 'duration' + msg_template = 'invalid duration format' + + +class UUIDError(PydanticTypeError): + code = 'uuid' + msg_template = 'value is not a valid uuid' + + +class UUIDVersionError(PydanticValueError): + code = 'uuid.version' + msg_template = 'uuid version {required_version} expected' + + def __init__(self, *, required_version: int) -> None: + super().__init__(required_version=required_version) diff --git a/pydantic/exceptions.py b/pydantic/exceptions.py deleted file mode 100644 index 534798b..0000000 --- a/pydantic/exceptions.py +++ /dev/null @@ -1,97 +0,0 @@ -import inspect -import json - -from .utils import to_snake_case - -__all__ = ( - 'Error', - 'ValidationError', - 'ConfigError', - 'Missing', - 'Extra', -) - - -class Error: - __slots__ = 'exc_info', 'loc' - - def __init__(self, exc, *, loc): - self.exc_info = exc - self.loc = loc if isinstance(loc, tuple) else (loc,) - - @property - def msg(self): - return str(self.exc_info) - - @property - def type_(self): - bases = [] - for b in inspect.getmro(type(self.exc_info)): # pragma: no branch - bases.append(b.__name__) - if b in (ValueError, TypeError): - break - - return to_snake_case('.'.join(bases[::-1])) - - -class ValidationError(ValueError): - __slots__ = 'errors', 'message' - - def __init__(self, errors): - self.errors = errors - self.message = 'validation errors' - - super().__init__(self.message) - - @property - def display_errors(self): - return display_errors(self.flatten_errors()) - - def __str__(self): - return f'{self.message}\n{self.display_errors}' - - def flatten_errors(self): - return list(flatten_errors(self.errors)) - - def json(self, *, indent=2): - return json.dumps(self.flatten_errors(), indent=indent, sort_keys=True) - - -class ConfigError(RuntimeError): - pass - - -class Missing(ValueError): - pass - - -class Extra(ValueError): - pass - - -def display_errors(errors): - return '\n'.join( - f'{_display_error_loc(e["loc"])}\n {e["msg"]} (type={e["type"]})' - for e in errors - ) - - -def _display_error_loc(loc): - return ' -> '.join(str(l) for l in loc) - - -def flatten_errors(errors, *, loc=None): - for error in errors: - if isinstance(error, Error): - if isinstance(error.exc_info, ValidationError): - yield from flatten_errors(error.exc_info.errors, loc=error.loc) - else: - yield { - 'loc': error.loc if loc is None else loc + error.loc, - 'msg': error.msg, - 'type': error.type_, - } - elif isinstance(error, list): - yield from flatten_errors(error) - else: - raise RuntimeError(f'Unknown error object: {error}') diff --git a/pydantic/fields.py b/pydantic/fields.py index 36d6098..a86cf11 100644 --- a/pydantic/fields.py +++ b/pydantic/fields.py @@ -2,9 +2,10 @@ import inspect from enum import IntEnum from typing import Any, Callable, List, Mapping, NamedTuple, Set, Type, Union -from .exceptions import ConfigError, Error +from .error_wrappers import ErrorWrapper +from .errors import ConfigError, SequenceError from .utils import display_as_type -from .validators import NoneType, find_validators, not_none_validator +from .validators import NoneType, dict_validator, find_validators, not_none_validator Required: Any = Ellipsis @@ -238,10 +239,12 @@ class Field: def _validate_sequence(self, v, values, loc, cls): result, errors = [], [] + try: v_iter = enumerate(v) - except TypeError as exc: - return v, Error(exc, loc=loc) + except TypeError: + return v, ErrorWrapper(SequenceError(), loc=loc, config=self.model_config) + for i, v_ in v_iter: v_loc = *loc, i single_result, single_errors = self._validate_singleton(v_, values, v_loc, cls) @@ -249,19 +252,17 @@ class Field: errors.append(single_errors) else: result.append(single_result) + if errors: return v, errors else: return result, None def _validate_mapping(self, v, values, loc, cls): - if isinstance(v, dict): - v_iter = v - else: - try: - v_iter = dict(v) - except TypeError: - return v, Error(TypeError(f'value is not a valid dict, got {display_as_type(v)}'), loc=loc) + try: + v_iter = dict_validator(v) + except TypeError as exc: + return v, ErrorWrapper(exc, loc=loc, config=self.model_config) result, errors = {}, [] for k, v_ in v_iter.items(): @@ -309,7 +310,7 @@ class Field: # ValidatorSignature.CLS_VALUE_KWARGS v = validator(cls, v, values=values, config=self.model_config, field=self) except (ValueError, TypeError) as exc: - return v, Error(exc, loc=loc) + return v, ErrorWrapper(exc, loc=loc, config=self.model_config) return v, None def __repr__(self): diff --git a/pydantic/main.py b/pydantic/main.py index f638fa5..a7a56cb 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -6,7 +6,8 @@ from pathlib import Path from types import FunctionType from typing import Any, Dict, Set, Type, Union -from .exceptions import ConfigError, Error, Extra, Missing, ValidationError +from .error_wrappers import ErrorWrapper, ValidationError +from .errors import ConfigError, ExtraError, MissingError from .fields import Field, Validator from .parse import Protocol, load_file, load_str_bytes from .types import StrBytes @@ -18,8 +19,6 @@ class BaseConfig: anystr_strip_whitespace = False min_anystr_length = 0 max_anystr_length = 2 ** 16 - min_number_size = -2 ** 64 - max_number_size = 2 ** 64 validate_all = False ignore_extra = True allow_extra = False @@ -28,6 +27,7 @@ class BaseConfig: use_enum_values = False fields = {} validate_assignment = False + error_msg_templates: Dict[str, str] = {} @classmethod def get_field_config(cls, name): @@ -137,10 +137,6 @@ class MetaModel(ABCMeta): return super().__new__(mcs, name, bases, new_namespace) -MISSING = Missing('field required') -EXTRA = Extra('extra fields not permitted') - - class BaseModel(metaclass=MetaModel): # populated by the metaclass, defined here to help IDEs only __fields__ = {} @@ -191,7 +187,7 @@ class BaseModel(metaclass=MetaModel): def parse_obj(cls, obj): if not isinstance(obj, dict): exc = TypeError(f'{cls.__name__} expected dict not {type(obj).__name__}') - raise ValidationError([Error(exc, loc='__obj__')]) + raise ValidationError([ErrorWrapper(exc, loc='__obj__')]) return cls(**obj) @classmethod @@ -204,7 +200,7 @@ class BaseModel(metaclass=MetaModel): obj = load_str_bytes(b, proto=proto, content_type=content_type, encoding=encoding, allow_pickle=allow_pickle) except (ValueError, TypeError, UnicodeDecodeError) as e: - raise ValidationError([Error(e, loc='__obj__')]) + raise ValidationError([ErrorWrapper(e, loc='__obj__')]) return cls.parse_obj(obj) @classmethod @@ -265,22 +261,22 @@ class BaseModel(metaclass=MetaModel): errors = [] for name, field in self.__fields__.items(): - value = input_data.get(field.alias, MISSING) - if value is MISSING and self.__config__.allow_population_by_alias and field.alt_alias: - value = input_data.get(field.name, MISSING) + value = input_data.get(field.alias, ...) + if value is ... and self.__config__.allow_population_by_alias and field.alt_alias: + value = input_data.get(field.name, ...) - if value is MISSING: + if value is ...: if self.__config__.validate_all or field.validate_always: value = field.default else: if field.required: - errors.append(Error(MISSING, loc=field.alias)) + errors.append(ErrorWrapper(MissingError(), loc=field.alias, config=self.__config__)) else: values[name] = field.default continue v_, errors_ = field.validate(value, values, loc=field.alias, cls=self.__class__) - if isinstance(errors_, Error): + if isinstance(errors_, ErrorWrapper): errors.append(errors_) elif isinstance(errors_, list): errors.extend(errors_) @@ -296,7 +292,7 @@ class BaseModel(metaclass=MetaModel): else: # config.ignore_extra is False for field in sorted(extra): - errors.append(Error(EXTRA, loc=field)) + errors.append(ErrorWrapper(ExtraError(), loc=field, config=self.__config__)) if errors: raise ValidationError(errors) diff --git a/pydantic/types.py b/pydantic/types.py index 27e8620..72b7175 100644 --- a/pydantic/types.py +++ b/pydantic/types.py @@ -3,9 +3,10 @@ from decimal import Decimal from typing import Optional, Pattern, Type, Union from uuid import UUID +from . import errors from .utils import import_string, make_dsn, validate_email -from .validators import (anystr_length_validator, anystr_strip_whitespace, decimal_validator, not_none_validator, - number_size_validator, str_validator) +from .validators import (anystr_length_validator, anystr_strip_whitespace, decimal_validator, float_validator, + int_validator, not_none_validator, number_size_validator, str_validator) try: import email_validator @@ -54,7 +55,7 @@ class StrictStr(str): @classmethod def validate(cls, v): if not isinstance(v, str): - raise ValueError(f'strict string: str expected not {type(v)}') + raise errors.StrError() return v @@ -80,7 +81,7 @@ class ConstrainedStr(str): if cls.regex: if not cls.regex.match(value): - raise ValueError(f'string does not match regex "{cls.regex.pattern}"') + raise errors.StrRegexError(pattern=cls.regex.pattern) return value @@ -91,6 +92,7 @@ class EmailStr(str): # included here and below so the error happens straight away if email_validator is None: raise ImportError('email-validator is not installed, run `pip install pydantic[email]`') + yield str_validator yield cls.validate @@ -110,6 +112,7 @@ class NameEmail: def get_validators(cls): if email_validator is None: raise ImportError('email-validator is not installed, run `pip install pydantic[email]`') + yield str_validator yield cls.validate @@ -149,8 +152,7 @@ class PyObject: try: return import_string(value) except ImportError as e: - # errors must be TypeError or ValueError - raise ValueError(str(e)) from e + raise errors.PyObjectError() from e class DSN(str): @@ -167,9 +169,11 @@ class DSN(str): def validate(cls, value, values, **kwarg): if value: return value + kwargs = {f: values.get(cls.prefix + f) for f in cls.fields} if kwargs['driver'] is None: - raise ValueError(f'"{cls.prefix}driver" field may not be missing or None') + raise errors.DSNDriverIsEmptyError() + return make_dsn(**kwargs) @@ -179,7 +183,7 @@ class ConstrainedInt(int): @classmethod def get_validators(cls): - yield int + yield int_validator yield number_size_validator @@ -203,7 +207,7 @@ class ConstrainedFloat(float): @classmethod def get_validators(cls): - yield float + yield float_validator yield number_size_validator @@ -238,7 +242,7 @@ class ConstrainedDecimal(Decimal): def validate(cls, value: Decimal) -> Decimal: digit_tuple, exponent = value.as_tuple()[1:] if exponent in {'F', 'n', 'N'}: - raise ValueError(f'value is not a valid decimal, got {value}') + raise errors.DecimalIsNotFiniteError() if exponent >= 0: # A positive exponent adds that many trailing zeros. @@ -258,15 +262,15 @@ class ConstrainedDecimal(Decimal): whole_digits = digits - decimals if cls.max_digits is not None and digits > cls.max_digits: - raise ValueError(f'ensure that there are no more than {cls.max_digits} digits in total') + raise errors.DecimalMaxDigitsError(max_digits=cls.max_digits) if cls.decimal_places is not None and decimals > cls.decimal_places: - raise ValueError(f'ensure that there are no more than {cls.decimal_places} decimal places') + raise errors.DecimalMaxPlacesError(decimal_places=cls.decimal_places) if cls.max_digits is not None and cls.decimal_places is not None: expected = cls.max_digits - cls.decimal_places if whole_digits > expected: - raise ValueError(f'ensure that there are no more than {expected} digits before the decimal point') + raise errors.DecimalWholeDigitsError(whole_digits=expected) return value diff --git a/pydantic/utils.py b/pydantic/utils.py index 767dc8d..5c8003d 100644 --- a/pydantic/utils.py +++ b/pydantic/utils.py @@ -2,11 +2,14 @@ import re from importlib import import_module from typing import Tuple, _TypingBase +from . import errors + try: import email_validator except ImportError: email_validator = None + PRETTY_REGEX = re.compile(r'([\w ]*?) *<(.*)> *') @@ -29,8 +32,14 @@ def validate_email(value) -> Tuple[str, str]: name, value = m.groups() else: name = None + email = value.strip() - email_validator.validate_email(email, check_deliverability=False) + + try: + email_validator.validate_email(email, check_deliverability=False) + except email_validator.EmailNotValidError as e: + raise errors.EmailError() from e + return name or email[:email.index('@')], email.lower() diff --git a/pydantic/validators.py b/pydantic/validators.py index 700f1a4..429fc2e 100644 --- a/pydantic/validators.py +++ b/pydantic/validators.py @@ -6,8 +6,8 @@ from pathlib import Path from typing import Any from uuid import UUID +from . import errors from .datetime_parse import parse_date, parse_datetime, parse_duration, parse_time -from .exceptions import ConfigError from .utils import display_as_type NoneType = type(None) @@ -15,7 +15,7 @@ NoneType = type(None) def not_none_validator(v): if v is None: - raise TypeError('None is not an allow value') + raise errors.NoneIsNotAllowedError() return v @@ -28,13 +28,20 @@ def str_validator(v) -> str: # is there anything else we want to add here? If you think so, create an issue. return str(v) else: - raise TypeError(f'str or byte type expected not {display_as_type(v)}') + raise errors.StrError() def bytes_validator(v) -> bytes: - if isinstance(v, (bytes, NoneType)): + if isinstance(v, bytes): return v - return str_validator(v).encode() + elif isinstance(v, bytearray): + return bytes(v) + elif isinstance(v, str): + return v.encode('utf-8') + elif isinstance(v, (float, int, Decimal)): + return str(v).encode('utf-8') + else: + raise errors.BytesError() BOOL_STRINGS = { @@ -55,14 +62,36 @@ def bool_validator(v) -> bool: return bool(v) -def number_size_validator(v, field, config, **kwargs): - min_size = getattr(field.type_, 'gt', config.min_number_size) - if min_size is not None and v <= min_size: - raise ValueError(f'size less than minimum allowed: {min_size}') +def int_validator(v) -> int: + if isinstance(v, int): + return v - max_size = getattr(field.type_, 'lt', config.max_number_size) - if max_size is not None and v >= max_size: - raise ValueError(f'size greater than maximum allowed: {max_size}') + try: + v = int(v) + except (TypeError, ValueError) as e: + raise errors.IntegerError() from e + + return v + + +def float_validator(v) -> float: + if isinstance(v, float): + return v + + try: + v = float(v) + except (TypeError, ValueError) as e: + raise errors.FloatError() from e + + return v + + +def number_size_validator(v, field, config, **kwargs): + if field.type_.gt is not None and v < field.type_.gt: + raise errors.NumberMinSizeError(limit_value=field.type_.gt) + + if field.type_.lt is not None and v > field.type_.lt: + raise errors.NumberMaxSizeError(limit_value=field.type_.lt) return v @@ -72,11 +101,11 @@ def anystr_length_validator(v, field, config, **kwargs): min_length = getattr(field.type_, 'min_length', config.min_anystr_length) if min_length is not None and v_len < min_length: - raise ValueError(f'length less than minimum allowed: {min_length}') + raise errors.AnyStrMinLengthError(limit_value=min_length) max_length = getattr(field.type_, 'max_length', config.max_anystr_length) if max_length is not None and v_len > max_length: - raise ValueError(f'length greater than maximum allowed: {max_length}') + raise errors.AnyStrMaxLengthError(limit_value=max_length) return v @@ -92,52 +121,87 @@ def anystr_strip_whitespace(v, field, config, **kwargs): def ordered_dict_validator(v) -> OrderedDict: if isinstance(v, OrderedDict): return v - return OrderedDict(v) + + try: + v = OrderedDict(v) + except (TypeError, ValueError) as e: + raise errors.DictError() from e + + return v def dict_validator(v) -> dict: if isinstance(v, dict): return v + try: - return dict(v) - except TypeError as e: - raise TypeError(f'value is not a valid dict, got {display_as_type(v)}') from e + v = dict(v) + except (TypeError, ValueError) as e: + raise errors.DictError() from e + + return v def list_validator(v) -> list: if isinstance(v, list): return v - return list(v) + + try: + v = list(v) + except TypeError as e: + raise errors.ListError() from e + + return v def tuple_validator(v) -> tuple: if isinstance(v, tuple): return v - return tuple(v) + + try: + v = tuple(v) + except TypeError as e: + raise errors.TupleError() from e + + return v def set_validator(v) -> set: if isinstance(v, set): return v - return set(v) + + try: + v = set(v) + except TypeError as e: + raise errors.SetError() from e + + return v def enum_validator(v, field, config, **kwargs) -> Enum: - enum_v = field.type_(v) + try: + enum_v = field.type_(v) + except ValueError as e: + raise errors.EnumError() from e + return enum_v.value if config.use_enum_values else enum_v def uuid_validator(v, field, config, **kwargs) -> UUID: - if isinstance(v, str): - v = UUID(v) - elif isinstance(v, (bytes, bytearray)): - v = UUID(v.decode()) - elif not isinstance(v, UUID): - raise ValueError(f'str, byte or native UUID type expected not {type(v)}') + try: + if isinstance(v, str): + v = UUID(v) + elif isinstance(v, (bytes, bytearray)): + v = UUID(v.decode()) + except ValueError as e: + raise errors.UUIDError() from e + + if not isinstance(v, UUID): + raise errors.UUIDError() required_version = getattr(field.type_, '_required_version', None) if required_version and v.version != required_version: - raise ValueError(f'uuid version {required_version} expected, not {v.version}') + raise errors.UUIDVersionError(required_version=required_version) return v @@ -153,10 +217,22 @@ def decimal_validator(v) -> Decimal: try: v = Decimal(v) except DecimalException as e: - raise TypeError(f'value is not a valid decimal, got {display_as_type(v)}') from e + raise errors.DecimalError() from e if not v.is_finite(): - raise TypeError(f'value is not a valid decimal, got {display_as_type(v)}') + raise errors.DecimalIsNotFiniteError() + + return v + + +def path_validator(v) -> Path: + if isinstance(v, Path): + return v + + try: + v = Path(v) + except TypeError as e: + raise errors.PathError() from e return v @@ -169,10 +245,10 @@ _VALIDATORS = [ (bytes, [not_none_validator, bytes_validator, anystr_strip_whitespace, anystr_length_validator]), (bool, [bool_validator]), - (int, [int, number_size_validator]), - (float, [float, number_size_validator]), + (int, [int_validator]), + (float, [float_validator]), - (Path, [Path]), + (Path, [path_validator]), (datetime, [parse_datetime]), (date, [parse_date]), @@ -197,5 +273,5 @@ def find_validators(type_): if issubclass(type_, val_type): return validators except TypeError as e: - raise TypeError(f'error checking inheritance of {type_!r} (type: {display_as_type(type_)})') from e - raise ConfigError(f'no validator found for {type_}') + raise RuntimeError(f'error checking inheritance of {type_!r} (type: {display_as_type(type_)})') from e + raise errors.ConfigError(f'no validator found for {type_}') diff --git a/tests/test_complex.py b/tests/test_complex.py index 463ba10..ded7228 100644 --- a/tests/test_complex.py +++ b/tests/test_complex.py @@ -31,13 +31,13 @@ def test_str_bytes(): assert exc_info.value.flatten_errors() == [ { 'loc': ('v',), - 'msg': 'None is not an allow value', - 'type': 'type_error', + 'msg': 'none is not an allow value', + 'type': 'type_error.none.not_allowed', }, { 'loc': ('v',), - 'msg': 'None is not an allow value', - 'type': 'type_error', + 'msg': 'none is not an allow value', + 'type': 'type_error.none.not_allowed', }, ] @@ -87,13 +87,13 @@ def test_union_int_str(): assert exc_info.value.flatten_errors() == [ { 'loc': ('v',), - 'msg': 'int() argument must be a string, a bytes-like object or a number, not \'NoneType\'', - 'type': 'type_error', + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', }, { 'loc': ('v',), - 'msg': 'None is not an allow value', - 'type': 'type_error', + 'msg': 'none is not an allow value', + 'type': 'type_error.none.not_allowed', }, ] @@ -121,13 +121,13 @@ def test_typed_list(): assert exc_info.value.flatten_errors() == [ { 'loc': ('v', 1), - 'msg': 'invalid literal for int() with base 10: \'x\'', - 'type': 'value_error', + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', }, { 'loc': ('v', 2), - 'msg': 'invalid literal for int() with base 10: \'y\'', - 'type': 'value_error', + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', }, ] @@ -136,8 +136,8 @@ def test_typed_list(): assert exc_info.value.flatten_errors() == [ { 'loc': ('v',), - 'msg': '\'int\' object is not iterable', - 'type': 'type_error', + 'msg': 'value is not a valid sequence', + 'type': 'type_error.sequence', }, ] @@ -154,8 +154,8 @@ def test_typed_set(): assert exc_info.value.flatten_errors() == [ { 'loc': ('v', 1), - 'msg': 'invalid literal for int() with base 10: \'x\'', - 'type': 'value_error', + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', }, ] @@ -185,8 +185,8 @@ def test_typed_dict(value, result): [ { 'loc': ('v',), - 'msg': 'value is not a valid dict, got int', - 'type': 'type_error', + 'msg': 'value is not a valid dict', + 'type': 'type_error.dict', }, ], ), @@ -195,8 +195,8 @@ def test_typed_dict(value, result): [ { 'loc': ('v', 'a'), - 'msg': 'invalid literal for int() with base 10: \'b\'', - 'type': 'value_error', + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', }, ], ), @@ -205,8 +205,8 @@ def test_typed_dict(value, result): [ { 'loc': ('v',), - 'msg': 'value is not a valid dict, got list', - 'type': 'type_error', + 'msg': 'value is not a valid dict', + 'type': 'type_error.dict', }, ], ), @@ -231,8 +231,8 @@ def test_dict_key_error(): assert exc_info.value.flatten_errors() == [ { 'loc': ('v', '__key__'), - 'msg': 'invalid literal for int() with base 10: \'foo\'', - 'type': 'value_error', + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', }, ] @@ -279,8 +279,8 @@ def test_recursive_list(): assert exc_info.value.flatten_errors() == [ { 'loc': ('v', 0), - 'msg': 'dictionary update sequence element #0 has length 1; 2 is required', - 'type': 'value_error', + 'msg': 'value is not a valid dict', + 'type': 'type_error.dict', }, ] @@ -315,13 +315,13 @@ def test_list_unions(): assert exc_info.value.flatten_errors() == [ { 'loc': ('v', 2), - 'msg': 'int() argument must be a string, a bytes-like object or a number, not \'NoneType\'', - 'type': 'type_error', + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', }, { 'loc': ('v', 2), - 'msg': 'None is not an allow value', - 'type': 'type_error', + 'msg': 'none is not an allow value', + 'type': 'type_error.none.not_allowed', }, ] @@ -390,8 +390,8 @@ def test_alias_error(): assert exc_info.value.flatten_errors() == [ { 'loc': ('_a',), - 'msg': 'invalid literal for int() with base 10: \'foo\'', - 'type': 'value_error', + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', }, ] @@ -445,7 +445,7 @@ def test_inheritance(): def test_invalid_type(): - with pytest.raises(TypeError) as exc_info: + with pytest.raises(RuntimeError) as exc_info: class Model(BaseModel): x: 43 = 123 assert "error checking inheritance of 43 (type: int)" in str(exc_info) @@ -475,8 +475,8 @@ def test_valid_string_types(value, expected): [ { 'loc': ('v',), - 'msg': 'str or byte type expected not dict', - 'type': 'type_error', + 'msg': 'str type expected', + 'type': 'type_error.str', }, ], ), @@ -485,8 +485,8 @@ def test_valid_string_types(value, expected): [ { 'loc': ('v',), - 'msg': 'str or byte type expected not list', - 'type': 'type_error', + 'msg': 'str type expected', + 'type': 'type_error.str', }, ], ) @@ -550,8 +550,8 @@ def test_string_none(): assert exc_info.value.flatten_errors() == [ { 'loc': ('a',), - 'msg': 'None is not an allow value', - 'type': 'type_error', + 'msg': 'none is not an allow value', + 'type': 'type_error.none.not_allowed', }, ] diff --git a/tests/test_create_model.py b/tests/test_create_model.py index cd3eae4..4c4bb36 100644 --- a/tests/test_create_model.py +++ b/tests/test_create_model.py @@ -1,6 +1,6 @@ import pytest -from pydantic import BaseModel, ConfigError, ValidationError, create_model, validator +from pydantic import BaseModel, ValidationError, create_model, errors, validator def test_create_model(): @@ -31,12 +31,12 @@ def test_invalid_name(): def test_field_wrong_tuple(): - with pytest.raises(ConfigError): + with pytest.raises(errors.ConfigError): create_model('FooModel', foo=(1, 2, 3)) def test_config_and_base(): - with pytest.raises(ConfigError): + with pytest.raises(errors.ConfigError): create_model('FooModel', __config__=BaseModel.Config, __base__=BaseModel) diff --git a/tests/test_datetime_parse.py b/tests/test_datetime_parse.py index 9423755..fa291b6 100644 --- a/tests/test_datetime_parse.py +++ b/tests/test_datetime_parse.py @@ -10,6 +10,7 @@ from datetime import date, datetime, time, timedelta, timezone import pytest +from pydantic import errors from pydantic.datetime_parse import parse_date, parse_datetime, parse_duration, parse_time @@ -28,12 +29,12 @@ def create_tz(minutes): ('2012-4-9', date(2012, 4, 9)), (date(2012, 4, 9), date(2012, 4, 9)), # Invalid inputs - ('x20120423', ValueError), - ('2012-04-56', ValueError), + ('x20120423', errors.DateError), + ('2012-04-56', errors.DateError), ]) def test_date_parsing(value, result): - if result == ValueError: - with pytest.raises(ValueError): + if result == errors.DateError: + with pytest.raises(errors.DateError): parse_date(value) else: assert parse_date(value) == result @@ -47,12 +48,12 @@ def test_date_parsing(value, result): ('4:8:16', time(4, 8, 16)), (time(4, 8, 16), time(4, 8, 16)), # Invalid inputs - ('091500', ValueError), - ('09:15:90', ValueError), + ('091500', errors.TimeError), + ('09:15:90', errors.TimeError), ]) def test_time_parsing(value, result): - if result == ValueError: - with pytest.raises(ValueError): + if result == errors.TimeError: + with pytest.raises(errors.TimeError): parse_time(value) else: assert parse_time(value) == result @@ -79,12 +80,12 @@ def test_time_parsing(value, result): (datetime(2017, 5, 5), datetime(2017, 5, 5)), (0, datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc)), # Invalid inputs - ('x20120423091500', ValueError), - ('2012-04-56T09:15:90', ValueError), + ('x20120423091500', errors.DateTimeError), + ('2012-04-56T09:15:90', errors.DateTimeError), ]) def test_datetime_parsing(value, result): - if result == ValueError: - with pytest.raises(ValueError): + if result == errors.DateTimeError: + with pytest.raises(errors.DateTimeError): parse_datetime(value) else: assert parse_datetime(value) == result @@ -132,9 +133,9 @@ def test_parse_python_format(delta): ('-1:15:30', timedelta(hours=-1, minutes=15, seconds=30)), ('-30.1', timedelta(seconds=-30, milliseconds=-100)), # iso_8601 - ('P4Y', ValueError), - ('P4M', ValueError), - ('P4W', ValueError), + ('P4Y', errors.DurationError), + ('P4M', errors.DurationError), + ('P4W', errors.DurationError), ('P4D', timedelta(days=4)), ('P0.5D', timedelta(hours=12)), ('PT5H', timedelta(hours=5)), @@ -143,8 +144,8 @@ def test_parse_python_format(delta): ('PT0.000005S', timedelta(microseconds=5)), ]) def test_parse_durations(value, result): - if result == ValueError: - with pytest.raises(ValueError): + if result == errors.DurationError: + with pytest.raises(errors.DurationError): parse_duration(value) else: assert parse_duration(value) == result diff --git a/tests/test_error_wrappers.py b/tests/test_error_wrappers.py new file mode 100644 index 0000000..2c100d4 --- /dev/null +++ b/tests/test_error_wrappers.py @@ -0,0 +1,324 @@ +from typing import Dict, List, Union +from uuid import UUID, uuid4 + +import pytest + +from pydantic import UUID1, BaseModel, conint, errors +from pydantic.error_wrappers import ValidationError, flatten_errors, get_exc_type + + +@pytest.mark.parametrize('result,expected', ( + ( + 'display_errors', + """\ +a + value is not a valid integer (type=type_error.integer) +b -> x + field required (type=value_error.missing) +b -> z + field required (type=value_error.missing) +c -> 0 -> x + value is not a valid integer (type=type_error.integer) +d + value is not a valid integer (type=type_error.integer) +d + value is not a valid uuid (type=type_error.uuid) +e -> __key__ + value is not a valid integer (type=type_error.integer) +f -> 0 + value is not a valid integer (type=type_error.integer) +f -> 0 + none is not an allow value (type=type_error.none.not_allowed) +g + uuid version 1 expected (type=value_error.uuid.version; required_version=1) +h + yet another error message template 42 (type=value_error.number.min_size; limit_value=42)""", + ), + ( + 'flatten_errors', + [ + { + 'loc': ( + 'a', + ), + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', + }, + { + 'loc': ( + 'b', + 'x', + ), + 'msg': 'field required', + 'type': 'value_error.missing', + }, + { + 'loc': ( + 'b', + 'z', + ), + 'msg': 'field required', + 'type': 'value_error.missing', + }, + { + 'loc': ( + 'c', + 0, + 'x', + ), + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', + }, + { + 'loc': ( + 'd', + ), + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', + }, + { + 'loc': ( + 'd', + ), + 'msg': 'value is not a valid uuid', + 'type': 'type_error.uuid', + }, + { + 'loc': ( + 'e', + '__key__', + ), + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', + }, + { + 'loc': ( + 'f', + 0, + ), + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', + }, + { + 'loc': ( + 'f', + 0, + ), + 'msg': 'none is not an allow value', + 'type': 'type_error.none.not_allowed', + }, + { + 'loc': ( + 'g', + ), + 'msg': 'uuid version 1 expected', + 'type': 'value_error.uuid.version', + 'ctx': { + 'required_version': 1, + }, + }, + { + 'loc': ( + 'h', + ), + 'msg': 'yet another error message template 42', + 'type': 'value_error.number.min_size', + 'ctx': { + 'limit_value': 42, + }, + } + ], + ), + ( + 'json', + """\ +[ + { + "loc": [ + "a" + ], + "msg": "value is not a valid integer", + "type": "type_error.integer" + }, + { + "loc": [ + "b", + "x" + ], + "msg": "field required", + "type": "value_error.missing" + }, + { + "loc": [ + "b", + "z" + ], + "msg": "field required", + "type": "value_error.missing" + }, + { + "loc": [ + "c", + 0, + "x" + ], + "msg": "value is not a valid integer", + "type": "type_error.integer" + }, + { + "loc": [ + "d" + ], + "msg": "value is not a valid integer", + "type": "type_error.integer" + }, + { + "loc": [ + "d" + ], + "msg": "value is not a valid uuid", + "type": "type_error.uuid" + }, + { + "loc": [ + "e", + "__key__" + ], + "msg": "value is not a valid integer", + "type": "type_error.integer" + }, + { + "loc": [ + "f", + 0 + ], + "msg": "value is not a valid integer", + "type": "type_error.integer" + }, + { + "loc": [ + "f", + 0 + ], + "msg": "none is not an allow value", + "type": "type_error.none.not_allowed" + }, + { + "ctx": { + "required_version": 1 + }, + "loc": [ + "g" + ], + "msg": "uuid version 1 expected", + "type": "value_error.uuid.version" + }, + { + "ctx": { + "limit_value": 42 + }, + "loc": [ + "h" + ], + "msg": "yet another error message template 42", + "type": "value_error.number.min_size" + } +]""" + ), + ( + '__str__', + """\ +validation errors +a + value is not a valid integer (type=type_error.integer) +b -> x + field required (type=value_error.missing) +b -> z + field required (type=value_error.missing) +c -> 0 -> x + value is not a valid integer (type=type_error.integer) +d + value is not a valid integer (type=type_error.integer) +d + value is not a valid uuid (type=type_error.uuid) +e -> __key__ + value is not a valid integer (type=type_error.integer) +f -> 0 + value is not a valid integer (type=type_error.integer) +f -> 0 + none is not an allow value (type=type_error.none.not_allowed) +g + uuid version 1 expected (type=value_error.uuid.version; required_version=1) +h + yet another error message template 42 (type=value_error.number.min_size; limit_value=42)""" + ), +)) +def test_validation_error(result, expected): + class SubModel(BaseModel): + x: int + y: int + z: str + + class Model(BaseModel): + a: int + b: SubModel + c: List[SubModel] + d: Union[int, UUID] + e: Dict[int, str] + f: List[Union[int, str]] + g: UUID1 + h: conint(gt=42) + + class Config: + error_msg_templates = { + 'value_error.number.min_size': 'yet another error message template {limit_value}', + } + + with pytest.raises(ValidationError) as exc_info: + Model.parse_obj({ + 'a': 'not_int', + 'b': { + 'y': 42, + }, + 'c': [ + { + 'x': 'not_int', + 'y': 42, + 'z': 'string', + }, + ], + 'd': 'string', + 'e': { + 'not_int': 'string', + }, + 'f': [ + None, + ], + 'g': uuid4(), + 'h': 21, + }) + + result = getattr(exc_info.value, result) + if callable(result): + result = result() + + assert result == expected + + +def test_flatten_errors_unknown_error_object(): + with pytest.raises(RuntimeError): + list(flatten_errors([object])) + + +@pytest.mark.parametrize('exc,type_', ( + (TypeError(), 'type_error'), + (ValueError(), 'value_error'), + (errors.DecimalIsNotFiniteError(), 'value_error.decimal.not_finite'), +)) +def test_get_exc_type(exc, type_): + if isinstance(type_, str): + assert get_exc_type(exc) == type_ + else: + with pytest.raises(type_) as exc_info: + get_exc_type(exc) + assert isinstance(exc_info.value, type_) diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..bf3a68b --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,16 @@ +import pytest + +from pydantic import PydanticTypeError + + +def test_pydantic_error(): + class TestError(PydanticTypeError): + code = 'test_code' + msg_template = 'test message template "{test_ctx}"' + + def __init__(self, *, test_ctx: int) -> None: + super().__init__(test_ctx=test_ctx) + + with pytest.raises(TestError) as exc_info: + raise TestError(test_ctx='test_value') + assert str(exc_info.value) == 'test message template "test_value"' diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py deleted file mode 100644 index c043056..0000000 --- a/tests/test_exceptions.py +++ /dev/null @@ -1,253 +0,0 @@ -from typing import Dict, List, Union -from uuid import UUID - -import pytest - -from pydantic import BaseModel -from pydantic.exceptions import ValidationError, flatten_errors - - -@pytest.mark.parametrize('result,expected', ( - ( - 'display_errors', - """\ -a - invalid literal for int() with base 10: 'not_int' (type=value_error) -b -> x - field required (type=value_error.missing) -b -> z - field required (type=value_error.missing) -c -> 0 -> x - invalid literal for int() with base 10: 'not_int' (type=value_error) -d - invalid literal for int() with base 10: 'string' (type=value_error) -d - badly formed hexadecimal UUID string (type=value_error) -e -> __key__ - invalid literal for int() with base 10: 'not_int' (type=value_error) -f -> 0 - int() argument must be a string, a bytes-like object or a number, not 'NoneType' (type=type_error) -f -> 0 - None is not an allow value (type=type_error)""", - ), - ( - 'flatten_errors', - [ - { - 'loc': ( - 'a', - ), - 'msg': 'invalid literal for int() with base 10: \'not_int\'', - 'type': 'value_error', - }, - { - 'loc': ( - 'b', - 'x', - ), - 'msg': 'field required', - 'type': 'value_error.missing', - }, - { - 'loc': ( - 'b', - 'z', - ), - 'msg': 'field required', - 'type': 'value_error.missing', - }, - { - 'loc': ( - 'c', - 0, - 'x', - ), - 'msg': 'invalid literal for int() with base 10: \'not_int\'', - 'type': 'value_error', - }, - { - 'loc': ( - 'd', - ), - 'msg': 'invalid literal for int() with base 10: \'string\'', - 'type': 'value_error', - }, - { - 'loc': ( - 'd', - ), - 'msg': 'badly formed hexadecimal UUID string', - 'type': 'value_error', - }, - { - 'loc': ( - 'e', - '__key__', - ), - 'msg': 'invalid literal for int() with base 10: \'not_int\'', - 'type': 'value_error', - }, - { - 'loc': ( - 'f', - 0, - ), - 'msg': 'int() argument must be a string, a bytes-like object or a number, not \'NoneType\'', - 'type': 'type_error', - }, - { - 'loc': ( - 'f', - 0, - ), - 'msg': 'None is not an allow value', - 'type': 'type_error', - }, - ], - ), - ( - 'json', - """\ -[ - { - "loc": [ - "a" - ], - "msg": "invalid literal for int() with base 10: 'not_int'", - "type": "value_error" - }, - { - "loc": [ - "b", - "x" - ], - "msg": "field required", - "type": "value_error.missing" - }, - { - "loc": [ - "b", - "z" - ], - "msg": "field required", - "type": "value_error.missing" - }, - { - "loc": [ - "c", - 0, - "x" - ], - "msg": "invalid literal for int() with base 10: 'not_int'", - "type": "value_error" - }, - { - "loc": [ - "d" - ], - "msg": "invalid literal for int() with base 10: 'string'", - "type": "value_error" - }, - { - "loc": [ - "d" - ], - "msg": "badly formed hexadecimal UUID string", - "type": "value_error" - }, - { - "loc": [ - "e", - "__key__" - ], - "msg": "invalid literal for int() with base 10: 'not_int'", - "type": "value_error" - }, - { - "loc": [ - "f", - 0 - ], - "msg": "int() argument must be a string, a bytes-like object or a number, not 'NoneType'", - "type": "type_error" - }, - { - "loc": [ - "f", - 0 - ], - "msg": "None is not an allow value", - "type": "type_error" - } -]""" - ), - ( - '__str__', - """\ -validation errors -a - invalid literal for int() with base 10: 'not_int' (type=value_error) -b -> x - field required (type=value_error.missing) -b -> z - field required (type=value_error.missing) -c -> 0 -> x - invalid literal for int() with base 10: 'not_int' (type=value_error) -d - invalid literal for int() with base 10: 'string' (type=value_error) -d - badly formed hexadecimal UUID string (type=value_error) -e -> __key__ - invalid literal for int() with base 10: 'not_int' (type=value_error) -f -> 0 - int() argument must be a string, a bytes-like object or a number, not 'NoneType' (type=type_error) -f -> 0 - None is not an allow value (type=type_error)""" - ), -)) -def test_validation_error(result, expected): - class SubModel(BaseModel): - x: int - y: int - z: str - - class Model(BaseModel): - a: int - b: SubModel - c: List[SubModel] - d: Union[int, UUID] - e: Dict[int, str] - f: List[Union[int, str]] - - with pytest.raises(ValidationError) as exc_info: - Model.parse_obj({ - 'a': 'not_int', - 'b': { - 'y': 42, - }, - 'c': [ - { - 'x': 'not_int', - 'y': 42, - 'z': 'string', - }, - ], - 'd': 'string', - 'e': { - 'not_int': 'string', - }, - 'f': [ - None, - ], - }) - - result = getattr(exc_info.value, result) - if callable(result): - result = result() - - assert result == expected - - -def test_flatten_errors_unknown_error_object(): - with pytest.raises(RuntimeError): - list(flatten_errors([object])) diff --git a/tests/test_main.py b/tests/test_main.py index af2b520..ff08a59 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from pydantic import BaseModel, ConfigError, NoneBytes, NoneStr, Required, ValidationError, constr +from pydantic import BaseModel, NoneBytes, NoneStr, Required, ValidationError, constr, errors def test_success(): @@ -40,13 +40,13 @@ def test_ultra_simple_failed(): assert exc_info.value.flatten_errors() == [ { 'loc': ('a',), - 'msg': 'could not convert string to float: \'x\'', - 'type': 'value_error', + 'msg': 'value is not a valid float', + 'type': 'type_error.float', }, { 'loc': ('b',), - 'msg': 'invalid literal for int() with base 10: \'x\'', - 'type': 'value_error', + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', }, ] @@ -55,7 +55,7 @@ def test_ultra_simple_repr(): m = UltraSimpleModel(a=10.2) assert repr(m) == '' assert repr(m.fields['a']) == ("") + "validators=['float_validator']>") assert dict(m) == {'a': 10.2, 'b': 10} @@ -126,13 +126,13 @@ def test_nullable_strings_fails(): assert exc_info.value.flatten_errors() == [ { 'loc': ('required_str_value',), - 'msg': 'None is not an allow value', - 'type': 'type_error', + 'msg': 'none is not an allow value', + 'type': 'type_error.none.not_allowed', }, { 'loc': ('required_bytes_value',), - 'msg': 'None is not an allow value', - 'type': 'type_error', + 'msg': 'none is not an allow value', + 'type': 'type_error.none.not_allowed', }, ] @@ -198,21 +198,21 @@ class InvalidValidator: def test_invalid_validator(): - with pytest.raises(ConfigError) as exc_info: + with pytest.raises(errors.ConfigError) as exc_info: class InvalidValidatorModel(BaseModel): x: InvalidValidator = ... assert exc_info.value.args[0].startswith('Invalid signature for validator') def test_no_validator(): - with pytest.raises(ConfigError) as exc_info: + with pytest.raises(errors.ConfigError) as exc_info: class NoValidatorModel(BaseModel): x: object = ... assert exc_info.value.args[0] == "no validator found for " def test_unable_to_infer(): - with pytest.raises(ConfigError) as exc_info: + with pytest.raises(errors.ConfigError) as exc_info: class InvalidDefinitionModel(BaseModel): x = None assert exc_info.value.args[0] == 'unable to infer type for attribute "x"' @@ -398,8 +398,8 @@ def test_validating_assignment_fail(): assert exc_info.value.flatten_errors() == [ { 'loc': ('a',), - 'msg': 'invalid literal for int() with base 10: \'b\'', - 'type': 'value_error', + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', }, ] @@ -408,8 +408,11 @@ def test_validating_assignment_fail(): assert exc_info.value.flatten_errors() == [ { 'loc': ('b',), - 'msg': 'length less than minimum allowed: 1', - 'type': 'value_error', + 'msg': 'ensure this value has at least 1 characters', + 'type': 'value_error.any_str.min_length', + 'ctx': { + 'limit_value': 1, + }, }, ] diff --git a/tests/test_settings.py b/tests/test_settings.py index 12e41d8..716b2d5 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -28,8 +28,8 @@ def test_sub_env_missing(): assert exc_info.value.flatten_errors() == [ { 'loc': ('apple',), - 'msg': 'None is not an allow value', - 'type': 'type_error', + 'msg': 'none is not an allow value', + 'type': 'type_error.none.not_allowed', }, ] diff --git a/tests/test_types.py b/tests/test_types.py index 0bd853f..fbd7180 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -4,6 +4,7 @@ from collections import OrderedDict from datetime import date, datetime, time, timedelta from decimal import Decimal from enum import Enum, IntEnum +from pathlib import Path from uuid import UUID import pytest @@ -38,8 +39,11 @@ def test_constrained_str_too_long(): assert exc_info.value.flatten_errors() == [ { 'loc': ('v',), - 'msg': 'length greater than maximum allowed: 10', - 'type': 'value_error', + 'msg': 'ensure this value has at most 10 characters', + 'type': 'value_error.any_str.max_length', + 'ctx': { + 'limit_value': 10, + }, }, ] @@ -76,13 +80,13 @@ def test_dsn_no_driver(): assert exc_info.value.flatten_errors() == [ { 'loc': ('db_driver',), - 'msg': 'None is not an allow value', - 'type': 'type_error', + 'msg': 'none is not an allow value', + 'type': 'type_error.none.not_allowed', }, { 'loc': ('dsn',), - 'msg': '"db_driver" field may not be missing or None', - 'type': 'value_error', + 'msg': '"driver" field may not be empty', + 'type': 'value_error.dsn.driver_is_empty', }, ] @@ -99,8 +103,8 @@ def test_module_import(): assert exc_info.value.flatten_errors() == [ { 'loc': ('module',), - 'msg': '"foobar" doesn\'t look like a module path', - 'type': 'value_error', + 'msg': 'ensure this value contains valid import path', + 'type': 'type_error.py_object', }, ] @@ -117,7 +121,6 @@ class CheckModel(BaseModel): class Config: anystr_strip_whitespace = True max_anystr_length = 10 - max_number_size = 100 @pytest.mark.parametrize('field,value,result', [ @@ -148,6 +151,10 @@ class CheckModel(BaseModel): ('bytes_check', b's', b's'), ('bytes_check', b' s ', b's'), ('bytes_check', 1, b'1'), + ('bytes_check', bytearray('xx', encoding='utf8'), b'xx'), + ('bytes_check', True, b'True'), + ('bytes_check', False, b'False'), + ('bytes_check', {}, ValidationError), ('bytes_check', 'x' * 11, ValidationError), ('bytes_check', b'x' * 11, ValidationError), @@ -159,9 +166,6 @@ class CheckModel(BaseModel): ('int_check', 12, 12), ('int_check', '12', 12), ('int_check', b'12', 12), - ('int_check', 123, ValidationError), - ('int_check', '123', ValidationError), - ('int_check', b'123', ValidationError), ('float_check', 1, 1.0), ('float_check', 1.0, 1.0), @@ -169,9 +173,6 @@ class CheckModel(BaseModel): ('float_check', '1', 1.0), ('float_check', b'1.0', 1.0), ('float_check', b'1', 1.0), - ('float_check', 123, ValidationError), - ('float_check', '123', ValidationError), - ('float_check', b'123', ValidationError), ('uuid_check', 'ebcdab58-6eb8-46fb-a190-d07a33e9eac8', UUID('ebcdab58-6eb8-46fb-a190-d07a33e9eac8')), ('uuid_check', UUID('ebcdab58-6eb8-46fb-a190-d07a33e9eac8'), UUID('ebcdab58-6eb8-46fb-a190-d07a33e9eac8')), @@ -210,8 +211,11 @@ def test_string_too_long(): assert exc_info.value.flatten_errors() == [ { 'loc': ('str_check',), - 'msg': 'length greater than maximum allowed: 10', - 'type': 'value_error', + 'msg': 'ensure this value has at most 10 characters', + 'type': 'value_error.any_str.max_length', + 'ctx': { + 'limit_value': 10, + }, }, ] @@ -222,51 +226,11 @@ def test_string_too_short(): assert exc_info.value.flatten_errors() == [ { 'loc': ('str_check',), - 'msg': 'length less than minimum allowed: 5', - 'type': 'value_error', - }, - ] - - -class NumberModel(BaseModel): - int_check: int - float_check: float - - class Config: - min_number_size = 5 - max_number_size = 10 - - -def test_number_too_big(): - with pytest.raises(ValidationError) as exc_info: - NumberModel(int_check=50, float_check=150) - assert exc_info.value.flatten_errors() == [ - { - 'loc': ('int_check',), - 'msg': 'size greater than maximum allowed: 10', - 'type': 'value_error', - }, - { - 'loc': ('float_check',), - 'msg': 'size greater than maximum allowed: 10', - 'type': 'value_error', - }, - ] - - -def test_number_too_small(): - with pytest.raises(ValidationError) as exc_info: - NumberModel(int_check=1, float_check=2.5) - assert exc_info.value.flatten_errors() == [ - { - 'loc': ('int_check',), - 'msg': 'size less than minimum allowed: 5', - 'type': 'value_error', - }, - { - 'loc': ('float_check',), - 'msg': 'size less than minimum allowed: 5', - 'type': 'value_error', + 'msg': 'ensure this value has at least 5 characters', + 'type': 'value_error.any_str.min_length', + 'ctx': { + 'limit_value': 5, + }, }, ] @@ -302,23 +266,23 @@ def test_datetime_errors(): assert exc_info.value.flatten_errors() == [ { 'loc': ('dt',), - 'msg': 'month must be in 1..12', - 'type': 'value_error', + 'msg': 'invalid datetime format', + 'type': 'type_error.datetime', }, { 'loc': ('date_',), - 'msg': 'Invalid date format', - 'type': 'value_error', + 'msg': 'invalid date format', + 'type': 'type_error.date', }, { 'loc': ('time_',), - 'msg': 'hour must be in 0..23', - 'type': 'value_error', + 'msg': 'invalid time format', + 'type': 'type_error.time', }, { 'loc': ('duration',), - 'msg': 'Invalid duration format', - 'type': 'value_error', + 'msg': 'invalid duration format', + 'type': 'type_error.duration', }, ] @@ -351,9 +315,9 @@ def test_enum_fails(): assert exc_info.value.flatten_errors() == [ { 'loc': ('tool',), - 'msg': '3 is not a valid ToolEnum', - 'type': 'value_error', - } + 'msg': 'value is not a valid enumeration member', + 'type': 'type_error.enum', + }, ] @@ -406,22 +370,28 @@ def test_string_fails(): { 'loc': ('str_regex',), 'msg': 'string does not match regex "^xxx\\d{3}$"', - 'type': 'value_error', + 'type': 'value_error.str.regex', + 'ctx': { + 'pattern': '^xxx\\d{3}$', + }, }, { 'loc': ('str_min_length',), - 'msg': 'length less than minimum allowed: 5', - 'type': 'value_error', + 'msg': 'ensure this value has at least 5 characters', + 'type': 'value_error.any_str.min_length', + 'ctx': { + 'limit_value': 5, + }, }, { 'loc': ('str_email',), - 'msg': 'The email address contains invalid characters before the @-sign: <.', - 'type': 'value_error.email_not_valid_error.email_syntax_error', + 'msg': 'value is not a valid email address', + 'type': 'value_error.email', }, { 'loc': ('name_email',), - 'msg': 'The email address contains invalid characters before the @-sign: .', - 'type': 'value_error.email_not_valid_error.email_syntax_error', + 'msg': 'value is not a valid email address', + 'type': 'value_error.email', }, ] @@ -440,151 +410,176 @@ def test_email_validator_not_installed_name_email(): str_email: NameEmail = ... -class ListDictTupleModel(BaseModel): - a: dict = None - b: list = None - c: OrderedDict = None - d: tuple = None - - def test_dict(): - assert ListDictTupleModel(a={1: 10, 2: 20}).a == {1: 10, 2: 20} - assert ListDictTupleModel(a=[(1, 2), (3, 4)]).a == {1: 2, 3: 4} + class Model(BaseModel): + v: dict + + assert Model(v={1: 10, 2: 20}).v == {1: 10, 2: 20} + assert Model(v=[(1, 2), (3, 4)]).v == {1: 2, 3: 4} with pytest.raises(ValidationError) as exc_info: - ListDictTupleModel(a=[1, 2, 3]) + Model(v=[1, 2, 3]) assert exc_info.value.flatten_errors() == [ { - 'loc': ('a',), - 'msg': 'value is not a valid dict, got list', - 'type': 'type_error', + 'loc': ('v',), + 'msg': 'value is not a valid dict', + 'type': 'type_error.dict', }, ] def test_list(): - m = ListDictTupleModel(b=[1, 2, '3']) - assert m.a is None - assert m.b == [1, 2, '3'] - assert ListDictTupleModel(b='xyz').b == ['x', 'y', 'z'] - assert ListDictTupleModel(b=(i**2 for i in range(5))).b == [0, 1, 4, 9, 16] + class Model(BaseModel): + v: list + + assert Model(v=[1, 2, '3']).v == [1, 2, '3'] + assert Model(v='xyz').v == ['x', 'y', 'z'] + assert Model(v=(i**2 for i in range(5))).v == [0, 1, 4, 9, 16] with pytest.raises(ValidationError) as exc_info: - ListDictTupleModel(b=1) + Model(v=1) assert exc_info.value.flatten_errors() == [ { - 'loc': ('b',), - 'msg': '\'int\' object is not iterable', - 'type': 'type_error', + 'loc': ('v',), + 'msg': 'value is not a valid list', + 'type': 'type_error.list', }, ] def test_ordered_dict(): - assert ListDictTupleModel(c=OrderedDict([(1, 10), (2, 20)])).c == OrderedDict([(1, 10), (2, 20)]) - assert ListDictTupleModel(c={1: 10, 2: 20}).c in (OrderedDict([(1, 10), (2, 20)]), OrderedDict([(2, 20), (1, 10)])) - assert ListDictTupleModel(c=[(1, 2), (3, 4)]).c == OrderedDict([(1, 2), (3, 4)]) + class Model(BaseModel): + v: OrderedDict + + assert Model(v=OrderedDict([(1, 10), (2, 20)])).v == OrderedDict([(1, 10), (2, 20)]) + assert Model(v={1: 10, 2: 20}).v in (OrderedDict([(1, 10), (2, 20)]), OrderedDict([(2, 20), (1, 10)])) + assert Model(v=[(1, 2), (3, 4)]).v == OrderedDict([(1, 2), (3, 4)]) with pytest.raises(ValidationError) as exc_info: - ListDictTupleModel(c=[1, 2, 3]) + Model(v=[1, 2, 3]) assert exc_info.value.flatten_errors() == [ { - 'loc': ('c',), - 'msg': '\'int\' object is not iterable', - 'type': 'type_error', + 'loc': ('v',), + 'msg': 'value is not a valid dict', + 'type': 'type_error.dict', }, ] def test_tuple(): - m = ListDictTupleModel(d=(1, 2, '3')) - assert m.a is None - assert m.d == (1, 2, '3') - assert m.dict() == {'a': None, 'b': None, 'c': None, 'd': (1, 2, '3')} - assert ListDictTupleModel(d='xyz').d == ('x', 'y', 'z') - assert ListDictTupleModel(d=(i**2 for i in range(5))).d == (0, 1, 4, 9, 16) + class Model(BaseModel): + v: tuple + + assert Model(v=(1, 2, '3')).v == (1, 2, '3') + assert Model(v='xyz').v == ('x', 'y', 'z') + assert Model(v=(i**2 for i in range(5))).v == (0, 1, 4, 9, 16) with pytest.raises(ValidationError) as exc_info: - ListDictTupleModel(d=1) + Model(v=1) assert exc_info.value.flatten_errors() == [ { - 'loc': ('d',), - 'msg': '\'int\' object is not iterable', - 'type': 'type_error', - }, - ] - - -class IntModel(BaseModel): - a: PositiveInt = None - b: NegativeInt = None - c: conint(gt=4, lt=10) = None - - -def test_int_validation(): - m = IntModel(a=5, b=-5, c=5) - assert m == {'a': 5, 'b': -5, 'c': 5} - - with pytest.raises(ValidationError) as exc_info: - IntModel(a=-5, b=5, c=-5) - assert exc_info.value.flatten_errors() == [ - { - 'loc': ('a',), - 'msg': 'size less than minimum allowed: 0', - 'type': 'value_error', - }, - { - 'loc': ('b',), - 'msg': 'size greater than maximum allowed: 0', - 'type': 'value_error', - }, - { - 'loc': ('c',), - 'msg': 'size less than minimum allowed: 4', - 'type': 'value_error', - }, - ] - - -class FloatModel(BaseModel): - a: PositiveFloat = None - b: NegativeFloat = None - c: confloat(gt=4, lt=12.2) = None - - -def test_float_validation(): - m = FloatModel(a=5.1, b=-5.2, c=5.3) - assert m == {'a': 5.1, 'b': -5.2, 'c': 5.3} - - with pytest.raises(ValidationError) as exc_info: - FloatModel(a=-5.1, b=5.2, c=-5.3) - assert exc_info.value.flatten_errors() == [ - { - 'loc': ('a',), - 'msg': 'size less than minimum allowed: 0', - 'type': 'value_error', - }, - { - 'loc': ('b',), - 'msg': 'size greater than maximum allowed: 0', - 'type': 'value_error', - }, - { - 'loc': ('c',), - 'msg': 'size less than minimum allowed: 4', - 'type': 'value_error', + 'loc': ('v',), + 'msg': 'value is not a valid tuple', + 'type': 'type_error.tuple', }, ] def test_set(): - class SetModel(BaseModel): - v: set = ... + class Model(BaseModel): + v: set - m = SetModel(v=[1, 2, 3]) - assert m.v == {1, 2, 3} - assert m.dict() == {'v': {1, 2, 3}} - assert SetModel(v={'a', 'b', 'c'}).v == {'a', 'b', 'c'} + assert Model(v={1, 2, 2, '3'}).v == {1, 2, '3'} + assert Model(v='xyzxyz').v == {'x', 'y', 'z'} + assert Model(v={i**2 for i in range(5)}).v == {0, 1, 4, 9, 16} + + with pytest.raises(ValidationError) as exc_info: + Model(v=1) + assert exc_info.value.flatten_errors() == [ + { + 'loc': ('v',), + 'msg': 'value is not a valid set', + 'type': 'type_error.set', + }, + ] + + +def test_int_validation(): + class Model(BaseModel): + a: PositiveInt = None + b: NegativeInt = None + c: conint(gt=4, lt=10) = None + + m = Model(a=5, b=-5, c=5) + assert m == {'a': 5, 'b': -5, 'c': 5} + + with pytest.raises(ValidationError) as exc_info: + Model(a=-5, b=5, c=-5) + assert exc_info.value.flatten_errors() == [ + { + 'loc': ('a',), + 'msg': 'ensure this value is greater than 0', + 'type': 'value_error.number.min_size', + 'ctx': { + 'limit_value': 0, + }, + }, + { + 'loc': ('b',), + 'msg': 'ensure this value is less than 0', + 'type': 'value_error.number.max_size', + 'ctx': { + 'limit_value': 0, + }, + }, + { + 'loc': ('c',), + 'msg': 'ensure this value is greater than 4', + 'type': 'value_error.number.min_size', + 'ctx': { + 'limit_value': 4, + }, + }, + ] + + +def test_float_validation(): + class Model(BaseModel): + a: PositiveFloat = None + b: NegativeFloat = None + c: confloat(gt=4, lt=12.2) = None + + m = Model(a=5.1, b=-5.2, c=5.3) + assert m == {'a': 5.1, 'b': -5.2, 'c': 5.3} + + with pytest.raises(ValidationError) as exc_info: + Model(a=-5.1, b=5.2, c=-5.3) + assert exc_info.value.flatten_errors() == [ + { + 'loc': ('a',), + 'msg': 'ensure this value is greater than 0', + 'type': 'value_error.number.min_size', + 'ctx': { + 'limit_value': 0, + }, + }, + { + 'loc': ('b',), + 'msg': 'ensure this value is less than 0', + 'type': 'value_error.number.max_size', + 'ctx': { + 'limit_value': 0, + }, + }, + { + 'loc': ('c',), + 'msg': 'ensure this value is greater than 4', + 'type': 'value_error.number.min_size', + 'ctx': { + 'limit_value': 4, + }, + }, + ] def test_strict_str(): @@ -609,8 +604,8 @@ def test_uuid_error(): assert exc_info.value.flatten_errors() == [ { 'loc': ('v',), - 'msg': 'badly formed hexadecimal UUID string', - 'type': 'value_error', + 'msg': 'value is not a valid uuid', + 'type': 'type_error.uuid', }, ] @@ -644,23 +639,35 @@ def test_uuid_validation(): assert exc_info.value.flatten_errors() == [ { 'loc': ('a',), - 'msg': 'uuid version 1 expected, not 5', - 'type': 'value_error', + 'msg': 'uuid version 1 expected', + 'type': 'value_error.uuid.version', + 'ctx': { + 'required_version': 1, + }, }, { 'loc': ('b',), - 'msg': 'uuid version 3 expected, not 4', - 'type': 'value_error', + 'msg': 'uuid version 3 expected', + 'type': 'value_error.uuid.version', + 'ctx': { + 'required_version': 3, + }, }, { 'loc': ('c',), - 'msg': 'uuid version 4 expected, not 3', - 'type': 'value_error', + 'msg': 'uuid version 4 expected', + 'type': 'value_error.uuid.version', + 'ctx': { + 'required_version': 4, + }, }, { 'loc': ('d',), - 'msg': 'uuid version 5 expected, not 1', - 'type': 'value_error', + 'msg': 'uuid version 5 expected', + 'type': 'value_error.uuid.version', + 'ctx': { + 'required_version': 5, + }, }, ] @@ -693,34 +700,139 @@ def test_anystr_strip_whitespace_disabled(): @pytest.mark.parametrize('type_,value,result', [ (condecimal(gt=Decimal('42.24')), Decimal('43'), Decimal('43')), - (condecimal(gt=Decimal('42.24')), Decimal('42'), ValidationError), + (condecimal(gt=Decimal('42.24')), Decimal('42'), [ + { + 'loc': ('foo',), + 'msg': 'ensure this value is greater than 42.24', + 'type': 'value_error.number.min_size', + 'ctx': { + 'limit_value': Decimal('42.24'), + }, + }, + ]), (condecimal(lt=Decimal('42.24')), Decimal('42'), Decimal('42')), - (condecimal(lt=Decimal('42.24')), Decimal('43'), ValidationError), + (condecimal(lt=Decimal('42.24')), Decimal('43'), [ + { + 'loc': ('foo',), + 'msg': 'ensure this value is less than 42.24', + 'type': 'value_error.number.max_size', + 'ctx': { + 'limit_value': Decimal('42.24'), + }, + }, + ]), (condecimal(max_digits=2, decimal_places=2), Decimal('0.99'), Decimal('0.99')), - (condecimal(max_digits=2, decimal_places=1), Decimal('0.99'), ValidationError), - (condecimal(max_digits=3, decimal_places=1), Decimal('999'), ValidationError), + (condecimal(max_digits=2, decimal_places=1), Decimal('0.99'), [ + { + 'loc': ('foo',), + 'msg': 'ensure that there are no more than 1 decimal places', + 'type': 'value_error.decimal.max_places', + 'ctx': { + 'decimal_places': 1, + }, + }, + ]), + (condecimal(max_digits=3, decimal_places=1), Decimal('999'), [ + { + 'loc': ('foo',), + 'msg': 'ensure that there are no more than 2 digits before the decimal point', + 'type': 'value_error.decimal.whole_digits', + 'ctx': { + 'whole_digits': 2, + }, + }, + ]), (condecimal(max_digits=4, decimal_places=1), Decimal('999'), Decimal('999')), (condecimal(max_digits=20, decimal_places=2), Decimal('742403889818000000'), Decimal('742403889818000000')), (condecimal(max_digits=20, decimal_places=2), Decimal('7.42403889818E+17'), Decimal('7.42403889818E+17')), - (condecimal(max_digits=20, decimal_places=2), Decimal('7424742403889818000000'), ValidationError), + (condecimal(max_digits=20, decimal_places=2), Decimal('7424742403889818000000'), [ + { + 'loc': ('foo',), + 'msg': 'ensure that there are no more than 20 digits in total', + 'type': 'value_error.decimal.max_digits', + 'ctx': { + 'max_digits': 20, + }, + }, + ]), (condecimal(max_digits=5, decimal_places=2), Decimal('7304E-1'), Decimal('7304E-1')), - (condecimal(max_digits=5, decimal_places=2), Decimal('7304E-3'), ValidationError), + (condecimal(max_digits=5, decimal_places=2), Decimal('7304E-3'), [ + { + 'loc': ('foo',), + 'msg': 'ensure that there are no more than 2 decimal places', + 'type': 'value_error.decimal.max_places', + 'ctx': { + 'decimal_places': 2, + }, + }, + ]), (condecimal(max_digits=5, decimal_places=5), Decimal('70E-5'), Decimal('70E-5')), - (condecimal(max_digits=5, decimal_places=5), Decimal('70E-6'), ValidationError), + (condecimal(max_digits=5, decimal_places=5), Decimal('70E-6'), [ + { + 'loc': ('foo',), + 'msg': 'ensure that there are no more than 5 digits in total', + 'type': 'value_error.decimal.max_digits', + 'ctx': { + 'max_digits': 5, + }, + }, + ]), *[ - (condecimal(decimal_places=2, max_digits=10), Decimal(value), ValidationError) + (condecimal(decimal_places=2, max_digits=10), value, [ + { + 'loc': ('foo',), + 'msg': 'value is not a valid decimal', + 'type': 'value_error.decimal.not_finite', + }, + ]) for value in ( - 'NaN', '-NaN', '+NaN', 'sNaN', '-sNaN', '+sNaN', - 'Inf', '-Inf', '+Inf', 'Infinity', '-Infinity', '-Infinity', + 'NaN', '-NaN', '+NaN', 'sNaN', '-sNaN', '+sNaN', 'Inf', '-Inf', '+Inf', + 'Infinity', '-Infinity', '-Infinity', + ) + ], + *[ + (condecimal(decimal_places=2, max_digits=10), Decimal(value), [ + { + 'loc': ('foo',), + 'msg': 'value is not a valid decimal', + 'type': 'value_error.decimal.not_finite', + }, + ]) + for value in ( + 'NaN', '-NaN', '+NaN', 'sNaN', '-sNaN', '+sNaN', 'Inf', '-Inf', '+Inf', + 'Infinity', '-Infinity', '-Infinity', ) ], ]) def test_decimal_validation(type_, value, result): model = create_model('DecimalModel', foo=(type_, ...)) - kwargs = {'foo': value} - if result == ValidationError: - with pytest.raises(ValidationError): - model(**kwargs) + if not isinstance(result, Decimal): + with pytest.raises(ValidationError) as exc_info: + model(foo=value) + assert exc_info.value.flatten_errors() == result else: - assert model(**kwargs).dict()['foo'] == result + assert model(foo=value).foo == result + + +@pytest.mark.parametrize('value,result', ( + ('/test/path', Path('/test/path')), + (Path('/test/path'), Path('/test/path')), + (None, [ + { + 'loc': ('foo',), + 'msg': 'value is not a valid path', + 'type': 'type_error.path', + }, + ]), +)) +def test_path_validation(value, result): + class Model(BaseModel): + foo: Path + + if not isinstance(result, Path): + with pytest.raises(ValidationError) as exc_info: + Model(foo=value) + assert exc_info.value.flatten_errors() == result + else: + assert Model(foo=value).foo == result diff --git a/tests/test_validators.py b/tests/test_validators.py index 3dd36ea..f7abef6 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -2,7 +2,7 @@ from typing import List import pytest -from pydantic import BaseModel, ConfigError, ValidationError, validator +from pydantic import BaseModel, ValidationError, errors, validator def test_simple(): @@ -140,8 +140,8 @@ def test_validating_assignment_dict(): assert exc_info.value.flatten_errors() == [ { 'loc': ('a',), - 'msg': 'invalid literal for int() with base 10: \'x\'', - 'type': 'value_error', + 'msg': 'value is not a valid integer', + 'type': 'type_error.integer', }, ] @@ -191,7 +191,7 @@ def test_classmethod(): def test_duplicates(): - with pytest.raises(ConfigError) as exc_info: + with pytest.raises(errors.ConfigError) as exc_info: class Model(BaseModel): a: str b: str @@ -208,7 +208,7 @@ def test_duplicates(): def test_use_bare(): - with pytest.raises(ConfigError) as exc_info: + with pytest.raises(errors.ConfigError) as exc_info: class Model(BaseModel): a: str @@ -219,7 +219,7 @@ def test_use_bare(): def test_use_no_fields(): - with pytest.raises(ConfigError) as exc_info: + with pytest.raises(errors.ConfigError) as exc_info: class Model(BaseModel): a: str @@ -320,7 +320,7 @@ def test_wildcard_validator_error(): def test_invalid_field(): - with pytest.raises(ConfigError) as exc_info: + with pytest.raises(errors.ConfigError) as exc_info: class Model(BaseModel): a: str