Nautobot Apps and Data Model Relationships

Blog Detail

When developing a Nautobot App, there are multiple ways to integrate any new data models belonging to that App with the core data models provided by Nautobot itself. I’m writing to share a few quick tips about which approaches to choose.

Classes of Data Relationships

There are four basic classes of data relationships you might wish to implement in your App:

  1. One to One: Each record of type A relates to at most one record of type B and vice versa. For example, a VirtualChassis has at most one Device serving as the primary for that chassis, and a Device is the primary for at most one VirtualChassis.
  2. One to Many: Each record of type A relates to any number of records of type B, but each record of type B relates to at most one record of type A. For example, a Location may have many Racks, but each Rack has only one Location.
  3. Many to One: The reverse of the previous class. I’m calling it out as a separate item, because in some cases it needs to be handled differently when developing an App.
  4. Many to Many: Any number of records of type A relate to any number of records of type B. For example, a VRF might have many associated RouteTarget records as its import and export targets, and a RouteTarget might be reused across many VRF records.

Options for Implementing Data Relationships in Nautobot

The first, and seemingly easiest, approach to implement would be something like a CharField on your App’s model (or a String-type CustomField added to a core model) that identifies a related record(s) by its nameslug, or similar natural key. I’m including this only for completeness, as really you should never do this. It has many drawbacks, notably in terms of data validation and consistency. For example, there’s no inherent guarantee that the related record exists in the first place, or that it will continue to exist so long as you have a reference to it. Nautobot is built atop a relational database and as such has first-class support for representing and tracking object relationships at the database level. You should take advantage of these features instead!

The next, and most traditional, approach is to represent data relationships using native database features such as foreign keys. This has a lot of advantages, including database validation, data consistency, and optimal performance. In most cases, this will be your preferred approach when developing new data models in your App, but there are a few cases where it isn’t possible.

The final approach, which is specific to Nautobot, is to make use of Nautobot’s Relationship feature, which allows a user or developer to define arbitrary data relationships between any two models. This is an extremely powerful and flexible feature, and is especially useful to a Nautobot user who wishes to associate existing models in a new way, but from an App developer standpoint, it should often be your fallback choice rather than your first choice, because it lacks some of the performance advantages of native database constructs.

Implementing One-to-One Data Relationships

A one-to-one relationship between App data models, or between an App model and a core Nautobot model, should generally be implemented as a Django OneToOneField on the appropriate App data model. This is a special case of a ForeignKey and provides all of the same inherent performance and data consistency benefits. You can use Django features such as on_delete=models.PROTECT or on_delete=models.CASCADE to control how your data model will automatically respond when the other related model is deleted.

An example from the nautobot-firewall-models App:

class CapircaPolicy(PrimaryModel):
    """CapircaPolicy model."""

    device = models.OneToOneField(
        to="dcim.Device",
        blank=True,
        null=True,
        on_delete=models.CASCADE,
        related_name="capirca_policy",
    )

In this example, each CapircaPolicy maps to at most one Device, and vice versa. Deleting a Device will result in its associated CapircaPolicy being automatically deleted as well.

If, and only if, your App needs to define a new relationship between two core Nautobot models, you cannot use a OneToOneField because an App cannot directly modify a core model. In this case, your fallback option would be to create a one-to-one Relationship record as the way of adding this data relationship. This is a pretty rare case, so I don’t have a real-world example to point to, but it would conceptually be implemented using the nautobot_database_ready signal:

def handle_nautobot_database_ready(sender, *, apps, **kwargs):
    Relationship.objects.get_or_create(
        slug="originating_device_to_vrf",
        defaults={
            "name": "Originating Device to VRF",
            "type": RelationshipTypeChoices.TYPE_ONE_TO_ONE,
            "source_type": ContentType.objects.get_for_model(Device),
            "destination_type": ContentType.objects.get_for_model(VRF),
        },
    )

Implementing One-to-Many and Many-to-One Data Relationships

A one-to-many or many-to-one data relationship between two App models should be implemented as a standard Django ForeignKey field from the “many” model to the “one” model. The same approach works for a many-to-one relationship from an App model to a core Nautobot model.

An example from the nautobot-device-lifecycle-mgmt App:

class SoftwareLCM(PrimaryModel):
    """Software Life-Cycle Management model."""

    device_platform = models.ForeignKey(
        to="dcim.Platform",
        on_delete=models.CASCADE,
        verbose_name="Device Platform"
    )

In this example, many SoftwareLCM may all map to a single Platform, and deleting a Platform will automatically delete all such SoftwareLCM records.

Because, again, an App cannot directly modify a core model, this approach cannot be used for a one-to-many relation from an App model to a core model, or between two core models, because it would require adding a ForeignKey on the core model itself. In this case, you’ll need to create a Relationship, as in this example from the nautobot-ssot App’s Infoblox integration:

def nautobot_database_ready_callback(sender, *, apps, **kwargs):
    # ...

    # add Prefix -> VLAN Relationship
    relationship_dict = {
        "name": "Prefix -> VLAN",
        "slug": "prefix_to_vlan",
        "type": RelationshipTypeChoices.TYPE_ONE_TO_MANY,
        "source_type": ContentType.objects.get_for_model(Prefix),
        "source_label": "Prefix",
        "destination_type": ContentType.objects.get_for_model(VLAN),
        "destination_label": "VLAN",
    }
    Relationship.objects.get_or_create(name=relationship_dict["name"], defaults=relationship_dict)

Implementing Many-to-Many Data Relationships

A many-to-many data relationship involving App models should be implemented via a Django ManyToManyField. An example from the nautobot-circuit-maintenance App:

class NotificationSource(OrganizationalModel):
    # ...

    providers = models.ManyToManyField(
        Provider,
        help_text="The Provider(s) that this Notification Source applies to.",
        blank=True,
    )

One NotificationSource can provide notifications for many different Providers, and any given Provider may have multiple distinct NotificationSources.

Once again, the only exception is when a relationship between two core Nautobot models is desired, in which case use of a Relationship would be required. This is another fairly rare case and so I don’t have a real-world example to point to here, but it would follow the similar pattern to the other Relationship examples above.

Conclusion and Summary

Here’s a handy table summarizing which approach to take for various data relationships:

Model AModel BCardinalityRecommended Approach
App modelApp modelOne-to-OneOneToOneField on either model
App modelApp modelOne-to-ManyForeignKey on model B
App modelApp modelMany-to-OneForeignKey on model A
App modelApp modelMany-to-ManyManyToManyField on either model
App modelCore modelOne-to-OneOneToOneField on model A
App modelCore modelOne-to-ManyRelationship definition
App modelCore modelMany-to-OneForeignKey on model A
App modelCore modelMany-to-ManyManyToManyField on model A
Core modelCore modelOne-to-OneRelationship definition
Core modelCore modelOne-to-ManyRelationship definition
Core modelCore modelMany-to-OneRelationship definition
Core modelCore modelMany-to-ManyRelationship definition

Conclusion

I hope you’ve found this post useful. Go forth and model some data!

-Glenn



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 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!