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)
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
kwargs (no copies made).