From f41e3afaa0c0ac8a88226ca65d95a9f45ea4c4d0 Mon Sep 17 00:00:00 2001 From: "Vitaly R. Samigullin" Date: Fri, 15 Mar 2019 13:18:06 +0300 Subject: [PATCH] ipaddress.IPAddress support (#417) * ipaddress-compatible types added, fix #333 * Unittests for ipaddress-types added * Docs updated after ipaddress-types added * HISTORY.rst updated to reflect ipaddress-related types introduction * Fix docs table format * Strings double quotes reverted * ipaddress types support fixed, IPvAnyAddress type redefined * Error handling fixed for ipaddress-related types * Positive cases for IPv4Address and IPv6Address types in unittests added --- HISTORY.rst | 1 + docs/examples/exotic.py | 14 ++- docs/schema_mapping.py | 7 ++ pydantic/errors.py | 12 +++ pydantic/types.py | 21 +++- pydantic/validators.py | 19 ++++ tests/test_types_ipaddress.py | 184 ++++++++++++++++++++++++++++++++++ 7 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 tests/test_types_ipaddress.py diff --git a/HISTORY.rst b/HISTORY.rst index 93aeacb..11389f9 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -6,6 +6,7 @@ History v0.20.2 (unreleased) .................... * fix typo in `NoneIsNotAllowedError` message, #414 by @YaraslauZhylko +* add ``IPAddress``, ``IPv4Address`` and ``IPv6Address`` types, #333 by @pilosus v0.20.1 (2019-02-26) .................... diff --git a/docs/examples/exotic.py b/docs/examples/exotic.py index 6700326..08f5bbb 100644 --- a/docs/examples/exotic.py +++ b/docs/examples/exotic.py @@ -1,11 +1,12 @@ import uuid from decimal import Decimal +from ipaddress import IPv4Address, IPv6Address from pathlib import Path from uuid import UUID from pydantic import (DSN, UUID1, UUID3, UUID4, UUID5, BaseModel, DirectoryPath, EmailStr, FilePath, NameEmail, NegativeFloat, NegativeInt, PositiveFloat, PositiveInt, PyObject, UrlStr, conbytes, condecimal, - confloat, conint, constr) + confloat, conint, constr, IPvAnyAddress) class Model(BaseModel): @@ -56,6 +57,9 @@ class Model(BaseModel): uuid_v3: UUID3 = None uuid_v4: UUID4 = None uuid_v5: UUID5 = None + ipvany: IPvAnyAddress = None + ipv4: IPv4Address = None + ipv6: IPv6Address = None m = Model( cos_function='math.cos', @@ -88,7 +92,10 @@ m = Model( uuid_v1=uuid.uuid1(), uuid_v3=uuid.uuid3(uuid.NAMESPACE_DNS, 'python.org'), uuid_v4=uuid.uuid4(), - uuid_v5=uuid.uuid5(uuid.NAMESPACE_DNS, 'python.org') + uuid_v5=uuid.uuid5(uuid.NAMESPACE_DNS, 'python.org'), + ipvany=IPv4Address('192.168.0.1'), + ipv4=IPv4Address('255.255.255.255'), + ipv6=IPv6Address('ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff') ) print(m.dict()) """ @@ -126,5 +133,8 @@ print(m.dict()) 'uuid_v3': UUID('6fa459ea-ee8a-3ca4-894e-db77e160355e'), 'uuid_v4': UUID('22209f7a-aad1-491c-bb83-ea19b906d210'), 'uuid_v5': UUID('886313e1-3b8a-5372-9b90-0c9aee199e5d'), + 'ip'='192.168.0.1', + 'ipv4'='255.255.255.255', + 'ipv6'='ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', } """ diff --git a/docs/schema_mapping.py b/docs/schema_mapping.py index 088bfe4..ad42438 100755 --- a/docs/schema_mapping.py +++ b/docs/schema_mapping.py @@ -240,6 +240,13 @@ table = [ 'Pydantic standard "format" extension', '' ], + [ + 'IPvAnyAddress', + 'string', + '{"format": "ipvanyaddress"}', + 'Pydantic standard "format" extension', + 'IPv4 or IPv4 address as used in ``ipaddress`` module', + ], [ 'StrictStr', 'string', diff --git a/pydantic/errors.py b/pydantic/errors.py index 48d7307..68eba2f 100644 --- a/pydantic/errors.py +++ b/pydantic/errors.py @@ -288,3 +288,15 @@ class DataclassTypeError(PydanticTypeError): class CallableError(PydanticTypeError): msg_template = '{value} is not callable' + + +class IPvAnyAddressError(PydanticValueError): + msg_template = 'value is not a valid IPv4 or IPv6 address' + + +class IPv4AddressError(PydanticValueError): + msg_template = 'value is not a valid IPv4 address' + + +class IPv6AddressError(PydanticValueError): + msg_template = 'value is not a valid IPv6 address' diff --git a/pydantic/types.py b/pydantic/types.py index 6e1b9c6..2a644db 100644 --- a/pydantic/types.py +++ b/pydantic/types.py @@ -1,12 +1,13 @@ import json import re from decimal import Decimal +from ipaddress import IPv4Address, IPv6Address, _BaseAddress from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, Generator, Optional, Pattern, Set, Type, Union, cast from uuid import UUID from . import errors -from .utils import AnyType, import_string, make_dsn, url_regex_generator, validate_email +from .utils import AnyType, change_exception, import_string, make_dsn, url_regex_generator, validate_email from .validators import ( anystr_length_validator, anystr_strip_whitespace, @@ -61,6 +62,7 @@ __all__ = [ 'DirectoryPath', 'Json', 'JsonWrapper', + 'IPvAnyAddress', ] NoneStr = Optional[str] @@ -71,6 +73,7 @@ OptionalInt = Optional[int] OptionalIntFloat = Union[OptionalInt, float] OptionalIntFloatDecimal = Union[OptionalIntFloat, Decimal] + if TYPE_CHECKING: # pragma: no cover from .utils import AnyCallable @@ -497,3 +500,19 @@ class Json(metaclass=JsonMeta): raise errors.JsonError() except TypeError: raise errors.JsonTypeError() + + +class IPvAnyAddress(_BaseAddress): + @classmethod + def __get_validators__(cls) -> 'CallableGenerator': + yield cls.validate + + @classmethod + def validate(cls, value: Union[str, bytes, int]) -> Union[IPv4Address, IPv6Address]: + try: + return IPv4Address(value) + except ValueError: + pass + + with change_exception(errors.IPvAnyAddressError, ValueError): + return IPv6Address(value) diff --git a/pydantic/validators.py b/pydantic/validators.py index 7cef624..3a28c6a 100644 --- a/pydantic/validators.py +++ b/pydantic/validators.py @@ -3,6 +3,7 @@ from collections import OrderedDict from datetime import date, datetime, time, timedelta from decimal import Decimal, DecimalException from enum import Enum +from ipaddress import IPv4Address, IPv6Address from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Pattern, Set, Tuple, Type, TypeVar, Union, cast from uuid import UUID @@ -218,6 +219,22 @@ def decimal_validator(v: Any) -> Decimal: return v +def ip_v4_address_validator(v: Any) -> IPv4Address: + if isinstance(v, IPv4Address): + return v + + with change_exception(errors.IPv4AddressError, ValueError): + return IPv4Address(v) + + +def ip_v6_address_validator(v: Any) -> IPv6Address: + if isinstance(v, IPv6Address): + return v + + with change_exception(errors.IPv6AddressError, ValueError): + return IPv6Address(v) + + def path_validator(v: Any) -> Path: if isinstance(v, Path): return v @@ -284,6 +301,8 @@ _VALIDATORS: List[Tuple[AnyType, List[AnyCallable]]] = [ (set, [set_validator]), (UUID, [not_none_validator, uuid_validator]), (Decimal, [not_none_validator, decimal_validator]), + (IPv4Address, [not_none_validator, ip_v4_address_validator]), + (IPv6Address, [not_none_validator, ip_v6_address_validator]), ] diff --git a/tests/test_types_ipaddress.py b/tests/test_types_ipaddress.py new file mode 100644 index 0000000..e101694 --- /dev/null +++ b/tests/test_types_ipaddress.py @@ -0,0 +1,184 @@ +from ipaddress import IPv4Address, IPv6Address + +import pytest + +from pydantic import BaseModel, IPvAnyAddress, ValidationError + + +@pytest.mark.parametrize( + 'value,cls', + [ + ('0.0.0.0', IPv4Address), + ('1.1.1.1', IPv4Address), + ('10.10.10.10', IPv4Address), + ('192.168.0.1', IPv4Address), + ('255.255.255.255', IPv4Address), + ('::1:0:1', IPv6Address), + ('ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', IPv6Address), + (b'\x00\x00\x00\x00', IPv4Address), + (b'\x01\x01\x01\x01', IPv4Address), + (b'\n\n\n\n', IPv4Address), + (b'\xc0\xa8\x00\x01', IPv4Address), + (b'\xff\xff\xff\xff', IPv4Address), + (b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01', IPv6Address), + (b'\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff', IPv6Address), + (0, IPv4Address), + (16_843_009, IPv4Address), + (168_430_090, IPv4Address), + (3_232_235_521, IPv4Address), + (4_294_967_295, IPv4Address), + (4_294_967_297, IPv6Address), + (340_282_366_920_938_463_463_374_607_431_768_211_455, IPv6Address), + (IPv4Address('192.168.0.1'), IPv4Address), + (IPv6Address('::1:0:1'), IPv6Address), + ], +) +def test_ipaddress_success(value, cls): + class Model(BaseModel): + ip: IPvAnyAddress + + assert Model(ip=value).ip == cls(value) + + +@pytest.mark.parametrize( + 'value', + [ + '0.0.0.0', + '1.1.1.1', + '10.10.10.10', + '192.168.0.1', + '255.255.255.255', + b'\x00\x00\x00\x00', + b'\x01\x01\x01\x01', + b'\n\n\n\n', + b'\xc0\xa8\x00\x01', + b'\xff\xff\xff\xff', + 0, + 16_843_009, + 168_430_090, + 3_232_235_521, + 4_294_967_295, + IPv4Address('0.0.0.0'), + IPv4Address('1.1.1.1'), + IPv4Address('10.10.10.10'), + IPv4Address('192.168.0.1'), + IPv4Address('255.255.255.255'), + ], +) +def test_ipv4address_success(value): + class Model(BaseModel): + ipv4: IPv4Address + + assert Model(ipv4=value).ipv4 == IPv4Address(value) + + +@pytest.mark.parametrize( + 'value', + [ + '::1:0:1', + 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff', + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x01', + b'\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff', + 4_294_967_297, + 340_282_366_920_938_463_463_374_607_431_768_211_455, + IPv6Address('::1:0:1'), + IPv6Address('ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff'), + ], +) +def test_ipv6address_success(value): + class Model(BaseModel): + ipv6: IPv6Address + + assert Model(ipv6=value).ipv6 == IPv6Address(value) + + +@pytest.mark.parametrize( + 'value,errors', + [ + ( + 'hello,world', + [{'loc': ('ip',), 'msg': 'value is not a valid IPv4 or IPv6 address', 'type': 'value_error.ipvanyaddress'}], + ), + ( + '192.168.0.1.1.1', + [{'loc': ('ip',), 'msg': 'value is not a valid IPv4 or IPv6 address', 'type': 'value_error.ipvanyaddress'}], + ), + ( + -1, + [{'loc': ('ip',), 'msg': 'value is not a valid IPv4 or IPv6 address', 'type': 'value_error.ipvanyaddress'}], + ), + ( + 2 ** 128 + 1, + [{'loc': ('ip',), 'msg': 'value is not a valid IPv4 or IPv6 address', 'type': 'value_error.ipvanyaddress'}], + ), + ], +) +def test_ipaddress_fails(value, errors): + class Model(BaseModel): + ip: IPvAnyAddress + + with pytest.raises(ValidationError) as exc_info: + Model(ip=value) + assert exc_info.value.errors() == errors + + +@pytest.mark.parametrize( + 'value,errors', + [ + ( + 'hello,world', + [{'loc': ('ipv4',), 'msg': 'value is not a valid IPv4 address', 'type': 'value_error.ipv4address'}], + ), + ( + '192.168.0.1.1.1', + [{'loc': ('ipv4',), 'msg': 'value is not a valid IPv4 address', 'type': 'value_error.ipv4address'}], + ), + (-1, [{'loc': ('ipv4',), 'msg': 'value is not a valid IPv4 address', 'type': 'value_error.ipv4address'}]), + ( + 2 ** 32 + 1, + [{'loc': ('ipv4',), 'msg': 'value is not a valid IPv4 address', 'type': 'value_error.ipv4address'}], + ), + ( + IPv6Address('::0:1:0'), + [{'loc': ('ipv4',), 'msg': 'value is not a valid IPv4 address', 'type': 'value_error.ipv4address'}], + ), + ], +) +def test_ipv4address_fails(value, errors): + class Model(BaseModel): + ipv4: IPv4Address + + with pytest.raises(ValidationError) as exc_info: + Model(ipv4=value) + assert exc_info.value.errors() == errors + + +@pytest.mark.parametrize( + 'value,errors', + [ + ( + 'hello,world', + [{'loc': ('ipv6',), 'msg': 'value is not a valid IPv6 address', 'type': 'value_error.ipv6address'}], + ), + ( + '192.168.0.1.1.1', + [{'loc': ('ipv6',), 'msg': 'value is not a valid IPv6 address', 'type': 'value_error.ipv6address'}], + ), + (-1, [{'loc': ('ipv6',), 'msg': 'value is not a valid IPv6 address', 'type': 'value_error.ipv6address'}]), + ( + 2 ** 128 + 1, + [{'loc': ('ipv6',), 'msg': 'value is not a valid IPv6 address', 'type': 'value_error.ipv6address'}], + ), + ( + IPv4Address('192.168.0.1'), + [{'loc': ('ipv6',), 'msg': 'value is not a valid IPv6 address', 'type': 'value_error.ipv6address'}], + ), + ], +) +def test_ipv6address_fails(value, errors): + class Model(BaseModel): + ipv6: IPv6Address + + with pytest.raises(ValidationError) as exc_info: + Model(ipv6=value) + assert exc_info.value.errors() == errors