Ian ClearyRF Systems Engineer


Ian ClearyRF Systems Engineer


Python Decorators

How to create optionally callable python 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
1# examples
2import pytest
3
4# No need to write '@pytest.fixture()`
5@pytest.fixture
6def func():
7 return "This is just an example'
8
9
10@pytest.fixture(scope="session")
11def session_func():
12 return "This is just another example with a different scope"
conftest.py
1# examples
2import pytest
3
4# No need to write '@pytest.fixture()`
5@pytest.fixture
6def func():
7 return "This is just an example'
8
9
10@pytest.fixture(scope="session")
11def session_func():
12 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:

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

changeme/main.py
1# This is the module that will be imported by the user
2# there should be a folder called changeme with an __init__.py file
3import functools
4import typing
5
6
7def logged(func: typing.Callable = None, decimals: int = None) -> typing.Callable:
8 # Everything outside of the decorated function is executed
9 # when the logged function decorator is parsed by the interpreter
10
11 if func is None:
12 # This code is reached when the decorator is called with or without parameters
13 # It then returns a partial function with the parameters set
14 print(f"Decorator is a called function with decimals={decimals}")
15 return functools.partial(logged, decimals=decimals)
16
17 else:
18 # This code will always be reached eventually
19 # right away if the code decorator isn't a function call
20 # @logged
21 #
22 # or after the decorator is called with parameters, due to the above
23 # return functools.partial(logged, decimals=decimals)
24 #
25 # In the latter case, the code below is reached inside the partial function's execution
26 print(f"Decorator wrapped `{func.__name__}` {func}.")
27
28 # Everything inside of the `decorated` function below
29 # is executed when the decorated function is called
30 @functools.wraps(func)
31 def decorated(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
32 print(f"{func.__name__} called with args={args}, kwargs={kwargs}")
33 result = func(*args, **kwargs)
34 logged_result = result if decimals is None else round(result, decimals)
35 print(f"Logged Result:\t{logged_result}")
36 return result
37
38 print(f"Returning decorated function {decorated.__name__}\n\n")
39 return decorated
changeme/main.py
1# This is the module that will be imported by the user
2# there should be a folder called changeme with an __init__.py file
3import functools
4import typing
5
6
7def logged(func: typing.Callable = None, decimals: int = None) -> typing.Callable:
8 # Everything outside of the decorated function is executed
9 # when the logged function decorator is parsed by the interpreter
10
11 if func is None:
12 # This code is reached when the decorator is called with or without parameters
13 # It then returns a partial function with the parameters set
14 print(f"Decorator is a called function with decimals={decimals}")
15 return functools.partial(logged, decimals=decimals)
16
17 else:
18 # This code will always be reached eventually
19 # right away if the code decorator isn't a function call
20 # @logged
21 #
22 # or after the decorator is called with parameters, due to the above
23 # return functools.partial(logged, decimals=decimals)
24 #
25 # In the latter case, the code below is reached inside the partial function's execution
26 print(f"Decorator wrapped `{func.__name__}` {func}.")
27
28 # Everything inside of the `decorated` function below
29 # is executed when the decorated function is called
30 @functools.wraps(func)
31 def decorated(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
32 print(f"{func.__name__} called with args={args}, kwargs={kwargs}")
33 result = func(*args, **kwargs)
34 logged_result = result if decimals is None else round(result, decimals)
35 print(f"Logged Result:\t{logged_result}")
36 return result
37
38 print(f"Returning decorated function {decorated.__name__}\n\n")
39 return decorated

If we run the following script:

main.py
1from changeme.main import logged
2
3if __name__ == "__main__":
4
5 @logged
6 def add(x: float, y: float) -> float:
7 return x + y
8
9 @logged()
10 def add_wrapped_func(x: float, y: float) -> float:
11 return x + y
12
13 @logged(decimals=3)
14 def add_log_rounded_to_three_decimals(x: float, y: float) -> float:
15 return x + y
16
17 @logged(decimals=0)
18 def add_log_rounded_to_zero_decimals(x: float, y: float) -> float:
19 return x + y
20
21 ret1 = add(3.0, 4.1234)
22 print(f"Returned:\t{ret1}\n")
23
24 ret2 = add_wrapped_func(3.0, 4.1234)
25 print(f"Returned:\t{ret2}\n")
26
27 ret3 = add_log_rounded_to_three_decimals(3.0, 4.1234)
28 print(f"Returned:\t{ret3}\n")
29
30 ret4 = add_log_rounded_to_zero_decimals(3.0, 4.1234)
31 print(f"Returned:\t{ret4}\n")
main.py
1from changeme.main import logged
2
3if __name__ == "__main__":
4
5 @logged
6 def add(x: float, y: float) -> float:
7 return x + y
8
9 @logged()
10 def add_wrapped_func(x: float, y: float) -> float:
11 return x + y
12
13 @logged(decimals=3)
14 def add_log_rounded_to_three_decimals(x: float, y: float) -> float:
15 return x + y
16
17 @logged(decimals=0)
18 def add_log_rounded_to_zero_decimals(x: float, y: float) -> float:
19 return x + y
20
21 ret1 = add(3.0, 4.1234)
22 print(f"Returned:\t{ret1}\n")
23
24 ret2 = add_wrapped_func(3.0, 4.1234)
25 print(f"Returned:\t{ret2}\n")
26
27 ret3 = add_log_rounded_to_three_decimals(3.0, 4.1234)
28 print(f"Returned:\t{ret3}\n")
29
30 ret4 = add_log_rounded_to_zero_decimals(3.0, 4.1234)
31 print(f"Returned:\t{ret4}\n")

We get the following output in the terminal:

1python main.py
2Decorator wrapped `add` <function add at 0x100a24a40>.
3Returning decorated function add
4
5
6Decorator is a called function with decimals=None
7Decorator wrapped `add_wrapped_func` <function add_wrapped_func at 0x100b11440>.
8Returning decorated function add_wrapped_func
9
10
11Decorator is a called function with decimals=3
12Decorator wrapped `add_log_rounded_to_three_decimals` <function add_log_rounded_to_three_decimals at 0x100b0b2e0>.
13Returning decorated function add_log_rounded_to_three_decimals
14
15
16Decorator is a called function with decimals=0
17Decorator wrapped `add_log_rounded_to_zero_decimals` <function add_log_rounded_to_zero_decimals at 0x100bb3ba0>.
18Returning decorated function add_log_rounded_to_zero_decimals
19
20
21add called with args=(3.0, 4.1234), kwargs={}
22Logged Result: 7.1234
23Returned: 7.1234
24
25add_wrapped_func called with args=(3.0, 4.1234), kwargs={}
26Logged Result: 7.1234
27Returned: 7.1234
28
29add_log_rounded_to_three_decimals called with args=(3.0, 4.1234), kwargs={}
30Logged Result: 7.123
31Returned: 7.1234
32
33add_log_rounded_to_zero_decimals called with args=(3.0, 4.1234), kwargs={}
34Logged Result: 7.0
35Returned: 7.1234
1python main.py
2Decorator wrapped `add` <function add at 0x100a24a40>.
3Returning decorated function add
4
5
6Decorator is a called function with decimals=None
7Decorator wrapped `add_wrapped_func` <function add_wrapped_func at 0x100b11440>.
8Returning decorated function add_wrapped_func
9
10
11Decorator is a called function with decimals=3
12Decorator wrapped `add_log_rounded_to_three_decimals` <function add_log_rounded_to_three_decimals at 0x100b0b2e0>.
13Returning decorated function add_log_rounded_to_three_decimals
14
15
16Decorator is a called function with decimals=0
17Decorator wrapped `add_log_rounded_to_zero_decimals` <function add_log_rounded_to_zero_decimals at 0x100bb3ba0>.
18Returning decorated function add_log_rounded_to_zero_decimals
19
20
21add called with args=(3.0, 4.1234), kwargs={}
22Logged Result: 7.1234
23Returned: 7.1234
24
25add_wrapped_func called with args=(3.0, 4.1234), kwargs={}
26Logged Result: 7.1234
27Returned: 7.1234
28
29add_log_rounded_to_three_decimals called with args=(3.0, 4.1234), kwargs={}
30Logged Result: 7.123
31Returned: 7.1234
32
33add_log_rounded_to_zero_decimals called with args=(3.0, 4.1234), kwargs={}
34Logged Result: 7.0
35Returned: 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
1import functools
2import typing
3
4
5def decorate(func: typing.Callable = None, **options: typing.Any) -> typing.Callable:
6 if func is None:
7 return functools.partial(decorate, **options)
8
9 @functools.wraps(func)
10 def decorated(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
11 return func(*args, **kwargs)
12
13 return decorated
generic.py
1import functools
2import typing
3
4
5def decorate(func: typing.Callable = None, **options: typing.Any) -> typing.Callable:
6 if func is None:
7 return functools.partial(decorate, **options)
8
9 @functools.wraps(func)
10 def decorated(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
11 return func(*args, **kwargs)
12
13 return decorated

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

© 2023-present Ian Cleary.All Rights Reserved.
© 2023-present Ian Cleary.
All Rights Reserved.