A simple decorator written as a class, which counts how many times a function has been called
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) -&gt; 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) -&gt; 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) -&gt; 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) -&gt; 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) -&gt; 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) -&gt; 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) -&gt; 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) -&gt; 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.)