Repository: marshmallow-code/marshmallow (python)

Issue:
Feature Request:  Add IPv4Network and IPv6Network fields
Add the two network types added so that full network fields e.g. '192.168.1.0/24' can be validated along with a 'IPNetwork' type similar to the IP field that is a union of the two fields. 

These types are already in the ipaddress library <https://python.readthedocs.io/en/latest/library/ipaddress.html#ipaddress.IPv4Network>

Pull request description:
Implements IPInterface fields
Implements IPInterface fields that are a non-strict version of IPNetwork Types, where the IPInterfaces will always accept arbitrary host addresses and Mask, while IPNetwork will not allow this.

Implement in this PR are the following new fields:

- IPInterface
- IPv4Interface
- IPv6Interface

see <https://python.readthedocs.io/en/latest/library/ipaddress.html#interface-objects> for more information around these underlying types

closes #1733 



Patch:
diff --git a/AUTHORS.rst b/AUTHORS.rst
index 0cfdacb3..9fc0ddbb 100644
--- a/AUTHORS.rst
+++ b/AUTHORS.rst
@@ -158,3 +158,4 @@ Contributors (chronological)
 - Tamara `@infinityxxx <https://github.com/infinityxxx>`_
 - Stephen Rosen `@sirosen <https://github.com/sirosen>`_
 - Vladimir Mikhaylov `@vemikhaylov <https://github.com/vemikhaylov>`_
+- Stephen Eaton `@madeinoz67 <https://github.com/madeinoz67>`_
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index fe10b1ec..079638d2 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -1,6 +1,15 @@
 Changelog
 ---------
 
+3.11.0 (unreleased)
+*******************
+
+Features:
+
+- Add ``fields.IPInterface``, ``fields.IPv4Interface``, and
+  ``IPv6Interface`` (:issue:`1733`). Thanks :user:`madeinoz67`
+  for the suggestion and the PR.
+
 3.10.0 (2020-12-19)
 *******************
 
