Using Python Properties to Define Attributes

Blog Detail

A previous post introduced Python objects. That post primarily presented creating and using data attributes on objects. The focus there was on how data attributes behave; therefore only a simple interface for getting, setting, and deleting attributes was shown. Some problems with defining attributes ad hoc are:

  • The attributes are not guaranteed to exist on the object, and their data type is not defined
  • The attributes are unsafe, since users can overwrite an attribute’s value
  • The attributes cannot be documented in a consumable manner by the users

This entry will expand on that discussion to explain how to use properties as a means of standardizing, controlling, and documenting data attributes. Standardizing the interface creates a contract between the developers and users on what attributes will be available, and what information the attributes provide. Putting controls in place to prevent users from altering an attribute in violation of the contract helps to ensure the safety of other code that depends on the attribute. Documenting the attributes informs users of this contract.

Defining a Property

A property is a formal definition of a data attribute. The most common way of defining a property is using the @property decorator around the attribute definition. The following demonstrates creating a hostname property.

class Device:
    def __init__(self, host, username, password):
        self.host = host
        self.username = username
        self.password = password
        self.connection = self.connect()

    def connect(self):
        ...

    # Defining the hostname property
    @property
    def hostname(self):
        """The hostname of the device."""
        hostname = self.connection.send("show hostname")
        return hostname.strip()
>>> device = Device("switch01.business.com", "user", "pass")
>>> device.hostname
'switch01'

Note that properties are not callables (they do not support passing args or using parenthesis), as per the standard data attribute interface.

Caching a Property’s Value

The above example uses an established connection to retrieve the hostname from the device every time the property is accessed. When the property represents an object external to the codebase (as the example of a network device), it might be more desirable to fetch the information only the first time it is accessed. The property would then cache the result from the fetch, and return the cached data for all subsequent requests.

In order to cache the property’s data, a second, non-public attribute is needed for storing data. A typical implementation uses the property’s name, prefaced with either single or double underscores. The initial value of the attribute is set to None, and updated upon accessing the property.

class Device:
    def __init__(self, host, username, password):
        self.host = host
        self.username = username
        self.password = password
        self.connection = self.connect()
        # set cache to None
        self._hostname = None

    def connect(self):
        ...

    @property
    def hostname(self):
        """The hostname of the device."""
        # Checking if data needs to be retrieved
        if self._hostname is None:
            print("Fetching hostname")
            hostname = self.connection.send("show hostname")
            # Store hostname data in cache
            self._hostname = hostname.strip()
        else:
            print("Using cached hostname")

        return self._hostname
>>> device = Device("switch01.business.com", "user", "pass")
>>> device.hostname
'Fetching hostname'
'switch01'
>>> device.hostname
'Using cached hostname'
'switch01'

Considerations when Caching Data

Caching data can result in providing stale, incorrect data. The simple caching method used above does not ever invalidate the cached data, so the more time that passes from when the data is first fetched, the more likely the data is no longer valid. The example of hostname might be considered trivial, but data such as ARP, routing tables, and hardware status could have severe consequences when invalid data is returned.

If deciding to cache data, there are more advanced techniques that can be used for invalidating or refreshing data; answering these questions can help in deciding if implementing a more complex caching solution is worth the effort.

  • Can the data be changed?
  • Will the data be changed outside of the software doing the caching?
  • How frequently is the data changed?
  • Is accuracy or speed more important?
  • Will the data be fetched frequently?
  • What are the risks of returning stale data?
  • Will the code be used for long-running applications, or short-lived scripts?

Data that cannot be changed, or will only be updated by the application, can safely be cached indefinitely. Whereas data that changes frequently by external systems is better suited for not using a cache at all. Prioritizing speed over accuracy favors caching the data. However, if the data isn’t accessed frequently, then fetching it each time might be tolerable. There is greater value in more sophisticated caching solutions for code that is consumed by long-running applications, as it is more likely for data to be invalid the longer it is cached.

Getters, Setters, and Deleters

So far, only a property’s getter method has been discussed. Properties also support setter and deleter methods for setting and deleting the property’s value. These methods are defined by using a decorator of the name of the getter method, and attaching .setter and .deleter to it. Defining the setter and deleter methods are optional, but since they depend on the getter method in their definition, the getter method is required and must be defined first. If they are not explicitly defined, then an AttributeError is raised when an attempt is made to set or delete the attribute.

class Device:
    def __init__(self, host, username, password):
        ...
        self._hostname = None

    # Define the getter method first
    @property
    def hostname(self):
        """The hostname of the device."""
        if self._hostname is None:
            hostname = self.connection.send("show hostname")
            self._hostname = hostname.strip()
        return self._hostname

    # Define the hostname setter method
    @hostname.setter
    def hostname(self, value):
        self._hostname = value

    # Define the hostname deleter method
    @hostname.deleter
    def hostname(self):
        self._hostname = None

