madoee
madoee

Reputation: 66

Check that one and only one optional function parameter is set and get its value

I have the following function

def foo(a, b=None, c=None, d=None)

where of the optional parameters b, c, and d one and only one should be set to a numeric value (zero not allowed). The function should also accept other optional parameters, which I left out of the definition for the sake of clarity.

To check this, I get the lists of the parameter values and names as such:

def foo(a, b=None, c=None, d=None):
    print(locals().keys())
    print(locals().values())

which gives e.g.:

foo(a=1, b=2)
-
dict_keys(['d', 'c', 'b', 'a'])
dict_values([None, None, 2, 1])

as expected. The following list comprehension should then check the optional parameters against their value and only return the parameter with a true value.

fov_arg = [(arg, list(locals().values())[i]) for i, arg in enumerate(locals().keys() \
           if arg in ['b', 'c', 'd'] and list(locals().values())[i]]

I could then assert len(fov_arg) == 1 to see that one and only one of the optional parameters was set. The locals().values() is wrapped into the list() call as I am using python3.6, where locals().values() returns a dict view, not a list.

However, running the function with the list comprehension

def foo(a, b=None, c=None, d=None):
    print(locals().keys())
    print(locals().values())

    fov_arg = [(arg, list(locals().values())[i]) for i, arg in enumerate(locals().keys() \
           if arg in ['b', 'c', 'd'] and list(locals().values())[i]]

foo(a=1, b=2)

outputs the following:

dict_keys(['d', 'c', 'b', 'a'])
dict_values([None, None, 2, 1])

[('d', 'd'), ('c', 1), ('b', <enumerate object at 0x7fd90c43ba20>)]

I expected the output to be [(b, 2)]. I do not understand why d and c are in this list, as their values are None. In addition, both of them show the wrong values, and the value of b is an enumerate object.

To figure out what is going wrong, I rewrote the list comprehension into

for i, arg in enumerate(locals().keys()):
        if arg in ['b', 'c', 'd']:
            if list(locals().values())[i]:
                print(arg, list(locals().values())[i])

Calling the function with the same arguments as above gives me

File "test.py", line 3, in foo
    for i, arg in enumerate(locals().keys()):
RuntimeError: dictionary changed size during iteration

I see that the dictionary locals().keys() seems to be involved in the problem, but I do not understand what is going wrong and how I can fix this behaviour.

Upvotes: 1

Views: 1365

Answers (4)

Serge Ballesta
Serge Ballesta

Reputation: 148890

You can use a specific signature to declare the arguments and then define the function to accept *kwargs. The nice point is that the signature will be used by any smart editor to suggest the correct parameters, and the inspect module will be able to automatically check its correctness for an easier maintenance.

The drawback is that it will need customization when you add additional keyword parameters:

def foo(a, *, b=None, c=None, d=None):   # force b, c, and d to be passed by keyword
    pass

sig = inspect.signature(foo)

def foo(a, *args, **kwargs):
    if len(kwargs) != 1 or len(args) != 0 
        or (tuple(kwargs.values()))[0] is None):
        raise ValueError("1 and only 1 of b,c, d parameters required")
    bound = sig.bind_partial(a, *args, **kwargs)
    bound.apply_defaults()

    # actual processing...
    def do_process(a, b, c, d):
        print("foo", a, b, c, d)
    do_process(**bound.arguments)

foo.__signature__ = sig

It works rather well:

>>> foo(1, c=2)
foo 1 None 2 None
>>> foo(a =1, b=3)
foo 1 3 None None
>>> foo(1)
Traceback (most recent call last):
  File "<pyshell#136>", line 1, in <module>
    foo(1)
  File "<pyshell#132>", line 4, in foo
    raise ValueError("1 and only 1 of b,c, d parameters required")
ValueError: 1 and only 1 of b,c, d parameters required
>>> foo(1, b=2, c=3)
Traceback (most recent call last):
  File "<pyshell#137>", line 1, in <module>
    foo(1, b=2, c=3)
  File "<pyshell#132>", line 4, in foo
    raise ValueError("1 and only 1 of b,c, d parameters required")
ValueError: 1 and only 1 of b,c, d parameters required
>>> foo(a=1, d=None)
Traceback (most recent call last):
  File "<pyshell#138>", line 1, in <module>
    foo(a=1, d=None)
  File "<pyshell#132>", line 4, in foo
    raise ValueError("1 and only 1 of b,c, d parameters required")
ValueError: 1 and only 1 of b,c, d parameters required

Upvotes: 0

Alfe
Alfe

Reputation: 59426

I propose to do all the checks in one simple call:

def foo(a, **kwargs):
    [(name, value)] = list(kwargs.items())
    assert name in 'bcd'
    # now process a, name, and value

Now you have to pass a and exactly one additional argument to the function, otherwise the assignment will fail. The name of the additional argument needs to be one of b, c, d, otherwise the assert will fail.

This way it gets less cluttered and you have the name of the additional argument in the name variable.

If you have other optional parameters, they can easily go along with this:

def foo(a, some_other_optional_parameter=some_default_value, **kwargs):
    [(name, value)] = list(kwargs.items())
    assert name in 'bcd'
    # now process a, name, value, and some_other_optional_parameter

This can be called either as

foo(4, c=5)

or as

foo(4, c=5, some_other_optional_parameter=6)

Upvotes: 0

Neeldhara
Neeldhara

Reputation: 121

If **kwargs is not an option, following up on @Rob's answer, perhaps the following variation works:

def foo(a, b=None, c=None, d=None):
    localvars = locals()
    fov_arg = [(arg,localvars[arg]) for arg in localvars.keys()\
    if arg in ['b', 'c', 'd'] and localvars[arg]]

I've simplified your comprehension a bit, I think the enumerate, in particular, is avoidable.

Upvotes: 1

Robᵩ
Robᵩ

Reputation: 168616

what is going wrong

The locals() dictionary is updated every time you create a local variable. In particular, after you call enumerate(locals().keys()) and before you call locals().values())[i], you create the local variables i and arg. Therefore, the dict that the former returns has different membership than the dict that the latter returns.

Similarly, in your for loop:

for i, arg in enumerate(locals().keys()):

During the first iteration, locals() has only a, b, c, d. By the second iteration, it also has i and arg.

and how I can fix this behaviour.

Choose a simpler way of interpreting your arguments. May I suggest:

sum(i is not None for i in [b, c, d]) == 1

Other, potentially better, solutions are listed in the comments.

Upvotes: 1

Related Questions