A simple decorator written as a class, which counts how many times a function has been called

Can I improve its typing? Is there any other improvement or pythonic change that you would do?

F = TypeVar('F', bound=Callable[..., Any])

# This is mostly so that I practice using a class as a decorator.
class CountCalls:
"""Logs to DEBUG how many times a function gets called, saves the result in a newly created attribute num_calls."""
def __init__(self, func: F) -> None:
functools.update_wrapper(self, func)
self.func = func
self.num_calls: int = 0
self._logger = logging.getLogger(__name__ + '.' + self.func.__name__)
self.last_return_value = None

def __call__(self, *args: Any, **kwargs: Any) -> Any:
self.num_calls += 1
self._logger.debug(' called %s times', self.num_calls)
self.last_return_value = self.func(*args, **kwargs)
return self.last_return_value


Here's the decorator in action:

>>> @CountCalls
... def asdf(var: str):
...     print(var)
...     return len(var)
...
>>> asdf('Laur')
Laur
4
DEBUG:__main__.asdf: called 1 times
>>> asdf('python 3')
DEBUG:__main__.asdf: called 2 times
python 3
8
>>> asdf(3)
DEBUG:__main__.asdf: called 3 times
3
Traceback (most recent call last):
File "<input>", line 1, in <module>
File "C:/Projects/Python/he/src/he/decorators.py", line 156, in __call__
self.last_return_value = self.func(*args, **kwargs)
File "<input>", line 4, in asdf
TypeError: object of type 'int' has no len()
>>> asdf.num_calls
3


One thing you could try to improve the typing would be to type the method itself (although I'm not sure how well tools support it). Also, leading/trailing whitespace should be up to the logger, not the code using it.

F = TypeVar('F', bound=Callable[..., Any])

# This is mostly so that I practice using a class as a decorator.
class CountCalls:
"""Logs to DEBUG how many times a function gets called, saves the result in a newly created attribute num_calls."""
def __init__(self, func: F) -> None:
functools.update_wrapper(self, func)
self.func = func
self.num_calls: int = 0
self._logger = logging.getLogger(__name__ + '.' + self.func.__name__)
self.last_return_value = None

__call__: F

def __call__(self, *args: Any, **kwargs: Any) -> Any:
self.num_calls += 1
self._logger.debug(f'called %s times', self.num_calls)
self.last_return_value = self.func(*args, **kwargs)
return self.last_return_value


As for the code itself, you could make a callback-based API.

F = TypeVar('F', bound=Callable[..., Any])

# This is mostly so that I practice using a class as a decorator.
class CountCalls:
"""Logs to DEBUG how many times a function gets called, saves the result in a newly created attribute num_calls."""
def __init__(self, func: F, callback: Optional[Callable[[int, Tuple[Any], Dict[str, Any]], Any]] = None) -> None:
if callback is None:
logger = logging.getLogger(__name__ + '.' + self.func.__name__)

def callback(num_calls: int, args: Tuple[Any], kwargs: Dict[str, Any]):
self._logger.debug(f'called %s times', self.num_calls)

functools.update_wrapper(self, func)
self.func = func
self.callback = callback
self.num_calls: int = 0
self.last_return_value = None

__call__: F

def __call__(self, *args: Any, **kwargs: Any) -> Any:
self.num_calls += 1
self.callback(self.num_calls, args, kwargs)
self.last_return_value = self.func(*args, **kwargs)
return self.last_return_value


Or with the number of calls tracked in the callback (for increased flexibility):

F = TypeVar('F', bound=Callable[..., Any])

# This is mostly so that I practice using a class as a decorator.
class CountCalls:
"""Logs to DEBUG how many times a function gets called, saves the result in a newly created attribute num_calls."""
def __init__(self, func: F, callback: Optional[Callable[[int, Tuple[Any], Dict[str, Any]], Any]] = None) -> None:
if callback is None:
logger = logging.getLogger(__name__ + '.' + self.func.__name__)
num_calls: int = 0

def callback(args: Tuple[Any], kwargs: Dict[str, Any]):
nonlocal num_calls  # Not sure if this is necessary or not
num_calls += 1
self._logger.debug(f'called %s times', self.num_calls)

functools.update_wrapper(self, func)
self.func = func
self.callback = callback
self.last_return_value = None

__call__: F

def __call__(self, *args: Any, **kwargs: Any) -> Any:
self.callback(self.num_calls, args, kwargs)
self.last_return_value = self.func(*args, **kwargs)
return self.last_return_value


Earlier, to pass a keyword argument while using it, @functools.partial(CountCalls, callback=callback) was needed. Now, @CountCalls(callback=callback) can be used instead.

F = TypeVar('F', bound=Callable[..., Any])

# This is mostly so that I practice using a class as a decorator.
class CountCalls:
"""Logs to DEBUG how many times a function gets called, saves the result in a newly created attribute num_calls."""
def __init__(self, func: F = None, callback: Optional[Callable[[int, Tuple[Any], Dict[str, Any]], Any]] = None) -> None:
if callback is None:
logger = logging.getLogger(__name__ + '.' + self.func.__name__)
num_calls: int = 0

def callback(args: Tuple[Any], kwargs: Dict[str, Any]):
nonlocal num_calls  # Not sure if this is necessary or not
num_calls += 1
self._logger.debug(f'called %s times', self.num_calls)

if func is None:
return functools.partial(CountCalls, callback=callback)

functools.update_wrapper(self, func)
self.func = func
self.callback = callback
self.last_return_value = None

__call__: F

def __call__(self, *args: Any, **kwargs: Any) -> Any:
self.callback(self.num_calls, args, kwargs)
self.last_return_value = self.func(*args, **kwargs)
return self.last_return_value


(Note: none of this code has been tested.)

• Using %-formatting is the default way for the logging module (and it is not going to change due to backward compatibility). It is a bit special in that regard. One can change it to use parts of the str.format syntax, but not with keyword arguments. Using f-strings here is actually a potentially bad idea, because normally the string formatting is only performed if the message is of the right logging level, which means a lot when not having to do it for debug messages. Mar 10 '19 at 19:45
• Typing the __call__ method itself is genius. Regarding f-strings in logging, I have to agree with @Graipher that string interpolation is better suited for the logging module - that's why I used it in the first place. I'm starting to understand now what's with the callback part that you proposed, and that's genius as well; if I understand correctly, it allows changing the functionality of the decorator by supplying an optional function... I'll go into test mode for that part. Mar 10 '19 at 21:58
• @LaurențiuAndronache It would be nice if Python supported additional types of TypeVars for when tricks like this don't work (such as with typing the callback), but oh well... Also, with the callback API, I'll add a version where the number of calls is tracked by the callback for increased flexibility. Mar 10 '19 at 22:05
• note that in both examples with callbacks, you can't actually supply a callback to the decorator. TypeError: b() takes 0 positional arguments but 1 was given Mar 15 '19 at 22:25
• @LaurențiuAndronache Interesting.... What is b? The decorated function or the callback? What's its signature? Does the error occur when defining the decorated function or when calling it? How are you calling the decorator? I think functools.partial with a keyword argument (callback) is necessary. Mar 15 '19 at 22:33