Implementing Setters

As the example above shows, when using a cache to store an attribute’s value, the setter stores the data in the cache. If a cache is not being used, then either the object being represented would be updated (i.e. the device’s hostname would be configured with the value), or the setter method would not be implemented. When using a cache, the data can update the represented object when the value is set, but it is common to provide a method to sync local updates with the remote device in a bulk action. This limits the amount of times required to have blocking I/O operations, and also allows for implementing a diff method for viewing all changes that would be pushed before syncing the updates. If the attribute’s data is not synced upon setting the value on the property, then it is important that the caching solution used does not lose data that has not been pushed to the represented object.

Syncing Data when Setter is Called

class Device:
    def __init__(self, host, username, password):
        ...
        self.connection = self.connect()
        self._hostname = None

    def connect(self):
        ...

    @property
    def hostname(self):
        """The hostname of the device."""
        if self._hostname is None:
            hostname = self.connection.send("show hostname")
            self._hostname = hostname.strip()
        return self._hostname

    @hostname.setter
    def hostname(self, value):
        # Sync data with the device being represented
        self.connection.configure(f"hostname {value}")
        # Update cache value with new value
        self._hostname = value
>>> device = Device("switch01.business.com", "user", "pass")
>>> device.hostname
'switch01'
>>> # Set hostname to a new value
>>> device.hostname = "switch-01"
>>> device.hostname
'switch-01'
>>> # Show that hostname was synced to represented device
>>> device.connection.send("show hostname")
'switch-01'

Syncing Data with Sync Method

class Device:
    def __init__(self, host, username, password):
        ...
        self.connection = self.connect()
        self._hostname = None
        # Initialize dictionaries to use later in comparing configured and pending updates
        self._configured_values = {}
        self._new_values = {}

    def connect(self):
        ...

    @property
    def hostname(self):
        """The hostname of the device."""
        if self._hostname is None:
            hostname = self.connection.send("show hostname")
            self._hostname = hostname.strip()
            # Store device's configured value for later comparison
            self._configured_values["hostname"] = self._hostname
        return self._hostname

    @hostname.setter
    def hostname(self, value):
        if self.hostname != value:
            # Update _new_values dictionary
            self._new_values["hostname"] = value
            self._hostname = value

    def is_dirty(self):
        # The object is considered dirty of the `_new_values` dictionary has entries.
        return bool(self._new_values)

    def diff(self):
        ...

    def sync(self):
        ...
>>> device = Device("switch01.business.com", "user", "pass")
>>> device.hostname
'switch01'
>>> device.hostname = "switch-01"
>>> device.hostname
'switch-01'
>>> # Show that hostname has not be updated on device
>>> device.connection.send("show hostname")
'switch01'
>>> device.is_dirty()
True
>>> print(device.diff())
- hostname switch01
+ hostname switch-01
>>> # Sync hostname to device
>>> device.sync()
>>> device.connection.send("show hostname")
'switch-01'
>>> device.is_dirty()
False

Controlling Data Input

One important benefit of using properties to implement data attributes is the ability to control what data is accepted before setting the value of the attribute. For the hostname example, it could be helpful to validate the value passed is a string. It might also be useful to ensure it adheres to standard naming conventions. The below example will ensure that the hostname is less than or equal to 32 characters and does not contain an underscore.

class Device:
    def __init__(self, host, username, password):
        ...
        self.connection = self.connect()
        self._hostname = None

    def connect(self):
        ...

    @property
    def hostname(self):
        """The hostname of the device."""
        if self._hostname is None:
            hostname = self.connection.send("show hostname")
            self._hostname = hostname.strip()
        return self._hostname

    @hostname.setter
    def hostname(self, value):
        # Validate hostname value is acceptable
        if isinstance(value, str) and len(value) < 33 and "_" not in value:
            self._hostname = value
        else:
            # Raise an exception if the value is invalid
            raise ValueError(
                'The hostname value must be a string of 32 or less characters,'
                'and must not contain an "_"'
            )
>>> device = Device("switch01.business.com", "user", "pass")
>>> device.hostname
'switch01'
>>> # Pass the hostname setter an invalid value
>>> device.hostname = "switch_01"
...
ValueError: The hostname value must be a string of 32 or less characters, and must not contain an "_"
>>> # Show that hostname attribute has not changed values
>>> device.hostname
'switch01'

Implementing Deleters

The deleter method is the least implemented of the three property methods. This method often does not apply for the property, or it is difficult to determine the appropriate action to take when the deleter method is called. The hostname example being used is a perfect example of this. Likely, the closest thing to deleting a hostname will be resetting the value back to the Device’s default value. If the property does delete something on the object being represented, then the cache must also be updated.

