Python’s descriptors
In the previous write-up, I talked about adding temporal information to models. I stayed at the level of Python objects, suggesting adding a EffectivePeriod
attribute rather than interval/range/period APIs to the temporal models.
But when we start working with persisted models (ORM), we might need to deal with start_date
and end_date
directly at the model level. This article shows how descriptors can help us do that easily. Also, going a bit further with descriptors, we’ll rebuild queries helpers (hybrid
extensions).
Deriving an attribute
We’re stuck with start_date
and end_date
on the model. That doesn’t mean we should use them. We could totally add our effective_period
to it and use that instead... The challenge is to keep all that in sync. To get that, we can write the following code:
We can do even better by adding a setter to that property:
We can already enjoy access to the model.effective_period
, as promised in the intro 🎉
From Property to Descriptor
One small limitation here is when we try to share that behavior. We could put it in a class/mixin and rely on multiple inheritances to make that available across many models. An alternative is to use Python’s descriptors, which is exactly what @property
does under the hood...
A descriptor defines how to interact with an object’s attribute. This access can be customized as much as needed as behaviors are described by a plain old python object that can be customized through its __init__
method (not shown above).
Answering __get__
is enough to tell Python that model.effective_period
gets its value from DerivedEffectivePeriod.__get__(...)
! It’s the same for model.effective_period = ...
which will rely on DerivedEffectivePeriod.__set__(...)
to customize its behavior.
Identifying the caller
Another nice option descriptors offer: they allow to distinguish between:
- access from the class:
Model.effective_period
and - access from an instance:
model.effective_period
.
From there, it’s possible to behave differently depending on who the caller is. This will directly help us to achieve the second objective: to provide a similar feature as hybrid_{property,method}
...
We can update the previous descriptor:
This introduces the EffectivePeriodQueryHelper
that can build the proper SQL clauses based on the model’s columns.
Again, this is roughly what the hybrid
extension does under the hood when declaring an expression
: it keeps the expression
function within the descriptor and will use it when the hybrid attribute will be called from the class.
We now have a way to organize period logic for SQL generation and to query it from the ORM with:
Conclusion
Sharing code through composition often makes more sense than through inheritance. Python has a unique feature built-in in its execution model: descriptors. They are the foundation for many Python features such as the @property
decorator and for third parties libraries like sql-alchemy’s hybrid
. Leveraging them gives us more flexibility when we want to share behavior across the codebase when basic composition can’t cut it.
PS: If you have any tricks to type descriptors, and avoid the Union
from before, let me know! (💡 @overload
was suggested)
Appendix: complete samples
For both the previous article and this one, the supporting code can be found on Github.