Python

Optionally Callable Decorators

How to create optionally callable python parameterized decorators
My recommendation for new Python projects is to use uv package manager, which is an extremely fast Python package and project manager, written in Rust..
Learn how to install the uv package manager!

What are decorators?

A very handy pattern when needing to perform some action before and after a function is called is to use a decorator.

A decorator is a function that takes a function as an argument and returns a function.

The returned function is a wrapper around the original function, and can perform some action before and after the original function is called.

conftest.py
# examples
import pytest

# No need to write '@pytest.fixture()`
@pytest.fixture
def func():
    return "This is just an example'


@pytest.fixture(scope="session")
def session_func():
    return "This is just another example with a different scope"

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 invocations should be:

possible/invocations.py
@logged()
def foo():
    return

@logged(decimals=2)
def bar():
    return

# implementing this is the goal of this blog post; equivalent of `@logged()`.
@logged
def baz():
    return

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

changeme/main.py
# This is the module that will be imported by the user
# there should be a folder called changeme with an __init__.py file
import functools
import typing


def logged(func: typing.Callable = None, decimals: int = None) -> typing.Callable:
    # Everything outside of the decorated function is executed
    # when the logged function decorator is parsed by the interpreter

    if func is None:
        # This code is reached when the decorator is called with or without parameters
        # It then returns a partial function with the parameters set
        print(f"Decorator is a called function with decimals={decimals}")
        return functools.partial(logged, decimals=decimals)

    else:
        # This code will always be reached eventually
        # right away if the code decorator isn't a function call
        # @logged
        #
        # or after the decorator is called with parameters, due to the above
        # return functools.partial(logged, decimals=decimals)
        #
        # In the latter case, the code below is reached inside the partial function's execution
        print(f"Decorator wrapped `{func.__name__}` {func}.")

    # Everything inside of the `decorated` function below
    # is executed when the decorated function is called
    @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"Logged Result:\t{logged_result}")
        return result

    print(f"Returning decorated function {decorated.__name__}\n\n")
    return decorated

If we run the following script:

Note: The changeme package is just a placeholder for your package name.

main.py
from changeme.main import logged

if __name__ == "__main__":

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

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

    @logged(decimals=3)
    def add_log_rounded_to_three_decimals(x: float, y: float) -> float:
        return x + y

    @logged(decimals=0)
    def add_log_rounded_to_zero_decimals(x: float, y: float) -> float:
        return x + y

    ret1 = add(3.0, 4.1234)
    print(f"Returned:\t{ret1}\n")

    ret2 = add_wrapped_func(3.0, 4.1234)
    print(f"Returned:\t{ret2}\n")

    ret3 = add_log_rounded_to_three_decimals(3.0, 4.1234)
    print(f"Returned:\t{ret3}\n")

    ret4 = add_log_rounded_to_zero_decimals(3.0, 4.1234)
    print(f"Returned:\t{ret4}\n")

We get the following output in the terminal:

Terminal
python main.py
Decorator wrapped `add` <function add at 0x100a24a40>.
Returning decorated function add


Decorator is a called function with decimals=None
Decorator wrapped `add_wrapped_func` <function add_wrapped_func at 0x100b11440>.
Returning decorated function add_wrapped_func


Decorator is a called function with decimals=3
Decorator wrapped `add_log_rounded_to_three_decimals` <function add_log_rounded_to_three_decimals at 0x100b0b2e0>.
Returning decorated function add_log_rounded_to_three_decimals


Decorator is a called function with decimals=0
Decorator wrapped `add_log_rounded_to_zero_decimals` <function add_log_rounded_to_zero_decimals at 0x100bb3ba0>.
Returning decorated function add_log_rounded_to_zero_decimals


add called with args=(3.0, 4.1234), kwargs={}
Logged Result:  7.1234
Returned:       7.1234

add_wrapped_func called with args=(3.0, 4.1234), kwargs={}
Logged Result:  7.1234
Returned:       7.1234

add_log_rounded_to_three_decimals called with args=(3.0, 4.1234), kwargs={}
Logged Result:  7.123
Returned:       7.1234

add_log_rounded_to_zero_decimals called with args=(3.0, 4.1234), kwargs={}
Logged Result:  7.0
Returned:       7.1234

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.

generic.py
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. 🚀

Adapted from another article to help my understanding: Florimond Manca in 2019