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
Tags :
Contact Us to Learn More
Share details about yourself & someone from our team will reach out to you ASAP!