The original example in Getters, Setters, and Deleters decided to clear the value from the cache, but not make any changes on the device that is represented by the object. This is slightly better than not implementing the deleter method, since it does provide a public interface for invalidating the cache. It is important to note that the cache attribute was not deleted; instead it was set to None. This was done in order to prevent a subsequent getter call from raising an exception, since the getter relies on the cache attribute being defined. Since the delete method was called on the attribute, raising an AttributeError is a valid design. However, the dir and help functions will still show the hostname attribute, which leads to difficult debugging.

Delete Invalidates Cache

class Device:
    def __init__(self, host, username, password):
        ...
        self._hostname = None

    # Define the getter method first
    @property
    def hostname(self):
        """The hostname of the device."""
        if self._hostname is None:
            hostname = self.connection.send("show hostname")
            self._hostname = hostname.strip()
        return self._hostname

    # Define the hostname deleter method to invalidate cache
    @hostname.deleter
    def hostname(self):
        self._hostname = None
>>> device = Device("switch01.business.com", "user", "pass")
>>> device.hostname
'switch01'
>>> # Property value is stored in cache
>>> device._hostname
'switch01'
>>> # Invalidate cache with deleter method
>>> del(device.hostname)
>>> device._hostname is None
True
>>> # Repopulate cache
>>> device.hostname
'switch01'
>>> device._hostname
'switch01'

Delete Changes State

class Device:
    def __init__(self, host, username, password):
        ...
        self._hostname = None

    @property
    def hostname(self):
        """The hostname of the device."""
        if self._hostname is None:
            hostname = self.connection.send("show hostname")
            self._hostname = hostname.strip()
        return self._hostname

    # Define the hostname deleter method to invalidate cache
    @hostname.deleter
    def hostname(self):
        self.connection.config("no hostname")
        self._hostname = None
>>> device = Device("switch01.business.com", "user", "pass")
>>> device.hostname
'switch01'
>>> # Property value is stored in cache
>>> device._hostname
'switch01'
>>> # Remove hostname with deleter method
>>> del(device.hostname)
>>> # Verify cache was invalidated
>>> device._hostname is None
True
>>> # Verify hostname was set back to default
>>> device.hostname
'switch'

Delete Removes Cache Attribute

class Device:
    def __init__(self, host, username, password):
        ...
        self._hostname = None

    @property
    def hostname(self):
        """The hostname of the device."""
        if self._hostname is None:
            hostname = self.connection.send("show hostname")
            self._hostname = hostname.strip()
        return self._hostname

    @hostname.setter
    def hostname(setter, value):
        self.connection.config(f"hostname {hostname}")
        self._hostname = value

    # Define the hostname deleter method to remove cache attribute
    @hostname.deleter
    def hostname(self):
        self.connection.config("no hostname")
        del(self._hostname)
device = Device("switch01.business.com", "user", "pass")
>>> device.hostname
'switch01'
>>> # Property value is stored in cache
>>> device._hostname
'switch01'
>>> # Remove hostname with deleter method
>>> del(device.hostname)
>>> # Using getter method raises an AttributeError
>>> device.hostname
AttributeError: 'Device' object has no attribute '_hostname'
>>> # Set Hostname with setter
>>> device.hostname = "switch-01"
>>> device.hostname
'switch-01'

Documenting a Property

Documenting a property is done by providing a docstring on the getter method; the setter and deleter methods do not have separate docstrings. The docstring should provide any important information about the setter and deleter methods, such as any validation checks that the setter method performs on the passed value.

Calling help() on the property attribute can only be done from the class and not an instance of the class. Since properties are not callable, calling help() on an instance of the class will return the help text from the property’s returned value. Adding a setter method classifies the attribute as a data descriptor, which changes the location of the help text from the Readonly properties section to the Data descriptors section.

Documenting a Readonly Property

class Device:
    def __init__(self, host, username, password):
        self.host = host
        self.username = username
        self.password = password
        self.connection = self.connect()

    def connect(self):
        ...

    def config(self, commands):
        ...

    @property
    def hostname(self):
        """
        Connect to the device and return the configured hostname.

        This property is read-only; use the ``config`` method to change the hostname.

        Returns:
            str: The hostname of the device.
        """
        return self.connection.send("show hostname")
>>> # Help on the class defines the property under Readonly property
>>> help(Device)
class Device(builtins.object)
 |  Device(host, username, password)
 |  
 |  Methods defined here:
 |  
 |  ...
 |  
 |  ----------------------------------------------------------------------
 |  Readonly properties defined here:
 |  
 |  hostname
 |      Connect to the device and return the configured hostname.
 |
 |      This property is read-only; use the ``config`` method to change the hostname.
 |
 |      Returns:
 |           str: The hostname of the device.
 |
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |  
 |  ...
