Introduction to Python Classes – Part 3

Blog Detail

Last week we talked about class design, and this week we will discuss how to package the code for reusability. Packaging the code is useful when working on a larger project. With large projects, it is wise to have a directory structure that will allow the project to scale if additional code is required in the future.

Typically, I try to put each class in its own file. This isn’t necessary for code implementation, but it helps structure the code for future developers who might want to take a look at this project to find more easily where each class is defined.

Let’s look to see what classes we have defined so far over the past two tutorials.

Past Code

# ip_man.py
import os

import requests


class IpManager:
    """Class to assign IP prefixes in Nautobot via REST API"""

    def __init__(self):
        self.base_url = "https://demo.nautobot.com/api"
        _token = self._get_token()
        self.headers = {
            "Accept": "application/json",
            "Authorization": f"Token {_token}",
        }

    @staticmethod
    def _get_token():
        """Method to retrieve Nautobot authentication token"""
        return os.environ["NAUTOBOT_TOKEN"]

    def get_prefix(self, prefix_filter):
        """Method to retrieve a prefix from Nautobot

        Args:
            prefix_filter (dict): Dictionary supporting a Nautobot filter

        Returns:
            obj: Requests object containing Nautobot prefix

        """
        url = f"{self.base_url}/ipam/prefixes/"
        response = requests.get(url=url, headers=self.headers, params=prefix_filter)
        return response

    def new_prefix(self, parent_prefix_id, new_prefix):
        """Method to add a new prefix within a parent prefix to Nautobot

        Args:
            parent_prefix_id (str): UUID identifying a parent prefix
            new_prefix (dict): Dictionary defining new prefix

        Returns:
            obj: Requests object containing new Nautobot prefix

            >>>
        """
        url = f"{self.base_url}/ipam/prefixes/{parent_prefix_id}/available-prefixes/"
        body = new_prefix
        response = requests.post(url=url, headers=self.headers, json=body)
        return response

    def get_available_ips(self, prefix_id):
        """Method to retrieve unused available IP addresses within a prefix

        Args:
            prefix_id (str): UUID identifying a prefix

        Returns:
            obj: Request object containing list of available IP addresses within Nautobot prefix

        """
        url = f"{self.base_url}/ipam/prefixes/{prefix_id}/available-ips/"
        response = requests.get(url=url, headers=self.headers)
        return response

    def delete_prefix(self, prefix_id):
        """Method to delete a Nautobot prefix
        Args:
            prefix_id (str): UUID identifying a prefix

        Returns:
            None

        """
        url = f"{self.base_url}/ipam/prefixes/{prefix_id}/"
        response = requests.delete(url=url, headers=self.headers)
        return response
