How to write optionally callable parametrized decorators in Python

A memo on implementing parametrized decorators whose default behavior doesn't require empty parentheses.

Florimond Manca,

This blog post is a memo to myself, and to anyone who wants to know (or keeps forgetting like I do 😬) how to implement Python parametrized decorators without needing to call them in the no-arguments use case.

Not sure what I'm talking about? An example of this very handy pattern can be found in pytest fixtures:

import pytest


@pytest.fixture  # No need to write '@pytest.fixture()'
def app():
    ...


@pytest.fixture(scope="session")
def server():
    ...

Concrete example

Let's write an @logged decorator for numeric functions.

It accepts an optional decimals argument to round the result of the computation to a certain number of digits. If decimals is not given, we shouldn't round at all.

So, possible invokations should be:

  • @logged()
  • @logged(decimals=2)
  • @logged (Equivalent of @logged()) — implementing this is the goal of this blog post.

Cutting to the chase, here's the annotated solution:

import functools
import typing


def logged(func: typing.Callable = None, decimals: int = None) -> typing.Callable:
    if func is None:
        print(f"Called with decimals={decimals}")
        return functools.partial(logged, decimals=decimals)

    print(f"Called without parameters, func={func}.")

    @functools.wraps(func)
    def decorated(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
        print(f"{func.__name__} called with args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        logged_result = result if decimals is None else round(result, decimals)
        print(f"Result: {logged_result}")
        return result

    return decorated

If we run the following script:

@logged
def add(x: float, y: float) -> float:
    return x + y


@logged(decimals=2)
def mult(x: float, y: float) -> float:
    return x * y


add(2, 2)
mult(3, 4)

We get the following output:

Called without parameters, func=<function add at 0x10d8d8b70>.
Called with decimals=2
Called without parameters, func=<function mult at 0x10db18a60>.
add called with args=(2, 2), kwargs={}
Result: 4
mult called with args=(3, 4), kwargs={}
Result: 12

Boom.

Generic implementation

This 100% generic implementation is stripped of any comments and debug outputs. Just copy-paste it somewhere and adapt it to your needs.

import functools
import typing


def decorate(func: typing.Callable = None, **options: typing.Any) -> typing.Callable:
    if func is None:
        return functools.partial(decorate, **options)

    @functools.wraps(func)
    def decorated(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
        return func(*args, **kwargs)

    return decorated

That's it! Go add this extra juice to your decorator-based APIs. 🚀