diff --git a/.gitignore b/.gitignore index 262563382b..e0c46182de 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ dist/ .mypy_cache/ test.py .coverage +.hypothesis /htmlcov/ /benchmarks/*.json /docs/.changelog.md diff --git a/changes/2097-Zac-HD.md b/changes/2097-Zac-HD.md new file mode 100644 index 0000000000..8a985245d3 --- /dev/null +++ b/changes/2097-Zac-HD.md @@ -0,0 +1 @@ +Add a [Hypothesis](https://hypothesis.readthedocs.io/) plugin for easier [property-based testing](https://increment.com/testing/in-praise-of-property-based-testing/) with Pydantic's custom types - [usage details here](https://pydantic-docs.helpmanual.io/hypothesis_plugin/) diff --git a/docs/examples/hypothesis_property_based_test.py b/docs/examples/hypothesis_property_based_test.py new file mode 100644 index 0000000000..00eead5bc9 --- /dev/null +++ b/docs/examples/hypothesis_property_based_test.py @@ -0,0 +1,24 @@ +import typing +from hypothesis import given, strategies as st +from pydantic import BaseModel, EmailStr, PaymentCardNumber, PositiveFloat + + +class Model(BaseModel): + card: PaymentCardNumber + price: PositiveFloat + users: typing.List[EmailStr] + + +@given(st.builds(Model)) +def test_property(instance): + # Hypothesis calls this test function many times with varied Models, + # so you can write a test that should pass given *any* instance. + assert 0 < instance.price + assert all('@' in email for email in instance.users) + + +@given(st.builds(Model, price=st.floats(100, 200))) +def test_with_discount(instance): + # This test shows how you can override specific fields, + # and let Hypothesis fill in any you don't care about. + assert 100 <= instance.price <= 200 diff --git a/docs/hypothesis_plugin.md b/docs/hypothesis_plugin.md new file mode 100644 index 0000000000..90013bda8d --- /dev/null +++ b/docs/hypothesis_plugin.md @@ -0,0 +1,28 @@ +[Hypothesis](https://hypothesis.readthedocs.io/) is the Python library for +[property-based testing](https://increment.com/testing/in-praise-of-property-based-testing/). +Hypothesis can infer how to construct type-annotated classes, and supports builtin types, +many standard library types, and generic types from the +[`typing`](https://docs.python.org/3/library/typing.html) and +[`typing_extensions`](https://pypi.org/project/typing-extensions/) modules by default. + +From Pydantic v1.8 and [Hypothesis v5.29.0](https://hypothesis.readthedocs.io/en/latest/changes.html#v5-29-0), +Hypothesis will automatically load support for [custom types](usage/types.md) like +`PaymentCardNumber` and `PositiveFloat`, so that the +[`st.builds()`](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.builds) +and [`st.from_type()`](https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.from_type) +strategies support them without any user configuration. + + +### Example tests +```py +{!.tmp_examples/hypothesis_property_based_test.py!} +``` +_(This script is complete, it should run "as is")_ + + +### Use with JSON Schemas +To test client-side code, you can use [`Model.schema()`](usage/models.md) with the +[`hypothesis-jsonschema` package](https://pypi.org/project/hypothesis-jsonschema/) +to generate arbitrary JSON instances matching the schema. +For web API testing, [Schemathesis](https://schemathesis.readthedocs.io) provides +a higher-level wrapper and can detect both errors and security vulnerabilities. diff --git a/docs/requirements.txt b/docs/requirements.txt index 0b6e5dc2a4..87e939b08a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,7 @@ ansi2html==1.6.0 flake8==3.8.4 flake8-quotes==3.2.0 +hypothesis==5.44.0 mkdocs==1.1.2 mkdocs-exclude==1.0.2 mkdocs-material==6.2.8 diff --git a/mkdocs.yml b/mkdocs.yml index 6db1114da3..13d2ef3e1b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - benchmarks.md - 'Mypy plugin': mypy_plugin.md - 'PyCharm plugin': pycharm_plugin.md +- 'Hypothesis plugin': hypothesis_plugin.md - 'Code Generation': datamodel_code_generator.md - changelog.md diff --git a/pydantic/_hypothesis_plugin.py b/pydantic/_hypothesis_plugin.py new file mode 100644 index 0000000000..6de9a579b8 --- /dev/null +++ b/pydantic/_hypothesis_plugin.py @@ -0,0 +1,349 @@ +""" +Register Hypothesis strategies for Pydantic custom types. + +This enables fully-automatic generation of test data for most Pydantic classes. + +Note that this module has *no* runtime impact on Pydantic itself; instead it +is registered as a setuptools entry point and Hypothesis will import it if +Pydantic is installed. See also: + +https://hypothesis.readthedocs.io/en/latest/strategies.html#registering-strategies-via-setuptools-entry-points +https://hypothesis.readthedocs.io/en/latest/data.html#hypothesis.strategies.register_type_strategy +https://hypothesis.readthedocs.io/en/latest/strategies.html#interaction-with-pytest-cov +https://pydantic-docs.helpmanual.io/usage/types/#pydantic-types + +Note that because our motivation is to *improve user experience*, the strategies +are always sound (never generate invalid data) but sacrifice completeness for +maintainability (ie may be unable to generate some tricky but valid data). + +Finally, this module makes liberal use of `# type: ignore[]` pragmas. +This is because Hypothesis annotates `register_type_strategy()` with +`(T, SearchStrategy[T])`, but in most cases we register e.g. `ConstrainedInt` +to generate instances of the builtin `int` type which match the constraints. +""" + +import contextlib +import ipaddress +import json +import math +from fractions import Fraction +from typing import Callable, Dict, Type, Union, cast, overload + +import hypothesis.strategies as st + +import pydantic +import pydantic.color +import pydantic.types + +# FilePath and DirectoryPath are explicitly unsupported, as we'd have to create +# them on-disk, and that's unsafe in general without being told *where* to do so. +# +# URLs are unsupported because it's easy for users to define their own strategy for +# "normal" URLs, and hard for us to define a general strategy which includes "weird" +# URLs but doesn't also have unpredictable performance problems. +# +# conlist() and conset() are unsupported for now, because the workarounds for +# Cython and Hypothesis to handle parametrized generic types are incompatible. +# Once Cython can support 'normal' generics we'll revisit this. + +# Emails +try: + import email_validator +except ImportError: # pragma: no cover + pass +else: + + def is_valid_email(s: str) -> bool: + # Hypothesis' st.emails() occasionally generates emails like 0@A0--0.ac + # that are invalid according to email-validator, so we filter those out. + try: + email_validator.validate_email(s, check_deliverability=False) + return True + except email_validator.EmailNotValidError: + return False + + # Note that these strategies deliberately stay away from any tricky Unicode + # or other encoding issues; we're just trying to generate *something* valid. + st.register_type_strategy(pydantic.EmailStr, st.emails().filter(is_valid_email)) # type: ignore[arg-type] + st.register_type_strategy( + pydantic.NameEmail, + st.builds( + '{} <{}>'.format, # type: ignore[arg-type] + st.from_regex('[A-Za-z0-9_]+( [A-Za-z0-9_]+){0,5}', fullmatch=True), + st.emails().filter(is_valid_email), + ), + ) + +# PyObject - dotted names, in this case taken from the math module. +st.register_type_strategy( + pydantic.PyObject, + st.sampled_from( + [cast(pydantic.PyObject, f'math.{name}') for name in sorted(vars(math)) if not name.startswith('_')] + ), +) + +# CSS3 Colors; as name, hex, rgb(a) tuples or strings, or hsl strings +_color_regexes = ( + '|'.join( + ( + pydantic.color.r_hex_short, + pydantic.color.r_hex_long, + pydantic.color.r_rgb, + pydantic.color.r_rgba, + pydantic.color.r_hsl, + pydantic.color.r_hsla, + ) + ) + # Use more precise regex patterns to avoid value-out-of-range errors + .replace(pydantic.color._r_sl, r'(?:(\d\d?(?:\.\d+)?|100(?:\.0+)?)%)') + .replace(pydantic.color._r_alpha, r'(?:(0(?:\.\d+)?|1(?:\.0+)?|\.\d+|\d{1,2}%))') + .replace(pydantic.color._r_255, r'(?:((?:\d|\d\d|[01]\d\d|2[0-4]\d|25[0-4])(?:\.\d+)?|255(?:\.0+)?))') +) +st.register_type_strategy( + pydantic.color.Color, + st.one_of( + st.sampled_from(sorted(pydantic.color.COLORS_BY_NAME)), + st.tuples( + st.integers(0, 255), + st.integers(0, 255), + st.integers(0, 255), + st.none() | st.floats(0, 1) | st.floats(0, 100).map('{}%'.format), + ), + st.from_regex(_color_regexes, fullmatch=True), + ), +) + + +# Card numbers, valid according to the Luhn algorithm + + +def add_luhn_digit(card_number: str) -> str: + # See https://en.wikipedia.org/wiki/Luhn_algorithm + for digit in '0123456789': + with contextlib.suppress(Exception): + pydantic.PaymentCardNumber.validate_luhn_check_digit(card_number + digit) + return card_number + digit + raise AssertionError('Unreachable') # pragma: no cover + + +card_patterns = ( + # Note that these patterns omit the Luhn check digit; that's added by the function above + '4[0-9]{14}', # Visa + '5[12345][0-9]{13}', # Mastercard + '3[47][0-9]{12}', # American Express + '[0-26-9][0-9]{10,17}', # other (incomplete to avoid overlap) +) +st.register_type_strategy( + pydantic.PaymentCardNumber, + st.from_regex('|'.join(card_patterns), fullmatch=True).map(add_luhn_digit), # type: ignore[arg-type] +) + +# UUIDs +st.register_type_strategy(pydantic.UUID1, st.uuids(version=1)) # type: ignore[arg-type] +st.register_type_strategy(pydantic.UUID3, st.uuids(version=3)) # type: ignore[arg-type] +st.register_type_strategy(pydantic.UUID4, st.uuids(version=4)) # type: ignore[arg-type] +st.register_type_strategy(pydantic.UUID5, st.uuids(version=5)) # type: ignore[arg-type] + +# Secrets +st.register_type_strategy(pydantic.SecretBytes, st.binary().map(pydantic.SecretBytes)) +st.register_type_strategy(pydantic.SecretStr, st.text().map(pydantic.SecretStr)) + +# IP addresses, networks, and interfaces +st.register_type_strategy(pydantic.IPvAnyAddress, st.ip_addresses()) +st.register_type_strategy( + pydantic.IPvAnyInterface, + st.from_type(ipaddress.IPv4Interface) | st.from_type(ipaddress.IPv6Interface), +) +st.register_type_strategy( + pydantic.IPvAnyNetwork, + st.from_type(ipaddress.IPv4Network) | st.from_type(ipaddress.IPv6Network), +) + +# We hook into the con***() functions and the ConstrainedNumberMeta metaclass, +# so here we only have to register subclasses for other constrained types which +# don't go via those mechanisms. Then there are the registration hooks below. +st.register_type_strategy(pydantic.StrictBool, st.booleans()) +st.register_type_strategy(pydantic.StrictStr, st.text()) # type: ignore[arg-type] + + +# Constrained-type resolver functions +# +# For these ones, we actually want to inspect the type in order to work out a +# satisfying strategy. First up, the machinery for tracking resolver functions: + +RESOLVERS: Dict[type, Callable[[type], st.SearchStrategy]] = {} # type: ignore[type-arg] + + +@overload +def _registered(typ: Type[pydantic.types.T]) -> Type[pydantic.types.T]: + pass + + +@overload +def _registered(typ: pydantic.types.ConstrainedNumberMeta) -> pydantic.types.ConstrainedNumberMeta: + pass + + +def _registered( + typ: Union[Type[pydantic.types.T], pydantic.types.ConstrainedNumberMeta] +) -> Union[Type[pydantic.types.T], pydantic.types.ConstrainedNumberMeta]: + # This function replaces the version in `pydantic.types`, in order to + # effect the registration of new constrained types so that Hypothesis + # can generate valid examples. + pydantic.types._DEFINED_TYPES.add(typ) + for supertype, resolver in RESOLVERS.items(): + if issubclass(typ, supertype): + st.register_type_strategy(typ, resolver(typ)) # type: ignore + return typ + raise NotImplementedError(f'Unknown type {typ!r} has no resolver to register') # pragma: no cover + + +def resolves( + typ: Union[type, pydantic.types.ConstrainedNumberMeta] +) -> Callable[[Callable[..., st.SearchStrategy]], Callable[..., st.SearchStrategy]]: # type: ignore[type-arg] + def inner(f): # type: ignore + assert f not in RESOLVERS + RESOLVERS[typ] = f + return f + + return inner + + +# Type-to-strategy resolver functions + + +@resolves(pydantic.Json) +@resolves(pydantic.JsonWrapper) +def resolve_json(cls): # type: ignore[no-untyped-def] + try: + inner = st.none() if cls.inner_type is None else st.from_type(cls.inner_type) + except Exception: + finite = st.floats(allow_infinity=False, allow_nan=False) + inner = st.recursive( + base=st.one_of(st.none(), st.booleans(), st.integers(), finite, st.text()), + extend=lambda x: st.lists(x) | st.dictionaries(st.text(), x), + ) + return st.builds( + json.dumps, + inner, + ensure_ascii=st.booleans(), + indent=st.none() | st.integers(0, 16), + sort_keys=st.booleans(), + ) + + +@resolves(pydantic.ConstrainedBytes) +def resolve_conbytes(cls): # type: ignore[no-untyped-def] # pragma: no cover + min_size = cls.min_length or 0 + max_size = cls.max_length + if not cls.strip_whitespace: + return st.binary(min_size=min_size, max_size=max_size) + # Fun with regex to ensure we neither start nor end with whitespace + repeats = '{{{},{}}}'.format( + min_size - 2 if min_size > 2 else 0, + max_size - 2 if (max_size or 0) > 2 else '', + ) + if min_size >= 2: + pattern = rf'\W.{repeats}\W' + elif min_size == 1: + pattern = rf'\W(.{repeats}\W)?' + else: + assert min_size == 0 + pattern = rf'(\W(.{repeats}\W)?)?' + return st.from_regex(pattern.encode(), fullmatch=True) + + +@resolves(pydantic.ConstrainedDecimal) +def resolve_condecimal(cls): # type: ignore[no-untyped-def] + min_value = cls.ge + max_value = cls.le + if cls.gt is not None: + assert min_value is None, 'Set `gt` or `ge`, but not both' + min_value = cls.gt + if cls.lt is not None: + assert max_value is None, 'Set `lt` or `le`, but not both' + max_value = cls.lt + s = st.decimals(min_value, max_value, allow_nan=False) + if cls.lt is not None: + s = s.filter(lambda d: d < cls.lt) + if cls.gt is not None: + s = s.filter(lambda d: cls.gt < d) + return s + + +@resolves(pydantic.ConstrainedFloat) +def resolve_confloat(cls): # type: ignore[no-untyped-def] + min_value = cls.ge + max_value = cls.le + exclude_min = False + exclude_max = False + if cls.gt is not None: + assert min_value is None, 'Set `gt` or `ge`, but not both' + min_value = cls.gt + exclude_min = True + if cls.lt is not None: + assert max_value is None, 'Set `lt` or `le`, but not both' + max_value = cls.lt + exclude_max = True + return st.floats(min_value, max_value, exclude_min=exclude_min, exclude_max=exclude_max, allow_nan=False) + + +@resolves(pydantic.ConstrainedInt) +def resolve_conint(cls): # type: ignore[no-untyped-def] + min_value = cls.ge + max_value = cls.le + if cls.gt is not None: + assert min_value is None, 'Set `gt` or `ge`, but not both' + min_value = cls.gt + 1 + if cls.lt is not None: + assert max_value is None, 'Set `lt` or `le`, but not both' + max_value = cls.lt - 1 + + if cls.multiple_of is None or cls.multiple_of == 1: + return st.integers(min_value, max_value) + + # These adjustments and the .map handle integer-valued multiples, while the + # .filter handles trickier cases as for confloat. + if min_value is not None: + min_value = math.ceil(Fraction(min_value) / Fraction(cls.multiple_of)) + if max_value is not None: + max_value = math.floor(Fraction(max_value) / Fraction(cls.multiple_of)) + return st.integers(min_value, max_value).map(lambda x: x * cls.multiple_of) + + +@resolves(pydantic.ConstrainedStr) +def resolve_constr(cls): # type: ignore[no-untyped-def] # pragma: no cover + min_size = cls.min_length or 0 + max_size = cls.max_length + + if cls.regex is None and not cls.strip_whitespace: + return st.text(min_size=min_size, max_size=max_size) + + if cls.regex is not None: + strategy = st.from_regex(cls.regex) + if cls.strip_whitespace: + strategy = strategy.filter(lambda s: s == s.strip()) + elif cls.strip_whitespace: + repeats = '{{{},{}}}'.format( + min_size - 2 if min_size > 2 else 0, + max_size - 2 if (max_size or 0) > 2 else '', + ) + if min_size >= 2: + strategy = st.from_regex(rf'\W.{repeats}\W') + elif min_size == 1: + strategy = st.from_regex(rf'\W(.{repeats}\W)?') + else: + assert min_size == 0 + strategy = st.from_regex(rf'(\W(.{repeats}\W)?)?') + + if min_size == 0 and max_size is None: + return strategy + elif max_size is None: + return strategy.filter(lambda s: min_size <= len(s)) + return strategy.filter(lambda s: min_size <= len(s) <= max_size) + + +# Finally, register all previously-defined types, and patch in our new function +for typ in pydantic.types._DEFINED_TYPES: + _registered(typ) +pydantic.types._registered = _registered diff --git a/pydantic/types.py b/pydantic/types.py index b54182c7d5..b437fe1679 100644 --- a/pydantic/types.py +++ b/pydantic/types.py @@ -20,8 +20,10 @@ TypeVar, Union, cast, + overload, ) from uuid import UUID +from weakref import WeakSet from . import errors from .utils import import_string, update_not_none @@ -109,6 +111,30 @@ ModelOrDc = Type[Union['BaseModel', 'Dataclass']] +T = TypeVar('T') +_DEFINED_TYPES: 'WeakSet[type]' = WeakSet() + + +@overload +def _registered(typ: Type[T]) -> Type[T]: + pass + + +@overload +def _registered(typ: 'ConstrainedNumberMeta') -> 'ConstrainedNumberMeta': + pass + + +def _registered(typ: Union[Type[T], 'ConstrainedNumberMeta']) -> Union[Type[T], 'ConstrainedNumberMeta']: + # In order to generate valid examples of constrained types, Hypothesis needs + # to inspect the type object - so we keep a weakref to each contype object + # until it can be registered. When (or if) our Hypothesis plugin is loaded, + # it monkeypatches this function. + # If Hypothesis is never used, the total effect is to keep a weak reference + # which has minimal memory usage and doesn't even affect garbage collection. + _DEFINED_TYPES.add(typ) + return typ + class ConstrainedBytes(bytes): strip_whitespace = False @@ -134,10 +160,7 @@ class StrictBytes(ConstrainedBytes): def conbytes(*, strip_whitespace: bool = False, min_length: int = None, max_length: int = None) -> Type[bytes]: # use kwargs then define conf in a dict to aid with IDE type hinting namespace = dict(strip_whitespace=strip_whitespace, min_length=min_length, max_length=max_length) - return type('ConstrainedBytesValue', (ConstrainedBytes,), namespace) - - -T = TypeVar('T') + return _registered(type('ConstrainedBytesValue', (ConstrainedBytes,), namespace)) # This types superclass should be List[T], but cython chokes on that... @@ -272,7 +295,7 @@ def constr( curtail_length=curtail_length, regex=regex and re.compile(regex), ) - return type('ConstrainedStrValue', (ConstrainedStr,), namespace) + return _registered(type('ConstrainedStrValue', (ConstrainedStr,), namespace)) class StrictStr(ConstrainedStr): @@ -344,7 +367,7 @@ def __new__(cls, name: str, bases: Any, dct: Dict[str, Any]) -> 'ConstrainedInt' if new_cls.lt is not None and new_cls.le is not None: raise errors.ConfigError('bounds lt and le cannot be specified at the same time') - return new_cls + return _registered(new_cls) # type: ignore class ConstrainedInt(int, metaclass=ConstrainedNumberMeta): @@ -616,7 +639,7 @@ class JsonWrapper: class JsonMeta(type): def __getitem__(self, t: Type[Any]) -> Type[JsonWrapper]: - return type('JsonWrapperValue', (JsonWrapper,), {'inner_type': t}) + return _registered(type('JsonWrapperValue', (JsonWrapper,), {'inner_type': t})) class Json(metaclass=JsonMeta): @@ -726,6 +749,8 @@ def get_secret_value(self) -> bytes: class PaymentCardBrand(str, Enum): + # If you add another card type, please also add it to the + # Hypothesis strategy in `pydantic._hypothesis_plugin`. amex = 'American Express' mastercard = 'Mastercard' visa = 'Visa' diff --git a/setup.cfg b/setup.cfg index 37311f84fc..0154d92e9e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,6 @@ [tool:pytest] testpaths = tests +addopts = -p no:hypothesispytest filterwarnings = error ignore::DeprecationWarning:distutils diff --git a/setup.py b/setup.py index 3171cb77cb..bbed2bec9c 100644 --- a/setup.py +++ b/setup.py @@ -114,6 +114,7 @@ def extra(self): 'Operating System :: POSIX :: Linux', 'Environment :: Console', 'Environment :: MacOS X', + 'Framework :: Hypothesis', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: Internet', ], @@ -134,4 +135,5 @@ def extra(self): 'dotenv': ['python-dotenv>=0.10.4'], }, ext_modules=ext_modules, + entry_points={'hypothesis': ['_ = pydantic._hypothesis_plugin']}, ) diff --git a/tests/conftest.py b/tests/conftest.py index 0d1c6c434d..c562a8d9d7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,9 @@ import pytest from _pytest.assertion.rewrite import AssertionRewritingHook +# See https://hypothesis.readthedocs.io/en/latest/strategies.html#interaction-with-pytest-cov +pytest_plugins = ['hypothesis.extra.pytestplugin'] + def _extract_source_code_from_function(function): if function.__code__.co_argcount: diff --git a/tests/requirements-linting.txt b/tests/requirements-linting.txt index 1a2f8b7519..d8c20ee925 100644 --- a/tests/requirements-linting.txt +++ b/tests/requirements-linting.txt @@ -1,6 +1,7 @@ black==20.8b1 flake8==3.8.4 flake8-quotes==3.2.0 +hypothesis==5.44.0 isort==5.7.0 mypy==0.800 pycodestyle==2.6.0 diff --git a/tests/requirements-testing.txt b/tests/requirements-testing.txt index ec4398d21b..cfd2dd20d1 100644 --- a/tests/requirements-testing.txt +++ b/tests/requirements-testing.txt @@ -1,4 +1,5 @@ coverage==5.4 +hypothesis==5.44.0 # pin importlib-metadata as upper versions need typing-extensions to work if on python < 3.8 importlib-metadata==3.1.0;python_version<"3.8" mypy==0.800 diff --git a/tests/test_hypothesis_plugin.py b/tests/test_hypothesis_plugin.py new file mode 100644 index 0000000000..ed5257077d --- /dev/null +++ b/tests/test_hypothesis_plugin.py @@ -0,0 +1,108 @@ +import typing + +import pytest +from hypothesis import given, strategies as st + +import pydantic +from pydantic.networks import import_email_validator + + +def gen_models(): + class MiscModel(pydantic.BaseModel): + # Each of these models contains a few related fields; the idea is that + # if there's a bug we have neither too many fields to dig through nor + # too many models to read. + obj: pydantic.PyObject + color: pydantic.color.Color + json_any: pydantic.Json + + class StringsModel(pydantic.BaseModel): + card: pydantic.PaymentCardNumber + secbytes: pydantic.SecretBytes + secstr: pydantic.SecretStr + + class UUIDsModel(pydantic.BaseModel): + uuid1: pydantic.UUID1 + uuid3: pydantic.UUID3 + uuid4: pydantic.UUID4 + uuid5: pydantic.UUID5 + + class IPvAnyAddress(pydantic.BaseModel): + address: pydantic.IPvAnyAddress + + class IPvAnyInterface(pydantic.BaseModel): + interface: pydantic.IPvAnyInterface + + class IPvAnyNetwork(pydantic.BaseModel): + network: pydantic.IPvAnyNetwork + + class StrictNumbersModel(pydantic.BaseModel): + strictbool: pydantic.StrictBool + strictint: pydantic.StrictInt + strictfloat: pydantic.StrictFloat + strictstr: pydantic.StrictStr + + class NumbersModel(pydantic.BaseModel): + posint: pydantic.PositiveInt + negint: pydantic.NegativeInt + posfloat: pydantic.PositiveFloat + negfloat: pydantic.NegativeFloat + nonposint: pydantic.NonPositiveInt + nonnegint: pydantic.NonNegativeInt + nonposfloat: pydantic.NonPositiveFloat + nonnegfloat: pydantic.NonNegativeFloat + + class JsonModel(pydantic.BaseModel): + json_any: pydantic.Json + json_int: pydantic.Json[int] + json_float: pydantic.Json[float] + json_str: pydantic.Json[str] + json_int_or_str: pydantic.Json[typing.Union[int, str]] + json_list_of_float: pydantic.Json[typing.List[float]] + + class ConstrainedNumbersModel(pydantic.BaseModel): + conintt: pydantic.conint(gt=10, lt=100) + coninte: pydantic.conint(ge=10, le=100) + conintmul: pydantic.conint(ge=10, le=100, multiple_of=7) + confloatt: pydantic.confloat(gt=10, lt=100) + confloate: pydantic.confloat(ge=10, le=100) + condecimalt: pydantic.condecimal(gt=10, lt=100) + condecimale: pydantic.condecimal(ge=10, le=100) + + yield from ( + MiscModel, + StringsModel, + UUIDsModel, + IPvAnyAddress, + IPvAnyInterface, + IPvAnyNetwork, + StrictNumbersModel, + NumbersModel, + JsonModel, + ConstrainedNumbersModel, + ) + + try: + import_email_validator() + except ImportError: + pass + else: + + class EmailsModel(pydantic.BaseModel): + email: pydantic.EmailStr + name_email: pydantic.NameEmail + + yield EmailsModel + + +@pytest.mark.parametrize('model', gen_models()) +@given(data=st.data()) +def test_can_construct_models_with_all_fields(data, model): + # The value of this test is to confirm that Hypothesis knows how to provide + # valid values for each field - otherwise, this would raise ValidationError. + instance = data.draw(st.from_type(model)) + + # We additionally check that the instance really is of type `model`, because + # an evil implementation could avoid ValidationError by means of e.g. + # `st.register_type_strategy(model, st.none())`, skipping the constructor. + assert isinstance(instance, model)