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.
>>> 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.
- Classes provide a mechanism for grouping related variables and functions, especially useful when you have a requirement to manage and act upon data (state).
- Grouping of related functions helps promote modularity, readability, and reuse.
- 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.
- 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:
- Locate the parent prefix with options to filter by tenant, site, role (e.g., ‘point-to-point’), status, etc.
- Create a new unassigned prefix within the parent prefix container.
- List all available IP addresses within a child prefix.
- 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.
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'
>>>
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
Contact Us to Learn More
Share details about yourself & someone from our team will reach out to you ASAP!