Properties
A property is a special sort of class attribute. You access it like a standard attribute, but "under the hood," the interpreter runs methods ("getters" to access the data and "setters" to store new data) to produce the required results. The data-like syntax is easier to read and write than lots of method calls, yet the interposition of the method calls allows for data validation, active updating, and/or read-only attributes. Before looking in detail at properties, you should understand some of the reasons they are desirable.
In the last lesson, we learned about several special methods that let us access and control attributes—__getattr__(), __setattr__(), and __delattr__(). You can use these techniques to control the value of various attributes—but remember that the __getattr__() method will only be used if normal attribute access fails to find the named attribute. Therefore, you'll want to store the values of "managed" attributes (values that must be processed on retrieval) in a special directory, to ensure that normal attribute access does not find them. The following code sample demonstrates control of specific attributes via the __getattr__() method.
Suppose you want to keep a first name, last name, age, list of classes, and a grade for teachers in a school. Further suppose that you were prepared to allow some laxity in data entry, but that you always wanted to return the names properly capitalized, the age as an integer, the list of classes in sorted order and the grade as a string (though it should be entered as a number). This kind of management is precisely what the attribute-handling special methods were designed for.
As is usually the case, there must be test code, which follows first in the time-honored tradition of TDD—Test-Driven Development. Create test_teacher.py as shown:
import unittest
from teacher import Teacher
class TestTeacher(unittest.TestCase):
def setUp(self):
self.teacher = Teacher("steve",
"holden",
"63",
["Python 3-3","Python 3-1","Python 3-2"],
5)
def test_get(self):
self.assertEqual(self.teacher.first_name, "Steve")
self.assertEqual(self.teacher.last_name, "Holden")
self.assertEqual(self.teacher.age, 63)
self.assertEqual(self.teacher.classes, ["Python 3-1","Python 3-2","Python 3-3"])
self.assertEqual(self.teacher.grade, "Fifth")
self.teacher.description = "curmudgeon"
self.assertEqual(self.teacher.description, "curmudgeon")
if __name__ == "__main__":
unittest.main()
We'll start out with a simplistic implementation of the Teacher class that simply stores the attributes as regular values and relies on the standard Python mechanism for attribute retrieval. Since no transformation is taking place on the data, it should not be too surprising if this first naive implementation fails. Create teacher.py in the same folder as shown:
class Teacher(object):
grades = {1: "First", 2: "Second", 3: "Third", 4: "Fourth", 5: "Fifth"}
def __init__(self, first_name, last_name, age, classes, grade):
self.first_name = first_name
self.last_name = last_name
self.age = age
self.classes = classes
self.grade = grade
Save both files and run test_teacher.py. Sure enough, you see a failure:
F
======================================================================
FAIL: test_get (__main__.TestTeacher.test_get)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_teacher.py", line 14, in test_get
self.assertEqual(self.teacher.first_name, "Steve")
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
AssertionError: 'steve' != 'Steve'
- steve
? ^
+ Steve
? ^
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
| Modern Python | Current Python's unittest output includes the full test id
(test_get (__main__.TestTeacher.test_get)) and caret/diff lines in
AssertionError messages. The original output showed only
test_get (__main__.TestTeacher) and a plain assertion message. The test
logic and result (1 failure) are unchanged. |
One of the beauties of Python, however, is that it is almost infinitely flexible, and so it is quite possible to change this implementation to do what is required. Although it may seem contradictory, the first thing you need to change is the way the object stores attributes—until you do that, the attribute assignments will always result in their being available without invoking __getattr__().
"""
Demonstrate simple attribute management
"""
class Teacher(object):
grades = {1: "First", 2: "Second", 3: "Third", 4: "Fourth", 5: "Fifth"}
def __init__(self, first_name, last_name, age, classes, grade):
self.__dict__['_attrs'] = {}
self.first_name = first_name
self.last_name = last_name
self.age = age
self.classes = classes
self.grade = grade
def __setattr__(self, name, value):
self._attrs[name] = value
def __getattr__(self, name):
if name not in self._attrs:
raise AttributeError("Teacher has no attribute {0!r}".format(name))
value = self._attrs[name]
if name in ("first_name", "last_name"):
return value.capitalize()
elif name == "age":
return int(value)
elif name == "classes":
return sorted(value)
elif name == "grade":
return self.grades[value]
else:
return value
Here the __init__ method creates a regular attribute called _attrs, a dict in which the attribute values are kept, by making a direct entry in the instance's __dict__. It uses this technique to avoid a direct assignment, which would invoke the instance's __setattr__() method. That method attempts to store the attribute value against its name self._attrs, which would need to be looked up by __getattr__(). This in turn would try and find the name "_attrs" in the self._attrs dict, which would again invoke __getattr__(), and so on. This infinite regression would only terminate when the interpreter ran out of stack, the area of memory where it stores partially-completed function namespaces.
| Note | The convention in Python is that attributes whose names begin with "_" are internal to the implementation of a class. Because of that, such attributes don't appear in help but do appear in the output from dir(). While there is nothing to stop you from accessing these attributes directly, the naming convention acts as a flag that outside interference is likely to break the internal logic. |
Now, all attributes are stored in the _attrs dict, and the __getattr__() method uses the name of the retrieved attribute to decide what processing needs to be performed on the stored value in order to meet specifications. Save both files and run test_teacher.py. Happily, the updated object should now pass its tests.
. ---------------------------------------------------------------------- Ran 1 test in 0.000s OK
This may not look so bad at a glance, but maintenance for this code is challenging. For example, if you wanted to create a subclass that had different behavior on just the "age" attribute, you would have to rewrite the __getattr__() method for the child class. Then if the parent had a bug, you might have to rewrite both the parent and child. As you might imagine, this quickly leads to fragile code, and tends to encourage code duplication (which is normally held to be a bad thing).
For example, suppose you want to create a Teacher subclass that supports gender differences. If male, the Teacher subclass returns "Mr." at the start of "first_name." If female, it returns "Ms." The current design forces you to completely rewrite the __getattr__() method, because it is "monolithic"—all the attributes are dealt with in the same method, so changing the response for just one attribute is difficult or impossible.
An alternative is to use properties. Properties let you assign computations to accesses involving a specific attribute, so if you inherit the class, you can easily extend it without having to dance around the subtleties of __getattr__(). This allows you to easily change one small method without worrying about tangling with a multitude of unrelated attributes.
A property in Python is a data component to which access is mediated by methods, even though the user of the property can treat it as a simple data attribute. This allows you to hide a layer of logic underneath attribute-style access to an object's data.
| Note | If you know in advance that the logic is required, there is something to recommend simply writing the methods and documenting them as the necessary solution to the problem. Properties excel when the logic needs to be introduced later, after you have already written code that treats the data as simple attributes. Under those circumstances, properties allow you to insert a layer of logic without changing the code that currently uses the attributes. |
Like a lot of other programming descriptions, this sounds a lot more complex than it is. And since a bit of code often helps to clarify new concepts, let's construct the teacher class with properties using the techniques as we've described:
class Teacher(object):
grades = {1: "First", 2: "Second", 3: "Third", 4: "Fourth", 5: "Fifth"}
def __init__(self, first_name, last_name, age, classes, grade):
self._first_name = first_name # internal data attributes are set
self._last_name = last_name
self._age = age
self._classes = classes
self._grade = grade
def first_name(self):
return self._first_name.capitalize()
first_name = property(first_name)
def last_name(self):
return self._last_name.capitalize()
last_name = property(last_name)
def age(self):
return int(self._age)
age = property(age)
def classes(self):
return sorted(self._classes)
classes = property(classes)
def grade(self):
return self.grades[self._grade]
grade = property(grade)
Save both files and run test_teacher.py.
. ---------------------------------------------------------------------- Ran 1 test in 0.000s OK
Thanks to the magic of unittest, this demonstration of a new programming technique appears to be a valid refactoring. At least you have passed a definite "smoke test" by passing the current tests. Let's review the changes in the code of teacher.py:
def __init__(self, first_name, last_name, age, classes, grade): self._first_name = first_name # internal data attributes are set self._last_name = last_name self._age = age self._classes = classes self._grade = grade
In the __init__() method, we set first_name via self._first_name. This is done to provide a data attribute on which to base the first_name property (if it had the same name as the property, the assignment would overwrite the method!). We made similar changes for the other managed attributes.
def first_name(self): return self._first_name.capitalize() first_name = property(first_name) def last_name(self): return self._last_name.capitalize() last_name = property(last_name) def age(self): return int(self._age) age = property(age) def classes(self): return sorted(self._classes) classes = property(classes) def grade(self): return self.grades[self._grade] grade = property(grade)
The first_name() method accesses the _first_name data attribute, and processes it before returning it as the value of the attribute. We provide similar methods for the other managed attributes.
The first_name() method becomes a property when it is replaced inside the class body by the result of calling the built-in property() function with the method as an argument. We changed the other managed attributes likewise into properties.
You can see that if you wanted to create a Teacher subclass where the first_name attribute was modified by a gender attribute, you would only need to redefine the first_name property in your subclass—the other property definitions would continue to stand. This is in distinction to the preceding class, whose "monolithic" (all in one piece) __getattr__() makes it hard to separate one attribute from another.
Because defining a function or method and then applying a function such as property() to it is a common pattern, Python has a special shorthand for it. The syntax we used above was:
def method(self, ...):
"""Method body."""
...
method = property(method)
This application of a function to another function is called decoration, and the applied function (in this case property) is called a decorator. If the method is lengthy, the final reassignment to the method name is easy to miss. Consequently you can also use the following syntax, which is merely a shortcut for the standard mechanism above:
@property
def method(self, ...):
"""Method body."""
...
You should find that the code works exactly the same using this syntax as it does using the standard property creation. Try it, just to be sure.
class Teacher(object):
grades = {1: "First", 2: "Second", 3: "Third", 4: "Fourth", 5: "Fifth"}
def __init__(self, first_name, last_name, age, classes, grade):
self._first_name = first_name # internal data attributes are set
self._last_name = last_name
self._age = age
self._classes = classes
self._grade = grade
@property
def first_name(self):
return self._first_name.capitalize()
def first_name(self):
return self._first_name.capitalize()
first_name = property(first_name)
@property
def last_name(self):
return self._last_name.capitalize()
last_name = property(last_name)
@property
def age(self):
return int(self._age)
age = property(age)
@property
def classes(self):
return sorted(self._classes)
classes = property(classes)
@property
def grade(self):
return self.grades[self._grade]
grade = property(grade)
As always, the first thing that you should do after changing your code is... run your tests! These two ways to apply properties to methods are entirely equivalent, so your tests should continue to pass.
| Modern Python | The decorator form (@property, @x.setter,
@x.deleter) introduced in this section is the idiomatic modern style.
The function-call form (name = property(name)) shown in the previous
section is still valid Python, but you will rarely see it in contemporary code. Where
practical, prefer the decorator form for clarity. |
Note that while the first implementation correctly allowed you to reassign the managed attributes through use of the __setattr__() method, this one does not. Neither can you delete managed attributes (which was also an issue with the earlier code, though we did not mention it at the time). You can verify this using an interactive interpreter session:
>>> from teacher import *
>>> t = Teacher("steve", "holden", "63",
... ["Python 3-3","Python 3-1","Python 3-2"], 5)
>>>
>>> t.first_name
'Steve'
>>> t.first_name = "joe"
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: property 'first_name' of 'Teacher' object has no setter
>>> del t.first_name
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: property 'first_name' of 'Teacher' object has no deleter
>>>
| Modern Python | In Python 3.11+, the AttributeError messages for read-only
properties changed from can't set attribute / can't delete
attribute to the more descriptive form shown above, identifying the property name
and the missing accessor. |
So at the moment you can neither assign to nor delete the managed attributes. You will be changing the tests to include these features shortly, to provide failing tests that new functionality in your teacher module can turn into success.
The built-in property function is actually rather more complicated than you have so far seen. Its full signature (the pattern of arguments it can be called with) is as follows.
property(fget=None, fset=None, fdel=None, doc=None)
fget is the getter function, fset is the setter function, fdel is the deleter function and doc is the documentation. So the reason that the properties you have defined so far cannot be set is that the decorator syntax only passes a single argument to the call of property(). This single positional argument is associated (positionally) with the fget parameter, so you can get the attribute value, but there is no way to set or delete the attributes (and no documentation!)
Properties do more than just provide the ability to compute values during retrieval of attributes. They also let you perform calculations while setting values. This is useful during validation of incoming data. For example, what if you wanted to confirm that the age attribute was passed a valid integer rather than converting it to an integer when it was accessed? Using standard techniques, you would declare a second method and pass it as the second argument to the call of property(). First, of course, we need to add a new test to the test suite that fails. This should be fairly easy with the experience we had in the interactive interpreter session above. Since we want the values we can assign to age to be limited to integers, we'll also add a test to make sure that any other type of data raises a ValueError exception.
import unittest
from teacher import Teacher
class TestTeacher(unittest.TestCase):
def setUp(self):
self.teacher = Teacher("steve",
"holden",
"63",
["Python 3-3","Python 3-1","Python 3-2"],
5)
def test_get(self):
self.assertEqual(self.teacher.first_name, "Steve")
self.assertEqual(self.teacher.last_name, "Holden")
self.assertEqual(self.teacher.age, 63)
self.assertEqual(self.teacher.classes, ["Python 3-1","Python 3-2","Python 3-3"])
self.assertEqual(self.teacher.grade, "Fifth")
self.teacher.description = "curmudgeon"
self.assertEqual(self.teacher.description, "curmudgeon")
def test_set(self):
self.teacher.age = "21"
self.assertEqual(self.teacher._age, 21)
self.assertEqual(self.teacher.age, 21)
self.assertRaises(ValueError, self.setAgeWrong)
def setAgeWrong(self):
self.teacher.age = "twentyone"
if __name__ == "__main__":
unittest.main()
Note that unittest.TestCase.assertRaises expects a function and an exception as arguments. It calls the function, and flags a failure if the call does not raise the specified exception type. After these modifications, you would expect your new test to fail, and sure enough it does (before it even gets around to testing to see whether the setAgeWrong method raises the required exception).
.E
======================================================================
ERROR: test_set (__main__.TestTeacher.test_set)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_teacher.py", line 23, in test_set
self.teacher.age = "21"
^^^^^^^^^^^^^^^^
AttributeError: property 'age' of 'Teacher' object has no setter
----------------------------------------------------------------------
Ran 2 tests in 0.002s
FAILED (errors=1)
Having updated the tests to make it obvious that an upgrade is required to the teacher module, we need to add the necessary new code. We'll start by using the standard method to give the age attribute both a getter and a setter.
class Teacher(object):
grades = {1: "First", 2: "Second", 3: "Third", 4: "Fourth", 5: "Fifth"}
def __init__(self, first_name, last_name, age, classes, grade):
self._first_name = first_name # internal data attributes are set
self._last_name = last_name
self.age = age
self._classes = classes
self._grade = grade
@property
def first_name(self):
return self._first_name.capitalize()
@property
def last_name(self):
return self._last_name.capitalize()
def getage(self):
return self._age
def setage(self, value):
self._age = int(value)
age = property(getage, setage, doc="Teacher's age: must be convertible to integer")
@property
def classes(self):
return sorted(self._classes)
@property
def grade(self):
return self.grades[self._grade]
| Note | You will see that the __init__() method is now relying on the property to establish the initial value of the managed attribute rather than directly assigning to the underlying data member. This is generally a good thing, since if the setter method performs validations these will also be applied to the initial value passed in as an argument to __init__(). |
Now the age attribute has both a getter and a setter, you should see that it passes all tests with flying colors.
.. ---------------------------------------------------------------------- Ran 2 tests in 0.000s OK
You may wonder whether it is possible to achieve the same ends using decorators, and the answer is yes. This is because a read-only property (which, you will remember, can be created with the use of a decorator because it only requires a single argument) has a setter() method that can be used to decorate a... setter method! This means that you can create the age property as before, with a decorator, and then decorate the setter() method with a method of the getter() property that you just created.
This may sound a little confusing, but once you have typed the code, it should seem a little more natural. The age() property goes back to its original code, and the age setter is decorated by one of the getter property's methods (the getter has been defined specifically to provide these extra methods as a convenience).
class Teacher(object):
grades = {1: "First", 2: "Second", 3: "Third", 4: "Fourth", 5: "Fifth"}
def __init__(self, first_name, last_name, age, classes, grade):
self._first_name = first_name # internal data attributes are set
self._last_name = last_name
self.age = age
self._classes = classes
self._grade = grade
@property
def first_name(self):
return self._first_name.capitalize()
@property
def last_name(self):
return self._last_name.capitalize()
@property
def getage(self):
return self._age
def setage(self, value):
@age.setter
def age(self, value):
self._age = int(value)
age = property(getage, setage, doc="Teacher's age: must be convertible to integer")
@property
def classes(self):
return sorted(self._classes)
@property
def grade(self):
return self.grades[self._grade]
| Note | The second age definition might be flagged as a "duplicate signature" in some IDEs; you can safely ignore this for now. |
You should, of course, confirm as usual that your tests continue to succeed.
Deleting attributes works in nearly the same fashion as setting attributes. Create yet another function with the same name as your attribute and place a @<my-attribute-name>.deleter. In our next example, removing a grade means creating a grade function, placing a @grade.deleter above it, and then in the logic, adding a year to the age of the teacher.
First, let's write a test for our expected behavior:
import unittest
from teacher import Teacher
class TestTeacher(unittest.TestCase):
def setUp(self):
self.teacher = Teacher("steve",
"holden",
"63",
["Python 3-3","Python 3-1","Python 3-2"],
5)
def test_get(self):
self.assertEqual(self.teacher.first_name, "Steve")
self.assertEqual(self.teacher.last_name, "Holden")
self.assertEqual(self.teacher.age, 63)
self.assertEqual(self.teacher.classes, ["Python 3-1","Python 3-2","Python 3-3"])
self.assertEqual(self.teacher.grade, "Fifth")
self.teacher.description = "curmudgeon"
self.assertEqual(self.teacher.description, "curmudgeon")
def test_set(self):
self.teacher.age = "21"
self.assertEqual(self.teacher._age, 21)
self.assertEqual(self.teacher.age, 21)
self.assertRaises(ValueError, self.setAgeWrong)
def setAgeWrong(self):
self.teacher.age = "twentyone"
def test_delete(self):
del self.teacher.grade
self.assertEqual(self.teacher.age, 64)
self.assertRaises(AttributeError, self.accessGrade)
def accessGrade(self):
return self.teacher.grade
if __name__ == "__main__":
unittest.main()
As usual, a newly added test should fail. You should verify this, as usual, by running the updated test suite.
E..
======================================================================
ERROR: test_delete (__main__.TestTeacher.test_delete)
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_teacher.py", line 32, in test_delete
del self.teacher.grade
^^^^^^^^^^^^^^^^^^
AttributeError: property 'grade' of 'Teacher' object has no deleter
----------------------------------------------------------------------
Ran 3 tests in 0.001s
FAILED (errors=1)
Now, modify the teacher.py code to add the deleter method:
class Teacher(object):
grades = {1: "First", 2: "Second", 3: "Third", 4: "Fourth", 5: "Fifth"}
def __init__(self, first_name, last_name, age, classes, grade):
self._first_name = first_name
self._last_name = last_name
self._age = age
self._classes = classes
self._grade = grade
@property
def first_name(self):
return self._first_name.capitalize()
@property
def last_name(self):
return self._last_name.capitalize()
@property
def age(self):
return int(self._age)
@age.setter
def age(self, value):
self._age = int(value)
@property
def classes(self):
return sorted(self._classes)
@property
def grade(self):
return self.grades[self._grade]
@grade.setter
def grade(self, value):
self.grades[value] # throw error if value != a key
self._grade = value
@grade.deleter
def grade(self):
self.age += 1
del self._grade
| Note | The updated "age" property now applies the int() built-in to its argument. This allows users to specify the age as a character string without the code breaking. Whether this is a good idea or not, and whether the setter should even accept strings or not, is an interesting question—one that we will ignore for now. |
Save it and run the test. All tests should pass immediately.
... ---------------------------------------------------------------------- Ran 3 tests in 0.000s OK
So you now understand how you can put logic behind all types of attribute access. Beware of using this technique when it isn't really necessary: remember, if you know method calls are going to be required from the outset, it is much better to build them into the API for your objects from the start. If the logic needs to be retrofitted, however, properties are a really useful way of fitting it.
Properties allow you to isolate each piece of logic in its own method, making it easy to extend and reuse as a parent superclass or to implement in child classes. They are often used for validation, logging, formatting, and a myriad of other tasks. The only possible downside to properties is that they require a little bit of extra work, but the extra functionality they provide is generally well worth that effort.