(END)
>>> # Help on the property fails when called from an instance of the class
>>> device = Device("switch01.business.com", "user", "pass")
>>> help(device.hostname)
No Python documentation found for 'switch01'.
Use help() to get the interactive help utility.
Use help(str) for help on the str class.
>>> # Help on the property works the same as methods when called from the class
>>> help(device.__class__.hostname)
Help on property:

        Connect to the device and return the configured hostname.

        This property is read-only; use the ``config`` method to change the hostname.

        Returns:
            str: The hostname of the device.
(END)

Documenting a Property with a Setter Method

class Device:
    def __init__(self, host, username, password):
        self.host = host
        self.username = username
        self.password = password
        self.connection = self.connect()

    def connect(self):
        ...

    def config(self, commands):
        ...

    @property
    def hostname(self):
        """
        Connect to the device and return the configured hostname.

        This property is read-write; use the ``config`` method to unset the hostname.
        Using the setter method will connect to the device and update the hostname.
        The setter method only accepts strings less than or equal to 32 characters,
        and must not contain an underscore, `_`.
 
        Returns:
            str: The hostname of the device.
        """
        return self.connection.send("show hostname")

    @hostname.setter
    def hostname(self, value):
        self.config(f"hostname {value}")

>>> # Help on the class defines the property under Data descriptors
>>> help(Device)
class Device(builtins.object)
 |  Device(host, username, password)
 |  
 |  Methods defined here:
 |  
 |  ...
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |  
 |  ...
 |
 |  hostname
 |      Connect to the device and return the configured hostname.
 |
 |      This property is read-write; use the ``config`` method to unset the hostname.
 |      Using the setter method will connect to the device and update the hostname.
 |      The setter method only accepts strings less than or equal to 32 characters,
 |      and must not contain an underscore, `_`.
 |
 |      Returns:
 |          str: The hostname of the device.
(END)

Documenting a Property with Setter and Deleter Methods

class Device:

    def __init__(self, host, username, password):
        self.host = host
        self.username = username
        self.password = password
        self.connection = self.connect()

    def connect(self):
        ...

    def config(self, commands):
        ...

    @property
    def hostname(self)
        """
        Connect to the device and return the configured hostname.

        This property is read-write-delete.
        Using the setter method will connect to the device and update the hostname.
        Using the deleter method will unset the hostname on the device.
        The setter method only accepts strings less than or equal to 32 characters,
        and must not contain an underscore, `_`.
 
        Returns:
            str: The hostname of the device.
        """
        return self.connection.send("show hostname")

    @hostname.setter
    def hostname(self, value):
        self.config(f"hostname {value}")

    @hostname.deleter
    def hostname(self):
        self.config("no hostname")
>>> # Help on the class defines the property under Data descriptors
>>> help(Device)
class Device(builtins.object)
 |  Device(host, username, password)
 |  
 |  Methods defined here:
 |  
 |  ...
 |  
 |  ----------------------------------------------------------------------
 |  Data descriptors defined here:
 |  
 |  ...
 |
 |  hostname
 |      Connect to the device and return the configured hostname.
 |      
 |      This property is read-write-delete.
 |      Using the setter method will connect to the device and update the hostname.
 |      Using the deleter method will unset the hostname on the device.
 |      The setter method only accepts strings less than or equal to 32 characters,
 |      and must not contain an underscore, `_`.
 |      
 |      Returns:
 |          str: The hostname of the device.
(END)

Conclusion

Using properties in Python is a helpful way to design user-friendly interfaces for managing data attributes, while also giving developers control of how the data is stored. There are several approaches to getting, setting, and deleting data attributes that can be implemented; the appropriate solution will depend on the type of data being managed, and the primary goals of the design. Caching values for data attributes is useful, but careful consideraton should be given to how users will interact with the data attributes, and what consequences can come from specific caching implementations. Documenting properties is an important step, as this will set expectations for users, and help them understand how to use the class effectively for their needs.

-Jacob



ntc img
ntc img

Contact Us to Learn More

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

Nautobot, Beyond Network Source of Truth

Blog Detail

As the title suggests, this post will use Nautobot for something other than a Network Source of Truth. This post will use Nautobot to solve one of life’s most difficult questions, perhaps one of the most frequently asked questions. This question is known to ruin the evening, but with Nautobot’s help, this no longer has to be the case: The question Nautobot will answer for us is, “What’s for dinner?”

This post will walk through using one new feature introduced in Nautobot version 1.1 and one existing feature, in order to have each Site in Nautobot provide a recipe recommendation. The two features are independent, but often work hand in hand to provide dynamic data to a specific entry in Nautobot. The primary feature that will provide the Site with a recipe is Custom Links. The Custom Link will make use of a Custom Jinja2 Filter in order to generate a recommendation from a third-party API.

