Skip to content

Commit

Permalink
ipaddress.IPAddress support (pydantic#417)
Browse files Browse the repository at this point in the history
* ipaddress-compatible types added, fix pydantic#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
  • Loading branch information
pilosus authored and samuelcolvin committed Mar 15, 2019
1 parent 37855aa commit f41e3af
Show file tree
Hide file tree
Showing 7 changed files with 255 additions and 3 deletions.
1 change: 1 addition & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
....................
Expand Down
14 changes: 12 additions & 2 deletions docs/examples/exotic.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -88,7 +92,10 @@ class Model(BaseModel):
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())
"""
Expand Down Expand Up @@ -126,5 +133,8 @@ class Model(BaseModel):
'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',
}
"""
7 changes: 7 additions & 0 deletions docs/schema_mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,13 @@
'Pydantic standard "format" extension',
''
],
[
'IPvAnyAddress',
'string',
'{"format": "ipvanyaddress"}',
'Pydantic standard "format" extension',
'IPv4 or IPv4 address as used in ``ipaddress`` module',
],
[
'StrictStr',
'string',
Expand Down
12 changes: 12 additions & 0 deletions pydantic/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
21 changes: 20 additions & 1 deletion pydantic/types.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -61,6 +62,7 @@
'DirectoryPath',
'Json',
'JsonWrapper',
'IPvAnyAddress',
]

NoneStr = Optional[str]
Expand All @@ -71,6 +73,7 @@
OptionalIntFloat = Union[OptionalInt, float]
OptionalIntFloatDecimal = Union[OptionalIntFloat, Decimal]


if TYPE_CHECKING: # pragma: no cover
from .utils import AnyCallable

Expand Down Expand Up @@ -497,3 +500,19 @@ def validate(cls, v: str) -> Any:
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)
19 changes: 19 additions & 0 deletions pydantic/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -284,6 +301,8 @@ def pattern_validator(v: Any) -> Pattern[str]:
(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]),
]


Expand Down
184 changes: 184 additions & 0 deletions tests/test_types_ipaddress.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit f41e3af

Please sign in to comment.