Working with IP addresses in Python - ipaddress library - part 2

Welcome to the second, and final, part of blog posts showing how to use Python to work with IP addresses.

In part one we learned how ipaddress library can help us when working with IP addresses. We then learned about Address and Network objects and operations we can perform on them.

Here I'll present Interface object, and its methods, before moving onto module level functions. After that I'll show how we can compare and sort ipaddress objects. We'll finish our journey with the module by talking about validation and exception handling.

Working with IP addresses in Python series:

Contents

Working with Interface objects

We are almost done with ipaddress module objects and I'll talk here shortly about Interface objects. As name implies these roughly correspond to addresses assigned to interfaces, be it on the servers or on the network devices.

Ok, but we already have Address and Network objects so why would we want to use Interface? There are some cases where you might want to do that:

  • You want to record IP address together with mask.
  • Input returned from elsewhere could be either a network or an IP address, Interface will accept either.

Now, as for the operations we can perform on Interfaces, these are largely same as the ones for Address objects. This is because Interface is a subclass of Address so it inherits its interface.

>>> ipifa = ipaddress.ip_interface("10.0.0.133")
>>> ipifa
IPv4Interface('10.0.0.133/32')

By default /32 mask will be used for IPv4 and /128 for IPv6. Or we can explicitly specify mask we desire:

ipifb = ipaddress.ip_interface("10.2.0.164/24")
>>> ipifb
IPv4Interface('10.2.0.164/24')

Going back to our previous ACL example, here's how we could quickly generate source network from the above Interface object:

src = "{}".format(ipifb.network.with_hostmask.replace("/", " "))
>>> src
'10.2.0.0 0.0.0.255'

The above shows example of moving from Interface object to Network object. We can also quickly get Address object out of Interface one:

>>> ipifb.ip
IPv4Address('10.2.0.164')

Similar to Address objects, we can get different string representations of the Interface objects, depending on our needs:

>>> ipifb.with_hostmask
'10.2.0.164/0.0.0.255'
>>> ipifb.with_prefixlen
'10.2.0.164/24'
>>> ipifb.with_netmask
'10.2.0.164/255.255.255.0'

And that's it for Interface object. The above examples hopefully show that it is sufficiently different from Address and Network object to warrant its existence.

Module level functions

Having reviewed objects offered by ipaddress module we'll move on to module level functions.

First of the module functions I want to show is summarize_address_range. This function tries to find the smallest amount of networks that can fit between given two addresses.

Results can seem bit quirky at first as the function is not trying to find network that contains both addresses but rather tries to minimize number of networks that can fit in, starting from address A and ending with address B, inclusive of those addresses.

Personally, I never found need to use it but it's here if you need it!

>>> ipsb = ipaddress.ip_address("172.16.0.88")
>>> ipse = ipaddress.ip_address("172.16.0.131")
>>> list(ipaddress.summarize_address_range(ipsb, ipse))
[IPv4Network('172.16.0.88/29'), IPv4Network('172.16.0.96/27'), IPv4Network('172.16.0.128/30')]

Second, and last, module function we'll try using is collapse_addresses. This is similar to summarize_address_range but works with Network objects.

This function takes a list of Networks and returns the smallest number of networks that would contain these. Essentially it will summarize adjacent networks of the same size or networks contained within other networks, and will leave other networks untouched.

>>> ipnb = ipaddress.ip_network("192.3.0.0/27")
>>> ipne = ipaddress.ip_network("192.3.0.32/28")
>>> list(ipaddress.collapse_addresses([ipnb, ipne]))
[IPv4Network('192.3.0.0/27'), IPv4Network('192.3.0.32/28')]
>>>
>>> ipne = ipaddress.ip_network("192.3.0.32/27")
>>> list(ipaddress.collapse_addresses([ipnb, ipne]))
[IPv4Network('192.3.0.0/26')]

In below example two /27s are adjacent so can be collapsed into /26. The resulting 192.3.0.0/26 is adjacent to 192.3.0.64/26 so we can collapse this two into the final result of 192.3.0.0/25