High-Level Architecture

The API used by the Custom Jinja2 Filter will be SpoonacularSpoonacular requires an account and token to use the API, but it allows for several API requests per day for free. The API also has several filtering capabilities, the two that will be used here are: cuisine and ingredients. A Nautobot Plugin needs to be created in order to give Nautobot access to the Custom Jinja2 Filter, which will also provide access to the Spoonacular API token using PLUGINS_CONFIG in nautbot_config.py.

The Custom Link will be added to the dcim | site Content Type, and will use the Site object to pass the Jinja2 Filter a cuisine and an ingredient. The cuisine will be obtained from the Site’s parent Region’s slug field, and the Site’s slug field will be used to provide an ingredient. This means that:

  • Each Region that is associated with a Site must have a valid Spoonacular cuisine for its slug value
  • Each Site must be associated to a Region
  • Each Site must have an ingredient for its slug value

A list of acceptable cuisines for the API can be found at Spoonacular Cuisines.

Project Structure

This Plugin will only provide a Jinja2 Filter, so the file structure is very simple:

  • nautobot_plugin_recipe_filter directory for the Plugin App
  • nautobot_plugin_recipe_filter/__init__.py file with the PluginConfig definition
  • nautobot_plugin_recipe_filter/recipe_filters.py for providing the filter to get recipe recommendations
  • pyproject.tomlREADME.md, and LICENSE for installing the Plugin in Nautobot.
$ tree nautobot-plugin-recipe-filter
.
├── nautobot_plugin_recipe_filter
│     ├── __init__.py
│     └── recipe_filters.py
├── LICENSE
├── pyproject.toml
└── README.md

nautobot_plugin_recipe_filter/init.py

"""Plugin declaration for nautobot_plugin_recipe_filter."""

__version__ = "0.1.0"

from nautobot.extras.plugins import PluginConfig


class NautobotPluginRecipeFilterConfig(PluginConfig):
    """Plugin configuration for the nautobot_plugin_recipe_filter plugin."""

    name = "nautobot_plugin_recipe_filter"
    verbose_name = "Nautobot Plugin Recipe Filter"
    version = __version__
    author = "Network to Code, LLC"
    description = "Nautobot Plugin Jinja2 Filter for Recipe Recommendations"
    required_settings = ["spoonacular_token"]
    min_version = "1.1.0"
    max_version = "1.9999"
    default_settings = {}
    caching_config = {}
    jinja_filters = "recipe_filters.py"


config = NautobotPluginRecipeFilterConfig

pyproject.toml

[tool.poetry]
name = "nautobot-plugin-recipe-filter"
version = "0.1.0"
description = "Nautobot Plugin Jinja2 Filter for Recipe Recommendations"
authors = ["Network to Code, LLC <info@networktocode.com>"]
license = "Apache-2.0"
readme = "README.md"
homepage = "https://github.com/networktocode/nautobot-plugin-recipe-filter"
repository = "https://github.com/networktocode/nautobot-plugin-recipe-filter"
keywords = ["nautobot", "nautobot-plugin"]
include = ["LICENSE", "README.md"]
packages = [
    { include = "nautobot_plugin_recipe_filter" },
]

[tool.poetry.dependencies]
python = "^3.6"
nautobot = "^v1.1.0"
requests = "*"

Creating the Custom Jinja2 Filter

Creating a Custom Jinja2 Filter that can be consumed within Nautobot follows the standard Jinja2 process, and is auto-registered to the Nautobot Environment when a Plugin is installed and enabled on the Nautobot instance. Each Plugin can define the name of the file where Jinja2 Filters should be loaded from using the PluginConfig; the default location is jinja_filters.py. The above PluginConfig specified recipe_filters.py, so the filters made available by this plugin will go in that file.

In order to register the Jinja2 Filter with Nautobot, the Filter function is decorated with @library.filter, where library is imported from the django_jinja library. The django_jinja library is included with Nautobot in order to support the Custom Jinja2 Filters feature. This Filter function will use the requests library to make API calls to the Spoonacular API. The Jinja2 Filter will return the URL to the recipe recommended by Spoonacular. In order to interact with the Spoonacular API, the token will be retrieved using the spoonacular_token setting in the PLUGINS_CONFIG; this setting is marked as required in the above PluginConfig.

recipe_filters.py

"""Custom filters for nautobot_plugin_recipe_filter."""
import requests

from django.conf import settings

from django_jinja import library


SPOONACULAR_TOKEN = settings.PLUGINS_CONFIG["nautobot_plugin_recipe_filter"]["spoonacular_token"]
SPOONACULAR_URL = "https://api.spoonacular.com/recipes"
SPOONACULAR_RECOMMENDATION_URL = f"{SPOONACULAR_URL}/complexSearch"  # URL to get recommendations
SPOONACULAR_RECIPE_URL = f"{SPOONACULAR_URL}//information"  # URL to get details of recommendation


