mirror of
https://github.com/kennethreitz/pydantic.git
synced 2026-06-05 23:00:18 +00:00
Add a Hypothesis plugin (#2097)
* Configure Hypothesis * Hypothesis plugin docs * Add Hypothesis plugin Co-authored-by: Samuel Colvin <s@muelcolvin.com>
This commit is contained in:
committed by
GitHub
parent
d0baf0f156
commit
771b0d3d92
@@ -16,6 +16,7 @@ dist/
|
||||
.mypy_cache/
|
||||
test.py
|
||||
.coverage
|
||||
.hypothesis
|
||||
/htmlcov/
|
||||
/benchmarks/*.json
|
||||
/docs/.changelog.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/)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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[<code>]` 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
|
||||
+32
-7
@@ -20,8 +20,10 @@ from typing import (
|
||||
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 @@ if TYPE_CHECKING:
|
||||
|
||||
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 @@ class ConstrainedNumberMeta(type):
|
||||
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 @@ class SecretBytes:
|
||||
|
||||
|
||||
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'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[tool:pytest]
|
||||
testpaths = tests
|
||||
addopts = -p no:hypothesispytest
|
||||
filterwarnings =
|
||||
error
|
||||
ignore::DeprecationWarning:distutils
|
||||
|
||||
@@ -114,6 +114,7 @@ setup(
|
||||
'Operating System :: POSIX :: Linux',
|
||||
'Environment :: Console',
|
||||
'Environment :: MacOS X',
|
||||
'Framework :: Hypothesis',
|
||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||
'Topic :: Internet',
|
||||
],
|
||||
@@ -134,4 +135,5 @@ setup(
|
||||
'dotenv': ['python-dotenv>=0.10.4'],
|
||||
},
|
||||
ext_modules=ext_modules,
|
||||
entry_points={'hypothesis': ['_ = pydantic._hypothesis_plugin']},
|
||||
)
|
||||
|
||||
@@ -9,6 +9,9 @@ from types import FunctionType
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user