Home > Software design >  What is the opposite of unpacking **kwargs in Python?
What is the opposite of unpacking **kwargs in Python?

Time:07-07

I've been learning about Python decorators and to practice using them, I'm writing a logging decorator that records when a function is called, the *args and **kwargs passed to the function, and a __repr__ of the function so it can be re-evaluated at a later time.

In Python, the __repr__() method of an object sometimes returns a string that can be used to re-create the object by passing that string to eval(). I'm trying to implement a form of __repr__ for functions.

Since I'm using a decorator that will wrap any function, I won't know beforehand what the function's parameters are, so I need to use *args and **kwargs to construct the __repr__, which is a string that looks like:

"function_name(positional_arg1, positional_arg2, keyword_arg1='some_value', keyword_arg2='other_value')"

To create that string representation, I need to reconstruct an argument list from **kwargs; that is, convert a dictionary in the form of {'kwarg1': val1, 'kwarg2': val2} to an argument list like: kwarg1=val1, kwarg2=val2.

(Note: unpacking *args to an argument list isn't a problem, since the tuple form of args is already an acceptable format for passing as positional arguments to a function after removing the parentheses from the tuple. That is, the args tuple: ('arg1', 'arg2') simply becomes 'arg1', 'arg2'. Thus, this question focuses on converting the kwargs dictionary back to an argument list.)

Below is what I have created so far. It works but isn't very elegant. Is there a simpler way to perform the opposite of unpacking kwargs?

# Create a class to store the state, so the logs can be recorded.
# The alternative is to use a global-scope dictionary and write
# to it, which is a much simpler implementation but not the
# best way to approach this problem.
class LoggerUtil():
    def __init__(self):
        self.record = {}
        self.count = 0
    
    # This is a triple-nested function because, when used as a decorator,
    # it will need to be called (e.g., "@LoggerUtil.log()", with parentheses at the end).
    # That is because the outermost level needs to retain a reference to 'self'.
    def log(self):
        def log_outer(func):
            def wrapper(*args, **kwargs):
                result = func(*args, **kwargs)
                # =======================================
                # MY IMPLEMENTATION OF REVERSE-UNPACKING
                # =======================================
                # Generate the kwargs string representation
                kwargs_repr = ''
                # If no kwargs are passed, kwargs holds an empty dictionary (i.e., dict())
                if kwargs != dict():
                    num_kwargs = len(kwargs)
                    # Loop through kwargs dictionary and convert to the 
                    # format required for an argument list (i.e., key1=val1 rather than {key1: val1})
                    for n, (kw, val) in enumerate(kwargs.items()):
                        kwargs_repr  = str(kw)   '='
                        # If the value is a string, it needs extra quotes so it stays
                        # a string after being passed to eval().
                        if type(val) == str:
                            kwargs_repr  = '"'   val   '"'
                        else:
                            kwargs_repr  = str(val)
                        # Add commas to separate arguments, up until the last argument
                        if n < num_kwargs - 1:
                            kwargs_repr  = ', '
                # =======================================
                # END OF REVERSE-UNPACKING
                # =======================================
                # Save log to the record
                self.record[self.count] = {
                    'function': func.__name__,
                    'args': args,
                    'kwargs': kwargs,
                    'result': result,
                    # ===========================================
                    # MY IMPLEMENTATION OF THE __repr___ PROPERTY
                    # ===========================================
                    'repr': (
                        f"{func.__name__}("
                        # str(args)[1:-1] removes the parentheses around the tuple
                        f"{str(args)[1:-1] if args != tuple() else ''}"
                        f"{', ' if args != tuple() and kwargs != dict() else ''}"
                        f"{kwargs_repr})"
                        )
                    }
                self.count  = 1
                return result
            return wrapper
        return log_outer

func_log = LoggerUtil()

@func_log.log()
def any_func(*args, **kwargs):
    print(f"Positional arguments passed: {args}")
    print(f"Keyword arguments passed: {kwargs}")
    print('-' * 30, '\n')
    return None



# ==============================
# TEST THE FUNCTION
# ==============================
any_func('pos_arg1', 'pos_arg2', start=0, stop=10, step=1)

# Prints to console:
# ``````````````````
# Positional arguments passed: ('pos_arg1', 'pos_arg2')
# Keyword arguments passed: {'start': 0, 'stop': 10, 'step': 1}
# ------------------------------ 

# ==============================
# DISPLAY THE LOGS
# ==============================
print(func_log.record, '\n')

# Prints to console: (with indentation added for clarity)
# ``````````````````
# {
#     0: {
#         'function': 'any_func', 
#         'args': ('pos_arg1', 'pos_arg2'), 
#         'kwargs': {'start': 0, 'stop': 10, 'step': 1}, 
#         'result': None, 
#         'repr': "any_func('pos_arg1', 'pos_arg2', start=0, stop=10, step=1)"
#     }
# }


# ==============================
# TEST THE repr PROPERTY
# ==============================
function_repr = func_log.record[0]['repr']
eval(function_repr)
# Prints to console:
# ``````````````````
# Positional arguments passed: ('pos_arg1', 'pos_arg2')
# Keyword arguments passed: {'start': 0, 'stop': 10, 'step': 1}
# ------------------------------ 

(Attribution: some inspiration for my technique came from this Stack Overflow answer: https://stackoverflow.com/a/10717810/17005348).

Notes on what I've tried so far

  • I know I can access the args and kwargs separately, like this:
pos_args = func_log.record[0]['args']
kw_args = func_log.record[0]['kwargs']
func_name = func_log.record[0]['function']

# If I know the name of the function:
any_func(*pos_args, **kw_args)

# Or, more generally:
kw_args = str(kw_args)[1:-1].replace('\': ', '=').replace("'", '')
eval(
    func_name 
      '(' 
      str(pos_args)[1:-1] 
      ',' 
      str(kw_args) 
      ')'
)

# Prints to the console:
# ``````````````````````
# Positional arguments passed: ('pos_arg1', 'pos_arg2')
# Keyword arguments passed: {'start': 0, 'stop': 10, 'step': 1}
# ------------------------------ 

# Positional arguments passed: ('pos_arg1', 'pos_arg2')
# Keyword arguments passed: {'start': 0, 'stop': 10, 'step': 1}
# ------------------------------ 

... but I'd like to use the repr form if possible to mimic the syntax used for creating an object (i.e., eval(repr(obj))) and to avoid the messy string concatenations used in the generic version.

  • List/tuple comprehensions: doesn't work because each key-value pair becomes a string, rather than the entire arguments list as a whole, which means that eval() doesn't recognize it as a list of keyword arguments. For example: tuple(str(k} '=' str(v) for k, v in kwargs.items())becomes ('key1=val1', 'key2=val2') instead of ('key1=val1, key2=val2').
  • Appending __repr__() to the end of the function, like: func(*args, **kwargs).__repr__(). This doesn't work because the func() is evaluated first, so the .__repr__() method just returns a string representation of the value returned by func() (None in the case of my example code above). Note that appending .__repr__() to the function call is the same as repr(func()).

CodePudding user response:

There's some serious caveats here, as not everything passed to any type of keyword argument will necessarily have a good representation and the representation may not work as you expect (reconstructing a copy of the original when evaluated).

Having said that, something like this:

def print_args(*args, **kwargs):
    print(', '.join(map(repr, args)),
          ', '.join(f'{k}={repr(v)}' for k, v in kwargs.items()))


print_args(1, 'test', [1, 2, 3], a='word', b={'key': 1, 'another': 2})

Output:

1, 'test', [1, 2, 3] a='word', b={'key': 1, 'another': 2}

Note: I didn't take the trouble of printing a comma between the two sections, I assume it's obvious what's going on there. But in case it annoys you:

def print_args(*args, **kwargs):
    print(', '.join(list(map(repr, args))   
                    [f'{k}={repr(v)}' for k, v in kwargs.items()]))

Output:

1, 'test', [1, 2, 3], a='word', b={'key': 1, 'another': 2}
  • Related