I ran across an issue that proved to be more complicated than I thought: Changing an arbitrary argument of a function purely based upon its name in Python - possibly via a decorator. I tried to implement a generic solution, taking into account Python's positional arguments, keyword arguments and argument defaults.
First things first, type hints purely for readability:
from typing import Any, Callable, Dict, List, Tuple
The following function is the core. It looks at func
, the target function, and tries to locate the argument named arg_name
. It then applies task
to it.
def _change_one_argument(
func: Callable,
arg_name: str,
task: Callable,
args: Tuple,
kwargs: Dict,
) -> Tuple[Tuple, Dict]:
"""
Apply `task` to one argument in `args` or `kwargs` of `func`
based on `argument`'s name.
"""
arg_names = func.__code__.co_varnames[:func.__code__.co_argcount]
arg_position = arg_names.index(arg_name)
if arg_position < len(args): # from args
old_value = args[arg_position]
args = list(args) # for future change and as a flag
elif arg_name in kwargs.keys(): # from kwargs
old_value = kwargs.get(arg_name)
else: # from default
offset = len(arg_names) - len(func.__defaults__)
old_value = func.__defaults__[arg_position - offset]
new_value = task(old_value)
if isinstance(args, list): # consider this as a flag
args[arg_position] = new_value
args = tuple(args) # change back to tuple
else:
kwargs = kwargs.copy() # beware the mutable dict
kwargs[arg_name] = new_value
return args, kwargs
As potential use case for testing, I built a decorator, which accepts an arbitrary number of keyword arguments. Each decorator argument represents one argument of the decorated function that is to be changed. The actual change is handled by a callable passed as the argument to the decorator.
def change_arguments(**changes: Callable):
"""
Name parameters via keywords and pass callables,
which are applied to `func`'s arguments.
"""
def outer(func: Callable):
def inner(*args: Any, **kwargs: Any):
for arg_name, task in changes.items():
args, kwargs = _change_one_argument(
func = func,
arg_name = arg_name,
task = task,
args = args,
kwargs = kwargs,
)
return func(*args, **kwargs)
return inner
return outer
One can test it for instance as follows:
@change_arguments(c = lambda x: x * 100)
def foo(a: int, b: int = 77, c: int = 88, d: int = 99):
e = 14
print(a, b, c, d, e)
foo(10, 11)
foo(10, 11, d = 13)
foo(10, 11, 12, 13)
foo(10, 11, c = 12, d = 13)
foo(10, 11, d = 13, c = 12)
It produces:
10 11 8800 99 14
10 11 8800 13 14
10 11 1200 13 14
10 11 1200 13 14
10 11 1200 13 14
I know that the error handling is not perfect. My solution also does not help every time if the decorated function is written in C and does, possibly, not accept keyword arguments. It also heavily relies on the fact that the argument has a name which is not, strictly speaking, always the case. Last but not least, my handling of mutable keyword argument dictionaries could be better. In the above case, it does not matter, but if _change_one_argument
was a directly callable API of some sort, I am not sure - maybe it should not return anything, accept args
only as a list and apply changes directly to args
and kwargs
(no copies made).