Callable properties in python
How to make python @property behave like a method
Why callable properties?
First of all: you probably should not use callable properties if you can somehow avoid it :)
The two main reasons to use callable properties in python that I see are the following:
- Playing around with the dynamic nature of python for the fun of it
- You had defined
@properties
in your API that later on you realized should have been aget_
method with a parameter, but you do not want break the old API.
(I had a case of no. 2)
Suppose we had built ourselves a nice little class that holds a list of fruit. At any given time only one fruit is the selected fruit, indicated by the index self.idx
.
We use a @property
to return the current selected fruit from the list:
class FruitList:
def __init__(self, fruits, idx=0):
self.fruits = fruits
self.idx = idx
@property
def selected_fruit(self):
return self.fruits[self.idx]
fl = FruitList(['apple', 'banana', 'pear'], idx=1)
print("init idx == 1, fruit = ", fl.selected_fruit)
fl.idx = 2
print("new idx == 2, fruit = ", fl.selected_fruit)
Your users have been happily using your FruitList class and seem happy with the API, but now a new request comes in: would it be possible to explicitly select a particular fruit by index?
You could ofcourse add a method
def get_selected_fruit(self, index=None):
if index is not None:
return self.fruits[self.index]
else return self.fruits[self.idx]
But now we have duplicate way of getting selected fruit, which makes the API more confusing. You could deprecate the old property, but that might break the API for older users.
So what if there would be a way to keep the old API functional, plus add the new functionality by doing one weird python trick?
We can do this by creating an object that:
- Is equal to a default index element when not called
- Returns the default index oelement when called without parameters
- Returns a specific index element when called with an index parameter
If the @property
would return such an object, then both the old API and new index selector API could be supported by a single @property
!
The trick is creating a new class that is an instance of the default index item (e.g. for 'banana'
the type would be str
), but also contains the full list of fruit, and has a __call__
method that returns the right fruit when called with a specific index, and otherwise just returns the default fruit.
Or in Python code:
from typing import List
def make_callable_default_list(source_list:List, default_index:int):
class DefaultList(type(source_list[default_index])):
def __new__(cls, default_value, source_list):
obj = type(source_list[default_index]).__new__(cls, default_value)
return obj
def __init__(self, default_value, source_list):
super().__init__()
self.source_list = source_list
self.default_type = type(default_value)
def __call__(self, index=None):
if index is not None:
return self.source_list[index]
else:
return self.default_type(self)
return DefaultList(source_list[default_index], source_list)
Now we simply make the property return such a DefaultList
:
class FruitList:
def __init__(self, fruits, idx=0):
self.fruits = fruits
self.idx = idx
@property
def selected_fruit(self):
return make_callable_default_list(self.fruits, self.idx)
When calling selected_fruit
as an attribute, it still works the same as before for our old users:
fl = FruitList(['apple', 'banana', 'pear'], idx=1)
print("Init self.idx == 1, fruit = ", fl.selected_fruit)
fl.idx = 2
print("Set self.idx = 2, fruit = ", fl.selected_fruit)
But now the property also works as a callable to get the fruit of a specific index for our new users:
print("Using callable without index, fruit = ", fl.selected_fruit())
print("Specifying idx = 0, fruit = ", fl.selected_fruit(0))
Using pd.DataFrame, pd.Series or np.ndarray as list elements
The above should work with most typical python objects, but if you happen to want to return a pd.DataFrame
or pd.Series
or a np.ndarray
, you need to slightly alter the code to get it to work, as these types are special in the way they are initialized. Below however some code that should work for all these types:
class DefaultDfList(pd.DataFrame):
""""
You have the set source_list manually!
e.g.
dfl = DefaultDfList(df1)
dfl.source_list = [df1, df2]
"""
_internal_names = list(pd.DataFrame._internal_names) + ['source_list']
_internal_names_set = set(_internal_names)
def __call__(self, index=None):
if index is not None:
return self.source_list[index]
else:
return pd.DataFrame(self)
@property
def _constructor(self):
return DefaultDfList
class DefaultSeriesList(pd.Series):
_internal_names = list(pd.Series._internal_names) + ['source_list']
_internal_names_set = set(_internal_names)
def __call__(self, index=None):
if index is not None:
return self.source_list[index]
else:
return pd.Series(self)
@property
def _constructor(self):
return DefaultSeriesList
class DefaultNpArrayList(np.ndarray):
def __new__(cls, default_array, source_list):
obj = np.asarray(default_array).view(cls)
obj.source_list = source_list
return obj
def __array_finalize__(self, obj):
if obj is None: return
self.source_list = getattr(obj, 'source_list', None)
def __array_wrap__(self, out_arr, context=None):
return np.ndarray.__array_wrap__(self, out_arr, context).view(np.ndarray)
def __call__(self, index=None):
if index is not None:
return self.source_list[index]
return self.view(np.ndarray)
def default_list(source_list:List, default_index:int):
"""
Normally gives the default_index item in a list.
If used as a callable, you can specify a specific index.
Use to make @property that you can pass optional index parameter to
"""
if isinstance(source_list[default_index], pd.DataFrame):
df_list = DefaultDfList(source_list[default_index])
df_list.source_list = source_list
return df_list
if isinstance(source_list[default_index], pd.Series):
s_list = DefaultSeriesList(source_list[default_index])
s_list.source_list = source_list
return s_list
if isinstance(source_list[default_index], np.ndarray):
a_list = DefaultNpArrayList(source_list[default_index], source_list)
return a_list
class DefaultList(type(source_list[default_index])):
def __new__(cls, default_value, source_list):
obj = type(source_list[default_index]).__new__(cls, default_value)
return obj
def __init__(self, default_value, source_list):
super().__init__()
self.source_list = source_list
self.default_type = type(default_value)
def __call__(self, index=None):
if index is not None:
return self.source_list[index]
else:
return self.default_type(self)
return DefaultList(source_list[default_index], source_list)