>>> ipna, ipnb, ipnc
(IPv4Network('192.3.0.0/27'), IPv4Network('192.3.0.32/27'), IPv4Network('192.3.0.64/26'))
>>> list(ipaddress.collapse_addresses([ipna, ipnb, ipnc]))
[IPv4Network('192.3.0.0/25')]

However, in the below examples only middle /29 can be collapsed into /27, which contains it:

>>> ipnd = ipaddress.ip_network("172.21.15.32/27")
>>> ipne = ipaddress.ip_network("172.21.15.40/29")
>>> ipnf = ipaddress.ip_network("172.21.15.72/29")
>>> list(ipaddress.collapse_addresses([ipnd, ipne, ipnf]))
[IPv4Network('172.21.15.32/27'), IPv4Network('172.21.15.72/29')]

Sorting and comparing objects

Sorting and comparing are very common operations in programming and I thought it'd make sense to have separate section to talk about them in the context of IP addressing objects.

Comparing

Objects of the same type can be directly compared using standard operators: <, >, ==, <=, >=.

As a quick example, let's do some comparing between Address objects:

>>> ipa = ipaddress.ip_address("10.0.0.134")
>>> ipb = ipaddress.ip_address("10.0.0.128")
>>> ipc = ipaddress.ip_address("10.0.0.128")
>>> ipa > ipb
True
>>> ipa < ipb
False
>>> ipb == ipc
True

Pretty standard stuff. How about Network objects?

It's possible to directly compare networks but results might be surprising if you don't look closely.

>>> ipn1 = ipaddress.ip_network("10.10.1.32/28")
>>> ipn2 = ipaddress.ip_network("10.10.1.32/27")
>>> ipn3 = ipaddress.ip_network("10.10.1.48/29")
>>> ipn1 < ipn3
True
>>> ipn1 == ipn2
False
>>> ipn1 > ipn2
True

What happened here? Is 10.10.1.32/28 larger than 10.10.1.32/27? The network itself is not, but the reason for this behavior is that for the purposes of sorting you'd want network with a less number of bit set to come first.

Below might help in visualizing this ordering:

>>> ipn1.with_netmask
'10.10.1.32/255.255.255.240'
>>> ipn2.with_netmask
'10.10.1.32/255.255.255.224'

With masks displayed you can see that ipn1 is indeed "larger" than ipn2.

Can we do comparison between Address and Network objects?

>>> ipa > ipn1
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: '>' not supported between instances of 'IPv4Address' and 'IPv4Network'

Ok, that'll be a no then.

But what if we really, really want to do that comparison? Well, we can cheat and make Interface objects out of them.

Which brings us to comparing Interface objects!

>>> ipif1 = ipaddress.ip_interface(ipa)
>>> ipif2 = ipaddress.ip_interface(ipn1)
>>> ipif1 > ipif2
False
>>> ipif1 < ipif2
True

So that works. Also as you can see here Interface objects can be created by providing Address or Network objects.

Logic for comparing Interface objects is same as the one for Network objects, that is object with fewer bits set comes first:

>>> ipifa = ipaddress.ip_interface("10.0.0.132/32")
>>> ipifb = ipaddress.ip_interface("10.0.0.132/31")
>>> ipifa > ipifb
True
>>> ipifa < ipifb
False

Sorting

We're done with comparing and we're moving onto sorting.

Sorting of Address, Network and Interface objects is supported out of the box, as long as container contains objects of the same type.

IP addresses:

>>> iparand = [ipaddress.ip_address(f"10.3.10.{randint(0,254)}") for _ in range(10)]
>>> iparand
[IPv4Address('10.3.10.3'), IPv4Address('10.3.10.112'), IPv4Address('10.3.10.111'), IPv4Address('10.3.10.175'),
 IPv4Address('10.3.10.51'), IPv4Address('10.3.10.42'), IPv4Address('10.3.10.233'), IPv4Address('10.3.10.247'),
 IPv4Address('10.3.10.152'), IPv4Address('10.3.10.134')]
