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:

  1. Playing around with the dynamic nature of python for the fun of it
  2. You had defined @properties in your API that later on you realized should have been a get_ method with a parameter, but you do not want break the old API.

(I had a case of no. 2)

Adding callable indexes to properties

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)
init idx == 1, fruit =  banana
new idx == 2, fruit =  pear

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?

Enter callable default lists

We can do this by creating an object that:

  1. Is equal to a default index element when not called
  2. Returns the default index oelement when called without parameters
  3. 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)
Init self.idx == 1, fruit =  banana
Set self.idx = 2, fruit =  pear

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 callable without index, fruit =  pear
Specifying idx = 0, fruit =  apple

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)

Conclusion

So there you have a nice example of how the dynamic nature of python allows you to do some pretty crazy things with your API.

Whether this is a good idea is ofcourse another question :)