diff --git a/src/marshmallow/fields.py b/src/marshmallow/fields.py
index 340fe83e..6b7b5e22 100644
--- a/src/marshmallow/fields.py
+++ b/src/marshmallow/fields.py
@@ -55,6 +55,9 @@ __all__ = [
     "IP",
     "IPv4",
     "IPv6",
+    "IPInterface",
+    "IPv4Interface",
+    "IPv6Interface",
     "Method",
     "Function",
     "Str",
@@ -1711,6 +1714,66 @@ class IPv6(IP):
     DESERIALIZATION_CLASS = ipaddress.IPv6Address
 
 
+class IPInterface(Field):
+    """A IPInterface field.
+
+    IP interface is the non-stict form of the IPNetwork type where arbitrary host
+    addresses are always accepted.
+
+    IPAddress and mask e.g. '192.168.0.2/24' or '192.168.0.2/255.255.255.0'
+
+    see https://python.readthedocs.io/en/latest/library/ipaddress.html#interface-objects
+
+    :parm bool exploded: If `True`, serialize ipv6 interface in long form, ie. with groups
+        consisting entirely of zeros included.
+    """
+
+    default_error_messages = {"invalid_ip_interface": "Not a valid IP interface."}
+
+    DESERIALIZATION_CLASS = None  # type: typing.Optional[typing.Type]
+
+    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.IPv4Interface, ipaddress.IPv6Interface]
+    ]:
+        if value is None:
+            return None
+        try:
+            return (self.DESERIALIZATION_CLASS or ipaddress.ip_interface)(
+                utils.ensure_text_type(value)
+            )
+        except (ValueError, TypeError) as error:
+            raise self.make_error("invalid_ip_interface") from error
+
+
+class IPv4Interface(IPInterface):
+    """A IPv4 Network Interface field."""
+
+    default_error_messages = {"invalid_ip_interface": "Not a valid IPv4 interface."}
+
+    DESERIALIZATION_CLASS = ipaddress.IPv4Interface
+
+
+class IPv6Interface(IPInterface):
+    """A IPv6 Network Interface field."""
+
+    default_error_messages = {"invalid_ip_interface": "Not a valid IPv6 interface."}
+
+    DESERIALIZATION_CLASS = ipaddress.IPv6Interface
+
+
 class Method(Field):
     """A field that takes the value returned by a `Schema` method.
 


Test patch:
diff --git a/tests/base.py b/tests/base.py
index 9ff27df5..3e342134 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -27,6 +27,12 @@ ALL_FIELDS = [
     fields.Email,
     fields.UUID,
     fields.Decimal,
+    fields.IP,
+    fields.IPv4,
+    fields.IPv6,
+    fields.IPInterface,
+    fields.IPv4Interface,
+    fields.IPv6Interface,
 ]
 
 ##### Custom asserts #####
diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py
index 084a966f..8d124b02 100644
--- a/tests/test_deserialization.py
+++ b/tests/test_deserialization.py
@@ -898,7 +898,8 @@ class TestFieldDeserialization:
         assert str(result) == ipv6_str
 
     @pytest.mark.parametrize(
-        "in_value", ["malformed", 123, b"\x01\x02\03", "192.168", "ff::aa:1::2"]
+        "in_value",
+        ["malformed", 123, b"\x01\x02\03", "192.168", "192.168.0.1/24", "ff::aa:1::2"],
     )
     def test_invalid_ip_deserialization(self, in_value):
         field = fields.IP()
@@ -916,7 +917,14 @@ class TestFieldDeserialization:
 
     @pytest.mark.parametrize(
         "in_value",
-        ["malformed", 123, b"\x01\x02\03", "192.168", "2a00:1450:4001:81d::200e"],
+        [
+            "malformed",
+            123,
+            b"\x01\x02\03",
+            "192.168",
+            "192.168.0.1/24",
+            "2a00:1450:4001:81d::200e",
+        ],
     )
     def test_invalid_ipv4_deserialization(self, in_value):
         field = fields.IPv4()
@@ -932,15 +940,88 @@ class TestFieldDeserialization:
         assert isinstance(result, ipaddress.IPv6Address)
         assert str(result) == ipv6_str
 
+    def test_ipinterface_field_deserialization(self):
+        field = fields.IPInterface()
+        ipv4interface_str = "140.82.118.3/24"
+        result = field.deserialize(ipv4interface_str)
+        assert isinstance(result, ipaddress.IPv4Interface)
+        assert str(result) == ipv4interface_str
+
+        ipv6interface_str = "2a00:1450:4001:824::200e/128"
+        result = field.deserialize(ipv6interface_str)
+        assert isinstance(result, ipaddress.IPv6Interface)
+        assert str(result) == ipv6interface_str
+
     @pytest.mark.parametrize(
-        "in_value", ["malformed", 123, b"\x01\x02\03", "ff::aa:1::2", "192.168.0.1"]
+        "in_value",
+        [
+            "malformed",
+            123,
+            b"\x01\x02\03",
+            "192.168",
+            "192.168.0.1/33",
+            "ff::aa:1::2",
+            "2a00:1450:4001:824::200e/129",
+        ],
     )
-    def test_invalid_ipv6_deserialization(self, in_value):
-        field = fields.IPv6()
+    def test_invalid_ipinterface_deserialization(self, in_value):
+        field = fields.IPInterface()
+        with pytest.raises(ValidationError) as excinfo:
+            field.deserialize(in_value)
+
+        assert excinfo.value.args[0] == "Not a valid IP interface."
+
+    def test_ipv4interface_field_deserialization(self):
+        field = fields.IPv4Interface()
+        ipv4interface_str = "140.82.118.3/24"
+        result = field.deserialize(ipv4interface_str)
+        assert isinstance(result, ipaddress.IPv4Interface)
+        assert str(result) == ipv4interface_str
+
+    @pytest.mark.parametrize(
+        "in_value",
+        [
+            "malformed",
+            123,
+            b"\x01\x02\03",
+            "192.168",
+            "192.168.0.1/33",
+            "2a00:1450:4001:81d::200e",
+            "2a00:1450:4001:824::200e/129",
+        ],
+    )
+    def test_invalid_ipv4interface_deserialization(self, in_value):
+        field = fields.IPv4Interface()
+        with pytest.raises(ValidationError) as excinfo:
+            field.deserialize(in_value)
+
+        assert excinfo.value.args[0] == "Not a valid IPv4 interface."
+
+    def test_ipv6interface_field_deserialization(self):
+        field = fields.IPv6Interface()
+        ipv6interface_str = "2a00:1450:4001:824::200e/128"
+        result = field.deserialize(ipv6interface_str)
+        assert isinstance(result, ipaddress.IPv6Interface)
+        assert str(result) == ipv6interface_str
+
+    @pytest.mark.parametrize(
+        "in_value",
+        [
+            "malformed",
+            123,
+            b"\x01\x02\03",
+            "ff::aa:1::2",
+            "192.168.0.1",
+            "192.168.0.1/24",
+            "2a00:1450:4001:824::200e/129",
+        ],
+    )
+    def test_invalid_ipv6interface_deserialization(self, in_value):
+        field = fields.IPv6Interface()
         with pytest.raises(ValidationError) as excinfo:
             field.deserialize(in_value)
 
-        assert excinfo.value.args[0] == "Not a valid IPv6 address."
+        assert excinfo.value.args[0] == "Not a valid IPv6 interface."
 
     def test_deserialization_function_must_be_callable(self):
         with pytest.raises(ValueError):
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index cdd7d253..7824dc45 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -194,6 +194,66 @@ class TestFieldSerialization:
         assert isinstance(field_exploded.serialize("ipv6", user), str)
         assert field_exploded.serialize("ipv6", user) == ipv6_exploded_string
 
+    def test_ip_interface_field(self, user):
+
+        ipv4interface_string = "192.168.0.1/24"
+        ipv6interface_string = "ffff::ffff/128"
+        ipv6interface_exploded_string = ipaddress.ip_interface(
+            "ffff::ffff/128"
+        ).exploded
+
+        user.ipv4interface = ipaddress.ip_interface(ipv4interface_string)
+        user.ipv6interface = ipaddress.ip_interface(ipv6interface_string)
+        user.empty_ipinterface = None
+
+        field_compressed = fields.IPInterface()
+        assert isinstance(field_compressed.serialize("ipv4interface", user), str)
+        assert field_compressed.serialize("ipv4interface", user) == ipv4interface_string
+        assert isinstance(field_compressed.serialize("ipv6interface", user), str)
+        assert field_compressed.serialize("ipv6interface", user) == ipv6interface_string
+        assert field_compressed.serialize("empty_ipinterface", user) is None
+
+        field_exploded = fields.IPInterface(exploded=True)
+        assert isinstance(field_exploded.serialize("ipv6interface", user), str)
+        assert (
+            field_exploded.serialize("ipv6interface", user)
+            == ipv6interface_exploded_string
+        )
+
+    def test_ipv4_interface_field(self, user):
+
+        ipv4interface_string = "192.168.0.1/24"
+
+        user.ipv4interface = ipaddress.ip_interface(ipv4interface_string)
+        user.empty_ipinterface = None
+
+        field = fields.IPv4Interface()
+        assert isinstance(field.serialize("ipv4interface", user), str)
+        assert field.serialize("ipv4interface", user) == ipv4interface_string
+        assert field.serialize("empty_ipinterface", user) is None
+
+    def test_ipv6_interface_field(self, user):
+
+        ipv6interface_string = "ffff::ffff/128"
+        ipv6interface_exploded_string = ipaddress.ip_interface(
+            "ffff::ffff/128"
+        ).exploded
+
+        user.ipv6interface = ipaddress.ip_interface(ipv6interface_string)
+        user.empty_ipinterface = None
+
+        field_compressed = fields.IPv6Interface()
+        assert isinstance(field_compressed.serialize("ipv6interface", user), str)
+        assert field_compressed.serialize("ipv6interface", user) == ipv6interface_string
+        assert field_compressed.serialize("empty_ipinterface", user) is None
+
+        field_exploded = fields.IPv6Interface(exploded=True)
+        assert isinstance(field_exploded.serialize("ipv6interface", user), str)
+        assert (
+            field_exploded.serialize("ipv6interface", user)
+            == ipv6interface_exploded_string
+        )
+
     def test_decimal_field(self, user):
         user.m1 = 12
         user.m2 = "12.355"