@library.filter
def get_recipe_url(cuisine:str, ingredient:str) -> str:
    """
    Get Recipe recommendation based on cuisine and single ingredient.
    
    Args:
        cuisine (str): The cuisine derived from Nautobot's Region.slug value.
        ingredient (str): The ingredient to include in the recipe based on Nautobot's Site.slug value.
    
    Returns:
        str: The URL of the recommended recipe.
    """
    recommendation_params = {
        "apiKey": SPOONACULAR_TOKEN,
        "number": 1,  # Limit number of responses to a single recipe
        "cuisine": cuisine,
        "includeIngredients": ingredient,
    }
    # Get recipe recommendation
    recommendation_response = requests.get(url=SPOONACULAR_RECOMMENDATION_URL, params=recommendation_params)
    recommendation_response.raise_for_status()
    recipe_id = recommendation_response.json()["results"][0]["id"]
    recipe_params = {"apiKey": SPOONACULAR_TOKEN}
    # Get recipe detailed information
    recipe_response = requests.get(url=SPOONACULAR_RECIPE_URL.format(id=recipe_id), params=recipe_params)
    recipe_response.raise_for_status()
    recipe_json = recipe_response.json()
    # Get URL to recipe on Spoonacular's website
    recipe_url = recipe_json["spoonacularSourceUrl"]

    return recipe_url

The Plugin is ready to be installed into the Nautobot environment. See the Plugin Installation Guide for details. The Database Migration and Collect Static Files steps can be skipped, since the Plugin does not use a database table and does not provide static files.

Example Config Settings

PLUGINS = ["nautobot_plugin_recipe_filter"]
PLUGINS_CONFIG = {
    "nautobot_plugin_recipe_filter": {
        "spoonacular_token": "abc123"
    }
}

The Jinja2 Filters provided by Plugins are available in Nautobot wherever Jinja2 text fields are used. This post will showcase using the above Jinja2 Filter to create a Custom Link; however, the Filter could also be used by the new Computed Fields feature added in v1.1.

The reason for using Custom Links in this instance is that the goal is to provide a link to another site with the recipe and cooking instructions. Computed Fields are designed to be text fields, and have HTML escaped; this means that an HTML hyperlink would be rendered as plain-text and not be clickable.

Nautobot’s Custom Link feature allows hyperlinks and groups of hyperlinks to be added to the upper-right side of pages displaying a single entry (DetailViews). Creating a Custom Link has several configuration options:

  • Content Type: Sets the Model that will have its entries display the hyperlink
  • Name: The name used to identify the Custom Link within Nautobot
  • Text: A Jinja2 expression that will be rendered and used as the text displayed for the hyperlink
  • URL: The URL that will be used for the hyperlink
  • Weight: Determines the ordering of the hyperlinks displayed
  • Group Name: Allows multiple hyperlinks to be displayed under a single drop-down button
  • Button Class: Determines the color of the button used for the hyperlink
  • New Window: Determines whether clicking the hyperlink should open the URL in a new tab or the current tab

There are two steps to creating most objects in Nautobot:

  • Browsing to the form to create the object from the main Navigation Bar
  • Filling out and submitting the form

Creating a Custom Link is done in the Web UI by browsing to Extensibility > Custom Links, and clicking the + icon to bring up the form to create a new Custom Link.

browse-to-add-custom-link

The Custom Link used to provide a hyperlink to Spoonacular’s recipe recommendation specifies a Content Type of dcim | site, and a Name and Text of Recipe Recommendation. The URL uses the Jinja2 Filter defined above to obtain the URL to the recipe, and clicks will open the hyperlink in a new tab.

The Jinja2 expression for the URL makes use of the variable obj, which is the Django ORM object for the database record. Since the Content Type for this Custom Link uses the Site model, it will be a record from the Site database table. Nautobot Site entries have foreign key fields to the Region, so both the Region’s slug (cuisine) and Site’s slug (ingredient) can be passed to the Custom Jinja2 Filter.

Once all required fields are filled out on the form, clicking Create will create the Custom Link.

create-custom-link

The Custom Link created above is displayed on every Site page. If the Jinja2 expression fails to render an HTML link, then the Custom Link button will not be clickable. In order to showcase the Custom Link, a guide is provided below to create a Region and Site, which results in a valid URL being created for the Site.

Guide to Create a Region

In order for a valid link to be shown, each Site must belong to a Region that has a slug value with a valid cuisine.

Browsing to Add Region Form

Creating a Region is done by browsing to Organization > Regions, and clicking the + icon to bring up the form to create a new Region.