<span role="button" tabindex="0" data-code="# prefix.py class Prefix: """Class to represent network prefixes""" def __init__(self, id, prefix, **kwargs): """Accepts Nautobot prefix dictionaries as input""" self.id = id self.prefix = prefix def __str__(self): """Represents this object as a string for pretty-printing""" return f"<{self.__class__.__name__} {self.prefix}>" def __repr__(self): """Represents this object as a string for use in the REPL""" return f"
# prefix.py
class Prefix:
    """Class to represent network prefixes"""

    def __init__(self, id, prefix, **kwargs):
        """Accepts Nautobot prefix dictionaries as input"""
        self.id = id
        self.prefix = prefix

    def __str__(self):
        """Represents this object as a string for pretty-printing"""
        return f"<{self.__class__.__name__} {self.prefix}>"

    def __repr__(self):
        """Represents this object as a string for use in the REPL"""
        return f"<{self.__class__.__name__} (id={self.id}, prefix={self.prefix})>"

    def __eq__(self, other):
        """Used to determine if two objects of this class are equal to each other"""
        return bool(other.id == self.id)

    def __hash__(self):
        """Used to calculate a unique hash for this object, in case we ever want to use it in a dictionary or a set"""
        return hash(self.id)

Restructuring the Files

So far, we have defined two classes, IpManager and Prefix. I would place each of these classes in its own file (ip_man.py and prefix.py). In order to create a package, we will restructure these files in the following format:

nautobot/
    ├── __init__.py
    └── nautobot_ip_manager/
        ├── __init__.py
        ├── ip_man.py
        └── prefix.py

I have this file structure in my /home/projects directory, but you can place these files wherever you wish.

Please note, you can change the folder names and structure above to whatever you wish. For this tutorial, we will continue with the Nautobot naming scheme.

Why So Many __init__.py Files?

With packaging, each subdirectory of your code must contain an __init__.py file in order for Python to understand that this directory contains code to package. These __init__.py files do not have to contain code, but they may if that is what your project requires.

Importing Your Package

With our files now restructured, we can import our code as a package.

First, cd to the directory above Nautobot.

cd /home/projects/

Go ahead and launch your Python interpreter.

python3

To import our classes, we can use the following import statement:

<span role="button" tabindex="0" data-code="from <package> import
from <package> import <classname>

such as:

<span role="button" tabindex="0" data-code=">>> from nautobot.nautobot_ip_manager.prefix import Prefix >>> from nautobot.nautobot_ip_manager.ip_man import IpManager >>> ip_mgr = IpManager() >>> r = ip_mgr.get_prefix({"prefix":"10.0.0.0/8"}) >>> nautobot_prefix_data = r.json()["results"] >>> prefix_objects = [Prefix(**item) for item in nautobot_prefix_data] >>> prefix_objects [<Prefix (id=08dabdef-26f1-4389-a9d7-4126da74f4ec, prefix=10.0.0.0/8)>, <Prefix (id=b4e452a2-25b2-4f26-beb5-dbe2bc2af868, prefix=10.0.0.0/8)>] >>> print(prefix_objects[0])
>>> from nautobot.nautobot_ip_manager.prefix import Prefix
>>> from nautobot.nautobot_ip_manager.ip_man import IpManager

>>> ip_mgr = IpManager()
>>> r = ip_mgr.get_prefix({"prefix":"10.0.0.0/8"})
>>> nautobot_prefix_data = r.json()["results"]
>>> prefix_objects = [Prefix(**item) for item in nautobot_prefix_data]
>>> prefix_objects
[<Prefix (id=08dabdef-26f1-4389-a9d7-4126da74f4ec, prefix=10.0.0.0/8)>, <Prefix (id=b4e452a2-25b2-4f26-beb5-dbe2bc2af868, prefix=10.0.0.0/8)>]
>>> print(prefix_objects[0])
<Prefix 10.0.0.0/8>

Another way to import a file is to use the import statement directly.

<span role="button" tabindex="0" data-code=">>> import nautobot.nautobot_ip_manager.prefix >>> import nautobot.nautobot_ip_manager.ip_man >>> ip_mgr = nautobot.nautobot_ip_manager.ip_man.IpManager() >>> r = ip_mgr.get_prefix({"prefix":"10.0.0.0/8"}) >>> nautobot_prefix_data = r.json()["results"] >>> prefix_objects = [nautobot.nautobot_ip_manager.prefix.Prefix(**item) for item in nautobot_prefix_data] >>> prefix_objects [<Prefix (id=08dabdef-26f1-4389-a9d7-4126da74f4ec, prefix=10.0.0.0/8)>, <Prefix (id=b4e452a2-25b2-4f26-beb5-dbe2bc2af868, prefix=10.0.0.0/8)>] >>> print(prefix_objects[0])
>>> import nautobot.nautobot_ip_manager.prefix
>>> import nautobot.nautobot_ip_manager.ip_man

>>> ip_mgr = nautobot.nautobot_ip_manager.ip_man.IpManager()
>>> r = ip_mgr.get_prefix({"prefix":"10.0.0.0/8"})
>>> nautobot_prefix_data = r.json()["results"]
>>> prefix_objects = [nautobot.nautobot_ip_manager.prefix.Prefix(**item) for item in nautobot_prefix_data]
>>> prefix_objects
[<Prefix (id=08dabdef-26f1-4389-a9d7-4126da74f4ec, prefix=10.0.0.0/8)>, <Prefix (id=b4e452a2-25b2-4f26-beb5-dbe2bc2af868, prefix=10.0.0.0/8)>]
>>> print(prefix_objects[0])
<Prefix 10.0.0.0/8>

What if I don’t want to store each class in its own file?

Storing each class in its own file helps with code structure, but it may become cumbersome when trying to import many classes.

Another way to structure the code is to place each class in the __init__.py file under nautobot_ip_manager.

nautobot/
    ├── __init__.py
    └── nautobot_ip_manager/
        └── __init__.py
<span role="button" tabindex="0" data-code="# __init__.py import os import requests class IpManager: """Class to assign IP prefixes in Nautobot via REST API""" def __init__(self): self.base_url = "https://demo.nautobot.com/api" _token = self._get_token() self.headers = { "Accept": "application/json", "Authorization": f"Token {_token}", } @staticmethod def _get_token(): """Method to retrieve Nautobot authentication token""" return os.environ["NAUTOBOT_TOKEN"] def get_prefix(self, prefix_filter): """Method to retrieve a prefix from Nautobot Args: prefix_filter (dict): Dictionary supporting a Nautobot filter Returns: obj: Requests object containing Nautobot prefix """ url = f"{self.base_url}/ipam/prefixes/" response = requests.get(url=url, headers=self.headers, params=prefix_filter) return response def new_prefix(self, parent_prefix_id, new_prefix): """Method to add a new prefix within a parent prefix to Nautobot Args: parent_prefix_id (str): UUID identifying a parent prefix new_prefix (dict): Dictionary defining new prefix Returns: obj: Requests object containing new Nautobot prefix >>> """ url = f"{self.base_url}/ipam/prefixes/{parent_prefix_id}/available-prefixes/" body = new_prefix response = requests.post(url=url, headers=self.headers, json=body) return response def get_available_ips(self, prefix_id): """Method to retrieve unused available IP addresses within a prefix Args: prefix_id (str): UUID identifying a prefix Returns: obj: Request object containing list of available IP addresses within Nautobot prefix """ url = f"{self.base_url}/ipam/prefixes/{prefix_id}/available-ips/" response = requests.get(url=url, headers=self.headers) return response def delete_prefix(self, prefix_id): """Method to delete a Nautobot prefix Args: prefix_id (str): UUID identifying a prefix Returns: None """ url = f"{self.base_url}/ipam/prefixes/{prefix_id}/" response = requests.delete(url=url, headers=self.headers) return response class Prefix: """Class to represent network prefixes""" def __init__(self, id, prefix, **kwargs): """Accepts Nautobot prefix dictionaries as input""" self.id = id self.prefix = prefix def __str__(self): """Represents this object as a string for pretty-printing""" return f"<{self.__class__.__name__} {self.prefix}>" def __repr__(self): """Represents this object as a string for use in the REPL""" return f"
# __init__.py
import os

import requests


class IpManager:
    """Class to assign IP prefixes in Nautobot via REST API"""

    def __init__(self):
        self.base_url = "https://demo.nautobot.com/api"
        _token = self._get_token()
        self.headers = {
            "Accept": "application/json",
            "Authorization": f"Token {_token}",
        }

    @staticmethod
    def _get_token():
        """Method to retrieve Nautobot authentication token"""
        return os.environ["NAUTOBOT_TOKEN"]

    def get_prefix(self, prefix_filter):
        """Method to retrieve a prefix from Nautobot

        Args:
            prefix_filter (dict): Dictionary supporting a Nautobot filter

        Returns:
            obj: Requests object containing Nautobot prefix

        """
        url = f"{self.base_url}/ipam/prefixes/"
        response = requests.get(url=url, headers=self.headers, params=prefix_filter)
        return response

    def new_prefix(self, parent_prefix_id, new_prefix):
        """Method to add a new prefix within a parent prefix to Nautobot

        Args:
            parent_prefix_id (str): UUID identifying a parent prefix
            new_prefix (dict): Dictionary defining new prefix

        Returns:
            obj: Requests object containing new Nautobot prefix

            >>>
        """
        url = f"{self.base_url}/ipam/prefixes/{parent_prefix_id}/available-prefixes/"
        body = new_prefix
        response = requests.post(url=url, headers=self.headers, json=body)
        return response

    def get_available_ips(self, prefix_id):
        """Method to retrieve unused available IP addresses within a prefix

        Args:
            prefix_id (str): UUID identifying a prefix

        Returns:
            obj: Request object containing list of available IP addresses within Nautobot prefix

        """
        url = f"{self.base_url}/ipam/prefixes/{prefix_id}/available-ips/"
        response = requests.get(url=url, headers=self.headers)
        return response

    def delete_prefix(self, prefix_id):
        """Method to delete a Nautobot prefix
        Args:
            prefix_id (str): UUID identifying a prefix

        Returns:
            None

        """
        url = f"{self.base_url}/ipam/prefixes/{prefix_id}/"
        response = requests.delete(url=url, headers=self.headers)
        return response

class Prefix:
    """Class to represent network prefixes"""

    def __init__(self, id, prefix, **kwargs):
        """Accepts Nautobot prefix dictionaries as input"""
        self.id = id
        self.prefix = prefix

    def __str__(self):
        """Represents this object as a string for pretty-printing"""
        return f"<{self.__class__.__name__} {self.prefix}>"

    def __repr__(self):
        """Represents this object as a string for use in the REPL"""
        return f"<{self.__class__.__name__} (id={self.id}, prefix={self.prefix})>"

    def __eq__(self, other):
        """Used to determine if two objects of this class are equal to each other"""
        return bool(other.id == self.id)

    def __hash__(self):
        """Used to calculate a unique hash for this object, in case we ever want to use it in a dictionary or a set"""
        return hash(self.id)

We will be able to import both of these classes now using one import statement.

<span role="button" tabindex="0" data-code=">>> from nautobot.nautobot_ip_manager import Prefix, IpManager >>> Prefix <class 'nautobot.nautobot_ip_manager.Prefix'> >>> IpManager
>>> from nautobot.nautobot_ip_manager import Prefix, IpManager
>>> Prefix
<class 'nautobot.nautobot_ip_manager.Prefix'>
>>> IpManager
<class 'nautobot.nautobot_ip_manager.IpManager'>

Conclusion

Packaging your code helps with scalability and code reusability. Without packaging, working on a larger project can devolve into a giant mess. I hope this helps with understanding how packaging works!

Thanks!

-Khurram



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Introduction to Python Classes – Part 2

Blog Detail

Last week we started a series on Python classes, and this week we’re continuing to talk about class design. We’re going to look at what it means when underscores (_s) appear at the beginning and/or end of a name in Python, and when you might want to use them or avoid using them in your own classes. We’ll be building on the example class introduced last week (below). First, a quick note on terminology: In Python parlance, “dunder” is short for “double underscore”, and it can refer to something with two underscores in front, like __method, or two underscores in front and behind, like __init__ (so one would usually pronounce __init__ “dunder init”).

# ip_man.py
import os

import requests


class IpManager:
    """Class to assign IP prefixes in Nautobot via REST API"""

    def __init__(self):
        self.base_url = "https://demo.nautobot.com/api"
        _token = self._get_token()
        self.headers = {
            "Accept": "application/json",
            "Authorization": f"Token {_token}",
        }

    @staticmethod
    def _get_token():
        """Method to retrieve Nautobot authentication token"""
        return os.environ["NAUTOBOT_TOKEN"]

    def get_prefix(self, prefix_filter):
        """Method to retrieve a prefix from Nautobot

        Args:
            prefix_filter (dict): Dictionary supporting a Nautobot filter

        Returns:
            obj: Requests object containing Nautobot prefix

        """
        url = f"{self.base_url}/ipam/prefixes/"
        response = requests.get(url=url, headers=self.headers, params=prefix_filter)
        return response

    def new_prefix(self, parent_prefix_id, new_prefix):
        """Method to add a new prefix within a parent prefix to Nautobot

        Args:
            parent_prefix_id (str): UUID identifying a parent prefix
            new_prefix (dict): Dictionary defining new prefix

        Returns:
            obj: Requests object containing new Nautobot prefix

            >>>
        """
        url = f"{self.base_url}/ipam/prefixes/{parent_prefix_id}/available-prefixes/"
        body = new_prefix
        response = requests.post(url=url, headers=self.headers, json=body)
        return response

    def get_available_ips(self, prefix_id):
        """Method to retrieve unused available IP addresses within a prefix

        Args:
            prefix_id (str): UUID identifying a prefix

        Returns:
            obj: Request object containing list of available IP addresses within Nautobot prefix

        """
        url = f"{self.base_url}/ipam/prefixes/{prefix_id}/available-ips/"
        response = requests.get(url=url, headers=self.headers)
        return response

    def delete_prefix(self, prefix_id):
        """Method to delete a Nautobot prefix
        Args:
            prefix_id (str): UUID identifying a prefix

        Returns:
            None

        """
        url = f"{self.base_url}/ipam/prefixes/{prefix_id}/"
        response = requests.delete(url=url, headers=self.headers)
        return response

One Trailing Underscore

One of the most notoriously difficult parts of programming is naming things. I often find myself wanting to name a variable idhashpass, or any number of words that are sadly already taken by Python builtins and the standard library. Python gives you the tools of your own demise here: it is possible to shadow (take the name of) builtins. Consider the following code:

<span role="button" tabindex="0" data-code=">>> s = "Hello World" >>> print(s) Hello World >>> print(id(s)) 4421427760 >>> id = 5 >>> print(id(s)) Traceback (most recent call last): File "<stdin>", line 1, in
>>> s = "Hello World"
>>> print(s)
Hello World
>>> print(id(s))
4421427760
>>> id = 5
>>> print(id(s))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'int' object is not callable

While id is a built-in part of Python, there’s nothing stopping me from using id as a variable name. If I do this, and then later in my code I use “id”, I might now be referring to the wrong “id” (depending on the context), and I’ve made my code at least difficult to understand, if not actually buggy. Adding an underscore to the ends of variable names is a common convention to solve this problem, so id would become id_, for example.

One Leading Underscore

A leading underscore is a design choice that says to users, “Don’t touch this, please.” Methods, variables, etc. with one leading underscore are not considered part of a class or module’s public API. This is a PEP-8 naming convention. In our example class, we named _get_token with a leading underscore. By choosing to start the name with an underscore, we signaled to consumers of our class that they should probably not call this method directly. And that’s appropriate in the case of IpManager, because if someone is importing this class and using it in their code, they’re probably not interested in the functionality that this method provides, but in the other parts of IpManager that provide useful features. Note that this is just a convention; nothing in Python forces methods or variables with one leading underscore to actually be “private,” but users who access these internals directly should be aware that the results may be unpredictable.

Two Leading Underscores

Two leading underscores is a design choice that says to users, “Don’t touch this, please. I really mean it.” It invokes name mangling. These methods and variables can still be accessed by outside code, but the outside code has to work slightly harder. For example:

<span role="button" tabindex="0" data-code=">>> class A: def __method(self): """A "private" method that we don't want others to call directly.""" print("I am a method in A") def method(self): """A "public" method that relies on a private method.""" self.__method() >>> obj = A() >>> obj.method() I am a method in A >>> obj.__method() Traceback (most recent call last): File "<stdin>", line 1, in
>>> class A:
        def __method(self):
            """A "private" method that we don't want others to call directly."""
            print("I am a method in A")
        def method(self):
            """A "public" method that relies on a private method."""
            self.__method()