>>> sorted(iparand)
[IPv4Address('10.3.10.3'), IPv4Address('10.3.10.42'), IPv4Address('10.3.10.51'), IPv4Address('10.3.10.111'),
 IPv4Address('10.3.10.112'), IPv4Address('10.3.10.134'), IPv4Address('10.3.10.152'), IPv4Address('10.3.10.175'),
 IPv4Address('10.3.10.233'), IPv4Address('10.3.10.247')]

IP networks:

>>> ipnrand = [ipaddress.ip_network(f"10.3.{randint(0,254)}.0/24") for _ in range(10)]
>>> ipnrand
[IPv4Network('10.3.98.0/24'), IPv4Network('10.3.121.0/24'), IPv4Network('10.3.154.0/24'), IPv4Network('10.3.121.0/24'),
 IPv4Network('10.3.56.0/24'), IPv4Network('10.3.190.0/24'), IPv4Network('10.3.250.0/24'), IPv4Network('10.3.7.0/24'),
 IPv4Network('10.3.246.0/24'), IPv4Network('10.3.30.0/24')]
>>> sorted(ipnrand)
[IPv4Network('10.3.7.0/24'), IPv4Network('10.3.30.0/24'), IPv4Network('10.3.56.0/24'), IPv4Network('10.3.98.0/24'),
 IPv4Network('10.3.121.0/24'), IPv4Network('10.3.121.0/24'), IPv4Network('10.3.154.0/24'), IPv4Network('10.3.190.0/24'),
 IPv4Network('10.3.246.0/24'), IPv4Network('10.3.250.0/24')]

IP interfaces:

>>> ipifrand = [ipaddress.ip_interface(i) for i in iparand[0:5] + ipnrand[5:]]
>>> ipifrand
[IPv4Interface('10.3.10.3/32'), IPv4Interface('10.3.10.112/32'), IPv4Interface('10.3.10.111/32'),
 IPv4Interface('10.3.10.175/32'), IPv4Interface('10.3.10.51/32'), IPv4Interface('10.3.190.0/24'),
 IPv4Interface('10.3.250.0/24'), IPv4Interface('10.3.7.0/24'), IPv4Interface('10.3.246.0/24'), IPv4Interface('10.3.30.0/24')]
>>> sorted(ipifrand)
[IPv4Interface('10.3.7.0/24'), IPv4Interface('10.3.10.3/32'), IPv4Interface('10.3.10.51/32'),
 IPv4Interface('10.3.10.111/32'), IPv4Interface('10.3.10.112/32'), IPv4Interface('10.3.10.175/32'),
 IPv4Interface('10.3.30.0/24'), IPv4Interface('10.3.190.0/24'), IPv4Interface('10.3.246.0/24'), IPv4Interface('10.3.250.0/24')]

All working great and sorting follows the rules of comparison we discussed in the previous section.

There is however one special case for when you have Address and Network objects that you don't want to convert to Interface objects but you want to have them in the same, sorted, container.

Luckily creators of ipaddress module covered this scenario and provide key function that can be given to sorted. This function is provided at the module level and it's called get_mixed_type_key. With this key we can sort container containing both Address and Network objects.

>>> ipmixrand = iparand[5:] + ipnrand[5:]
>>> sorted(ipmixrand)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: '<' not supported between instances of 'IPv4Network' and 'IPv4Address'
>>>
>>> sorted(ipmixrand, key=ipaddress.get_mixed_type_key)
[IPv4Network('10.3.7.0/24'), IPv4Address('10.3.10.42'), IPv4Address('10.3.10.134'), IPv4Address('10.3.10.152'),
 IPv4Address('10.3.10.233'), IPv4Address('10.3.10.247'), IPv4Network('10.3.30.0/24'), IPv4Network('10.3.190.0/24'),
 IPv4Network('10.3.246.0/24'), IPv4Network('10.3.250.0/24')]

Our first attempt at sorting failed but once key function was fed to sorted it all got sorted, so to speak.

Validation and handling exceptions

Last thing I want to cover is validating addresses and handling exceptions that might occur while working with ipaddress module.

Exceptions

