6.2. Consider @property Instead of Refactoring Attributes

The built-in @property decorator makes it easy for simple accesses of an instance’s attributes to act smarter (see Item 44: “Use Plain Attributes Instead of Setter and Getter Methods”). One advanced but common use of @property is transitioning what was once a simple numerical attribute into an on-the-fly calculation. This is extremely helpful because it lets you migrate all existing usage of a class to have new behaviors without requiring any of the call sites to be rewritten (which is especially important if there’s calling code that you don’t control). @property also provides an important stopgap for improving interfaces over time.

For example, say that I want to implement a leaky bucket quota using plain Python objects. Here, the Bucket class represents how much quota remains and the duration for which the quota will be available:

>>> from datetime import datetime, timedelta
>>>
>>> class Bucket:
>>>     def __init__(self, period):
>>>         self.period_delta = timedelta(seconds=period)
>>>         self.reset_time = datetime.now()
>>>         self.quota = 0
>>>
>>>     def __repr__(self):
>>>         return f'Bucket(quota={self.quota})'

The leaky bucket algorithm works by ensuring that, whenever the bucket is filled, the amount of quota does not carry over from one period to the next:

>>> def fill(bucket, amount):
>>>     now = datetime.now()
>>>     if (now - bucket.reset_time) > bucket.period_delta:
>>>         bucket.quota = 0
>>>         bucket.reset_time = now
>>>     bucket.quota += amount

Each time a quota consumer wants to do something, it must first ensure that it can deduct the amount of quota it needs to use:

>>> def deduct(bucket, amount):
>>>     now = datetime.now()
>>>     if (now - bucket.reset_time) > bucket.period_delta:
>>>         return False  # Bucket hasn't been filled this period
>>>     if bucket.quota - amount < 0:
>>>         return False  # Bucket was filled, but not enough
>>>
>>>     bucket.quota -= amount
>>>     return True       # Bucket had enough, quota consumed

To use this class, first I fill the bucket up:

>>> bucket = Bucket(60)
>>> fill(bucket, 100)
>>> print(bucket)
Bucket(quota=100)

Then, I deduct the quota that I need:

>>> if deduct(bucket, 99):
>>>     print('Had 99 quota')
>>> else:
>>>     print('Not enough for 99 quota')
>>> print(bucket)
Had 99 quota
Bucket(quota=1)

Eventually, I’m prevented from making progress because I try to deduct more quota than is available. In this case, the bucket’s quota level remains unchanged:

>>> if deduct(bucket, 3):
>>>     print('Had 3 quota')
>>> else:
>>>     print('Not enough for 3 quota')
>>> print(bucket)
Not enough for 3 quota
Bucket(quota=1)

The problem with this implementation is that I never know what quota level the bucket started with. The quota is deducted over the course of the period until it reaches zero. At that point, deduct will always return False until the bucket is refilled. When that happens, it would be useful to know whether callers to deduct are being blocked because the Bucket ran out of quota or because the Bucket never had quota during this period in the first place.

To fix this, I can change the class to keep track of the max_quota issued in the period and the quota_consumed in the period:

>>> class NewBucket:
>>>     def __init__(self, period):
>>>         self.period_delta = timedelta(seconds=period)
>>>         self.reset_time = datetime.now()
>>>         self.max_quota = 0
>>>         self.quota_consumed = 0
>>>
>>>     def __repr__(self):
>>>         return (f'NewBucket(max_quota={self.max_quota}, '
>>>                 f'quota_consumed={self.quota_consumed})')

To match the previous interface of the original Bucket class, I use a @property method to compute the current level of quota on-the-fly using these new attributes:

>>> class NewBucket:
>>>     def __init__(self, period):
>>>         self.period_delta = timedelta(seconds=period)
>>>         self.reset_time = datetime.now()
>>>         self.max_quota = 0
>>>         self.quota_consumed = 0
>>>
>>>     def __repr__(self):
>>>         return (f'NewBucket(max_quota={self.max_quota}, '
>>>                 f'quota_consumed={self.quota_consumed})')    @property
>>>     def quota(self):
>>>         return self.max_quota - self.quota_consumed

When the quota attribute is assigned, I take special action to be compatible with the current usage of the class by the fill and deduct functions:

>>> class NewBucket:
>>>     def __init__(self, period):
>>>         self.period_delta = timedelta(seconds=period)
>>>         self.reset_time = datetime.now()
>>>         self.max_quota = 0
>>>         self.quota_consumed = 0
>>>
>>>     def __repr__(self):
>>>         return (f'NewBucket(max_quota={self.max_quota}, '
>>>                 f'quota_consumed={self.quota_consumed})')
>>>     @quota.setter
>>>     def quota(self, amount):
>>>         delta = self.max_quota - amount
>>>         if amount == 0:
>>>             # Quota being reset for a new period
>>>             self.quota_consumed = 0
>>>             self.max_quota = 0
>>>         elif delta < 0:
>>>             # Quota being filled for the new period
>>>             assert self.quota_consumed == 0
>>>             self.max_quota = amount
>>>         else:
>>>            # Quota being consumed during the period
>>>             assert self.max_quota >= self.quota_consumed
>>>             self.quota_consumed += delta
---------------------------------------------------------------------------

NameError                                 Traceback (most recent call last)

Cell In [9], line 1
----> 1 class NewBucket:
      2     def __init__(self, period):
      3         self.period_delta = timedelta(seconds=period)


Cell In [9], line 11, in NewBucket()
      8 def __repr__(self):
      9     return (f'NewBucket(max_quota={self.max_quota}, '
     10             f'quota_consumed={self.quota_consumed})')
---> 11 @quota.setter
     12 def quota(self, amount):
     13     delta = self.max_quota - amount
     14     if amount == 0:
     15         # Quota being reset for a new period


NameError: name 'quota' is not defined

Rerunning the demo code from above produces the same results:

>>> bucket = NewBucket(60)
>>> print('Initial', bucket)
>>> fill(bucket, 100)
>>> print('Filled', bucket)
>>>
>>> if deduct(bucket, 99):
>>>     print('Had 99 quota')
>>> else:
>>>     print('Not enough for 99 quota')
>>> print('Now', bucket)
>>>
>>> if deduct(bucket, 3):
>>>     print('Had 3 quota')
>>> else:
>>>     print('Not enough for 3 quota')
>>>
>>> print('Still', bucket)

The best part is that the code using Bucket.quota doesn’t have to change or know that the class has changed. New usage of Bucket can do the right thing and access max_quota and quota_consumed directly.

I especially like @property because it lets you make incremental progress toward a better data model over time. Reading the Bucket example above, you may have thought that fill and deduct should have been implemented as instance methods in the first place. Although you’re probably right (see Item 37: “Compose Classes Instead of Nesting Many Levels of Built-in Types”), in practice there are many situations in which objects start with poorly defined interfaces or act as dumb data containers. This happens when code grows over time, scope increases, multiple authors contribute without anyone considering long-term hygiene, and so on.

@property is a tool to help you address problems you’ll come across in real-world code. Don’t overuse it. When you find yourself repeatedly extending @property methods, it’s probably time to refactor your class instead of further paving over your code’s poor design.

6.2.1. Things to Remember

✦ Use @property to give existing instance attributes new functionality.

✦ Make incremental progress toward better data models by using @property.

✦ Consider refactoring a class and all call sites when you find yourself using @property too heavily.