From d9460b228398be998721091fc330f23111f5c30d Mon Sep 17 00:00:00 2001 From: mgetka Date: Fri, 3 Jan 2020 14:40:34 +0100 Subject: [PATCH 01/19] Add IP address field type. --- AUTHORS.rst | 1 + src/marshmallow/fields.py | 38 +++++++++++++++++++++++++++++++++++ tests/test_deserialization.py | 33 ++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/AUTHORS.rst b/AUTHORS.rst index 073feddbe..f46963d58 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -141,3 +141,4 @@ Contributors (chronological) - `@jceresini `_ - Nikolay Shebanov `@killthekitten `_ - Taneli Hukkinen `@hukkinj1 `_ +- Michał Getka `@mgetka `_ diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index 4458fab1a..d020c75f5 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -5,6 +5,7 @@ import datetime as dt import numbers import uuid +import ipaddress import decimal import math import typing @@ -50,6 +51,7 @@ "Url", "URL", "Email", + "IP", "Method", "Function", "Str", @@ -1629,6 +1631,42 @@ def __init__(self, *args, **kwargs): self.validators = [validator] + original_validators +class IP(String): + """A IP address field.""" + + default_error_messages = {"invalid_ip": "Not a valid IP address."} + + def _validated( + self, value + ) -> typing.Optional[typing.Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]: + """Format the value or raise a :exc:`ValidationError` if an error occurs.""" + if value is None: + return None + if isinstance(value, (ipaddress.IPv4Address, ipaddress.IPv6Address)): + return value + try: + if isinstance(value, (int, bytes)): + # 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. " + "Got %s." + ) + return ipaddress.ip_address(value) + except (ValueError, TypeError) as error: + raise self.make_error("invalid_ip") from error + + def _serialize(self, value, attr, obj, **kwargs) -> typing.Optional[str]: + val = str(value) if value is not None else None + return super()._serialize(val, attr, obj, **kwargs) + + def _deserialize( + self, value, attr, data, **kwargs + ) -> typing.Optional[typing.Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]: + return self._validated(value) + + class Method(Field): """A field that takes the value returned by a `Schema` method. diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py index b71fed963..cd06bb52b 100644 --- a/tests/test_deserialization.py +++ b/tests/test_deserialization.py @@ -1,5 +1,6 @@ import datetime as dt import uuid +import ipaddress import decimal import math @@ -841,6 +842,38 @@ 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 + + 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_deserialization_function_must_be_callable(self): with pytest.raises(ValueError): fields.Function(lambda x: None, deserialize="notvalid") From 809cd9e3151507cc1133b770bda3fe734cfaccb9 Mon Sep 17 00:00:00 2001 From: mgetka Date: Fri, 3 Jan 2020 14:55:50 +0100 Subject: [PATCH 02/19] Skip input value logging in IP field validation --- src/marshmallow/fields.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index d020c75f5..990e1befe 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1650,8 +1650,7 @@ def _validated( # marshalling, integer and binary address representation parsing may lead to # confusion. raise TypeError( - "Only dot-decimal and hexadecimal groups notations are supported. " - "Got %s." + "Only dot-decimal and hexadecimal groups notations are supported." ) return ipaddress.ip_address(value) except (ValueError, TypeError) as error: From bbe71aaad4fd9411cabbf103e150f46bd8fbf218 Mon Sep 17 00:00:00 2001 From: mgetka Date: Mon, 13 Jan 2020 16:06:01 +0100 Subject: [PATCH 03/19] Omit redundant serialization step for IP field --- src/marshmallow/fields.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index 990e1befe..a81c04f3b 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1656,10 +1656,6 @@ def _validated( except (ValueError, TypeError) as error: raise self.make_error("invalid_ip") from error - def _serialize(self, value, attr, obj, **kwargs) -> typing.Optional[str]: - val = str(value) if value is not None else None - return super()._serialize(val, attr, obj, **kwargs) - def _deserialize( self, value, attr, data, **kwargs ) -> typing.Optional[typing.Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]: From 985f2ba414a8abcfcc5e8680e7cf1de09e53b5f9 Mon Sep 17 00:00:00 2001 From: mgetka Date: Mon, 13 Jan 2020 16:53:27 +0100 Subject: [PATCH 04/19] Drop deserialized value handling in IP field validation --- src/marshmallow/fields.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index a81c04f3b..0cd963c21 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1642,8 +1642,6 @@ def _validated( """Format the value or raise a :exc:`ValidationError` if an error occurs.""" if value is None: return None - if isinstance(value, (ipaddress.IPv4Address, ipaddress.IPv6Address)): - return value try: if isinstance(value, (int, bytes)): # ip_address function is flexible in the terms of input value. In the case of From 4e0d54ff800fdba0b51d49d8a5bd680389cc0c44 Mon Sep 17 00:00:00 2001 From: mgetka Date: Mon, 13 Jan 2020 17:11:24 +0100 Subject: [PATCH 05/19] Allow for IPv6 exploded form serialization --- src/marshmallow/fields.py | 14 +++++++++++++- tests/test_serialization.py | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index 0cd963c21..63b12e3a7 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1632,10 +1632,17 @@ def __init__(self, *args, **kwargs): class IP(String): - """A IP address 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 _validated( self, value ) -> typing.Optional[typing.Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]: @@ -1654,6 +1661,11 @@ def _validated( except (ValueError, TypeError) as error: raise self.make_error("invalid_ip") from error + def _serialize(self, value, attr, obj, **kwargs) -> typing.Optional[str]: + if value is not None and self.exploded: + value = value.exploded + return super()._serialize(value, attr, obj, **kwargs) + def _deserialize( self, value, attr, data, **kwargs ) -> typing.Optional[typing.Union[ipaddress.IPv4Address, ipaddress.IPv6Address]]: diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 7a25bd73c..dadd200e0 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -4,6 +4,7 @@ import itertools import decimal import uuid +import ipaddress import pytest @@ -143,6 +144,27 @@ 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_decimal_field(self, user): user.m1 = 12 user.m2 = "12.355" From 202392dc84702ab6a9f3a4ce725380ab9a268101 Mon Sep 17 00:00:00 2001 From: mgetka Date: Mon, 13 Jan 2020 17:28:14 +0100 Subject: [PATCH 06/19] IP v4/v6 specific fields added --- src/marshmallow/fields.py | 44 ++++++++++++++++++++++++++++++++++ tests/test_deserialization.py | 45 +++++++++++++++++++++++++++++++++++ tests/test_serialization.py | 29 ++++++++++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index 63b12e3a7..dab0ed8ee 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1672,6 +1672,50 @@ def _deserialize( return self._validated(value) +class IPv4(IP): + """A IPv4 address field.""" + + default_error_messages = {"invalid_ip": "Not a valid IPv4 address."} + + def _validated(self, value) -> typing.Optional[ipaddress.IPv4Address]: + """Format the value or raise a :exc:`ValidationError` if an error occurs.""" + if value is None: + return None + try: + if isinstance(value, (int, bytes)): + raise TypeError("Only dot-decimal notation is supported.") + return ipaddress.IPv4Address(value) + except (ValueError, TypeError) as error: + raise self.make_error("invalid_ip") from error + + def _deserialize( + self, value, attr, data, **kwargs + ) -> typing.Optional[ipaddress.IPv4Address]: + return self._validated(value) + + +class IPv6(IP): + """A IPv6 address field.""" + + default_error_messages = {"invalid_ip": "Not a valid IPv6 address."} + + def _validated(self, value) -> typing.Optional[ipaddress.IPv6Address]: + """Format the value or raise a :exc:`ValidationError` if an error occurs.""" + if value is None: + return None + try: + if isinstance(value, (int, bytes)): + raise TypeError("Only dot-decimal notation is supported.") + return ipaddress.IPv6Address(value) + except (ValueError, TypeError) as error: + raise self.make_error("invalid_ip") from error + + def _deserialize( + self, value, attr, data, **kwargs + ) -> typing.Optional[ipaddress.IPv6Address]: + return self._validated(value) + + class Method(Field): """A field that takes the value returned by a `Schema` method. diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py index cd06bb52b..9a8c1caa3 100644 --- a/tests/test_deserialization.py +++ b/tests/test_deserialization.py @@ -874,6 +874,51 @@ def test_invalid_ip_deserialization(self, 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") diff --git a/tests/test_serialization.py b/tests/test_serialization.py index dadd200e0..2278e939d 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -165,6 +165,35 @@ def test_ip_address_field(self, user): 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" From 8528581a047fe22e461a9a2cf780d5212b3b711f Mon Sep 17 00:00:00 2001 From: mgetka Date: Mon, 13 Jan 2020 17:38:41 +0100 Subject: [PATCH 07/19] Expose IP v4/v6 specific fields for wildcard imports --- src/marshmallow/fields.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index dc27edf55..588493ea1 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -52,6 +52,8 @@ "URL", "Email", "IP", + "IPv4", + "IPv6", "Method", "Function", "Str", From e1e9d3d5972785de44177e9b5a7cb11f07665ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Getka?= Date: Mon, 13 Jan 2020 19:01:30 +0100 Subject: [PATCH 08/19] Fix exception message on invalid IPv6 field value --- src/marshmallow/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index 588493ea1..c0a29ebab 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1712,7 +1712,7 @@ def _validated(self, value) -> typing.Optional[ipaddress.IPv6Address]: return None try: if isinstance(value, (int, bytes)): - raise TypeError("Only dot-decimal notation is supported.") + 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 From 62fdfa88acacccbfb2e5730c6ed02ca7b8f6b33c Mon Sep 17 00:00:00 2001 From: mgetka Date: Wed, 29 Jan 2020 20:08:24 +0100 Subject: [PATCH 09/19] IP addresses fields inherits from Field base class --- src/marshmallow/fields.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index b0f0b0a70..d1f8492cd 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1634,7 +1634,7 @@ def __init__(self, *args, **kwargs): self.validators = [validator] + original_validators -class IP(String): +class IP(Field): """A IP address field. :param bool exploded: If `True`, serialize ipv6 address in long form, ie. with groups @@ -1665,9 +1665,11 @@ def _validated( raise self.make_error("invalid_ip") from error def _serialize(self, value, attr, obj, **kwargs) -> typing.Optional[str]: - if value is not None and self.exploded: - value = value.exploded - return super()._serialize(value, attr, obj, **kwargs) + if value is None: + return None + if self.exploded: + return value.exploded + return value.compressed def _deserialize( self, value, attr, data, **kwargs From bb84491c14cf915489658e69de2638bd94e33b75 Mon Sep 17 00:00:00 2001 From: mgetka Date: Wed, 29 Jan 2020 20:24:29 +0100 Subject: [PATCH 10/19] Move _validated method functionalities of IP fields into _deserialize --- src/marshmallow/fields.py | 44 +++++++++++++-------------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index d1f8492cd..ea45dbc4b 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1646,10 +1646,16 @@ def __init__(self, *args, exploded=False, **kwargs): super().__init__(*args, **kwargs) self.exploded = exploded - def _validated( - self, value + 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]]: - """Format the value or raise a :exc:`ValidationError` if an error occurs.""" if value is None: return None try: @@ -1664,26 +1670,15 @@ def _validated( except (ValueError, TypeError) as error: raise self.make_error("invalid_ip") from error - 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]]: - return self._validated(value) - class IPv4(IP): """A IPv4 address field.""" default_error_messages = {"invalid_ip": "Not a valid IPv4 address."} - def _validated(self, value) -> typing.Optional[ipaddress.IPv4Address]: - """Format the value or raise a :exc:`ValidationError` if an error occurs.""" + def _deserialize( + self, value, attr, data, **kwargs + ) -> typing.Optional[ipaddress.IPv4Address]: if value is None: return None try: @@ -1693,19 +1688,15 @@ def _validated(self, value) -> typing.Optional[ipaddress.IPv4Address]: except (ValueError, TypeError) as error: raise self.make_error("invalid_ip") from error - def _deserialize( - self, value, attr, data, **kwargs - ) -> typing.Optional[ipaddress.IPv4Address]: - return self._validated(value) - class IPv6(IP): """A IPv6 address field.""" default_error_messages = {"invalid_ip": "Not a valid IPv6 address."} - def _validated(self, value) -> typing.Optional[ipaddress.IPv6Address]: - """Format the value or raise a :exc:`ValidationError` if an error occurs.""" + def _deserialize( + self, value, attr, data, **kwargs + ) -> typing.Optional[ipaddress.IPv6Address]: if value is None: return None try: @@ -1715,11 +1706,6 @@ def _validated(self, value) -> typing.Optional[ipaddress.IPv6Address]: except (ValueError, TypeError) as error: raise self.make_error("invalid_ip") from error - def _deserialize( - self, value, attr, data, **kwargs - ) -> typing.Optional[ipaddress.IPv6Address]: - return self._validated(value) - class Method(Field): """A field that takes the value returned by a `Schema` method. From 2d1dbe0526d4c2ff451a10128df64c386fbf1fa4 Mon Sep 17 00:00:00 2001 From: mgetka Date: Wed, 5 Feb 2020 13:06:55 +0100 Subject: [PATCH 11/19] Do not test unintended features of IP fields. --- tests/test_deserialization.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py index 9a8c1caa3..cb126ecf2 100644 --- a/tests/test_deserialization.py +++ b/tests/test_deserialization.py @@ -849,21 +849,11 @@ def test_ip_field_deserialization(self): 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 - 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"] ) @@ -881,11 +871,6 @@ def test_ipv4_field_deserialization(self): 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"], @@ -904,11 +889,6 @@ def test_ipv6_field_deserialization(self): 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"] ) From 474e06ddf74e1260cc9890a2b62013e32c37210b Mon Sep 17 00:00:00 2001 From: mgetka Date: Wed, 5 Feb 2020 13:19:24 +0100 Subject: [PATCH 12/19] decimal representation deserialization in IP field --- src/marshmallow/fields.py | 13 ++++++++----- tests/test_deserialization.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index bd98390ee..930a8ba47 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1624,13 +1624,16 @@ 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.""" + consisting entirely of zeros included. + :param bool allow_decimal: If `True`, accept decimal IP address representations while + deserializing..""" default_error_messages = {"invalid_ip": "Not a valid IP address."} - def __init__(self, *args, exploded=False, **kwargs): + def __init__(self, *args, exploded=False, allow_decimal=False, **kwargs): super().__init__(*args, **kwargs) self.exploded = exploded + self.allow_decimal = allow_decimal def _serialize(self, value, attr, obj, **kwargs) -> typing.Optional[str]: if value is None: @@ -1645,7 +1648,7 @@ def _deserialize( if value is None: return None try: - if isinstance(value, (int, bytes)): + if isinstance(value, (int, bytes)) and not self.allow_decimal: # 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. @@ -1668,7 +1671,7 @@ def _deserialize( if value is None: return None try: - if isinstance(value, (int, bytes)): + if isinstance(value, (int, bytes)) and not self.allow_decimal: raise TypeError("Only dot-decimal notation is supported.") return ipaddress.IPv4Address(value) except (ValueError, TypeError) as error: @@ -1686,7 +1689,7 @@ def _deserialize( if value is None: return None try: - if isinstance(value, (int, bytes)): + if isinstance(value, (int, bytes)) and not self.allow_decimal: raise TypeError("Only hexadecimal groups notation is supported.") return ipaddress.IPv6Address(value) except (ValueError, TypeError) as error: diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py index cb126ecf2..00b963687 100644 --- a/tests/test_deserialization.py +++ b/tests/test_deserialization.py @@ -854,6 +854,13 @@ def test_ip_field_deserialization(self): assert isinstance(result, ipaddress.IPv6Address) assert str(result) == ipv6_str + field = fields.IP(allow_decimal=True) + result = field.deserialize(1) + assert str(result) == "0.0.0.1" + + result = field.deserialize(2 ** 128 - 1) + assert str(result) == "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" + @pytest.mark.parametrize( "in_value", ["malformed", 123, b"\x01\x02\03", "192.168", "ff::aa:1::2"] ) @@ -871,6 +878,10 @@ def test_ipv4_field_deserialization(self): assert isinstance(result, ipaddress.IPv4Address) assert str(result) == ipv4_str + field = fields.IPv4(allow_decimal=True) + result = field.deserialize(1) + assert str(result) == "0.0.0.1" + @pytest.mark.parametrize( "in_value", ["malformed", 123, b"\x01\x02\03", "192.168", "2a00:1450:4001:81d::200e"], @@ -889,6 +900,10 @@ def test_ipv6_field_deserialization(self): assert isinstance(result, ipaddress.IPv6Address) assert str(result) == ipv6_str + field = fields.IPv6(allow_decimal=True) + result = field.deserialize(1) + assert str(result) == "::1" + @pytest.mark.parametrize( "in_value", ["malformed", 123, b"\x01\x02\03", "ff::aa:1::2", "192.168.0.1"] ) From a5e4e4c52eece8bfb6de3f0da46f8a1d679bbe10 Mon Sep 17 00:00:00 2001 From: mgetka Date: Wed, 19 Feb 2020 13:59:06 +0100 Subject: [PATCH 13/19] Revert "decimal representation deserialization in IP field" This reverts commit 474e06ddf74e1260cc9890a2b62013e32c37210b. --- src/marshmallow/fields.py | 13 +++++-------- tests/test_deserialization.py | 15 --------------- 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index 930a8ba47..bd98390ee 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1624,16 +1624,13 @@ 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. - :param bool allow_decimal: If `True`, accept decimal IP address representations while - deserializing..""" + consisting entirely of zeros included.""" default_error_messages = {"invalid_ip": "Not a valid IP address."} - def __init__(self, *args, exploded=False, allow_decimal=False, **kwargs): + def __init__(self, *args, exploded=False, **kwargs): super().__init__(*args, **kwargs) self.exploded = exploded - self.allow_decimal = allow_decimal def _serialize(self, value, attr, obj, **kwargs) -> typing.Optional[str]: if value is None: @@ -1648,7 +1645,7 @@ def _deserialize( if value is None: return None try: - if isinstance(value, (int, bytes)) and not self.allow_decimal: + if isinstance(value, (int, bytes)): # 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. @@ -1671,7 +1668,7 @@ def _deserialize( if value is None: return None try: - if isinstance(value, (int, bytes)) and not self.allow_decimal: + if isinstance(value, (int, bytes)): raise TypeError("Only dot-decimal notation is supported.") return ipaddress.IPv4Address(value) except (ValueError, TypeError) as error: @@ -1689,7 +1686,7 @@ def _deserialize( if value is None: return None try: - if isinstance(value, (int, bytes)) and not self.allow_decimal: + if isinstance(value, (int, bytes)): raise TypeError("Only hexadecimal groups notation is supported.") return ipaddress.IPv6Address(value) except (ValueError, TypeError) as error: diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py index 00b963687..cb126ecf2 100644 --- a/tests/test_deserialization.py +++ b/tests/test_deserialization.py @@ -854,13 +854,6 @@ def test_ip_field_deserialization(self): assert isinstance(result, ipaddress.IPv6Address) assert str(result) == ipv6_str - field = fields.IP(allow_decimal=True) - result = field.deserialize(1) - assert str(result) == "0.0.0.1" - - result = field.deserialize(2 ** 128 - 1) - assert str(result) == "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" - @pytest.mark.parametrize( "in_value", ["malformed", 123, b"\x01\x02\03", "192.168", "ff::aa:1::2"] ) @@ -878,10 +871,6 @@ def test_ipv4_field_deserialization(self): assert isinstance(result, ipaddress.IPv4Address) assert str(result) == ipv4_str - field = fields.IPv4(allow_decimal=True) - result = field.deserialize(1) - assert str(result) == "0.0.0.1" - @pytest.mark.parametrize( "in_value", ["malformed", 123, b"\x01\x02\03", "192.168", "2a00:1450:4001:81d::200e"], @@ -900,10 +889,6 @@ def test_ipv6_field_deserialization(self): assert isinstance(result, ipaddress.IPv6Address) assert str(result) == ipv6_str - field = fields.IPv6(allow_decimal=True) - result = field.deserialize(1) - assert str(result) == "::1" - @pytest.mark.parametrize( "in_value", ["malformed", 123, b"\x01\x02\03", "ff::aa:1::2", "192.168.0.1"] ) From e29fbd0640cbc90d6b73f47abb40e5652a620a8e Mon Sep 17 00:00:00 2001 From: mgetka Date: Wed, 19 Feb 2020 14:04:21 +0100 Subject: [PATCH 14/19] IP fields accept only string encoded IP form --- src/marshmallow/fields.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index bd98390ee..52f5aafe2 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1645,7 +1645,7 @@ def _deserialize( if value is None: return None try: - if isinstance(value, (int, bytes)): + if not isinstance(value, str): # 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. @@ -1668,7 +1668,7 @@ def _deserialize( if value is None: return None try: - if isinstance(value, (int, bytes)): + if not isinstance(value, str): raise TypeError("Only dot-decimal notation is supported.") return ipaddress.IPv4Address(value) except (ValueError, TypeError) as error: @@ -1686,7 +1686,7 @@ def _deserialize( if value is None: return None try: - if isinstance(value, (int, bytes)): + if not isinstance(value, str): raise TypeError("Only hexadecimal groups notation is supported.") return ipaddress.IPv6Address(value) except (ValueError, TypeError) as error: From f197cafeb012409a807e9357caeaa05888d4f1cc Mon Sep 17 00:00:00 2001 From: mgetka Date: Wed, 26 Feb 2020 11:49:55 +0100 Subject: [PATCH 15/19] Ensure text type on IP fields deserialization --- src/marshmallow/fields.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index a5d37fd7e..060c87cf6 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1635,14 +1635,7 @@ def _deserialize( if value is None: return None try: - if not isinstance(value, str): - # 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) + return ipaddress.ip_address(utils.ensure_text_type(value)) except (ValueError, TypeError) as error: raise self.make_error("invalid_ip") from error @@ -1658,9 +1651,7 @@ def _deserialize( if value is None: return None try: - if not isinstance(value, str): - raise TypeError("Only dot-decimal notation is supported.") - return ipaddress.IPv4Address(value) + return ipaddress.IPv4Address(utils.ensure_text_type(value)) except (ValueError, TypeError) as error: raise self.make_error("invalid_ip") from error @@ -1676,9 +1667,7 @@ def _deserialize( if value is None: return None try: - if not isinstance(value, str): - raise TypeError("Only hexadecimal groups notation is supported.") - return ipaddress.IPv6Address(value) + return ipaddress.IPv6Address(utils.ensure_text_type(value)) except (ValueError, TypeError) as error: raise self.make_error("invalid_ip") from error @@ -1767,7 +1756,7 @@ def __init__( typing.Callable[[typing.Any], typing.Any], typing.Callable[[typing.Any, typing.Dict], typing.Any], ] = None, - **kwargs + **kwargs, ): # Set dump_only and load_only based on arguments kwargs["dump_only"] = bool(serialize) and not bool(deserialize) From bb9b6d8d9b2c0e59fcebd85fb409377ac1ae9118 Mon Sep 17 00:00:00 2001 From: mgetka Date: Wed, 26 Feb 2020 12:13:10 +0100 Subject: [PATCH 16/19] Partially revert "Ensure text type on IP fields deserialization" This partially reverts commit f197cafeb012409a807e9357caeaa05888d4f1cc. --- src/marshmallow/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index 060c87cf6..b87bf5864 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1756,7 +1756,7 @@ def __init__( typing.Callable[[typing.Any], typing.Any], typing.Callable[[typing.Any, typing.Dict], typing.Any], ] = None, - **kwargs, + **kwargs ): # Set dump_only and load_only based on arguments kwargs["dump_only"] = bool(serialize) and not bool(deserialize) From 4e7f99fa36bd5253706b92f57fc6821be860b87a Mon Sep 17 00:00:00 2001 From: mgetka Date: Mon, 10 Aug 2020 17:03:38 +0200 Subject: [PATCH 17/19] IP fields deserialization factorized --- src/marshmallow/fields.py | 26 +++++++------------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index 10da38a8d..6453f8228 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1637,6 +1637,8 @@ class IP(Field): default_error_messages = {"invalid_ip": "Not a valid IP address."} + DESERIALIZATION_CLASS: typing.Optional[typing.Type] = None + def __init__(self, *args, exploded=False, **kwargs): super().__init__(*args, **kwargs) self.exploded = exploded @@ -1654,7 +1656,9 @@ def _deserialize( if value is None: return None try: - return ipaddress.ip_address(utils.ensure_text_type(value)) + return (self.DESERIALIZATION_CLASS or ipaddress.ip_address)( + utils.ensure_text_type(value) + ) except (ValueError, TypeError) as error: raise self.make_error("invalid_ip") from error @@ -1664,15 +1668,7 @@ class IPv4(IP): 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: - return ipaddress.IPv4Address(utils.ensure_text_type(value)) - except (ValueError, TypeError) as error: - raise self.make_error("invalid_ip") from error + DESERIALIZATION_CLASS = ipaddress.IPv4Address class IPv6(IP): @@ -1680,15 +1676,7 @@ class IPv6(IP): 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: - return ipaddress.IPv6Address(utils.ensure_text_type(value)) - except (ValueError, TypeError) as error: - raise self.make_error("invalid_ip") from error + DESERIALIZATION_CLASS = ipaddress.IPv6Address class Method(Field): From b5bbf37e0fd2cc810de26cddd447a5e7bac05277 Mon Sep 17 00:00:00 2001 From: mgetka Date: Mon, 10 Aug 2020 17:20:49 +0200 Subject: [PATCH 18/19] py35 compliant class attribute typing in IP fields --- src/marshmallow/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py index 6453f8228..e703cf9fe 100644 --- a/src/marshmallow/fields.py +++ b/src/marshmallow/fields.py @@ -1637,7 +1637,7 @@ class IP(Field): default_error_messages = {"invalid_ip": "Not a valid IP address."} - DESERIALIZATION_CLASS: typing.Optional[typing.Type] = None + DESERIALIZATION_CLASS = None # type: typing.Optional[typing.Type] def __init__(self, *args, exploded=False, **kwargs): super().__init__(*args, **kwargs) From f8a53fa4b8703547f0e6c24d19f10dd2c0f7174e Mon Sep 17 00:00:00 2001 From: mgetka Date: Mon, 10 Aug 2020 18:41:44 +0200 Subject: [PATCH 19/19] Fix duplicate entry in AUTHORS.rst --- AUTHORS.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/AUTHORS.rst b/AUTHORS.rst index 15b04e7cc..1910953e7 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -146,7 +146,6 @@ Contributors (chronological) - `@dfirst `_ - Tim Gates `@timgates42 `_ - Nathan `@nbanmp `_ -- Michał Getka `@mgetka `_ - Ronan Murphy `@Resinderate `_ - Laurie Opperman `@EpicWink `_ - Ram Rachum `@cool-RR `_