When writing my post about functools.partial I noticed the presence of a functools.partialmethod function to apply partial to functions that will be used as methods. I had not realised of this before, but knowing how methods are implemented (functions implement the descriptor protocol, so when a function attached to a class is accessed through an object (receiver) the __get__ of the function gets invoked and returns a bound method) the issue becomes clear. From the documentation:
To support automatic creation of methods, functions include the __get__() method for binding methods during attribute access. This means that functions are non-data descriptors that return bound methods during dotted lookup from an instance.
functools.partial does not return another function (a Function is both a callable and descriptor), but a callable object that is an instance of a class other than Function (that has a __call__ method but is not a descriptor). Because of that, you can not use it as a method cause not having a __get__ you are losing the bound-method part. That's why a separate partialmethod function is needed.
We have this same problem with decorators that have been implemented as classes rather than as functions. When you apply one of these decorators, defined lets say as class MyDecorator, you create an instance of that MyDecorator class, and that instance is a callable object. If you apply MyDecorator to a method, obtaining a callable is not enough, we also need it also to have a __get__ that returns a bound method. Taking inspiration from here and here I've set up an example:
import types
class LoggedCall:
def __init__(self, f):
self.func = f
# for the __name__, __doc__, etc attributes to be set to those of the wrapped function
functools.update_wrapper(self, f)
def __call__(self, *args, **kwargs):
print(f"{self.func.__name__} invoked")
return self.func(*args, **kwargs)
def __get__(self, instance, cls):
# the reason for adding the "if instance is" is not in case we apply it to an independent function (that would work fine)
# is in case we decide to call it through the class and not an instance (see example below)
return types.MethodType(self, instance) if instance is not None else self
class Person:
def __init__(self, name: str):
self.name = name
@LoggedCall
def say_hi(self, someone: str):
print(f"{self.name} says hi to {someone}")
p1 = Person("Iyan")
p1.say_hi("Francois")
# it for this odd way to invoke it for which we need to add the "if instance" in the __get__
Person.say_hi(p1, "Antoine")
Notice that this example is quite a bit contrived. Rather than using a decorator class we could have used a decorator function, that feels way more natural, and needs no extra bound-method creation from our part:
def log_call(func):
functools.wraps(func)
def wrapper(*args, **kwargs):
print(f"{func.__name__} invoked")
return func(*args, **kwargs)
return wrapper
class Person2:
def __init__(self, name: str):
self.name = name
@log_call
def say_hi(self, someone: str):
print(f"{self.name} says hi to {someone}")
p1 = Person("Iyan")
p1.say_hi("Francois")
Notice how in both cases I'm using functools.wraps of functools.update_wrapper for the wrapper function to look like the wrapped function (attributes: __module__, __name__, __qualname__, __annotations__, __type_params__, and __doc__)
So all in all, when adding a callable object that is not a function to a class as a method we need that callable to behave like a function, returning a bound-method when being accessed. For that we have to make the class of our callable object a descriptor also, with a __get__ method that returns a bound-method (just as functions do).
While writing this post I've gone throug a couple discussions [1] and [2] about using decorator functions vs decorator classes. Even if we want to keep state, I'm much more inclined to use a decorator function returning a closure than to use a decorator class.