Skip to Main Content
Official App
Free – Google Play
Get it
FreshBooks is Loved by American Small Business Owners
FreshBooks is Loved by Canadian Small Business Owners
FreshBooks is Loved by Small Business Owners in the UK
Dev Blog

Logging actions with Python decorators Part II: Extracting call arguments

by Taavi on April 28/2010

[Update 2012-01-03]: Piotr points out that Python 2.7/3.2 introduced the new inspect.callargs function which does exactly this. So if you’re using 2.7 or above (or 3.2 or above), just use that. Thanks!

Let’s start with the delete_invoice example from part I:

@log_event('Delete Invoice', objectid_param='invoiceid')
def delete_invoice(self, invoiceid, **options):
    # ...
    return True

The trick is that what you log varies with the call parameters. And what happens if someone needs to add another parameter to the function? What if the caller used positional instead of keyword arguments? Can you imagine a world in which all of these logged the same message:

delete_invoice(invoiceid=3, convert_payments_to_credits=True)
delete_invoice(dry_run=True, invoiceid=3)

Well, there is hope. Python ships with the inspect module which lets you introspect modules, classes, and functions. Capturing *args and **kwargs lets you map the arguments passed to a function onto its expected arguments by name. Once you have a consistent way to get at a parameter by its name, the rest is easy.

There are three major cases to consider:

  1. A keyword argument. That’s easy, just extract it from **kwargs.
  2. A positional argument. That’s not too much harder. Just look at the list of parameter names, and pick the right one out of *args by index.
  3. The argument was not passed in at all: it has a default value. Okay, this one’s tricky.

When we have a default parameter we have to do a little more thinking. We know:

  1. The list of positional arguments passed in
  2. The list of all arguments
  3. The list of default argument values

Given this def and function call:

def args(one, two, three, four=4444, five=55555, six=666666): pass
# And this function call:
args(1, 2, 3, 4)

We have these lists (aligned vertically for clarity)

a = [    1,     2,       3,      4                ]
b = ['one', 'two', 'three', 'four', 'five',  'six']
c = [                         4444,  55555, 666666]

If we want one, two, three, or four, we can easily get those by finding the index of that argument in list (b) and see that the index also exists in list (a). But if we want the value of five, we have more work to do; in this case, we have to convert the index of five (which is 4) into the index of its default value (which is 1). We can do that by taking the index we already have, subtract the length of all parameters, then add the length of the defaults. It works out: 4 - 6 + 3 = 1. Of course, we have to make sure we don’t end up trying to take a negative index, in case someone didn’t pass in three, which doesn’t have a default value.

So, here’s the big nasty. In part III we’ll take a quick peek at the mixin I mentioned in the previous post.

import inspect
def extract_param_by_name(f, args, kwargs, param):
    """Find the value of a parameter by name, even if it was passed via *args or is a default value.
    Let's start with a fictional function:
    >>> def my_f(a,b,c='foo'):
    ...   {"a":a,"b":b,"c":c}
    Works with kwargs (easy):
    >>> extract_param_by_name(my_f, [], {'a':1}, 'a')
    Works with args (not obvious):
    >>> extract_param_by_name(my_f, [2], {}, 'a')
    Works with default kwargs (bet you didn't think about that one):
    >>> extract_param_by_name(my_f, [], {}, 'c')
    But of course you can override that:
    >>> extract_param_by_name(my_f, [99,98,97], {}, 'c')
    In different ways:
    >>> extract_param_by_name(my_f, [], {'c':'gar'}, 'c')
    And dies with "grace" when you do something silly:
    >>> extract_param_by_name(my_f, [], {}, 'a')
    Traceback (most recent call last):
    LoggerBadCallerParametersException: ("Caller didn't provide a required positional parameter '%s' at index %d", 'a', 0)
    >>> extract_param_by_name(my_f, [], {}, 'z')
    Traceback (most recent call last):
    LoggerUnknownParamException: ('Unknown param %s(%r) on %s', <type 'str'>, 'z', 'my_f')
    if param in kwargs:
        return kwargs[param]
        argspec = inspect.getargspec(f)
        if param in argspec.args:
            param_index = argspec.args.index(param)
            if len(args) > param_index:
                return args[param_index]
            if argspec.defaults is not None:
                # argsec.defaults holds the values for the LAST entries of argspec.args
                defaults_index = param_index - len(argspec.args) + len(argspec.defaults)
                if 0 <= defaults_index < len(argspec.defaults):
                    return argspec.defaults[defaults_index]
            raise LoggerBadCallerParametersException("Caller didn't provide a required positional parameter '%s' at index %d", param, param_index)
            raise LoggerUnknownParamException("Unknown param %s(%r) on %s", type(param), param, f.__name__)
class LoggerUnknownParamException(Exception):
class LoggerBadCallerParametersException(Exception):