Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add IP address field type. #1485

Merged
merged 26 commits into from
Sep 16, 2020
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d9460b2
Add IP address field type.
mgetka Jan 3, 2020
809cd9e
Skip input value logging in IP field validation
mgetka Jan 3, 2020
bbe71aa
Omit redundant serialization step for IP field
mgetka Jan 13, 2020
985f2ba
Drop deserialized value handling in IP field validation
mgetka Jan 13, 2020
4e0d54f
Allow for IPv6 exploded form serialization
mgetka Jan 13, 2020
202392d
IP v4/v6 specific fields added
mgetka Jan 13, 2020
0a85119
Merge branch 'dev' into ip-address-field
mgetka Jan 13, 2020
8528581
Expose IP v4/v6 specific fields for wildcard imports
mgetka Jan 13, 2020
e1e9d3d
Fix exception message on invalid IPv6 field value
mgetka Jan 13, 2020
2984436
Merge branch 'dev' into ip-address-field
mgetka Jan 24, 2020
62fdfa8
IP addresses fields inherits from Field base class
mgetka Jan 29, 2020
bb84491
Move _validated method functionalities of IP fields into _deserialize
mgetka Jan 29, 2020
4c4b971
Merge branch 'dev' into ip-address-field
mgetka Feb 4, 2020
2d1dbe0
Do not test unintended features of IP fields.
mgetka Feb 5, 2020
474e06d
decimal representation deserialization in IP field
mgetka Feb 5, 2020
a5e4e4c
Revert "decimal representation deserialization in IP field"
mgetka Feb 19, 2020
e29fbd0
IP fields accept only string encoded IP form
mgetka Feb 19, 2020
e748c43
Merge branch 'dev' into ip-address-field
mgetka Feb 19, 2020
f197caf
Ensure text type on IP fields deserialization
mgetka Feb 26, 2020
bb9b6d8
Partially revert "Ensure text type on IP fields deserialization"
mgetka Feb 26, 2020
e771afe
Merge branch 'dev' into ip-address-field
mgetka Mar 7, 2020
5885bcb
Merge branch dev' into ip-address-field
mgetka Aug 10, 2020
4e7f99f
IP fields deserialization factorized
mgetka Aug 10, 2020
b5bbf37
py35 compliant class attribute typing in IP fields
mgetka Aug 10, 2020
f8a53fa
Fix duplicate entry in AUTHORS.rst
mgetka Aug 10, 2020
1a7d92d
Merge branch 'dev' into ip-address-field
mgetka Sep 16, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,4 @@ Contributors (chronological)
- `@Reskov <https://github.com/Reskov>`_
- Albert Tugushev `@atugushev <https://github.com/atugushev>`_
- `@dfirst <https://github.com/dfirst>`_
- Michał Getka `@mgetka <https://github.com/mgetka>`_
77 changes: 77 additions & 0 deletions src/marshmallow/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import datetime as dt
import numbers
import uuid
import ipaddress
import decimal
import math
import typing
Expand Down Expand Up @@ -50,6 +51,9 @@
"Url",
"URL",
"Email",
"IP",
"IPv4",
"IPv6",
"Method",
"Function",
"Str",
Expand Down Expand Up @@ -1616,6 +1620,79 @@ def __init__(self, *args, **kwargs):
self.validators.insert(0, validator)


class IP(Field):
"""A IP address field.

:param bool exploded: If `True`, serialize ipv6 address in long form, ie. with groups
consisting entirely of zeros included."""

default_error_messages = {"invalid_ip": "Not a valid IP address."}

def __init__(self, *args, exploded=False, **kwargs):
super().__init__(*args, **kwargs)
self.exploded = exploded

def _serialize(self, value, attr, obj, **kwargs) -> typing.Optional[str]:
if value is None:
return None
if self.exploded:
return value.exploded
return value.compressed

def _deserialize(
self, value, attr, data, **kwargs
) -> typing.Optional[typing.Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]:
if value is None:
return None
try:
if isinstance(value, (int, bytes)):
mgetka marked this conversation as resolved.
Show resolved Hide resolved
# ip_address function is flexible in the terms of input value. In the case of
# marshalling, integer and binary address representation parsing may lead to
# confusion.
raise TypeError(
"Only dot-decimal and hexadecimal groups notations are supported."
)
return ipaddress.ip_address(value)
except (ValueError, TypeError) as error:
raise self.make_error("invalid_ip") from error


class IPv4(IP):
"""A IPv4 address field."""

default_error_messages = {"invalid_ip": "Not a valid IPv4 address."}

def _deserialize(
self, value, attr, data, **kwargs
) -> typing.Optional[ipaddress.IPv4Address]:
if value is None:
return None
try:
if isinstance(value, (int, bytes)):
raise TypeError("Only dot-decimal notation is supported.")
mgetka marked this conversation as resolved.
Show resolved Hide resolved
return ipaddress.IPv4Address(value)
except (ValueError, TypeError) as error:
raise self.make_error("invalid_ip") from error


class IPv6(IP):
"""A IPv6 address field."""

default_error_messages = {"invalid_ip": "Not a valid IPv6 address."}

def _deserialize(
self, value, attr, data, **kwargs
) -> typing.Optional[ipaddress.IPv6Address]:
if value is None:
return None
try:
if isinstance(value, (int, bytes)):
raise TypeError("Only hexadecimal groups notation is supported.")
return ipaddress.IPv6Address(value)
except (ValueError, TypeError) as error:
raise self.make_error("invalid_ip") from error


