Search code examples
pythonarguments

Got an unexpected keyword argument when calling function with *args


I have a Python framework that I'm trying to get to know. One of the classes has the following __init__():

def __init__(self, identity, direction, type, app_name, *aspects):

Usually (for clarity for myself), I try to call functions using their keywords, such as:

destination = RNS.Destination(
    identity=identity,
    direction=RNS.Destination.IN,
    type=RNS.Destination.SINGLE,
    app_name=APP_NAME,
    aspects='minimalsample',
    )

The exception I get when instantiating the class as above is Destination.__init__() got an unexpected keyword argument 'aspects'.

I think this is because the aspects argument is not really a keyword argument but rather a list of additional positional arguments. Ok, so it isn't really a kwarg, so I tried passing the 'minimalsample' argument as the first:

destination = RNS.Destination(
    'minimalsample',
    identity=identity,
    direction=RNS.Destination.IN,
    type=RNS.Destination.SINGLE,
    app_name=APP_NAME,
)

But then I get

TypeError: got multiple values for argument 'identity'

I think I see what is wrong here, but I have no idea how to call the function while retaining the clarity of using the keywords. Is there a good way?

This might have been asked before (plenty of times even), but I can't seem to find the answer with the search terms I'm using.


Solution

  • The documented signature is pretty clear.

    RNS.Destination(identity, direction, type, app_name, *aspects)

    It requires you to call it like this:

    import RNS
    from RNS.Destination import IN, SINGLE
    
    aspects: list[str] = ['minimalsample', 'largesample']
    
    dest = RNS.Destination(identity, IN, SINGLE, APP_NAME, *aspects)
    

    That approach would work just the same if aspects was a single-element list.

    Here are equivalent ways of passing in 1 or 2 aspects:

    • ... , APP_NAME, 'minimalsample')
    • ... , APP_NAME, 'minimalsample', 'largesample')

    The *args notation indicates we're passing in a list of zero or more arguments. It happens that this particular library wants more than zero arguments.

    The authors of this package could have chosen to accept an aspects: list[str] argument, or even aspects: list[str] | str, but that's not what they implemented. The 1st alternative forces callers to use more [ ] brackets than they might prefer for a common single-aspect use case. The 2nd alternative trades convenience against simple type-safety arguments.


    Sometimes you'll see a signature like this:

    def foo(a, b, c, d=None, e=0):

    You could call that with

    args = [a, b, c]
    kwargs = dict(d=None, e=0)
    
    foo(*args, **kwargs)
    

    Since Destination accepts a variable number of aspect strings, you don't have a choice, you need to list four positional args, then one-or-more aspects, followed by zero keyword args, if you want the bytecode to push all the right things onto the stack by the time the constructor executes.

    If you want to better understand the details, disassemble the bytecode of some calling function.


    When a library author is designing a Public API, there's more than one way to force callers to use positional args.

    https://docs.python.org/3/faq/programming.html#what-does-the-slash-in-the-parameter-list-of-a-function-mean

    What does the slash(/) in the parameter list of a function mean?

    A slash in the argument list of a function denotes that the parameters prior to it are positional-only. Positional-only parameters are the ones without an externally usable name.

    The documentation refers to PEP-570, which explains

    When designing APIs, library authors try to ensure correct and intended usage of an API

    and continues with a discussion of the careful choosing of names and several challenges.

    Something you're likely to see more often in signatures is a bare * star, which introduces keyword-only args. For example, a library author who is concerned about (x, y) vs (y, x) confusion, stemming from (lat, long) vs (long, lat), might choose a signature like this:

    def gis_location(*, x, y):
    

    This prevents attempted (incorrect!) calls such as

    lhr = gis_location(51.48, -0.46)
    

    and requires a more explicit

    lhr = gis_location(y=51.48, x=-0.46)