browse-to-add-region

Filling Out the Add Region Form

The Region used in this example is Spain, which correlates to the spanish cuisine used by the Spoonacular API.

Once all required fields have been filled out on the form, clicking Create will create the Region.

create-region

Guide to Create a Site

In addition to each Site being linked to a Region, the Site must also use an ingredient for the value of its slug field.

Browsing to Add Site Form

Creating a Site is done by browsing to Organization > Sites, and clicking the + icon to bring up the form to create a new Site.

browse-to-add-site

Filling Out the Add Site Form

The Site in this example is Madrid, and is assigned to the Spain Region created above. The slug (ingredient) value uses rice. In Nautobot, Sites require the status to be set; this Site uses the built-in Active Status.

The Site form is longer than the previous forms, but scrolling to the bottom of the form displays the familiar Create button to create the Site.

create-site

Viewing the Recipe Recommendation

Clicking on the Create button above redirects to the newly created Site Page with the Custom Link added to it.

The Custom Link for the recipe recommendation is displayed in the top-right corner of the page.

site-createda

Since the check box to open the hyperlink in a new tab was selected on the Create Custom Link form, clicking the Custom Link on the Site page opens the Spoonacular recipe page in a new tab. The recommendation returned in the example is Paella.

spoonacular-recommendation

Conclusion

Nautobot is more than a Source of Truth application. The newly added Custom Jinja2 Filters provide an easy way to perform more complex operations for fields that are rendered through Jinja2. An example of a field that supports Jinja2 expressions is the URL field in Custom Links. Custom Links can be added to Pages dynamically, and provide a convenient way of giving Users access to related pages.

The recipe recommendation example used here is perhaps not practical, but something similar could be built for linking to other Source of Truth systems. Maybe Nautobot is not the System of Record for certain data points, but is instead used to aggregate multiple data points into a single place for read-only operations. The Jinja2 Filter could look up the URL to the data point in the Application used as the System of Record. It might be handy to have a link in Nautobot that is able to take users to that page in order to manage the data there.



ntc img
ntc img

Contact Us to Learn More

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

Python Objects

Blog Detail

“Everything in Python is an object” has been a common refrain among the Python community. What is an object? A simple definition is: An object is an instance of a class. For example, strings are instances of the str class.

# Creating a string variable
>>> device_type = "switch"
# device_type is an instance of the str class
>>> type(device_type)
<class 'str'>
>>>

Classes themselves are instances of type.

# Creating a class object normally
>>> class Cisco:
...     vendor = "cisco"
...
>>> Cisco
<class '__main__.Cisco'>
>>> Cisco.vendor
'cisco'
>>> isinstance(Cisco, type)
True
# Creating a class object explicitly from type
>>> Arista = type("Arista", (), {"vendor": "arista"})
>>> Arista
<class '__main__.Arista'>
>>> Arista.vendor
'arista'
>>>

In addition to being an instance of a class, objects consist of attributes defining state and behavior. It is common to refer to state attributes as data attributes or variables, and behaviors are called methods. The Python builtin classes generally have many methods, but do not have data attributes. The str class provides methods for:

  • Manipulating the string (capitalize, format, etc.)
  • Finding information about the string (startswith, isnumeric, etc.)
  • Segmenting the string (partition, split, etc.)
>>> example = "this is a string"
# Capitalize the first letter of each word 
>>> example.title()
'This Is A String'
# Count the number of times the letter i appears
>>> example.count("i")
3
# Convert the string into a list of words
>>> example.split()
["this", "is", "a", "string"]

It is more common for custom classes to have data attributes that store information about the objects they imitate. For an object representing a switch, some example data attributes might be: modules, uptime, and reachability. Some example methods might be: reboot, and ping.

>>> switch = Arista(hostname="nyc-hq-rt1")
# Example of a data attribute
>>> switch.uptime
'4 years, 3 days'
# Example of a method attribute
>>> ping_stats = switch.ping("10.1.1.1", 5)
'pinging 10.1.1.1 times 5'
>>> ping_stats
{
    'attempts': 5,
    'success': 5,
    'fail': 0,
    'messages': ['pinged 10.1.1.1 in 0.21ms', 'pinged 10.1.1.1 in 0.14ms', ...]
}
>>>

Data Attributes

Data attributes are distinguished between class variables and instance variables. Class variables assign values that are the same for all objects of the class. Instance variables assign values for an instance of the class. Class variables are generally used for metadata fields, such as vendor in the first example. Instance variables contain more interesting information that the object methods act upon. Using the previous example, the reachability attribute might be used in a report to identify all switches that are unreachable. The Cisco class above is redefined to create instance variables upon initialization for hostname, connection status, and reachability information.