>>> obj = A()
>>> obj.method()
I am a method in A
>>> obj.__method()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'A' object has no attribute '__method'
>>> obj._A__method()
I am a method in A

Notice that while code in the A class itself can call self.__method(), code outside of the class has to use the mangled name obj._A__method in order to access that method directly. Continuing with this example class, if we wrote

class B(A):
    pass
>>> b = B()

then there would be no b.__method, even though b is an instance of B which inherits from A, and therefore has all of A’s methods by default. But b does have _A__method, which is what name mangling buys you. So as a design pattern, this can be used to hide methods, even from inheritor classes.

Two Leading and Two Trailing Underscores

And finally, two underscores in front and two behind indicates a “magic” method or variable. These are usually things that Python objects get for free, but you can reference them (or override them) directly if you need to. Examples include comparison operators like __eq__, and even certain globals like __name__ (of if __name__ == "__main__": fame).

Consider the contents of the IpManager class (above). We defined six methods, including __init__, but if we look at the contents of the class once it’s loaded into the interpreter, there’s much more there:

>>> from ip_man import IpManager
>>> dir(IpManager)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_get_token', 'delete_prefix', 'get_available_ips', 'get_prefix', 'new_prefix']

These magic methods were all applied to our class automatically by Python. The reason for this is that a lot of basic functionality under the hood in Python depends on objects having these methods. For example, the __str__ magic method is what the interpreter checks when it wants to convert an object to a string:

<span role="button" tabindex="0" data-code=">>> ip_mgr = IpManager() >>> str(ip_mgr) '<ip_man.IpManager object at 0x10de09cf0>' >>> ip_mgr.__str__() '
>>> ip_mgr = IpManager()
>>> str(ip_mgr)
'<ip_man.IpManager object at 0x10de09cf0>'
>>> ip_mgr.__str__()
'<ip_man.IpManager object at 0x10de09cf0>'

And other common operations like equality are implemented the same way:

>>> ip_mgr == 1
False
>>> ip_mgr == ip_mgr
True
>>> ip_mgr.__eq__(ip_mgr)
True

Designing a Class

So let’s take what we’ve learned and build a new class! Say we need to write a program to do some logic involving network prefixes. We might decide to write an object-oriented class to represent the concept of a prefix, so that we can then work with a bunch of these objects to figure out things like: Where are they being used in our infrastructure? Do they overlap? Are we aware of prefixes that still need to be imported to Nautobot? etc. The prefix objects we retrieve from Nautobot using IpManager each have an ID and a representation of the prefix in CIDR notation, as well as other properties. For now, our prefix class will keep track of just those two attributes, but we can expand it in the future to cover more attributes as our needs evolve:

<span role="button" tabindex="0" data-code="class Prefix: """Class to represent network prefixes""" def __init__(self, id, prefix, **kwargs): """Accepts Nautobot prefix dictionaries as input""" self.id = id self.prefix = prefix def __str__(self): """Represents this object as a string for pretty-printing""" return f"<{self.__class__.__name__} {self.prefix}>" def __repr__(self): """Represents this object as a string for use in the REPL""" return f"<{self.__class__.__name__} (id={self.id}, prefix={self.prefix})>" def __eq__(self, other): """Used to determine if two objects of this class are equal to each other""" return bool(other.id == self.id) def __hash__(self): """Used to calculate a unique hash for this object, in case we ever want to use it in a dictionary or a set""" return hash(self.id) >>> ip_mgr = IpManager() >>> r = ip_mgr.get_prefix({"prefix":"10.0.0.0/8"}) >>> nautobot_prefix_data = r.json()["results"] >>> prefix_objects = [Prefix(**item) for item in nautobot_prefix_data] >>> prefix_objects [<Prefix (id=08dabdef-26f1-4389-a9d7-4126da74f4ec, prefix=10.0.0.0/8)>, <Prefix (id=b4e452a2-25b2-4f26-beb5-dbe2bc2af868, prefix=10.0.0.0/8)>] >>> print(prefix_objects[0])
class Prefix:
    """Class to represent network prefixes"""

    def __init__(self, id, prefix, **kwargs):
        """Accepts Nautobot prefix dictionaries as input"""
        self.id = id
        self.prefix = prefix

    def __str__(self):
        """Represents this object as a string for pretty-printing"""
        return f"<{self.__class__.__name__} {self.prefix}>"

    def __repr__(self):
        """Represents this object as a string for use in the REPL"""
        return f"<{self.__class__.__name__} (id={self.id}, prefix={self.prefix})>"

    def __eq__(self, other):
        """Used to determine if two objects of this class are equal to each other"""
        return bool(other.id == self.id)

    def __hash__(self):
        """Used to calculate a unique hash for this object, in case we ever want to use it in a dictionary or a set"""
        return hash(self.id)

>>> ip_mgr = IpManager()
>>> r = ip_mgr.get_prefix({"prefix":"10.0.0.0/8"})
>>> nautobot_prefix_data = r.json()["results"]
>>> prefix_objects = [Prefix(**item) for item in nautobot_prefix_data]
>>> prefix_objects
[<Prefix (id=08dabdef-26f1-4389-a9d7-4126da74f4ec, prefix=10.0.0.0/8)>, <Prefix (id=b4e452a2-25b2-4f26-beb5-dbe2bc2af868, prefix=10.0.0.0/8)>]
>>> print(prefix_objects[0])
<Prefix 10.0.0.0/8>

Here we’ve implemented several custom magic methods. There are many more, but there’s no need to override them all if we don’t need them yet. Use of magic methods where appropriate can be a fantastic design pattern, because it lets us implement features in a way that users of our code will be able to take advantage of with little to no special knowledge of our code. For example, if we provided a string representation of a Prefix via a method called Prefix.to_string, that would be great, but by using Prefix.__str__ we allow people to simply call str(prefix) or print(prefix) and have it work as expected.

Note also that even though id in the __init__ method shadows the builtin id, I chose to leave it there instead of renaming it to id_ because in this case, we want to accept prefix config from Nautobot, and it uses id, not id_, so I made a design choice to keep the class easy to use. If I were going to send this code through a linter, I might have to add a comment explaining that this isn’t a problem in this case. But if at some point in the future I wrote some code further down in the same module that needed to use the builtin id, I’d be setting myself up for failure.


Conclusion

I hope this clears up some of the confusion around the special meaning of underscores on the edges of Python names. They can be used to avoid name clashes, indicate private methods and variables, and implement Magic methods. Magic methods can be incredibly powerful, and should be considered when designing any class. The Prefix class above was just an example, but if you need a class like this, consider writing a more robust version and contributing it to Netutils so the whole community can benefit!

This series continues next week with a discussion of packaging — until then!

-Micah



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Introduction to Python Classes – Part 1

Blog Detail

In this blog series we are going to provide an introduction to Python classes. The first blog will look at what classes are and how we can create and use a custom class. The second blog will explain the Python approach to private methods and variables including use of underscores when naming class attributes and methods. The final blog describes creating Python packages to help organize our code and promote reuse.

Python Classes/Objects

In Python you will interact with objects in almost all code you develop. A Python object is a collection (or encapsulation) of related variables (known as attributes) and functions (methods). The attributes define the objects state, and we use the methods to alter that state. If you are familiar with Python, you have already worked with Python objects, for example ‘str’, ‘int’, ‘list’, ‘dict’. Every object has a type, and we use Python classes to create new objects. For example, list() in Python is a class. And when we create a new list in Python terminology, we would say we are creating an instance (object) of the list class.

Let’s look at an example.

<span role="button" tabindex="0" data-code=">>> routers = ['R1', 'R2', 'R3'] >>> type(routers)
>>> routers = ['R1', 'R2', 'R3']
>>> type(routers)
<class 'list'>
>>> 

Here we have created a new list called routers and can see that its type is class ‘list’. In other words, ‘routers’ is an instance (object) of the ‘list’ class.

If we want to see all the methods of the list class, we can use the dir() function.

>>> dir(devices)
['__add__', '__class__', '__class_getitem__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__iadd__', '__imul__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__rmul__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'append', 'clear', 'copy', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort']
>>> 

You will notice there are many methods associated with a list. The methods with two prefix and suffix underscores are known as special/magic methods. The second blog in this series will discuss these in further detail. For now it is important to know these methods have special meaning internal to a class and are not intended to be called directly. This leaves us with a number of ‘normal’ methods that you may be familiar with that allow us to change the behavior of our objects state (i.e., the elements of the list).

For example, to add an item to our list, we can call the ‘append’ method:

>>> routers.append('R4')
>>> routers
['R1', 'R2', 'R3', 'R4']
>>> 

Or to delete an element from our list, we can call ‘pop’:

>>> routers.pop(1)
'R2'
>>> routers
['R1', 'R3', 'R4']
>>> 

We have seen an example of creating objects using a standard library class, but what if we want to create our own custom objects? First, we must define a class using the ‘class’ statement. The class is a template (or blueprint) for creating new objects, and it is here that we define the attributes and methods of our new class.

Advantages of Classes

Before we take a look at creating our own custom class, it is worthwhile listing the primary advantages of doing so.

  1. Classes provide a mechanism for grouping related variables and functions, especially useful when you have a requirement to manage and act upon data (state).
  2. Grouping of related functions helps promote modularity, readability, and reuse.
  3. Further promoting code reuse is the ability to use inheritance, whereby a new class can inherit the properties of an existing class, an important feature of Object Oriented Programming.
  4. When using inheritance, you can override any method by using the same name, arguments, and return type (known as method overriding). This allows you to reuse code while also customizing methods to suit your own specific needs.

How to Define a Class

To create our own custom object, we use the ‘class’ statement. For this example, we are going to create a class to reserve IP prefixes in Nautobot using the Nautobot REST API.

Our use case for this new Python class is to automate the reservation of prefixes to be used for later deployment on network devices. In our example we have assigned a parent prefix to each site for point-to-point connectivity (e.g., network device interconnects). Each child prefix in this parent has a prefix length of /31, allowing for two usable IP addresses. In order to assign an unused /31 point-to-point prefix, our class must support the following methods:

  1. Locate the parent prefix with options to filter by tenant, site, role (e.g., ‘point-to-point’), status, etc.
  2. Create a new unassigned prefix within the parent prefix container.
  3. List all available IP addresses within a child prefix.
  4. A delete option to support the decommissioning of prefixes.

With these objectives in mind, let’s take a look at our new class.

As a best practice, docstrings are used on our class and methods. The docstring can be accessed via the ‘__doc__’ special method, for example, print(IpManager.get_prefixes.__doc__).

import os

import requests


class IpManager:
    """Class to assign IP prefixes in Nautobot via REST API"""

    def __init__(self):
        self.base_url = "https://demo.nautobot.com/api"
        _token = self._get_token()
        self.headers = {
            "Accept": "application/json",
            "Authorization": f"Token {_token}",
        }
        
    @staticmethod
    def _get_token():
        """Method to retrieve Nautobot authentication token"""
        return os.environ["NAUTOBOT_TOKEN"]

    def get_prefix(self, prefix_filter):
        """Method to retrieve a prefix from Nautobot

        Args:
            prefix_filter (dict): Dictionary supporting a Nautobot filter

        Returns:
            obj: Requests object containing Nautobot prefix

        """
        url = f"{self.base_url}/ipam/prefixes/"
        response = requests.get(url=url, headers=self.headers, params=prefix_filter)
        return response

    def new_prefix(self, parent_prefix_id, new_prefix):
        """Method to add a new prefix within a parent prefix to Nautobot

        Args:
            parent_prefix_id (str): UUID identifying a parent prefix
            new_prefix (dict): Dictionary defining new prefix

        Returns:
            obj: Requests object containing new Nautobot prefix

            >>>
        """
        url = f"{self.base_url}/ipam/prefixes/{parent_prefix_id}/available-prefixes/"
        body = new_prefix
        response = requests.post(url=url, headers=self.headers, json=body)
        return response

    def get_available_ips(self, prefix_id):
        """Method to retrieve unused available IP addresses within a prefix

        Args:
            prefix_id (str): UUID identifying a prefix

        Returns:
            obj: Request object containing list of available IP addresses within Nautobot prefix

        """
        url = f"{self.base_url}/ipam/prefixes/{prefix_id}/available-ips/"
        response = requests.get(url=url, headers=self.headers)
        return response

    def delete_prefix(self, prefix_id):
        """Method to delete a Nautobot prefix
        Args:
            prefix_id (str): UUID identifying a prefix

        Returns:
            None

        """
        url = f"{self.base_url}/ipam/prefixes/{prefix_id}/"
        response = requests.delete(url=url, headers=self.headers)
        return response

As you can see, we have a number of methods defined to meet our objectives. Class methods are defined using the ‘def’ statement as we would when defining Python functions. You may have also noticed each method has a first argument of ‘self’, and we have defined an interesting-looking method called ‘__init__’. Let’s take a closer look at both of these.

The ‘self’ Argument

The ‘self’ argument of class methods has special meaning, and when used is always the first argument. Class methods operate on an instance of the class. To enable this, it is necessary to pass the instance as an argument to each method. This is the purpose of the ‘self’ argument. We could give this argument any arbitrary name, but it is advisable to stick to the convention of using ‘self’. Having passed ‘self’ as an argument, each method has access to all the instances attributes. An example can be seen in our code, where ‘self.headers’ is declared in __init__ and used in other methods.

A class method omitting the ‘self’ argument is known as a static method. A static method is loosely coupled to the class and does not require access to the class object. In our code, ‘_get_token()’ is an example of a static method.

The __init__ Method

def __init__(self):
        self.base_url = "https://demo.nautobot.com/api"
        _token = self._get_token()
        self.headers = {
            "Accept": "application/json",
            "Authorization": f"Token {_token}",
        }

__init__ is an example of a special/magic method and has special meaning. The double underscore is called a dunder and will be covered in the second post of this series. Whenever we instantiate an object, the code in the dunder init method is executed to initialize state of the new object. In our IpManager class, the dunder init method sets the base_url, retrieves the authentication token, and sets the HTTP headers.

As a simple example, we can add a print statement to a class to demonstrate the execution of dunder init code on instantiation:

>>> class SimpleClass():
...     def __init__(self):
...         print("Class Instance created")
... 
>>> a = SimpleClass()
Class Instance created
>>> 

Variables

You will notice two of the dunder init variables are prefixed with self and one is not. A Python class can have class, instance, or local variables. The difference between each type is the namespace they operate in.

  • A class variable will persist across all instances of the class.
  • An instance variable is prefixed ‘self.’ and is significant to each instance of the class independently.
  • A local variable (for example _token) is significant only within the function in which it is declared.

Using Our New Class

We will now demonstrate use of our new class using the Python REPL.

Import and Instantiate the Class

First we must import our class from our newly created module (ip_man.py) and create a new instance. We use the dir() function on our object to list its attributes and methods as created in our class above.


>>> from ip_man import IpManager
>>> ip_mgr = IpManager()
>>> dir(ip_mgr)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_get_token', 'base_url', 'delete_prefix', 'get_available_ips', 'get_prefix', 'headers', 'new_prefix']
>>> 

For this exercise, we are using the demo instance of Nautobot found at https://demo.nautobot.com. The goal is to assign a new /31 point-to-point prefix in the ‘ATL01’ site of the ‘Nautobot Airports’ tenant. The container prefix 10.0.192.0/18 is assigned to this site for point-to-point prefixes. From the output below we can see the most recent assignment is 10.0.192.34/31.

Using Our New Class

Retrieve the Parent Prefix

To create a new prefix, we need the prefix_id of the parent prefix. For this we will create a dictionary defining the filters necessary to retrieve the parent prefix. A status code of ‘200’ signifies our API call was successful.

>>> prefix_details = {
...     "tenant": "nautobot-airports",
...     "site": "atl01",
...     "role": "point-to-point",
...     "status": "container",
... }
>>> prefix = ip_mgr.get_prefix(prefix_details)
>>> prefix.status_code
200
>>> prefix.json()['results'][0]['display']
'10.0.192.0/18'
>>> 

Note the ‘results’ object is a list. It is possible Nautobot will return multiple prefixes matching the filters, each a separate element in the list. In such a scenario additional logic is required to determine which parent prefix has availability to add a new prefix.

Create a New Prefix

We now call the new_prefix method to create a new prefix. As per our class code, this method uses Nautobot’s available-prefixes endpoint to create a new prefix within the parent. A ‘201’ status code signifies a new prefix has been successfully created. As seen from the REPL output and Nautobot, the next available prefix 10.0.192.36/31 was created.

>>> new_prefix_details = {
...     "prefix_length": 31,
...     "tenant": prefix.json()["results"][0]["tenant"]["id"],
...     "site": prefix.json()["results"][0]["site"]["id"],
...     "role": prefix.json()["results"][0]["role"]["id"],
...     "is_pool": True,
...     "status": "p2p",
... }
>>> new_prefix = ip_mgr.new_prefix(prefix.json()["results"][0]["id"], new_prefix_details)
>>> new_prefix.status_code
201
>>> new_prefix.json()['display']
'10.0.192.36/31'
>>> 
Create a New Prefix

List Available IP Addresses in New Prefix

Using the ‘available_ips’ method, we can list the IP addresses available in our new prefix for assignment to network devices. As expected, we have two IP addresses available in the /31 subnet.

>>> available_ips = ip_mgr.get_available_ips(new_prefix.json()["id"])
>>> available_ips.status_code
200
>>> available_ips.json()
[{'family': 4, 'address': '10.0.192.36/31', 'vrf': None}, {'family': 4, 'address': '10.0.192.37/31', 'vrf': None}]
>>>

Tidy Up – Delete a Prefix

In the future if you want to decommission a prefix, you must first retrieve the prefix_id before calling the delete_prefix method. Let’s return Nautobot to the state we found it in by deleting our newly created prefix via the ‘delete_prefix’ method. A status code of ‘204’ confirms the deletion was successful.

>>> prefix_details = {"prefix": "10.0.192.36/31"} >>> prefix 
= ip_mgr.get_prefix(prefix_details) >>> del_response = ip_mgr.delete_prefix(prefix.json()["results"][0]["id"]) >>> del_response.status_code 204 >>>

Conclusion

I hope you found this introduction to Python classes helpful. If you are interested in building a module to interact with Nautobot via REST API, next steps to improve the module could include assigning IP addresses within a prefix to network device interfaces within Nautobot.

Having created this module, we can reuse the code in our Python projects. Examples include assigning prefixes as part of new branch deployment or adding/modifying infrastructure in a Data Center which may require loopback, point-to-point, and server prefixes. Stay tuned for the next blog post in the series, where we describe in more depth the use and meaning of underscores in variable and method naming.

-Nicholas


Tags :

ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!