Validation and exception handling is where we occasionally might choose to initiate objects directly instead of using built-in factory functions. This is because factory functions provide mostly generic exception messages.

See below difference in exceptions raised for an invalid IP address when using convenience function ip_address compared to one returned when using object IPv4Address directly:

>>> ipaddress.ip_address("192.10.1.267")
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "C:\Python37-4\lib\ipaddress.py", line 54, in ip_address
    address)
ValueError: '192.10.1.267' does not appear to be an IPv4 or IPv6 address
>>>
>>> ipaddress.IPv4Address("192.10.1.267")
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "C:\Python37-4\lib\ipaddress.py", line 1303, in __init__
    self._ip = self._ip_int_from_string(addr_str)
  File "C:\Python37-4\lib\ipaddress.py", line 1142, in _ip_int_from_string
    raise AddressValueError("%s in %r" % (exc, ip_str)) from None
ipaddress.AddressValueError: Octet 267 (> 255) not permitted in '192.10.1.267'

Now, you might be fine with less generic exception if all you care about is catching errors and not doing much more than that. Where more specificity helps is in cases where we want to provide feedback on what exactly went wrong.

Notice different, detailed, message we get when either octet is > 255 or when we use prefix length when creating new Address object:

>>> ipaddress.IPv4Address("192.10.257.254/32")
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "C:\Python37-4\lib\ipaddress.py", line 1302, in __init__
    raise AddressValueError("Unexpected '/' in %r" % address)
ipaddress.AddressValueError: Unexpected '/' in '192.10.257.254/32'
>>> ipaddress.IPv4Address("192.10.1.254/33")
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "C:\Python37-4\lib\ipaddress.py", line 1302, in __init__
    raise AddressValueError("Unexpected '/' in %r" % address)
ipaddress.AddressValueError: Unexpected '/' in '192.10.1.254/33'

If we wanted to provide feedback to end user we can do it simply by catching the exception and retrieving its message.

>>> try:
...     ipaddress.IPv4Address("192.10.1.254/32")
... except ipaddress.AddressValueError as e:
...     print("Error:", e)
...     
Error: Unexpected '/' in '192.10.1.254/32'

Exceptions raised by Interface objects can be handled in same way as the ones for Network objects so we won't talk about them separately.

Example: Validating IPv4 networks

Using exception handling knowledge we will write a simple validator for IPv4 networks. Input is a string representation of IP network and we just simply want to know if it's a valid network, if it's not we return message saying why it isn't.

import ipaddress
from typing import Tuple


def validate_ipv4_net(network: str) -> Tuple[bool, str]:
    """
    Checks if string is a valid IPv4 network

    :param network: string representation of IPv4 network
    :return: tuple of (bool, str). (True, msg) if valid; (False, msg) if invalid
    """
    try:
        ipaddress.IPv4Network(network)
    except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError) as e:
        valid = False
        msg = "Provided string is not a valid network: {}.".format(e)
    else:
        valid = True
        msg = "String is a network."

    return valid, msg


nets = ["192.0.2.0/255.254.254.255", "256.0.0.0/24", "10.0.0.1/30", "172.23.1.0/33"]

for n in nets:
    print(validate_ipv4_net(n))

And below is the result of running the code snippet:

(False, "Provided string is not a valid network: '255.254.254.255' is not a valid netmask.")
(False, "Provided string is not a valid network: Octet 256 (> 255) not permitted in '256.0.0.0'.")
(False, 'Provided string is not a valid network: 10.0.0.1/30 has host bits set.')
(False, "Provided string is not a valid network: '33' is not a valid netmask.")

I find that it works quite neatly and trumps writing a long block of if... elif statements.

Summary

We've come to the end of the post series which provided comprehensive overview of functionality provided by ipaddress module. I hope the examples and discussion presented here convinced you that this is a very useful module that is indispensable when it comes to working with IP objects. No need for regexes and custom logic when Python comes in armed with such an amazing library.

I find that by having an idea of what is possible to achieve with given library is half of the success. So while you might not have use for most of what this module provides at least you will know what is possible. With this being a standard library you will be able to get to work right away.

Happy IPing!

References