class Cisco:
    # Class variable
    vendor = "cisco"

    def __init__(self, hostname):
        # Instance variables
        self.hostname = hostname
        self.connected = False
        ping_reachability = self.ping(hostname, 2)
        if ping_reachability["success"]:
            self.connect()
            if self.connected:
                self.reachability = "authorized"
            else:
                self.reachability = "unauthorized"
        else:
            self.reachability = "unreachable"

    def connect(self):
        ...
        self.connected = True if connected else False

    def ping(self, destination, count):
        ...
        return {
            "attempts": count,
            "success": success_count,
            "fail": fail_count,
            "messages": [message for message in ping_results],
        }

Python uses dictionaries to store class and instance variables. Class variables share the same dictionary across all instances of the class, and instance variables are stored in a unique dictionary per instance. The class dict is stored in <class>.__dict__, and this is referenced on an instance with <obj>.__class__.__dict__. The instance dict is stored in <obj>.__dict__. Here are some examples showing how these two dictionaries behave.

# View the class dict
>>> Cisco.__dict__
mappingproxy({'vendor': 'cisco', ...})
# Create two objects of the Cisco class
>>> rt1 = Cisco("nyc-hq-rt1")
>>> rt2 = Cisco("nyc-dc-rt1")
# Viewing the instance dict
>>> rt1.__dict__
{'hostname': 'nyc-hq-rt1', 'connected': True, 'reachability': 'authorized'}
# Reachability is an instance variable and can have different values across objects
>>> rt1.reachability
'authorized'
>>> rt2.reachability
'unreachable'
# The class dict is referenced within instances
>>> Cisco.__dict__ == rt1.__class__.__dict__
True
# Changing the vendor on the class is reflected on all instances
>>> Cisco.vendor = "CSCO"
>>> rt1.vendor
'CSCO'
>>> rt2.vendor
'CSCO'
>>>

Attribute Precedence

This distinction between class and instance variables is important for understanding Python’s attribute access behavior. The standard attribute lookup uses the following precedence until the attribute is found, or an Error is raised:

  1. Attributes defined on the instance
  2. Attributes defined on the class
  3. Attributes defined on inherited classes

Practically, this means that overriding a class attribute on an instance will only affect the single instance and not other instances.

>>> rt1 = Cisco("nyc-hq-rt1")
>>> rt2 = Cisco("nyc-dc-rt1")
# rt1 and rt2 both have a value of "cisco" for the vendor attribute
>>> rt1.vendor == rt2.vendor == "cisco"
True
# rt1's instance dict does not have an attribute for `vendor`
>>> rt1.__dict__
{'hostname': 'nyc-hq-rt1', 'connected': True, 'reachability': 'authorized'}
>>> rt1.vendor = "Cisco"
>>> rt1.vendor
'Cisco'
# rt1's instance dict was updated with a `vendor` attribute
>>> rt1.__dict__
{'hostname': 'nyc-hq-rt1', 'connected': True, 'reachability': 'authorized', 'vendor': 'Cisco'}
# rt2's vendor attribute remains unchanged
>>> rt2.vendor
'cisco'

Mutable Attributes

The above demonstrates the built-in safety provided when naming instance variables collides with class variables. However, this behavior might have surprising results when class variables point to mutable objects (such as lists). The Arista class is rewritten below to have a class variable defining the modules, with the value using a list for modular chassis.

class Arista:
    vendor = "arista"
    modules = ["7500E-48S", "DCS-7500E-SUP"]
>>> rt1 = Arista("nyc-hq-rt1")
>>> rt2 = Arista("nyc-dc-rt1")
>>> rt1.modules
["7500E-48S", "DCS-7500E-SUP"]
# Appending to rt2's `modules` attribute affects rt1
>>> rt2.modules.append("7500E-72S")
>>> rt1.modules
["7500E-48S", "DCS-7500E-SUP", "7500E-72S"]
# Using the `+=` operator on rt2's `modules` attributed affects rt1
>>> rt2.modules += ["7500E-36Q"]
>>> rt1.modules
["7500E-48S", "DCS-7500E-SUP", "7500E-72S", "7500E-36Q"]
# Both modifications to rt2's `modules` attribute affect the class attribute
>>> Arista.modules
["7500E-48S", "DCS-7500E-SUP", "7500E-72S", "7500E-36Q"]
# All references to `modules` are the same shared object
>>> Arista.modules is rt1.modulues is rt2.modules
True
>>>

Conclusion

Understanding how objects work is critical to working with Python. Fundamental to working with objects is managing and using data attributes. Data attributes can be created on the class, or on each instance of the class. Instance level attributes have a higher precedence than class level attributes. Finally, when creating attributes on a class, it is important to consider how they will be used, and that mutable attributes can lead to unexpected behavior.

-Jacob



ntc img
ntc img

Contact Us to Learn More

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