class Method(Field):
"""A field that takes the value returned by a `Schema` method.

Expand Down
78 changes: 78 additions & 0 deletions tests/test_deserialization.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime as dt
import uuid
import ipaddress
import decimal
import math

Expand Down Expand Up @@ -841,6 +842,83 @@ def test_invalid_uuid_deserialization(self, in_value):

assert excinfo.value.args[0] == "Not a valid UUID."

def test_ip_field_deserialization(self):
field = fields.IP()
ipv4_str = "140.82.118.3"
result = field.deserialize(ipv4_str)
assert isinstance(result, ipaddress.IPv4Address)
assert str(result) == ipv4_str

ipv4 = ipaddress.ip_address("172.217.16.206")
result = field.deserialize(ipv4)
assert isinstance(result, ipaddress.IPv4Address)
assert result == ipv4

mgetka marked this conversation as resolved.
Show resolved Hide resolved
ipv6_str = "2a00:1450:4001:824::200e"
result = field.deserialize(ipv6_str)
assert isinstance(result, ipaddress.IPv6Address)
assert str(result) == ipv6_str

ipv6 = ipaddress.ip_address("2a00:1450:4001:81d::200e")
result = field.deserialize(ipv6)
assert isinstance(result, ipaddress.IPv6Address)
assert result == ipv6

@pytest.mark.parametrize(
"in_value", ["malformed", 123, b"\x01\x02\03", "192.168", "ff::aa:1::2"]
)
def test_invalid_ip_deserialization(self, in_value):
field = fields.IP()
with pytest.raises(ValidationError) as excinfo:
field.deserialize(in_value)

assert excinfo.value.args[0] == "Not a valid IP address."

def test_ipv4_field_deserialization(self):
field = fields.IPv4()
ipv4_str = "140.82.118.3"
result = field.deserialize(ipv4_str)
assert isinstance(result, ipaddress.IPv4Address)
assert str(result) == ipv4_str

ipv4 = ipaddress.ip_address("172.217.16.206")
result = field.deserialize(ipv4)
assert isinstance(result, ipaddress.IPv4Address)
assert result == ipv4

@pytest.mark.parametrize(
"in_value",
["malformed", 123, b"\x01\x02\03", "192.168", "2a00:1450:4001:81d::200e"],
)
def test_invalid_ipv4_deserialization(self, in_value):
field = fields.IPv4()
with pytest.raises(ValidationError) as excinfo:
field.deserialize(in_value)

assert excinfo.value.args[0] == "Not a valid IPv4 address."

def test_ipv6_field_deserialization(self):
field = fields.IPv6()
ipv6_str = "2a00:1450:4001:824::200e"
result = field.deserialize(ipv6_str)
assert isinstance(result, ipaddress.IPv6Address)
assert str(result) == ipv6_str

ipv6 = ipaddress.ip_address("2a00:1450:4001:81d::200e")
result = field.deserialize(ipv6)
assert isinstance(result, ipaddress.IPv6Address)
assert result == ipv6

@pytest.mark.parametrize(
"in_value", ["malformed", 123, b"\x01\x02\03", "ff::aa:1::2", "192.168.0.1"]
)
def test_invalid_ipv6_deserialization(self, in_value):
field = fields.IPv6()
with pytest.raises(ValidationError) as excinfo:
field.deserialize(in_value)

assert excinfo.value.args[0] == "Not a valid IPv6 address."

def test_deserialization_function_must_be_callable(self):
with pytest.raises(ValueError):
fields.Function(lambda x: None, deserialize="notvalid")
Expand Down
51 changes: 51 additions & 0 deletions tests/test_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import itertools
import decimal
import uuid
import ipaddress

import pytest

Expand Down Expand Up @@ -143,6 +144,56 @@ def test_uuid_field(self, user):
assert field.serialize("uuid1", user) == "12345678-1234-5678-1234-567812345678"
assert field.serialize("uuid2", user) is None

def test_ip_address_field(self, user):

ipv4_string = "192.168.0.1"
ipv6_string = "ffff::ffff"
ipv6_exploded_string = ipaddress.ip_address("ffff::ffff").exploded

user.ipv4 = ipaddress.ip_address(ipv4_string)
user.ipv6 = ipaddress.ip_address(ipv6_string)
user.empty_ip = None

field_compressed = fields.IP()
assert isinstance(field_compressed.serialize("ipv4", user), str)
assert field_compressed.serialize("ipv4", user) == ipv4_string
assert isinstance(field_compressed.serialize("ipv6", user), str)
assert field_compressed.serialize("ipv6", user) == ipv6_string
assert field_compressed.serialize("empty_ip", user) is None

field_exploded = fields.IP(exploded=True)
assert isinstance(field_exploded.serialize("ipv6", user), str)
assert field_exploded.serialize("ipv6", user) == ipv6_exploded_string

def test_ipv4_address_field(self, user):

ipv4_string = "192.168.0.1"

user.ipv4 = ipaddress.ip_address(ipv4_string)
user.empty_ip = None

field = fields.IPv4()
assert isinstance(field.serialize("ipv4", user), str)
assert field.serialize("ipv4", user) == ipv4_string
assert field.serialize("empty_ip", user) is None

def test_ipv6_address_field(self, user):

ipv6_string = "ffff::ffff"
ipv6_exploded_string = ipaddress.ip_address("ffff::ffff").exploded

user.ipv6 = ipaddress.ip_address(ipv6_string)
user.empty_ip = None

field_compressed = fields.IPv6()
assert isinstance(field_compressed.serialize("ipv6", user), str)
assert field_compressed.serialize("ipv6", user) == ipv6_string
assert field_compressed.serialize("empty_ip", user) is None

field_exploded = fields.IPv6(exploded=True)
assert isinstance(field_exploded.serialize("ipv6", user), str)
assert field_exploded.serialize("ipv6", user) == ipv6_exploded_string

def test_decimal_field(self, user):
user.m1 = 12
user.m2 = "12.355"
Expand Down