2
\$\begingroup\$

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
\$\endgroup\$
2
1
\$\begingroup\$

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.)

\$\endgroup\$
7
  • 1
    \$\begingroup\$ 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. \$\endgroup\$
    – Graipher
    Mar 10 '19 at 19:45
  • 1
    \$\begingroup\$ 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. \$\endgroup\$ Mar 10 '19 at 21:58
  • \$\begingroup\$ @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. \$\endgroup\$ Mar 10 '19 at 22:05
  • \$\begingroup\$ 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 \$\endgroup\$ Mar 15 '19 at 22:25
  • \$\begingroup\$ @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. \$\endgroup\$ Mar 15 '19 at 22:33

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.