5

I have a function which can take keyword arguments and I want to require one of them. Let's say dollar and euro and I want one and only one of them. Right now, I'm doing it like this (exemplification) but I find it quite complex. Is there any other better way?

def set_value(country, **kargs):

    if len(kargs) == 1:
        if kargs.keys()[0] == 'dollar':
            pass # do something
        elif kargs.keys()[0] == 'euro':
            pass # do something
        else:
            raise ValueError('One keyword argument is required: dollar=x or euro=x')
    else:
        raise ValueError('One keyword argument is required: dollar=x or euro=x')

Thanks!

Diego Herranz
  • 2,857
  • 2
  • 19
  • 34

3 Answers3

10

You can use set operations on the dictionary keys view:

if len(kargs.viewkeys() & {'dollar', 'euro'}) != 1:
    raise ValueError('One keyword argument is required: dollar=x or euro=x')

In Python 3, use kargs.keys() instead.

Demo of the different outcomes of the set operation:

>>> kargs = {'dollar': 1, 'euro': 3, 'foo': 'bar'}
>>> kargs.viewkeys() & {'dollar', 'euro'}
set(['dollar', 'euro'])
>>> del kargs['euro']
>>> kargs.viewkeys() & {'dollar', 'euro'}
set(['dollar'])
>>> del kargs['dollar']
>>> kargs.viewkeys() & {'dollar', 'euro'}
set([])

In other words, the & set intersection gives you a set of all keys present in both sets; both in the dictionary and in your explicit set literal. Only if one and only one of the named keys is present is the length of the intersection going to be 1.

If you do not want to allow any other keyword arguments besides dollar and euro, then you can also use proper subset tests. Using < with two sets is only True if the left-hand set is strictly smaller than the right-hand set; it only has fewer keys than the other set and no extra keys:

if {}.viewkeys() < kargs.viewkeys() < {'dollar', 'euro'}:
    raise ValueError('One keyword argument is required: dollar=x or euro=x')

On Python 3, that can be spelled as:

if set() < kargs.keys() < {'dollar', 'euro'}:

instead.

Demo:

>>> kargs = {'dollar': 1, 'euro': 3, 'foo': 'bar'}
>>> {}.viewkeys() < kargs.viewkeys() < {'dollar', 'euro'}
False
>>> del kargs['foo']
>>> {}.viewkeys() < kargs.viewkeys() < {'dollar', 'euro'}
False
>>> del kargs['dollar']
>>> {}.viewkeys() < kargs.viewkeys() < {'dollar', 'euro'}
True
>>> del kargs['euro']
>>> {}.viewkeys() < kargs.viewkeys() < {'dollar', 'euro'}
False

Note that now the 'foo' key is no longer acceptable.

Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
5

Let's not use kargs.keys()[0], because that depends on the order of the keys, which is unspecified. It works now, but it's fragile because it will break if you add another keyword argument or if you migrate to Python 3.

Note that you'll have to use a different sentinel if None is a valid value.

def set_value(country, dollar=None, euro=None):
    if dollar is None and euro is None:
        raise TypeError('Need dollar or euro argument')
    if dollar is not None and euro is not None:
        raise TypeError('Cannot have both dollar and euro argument')

In general, I try to stay away from using **kwargs unless I'm passing the keyword arguments to another function. If you use **kwargs, then you can call set_value(dolar=5) and never notice that you misspelled "dollar".

Also note that the correct exception to raise is TypeError.

However, if you anticipate an expanding range of currencies,

CURRENCIES = {'euro', 'dollar', 'quatloo', 'zorkmid'}
def set_value(country, **kwargs):
    if len(kwargs) != 1 or not CURRENCIES.issuperset(kwargs.keys()):
        raise TypeError('exactly one supported currency must be specified')

I would probably not use separate keyword arguments, however:

Value = collections.namedtuple('Value', 'currency amount')
def set_value(country, value):
    ...

set_value(country, Value('USD', Decimal('15.30'))
Community
  • 1
  • 1
Dietrich Epp
  • 205,541
  • 37
  • 345
  • 415
  • 3
    Because len(kargs) == 1, order does not matter as only one key is present. – Diego Herranz Oct 01 '13 at 18:13
  • 2
    What happens when you add another keyword argument? What happens when you migrate to Python 3? – Dietrich Epp Oct 01 '13 at 18:14
  • With another keyword argument you get ValueError because one and only one keyword argument is required. – Diego Herranz Oct 01 '13 at 18:16
  • 1
    I'm talking about adding another argument to the function, I'm not talking about adding another argument to the function call. – Dietrich Epp Oct 01 '13 at 18:17
  • If you're talking fragile, this will break if (now or in future) `None` is a meaningful value for either keyword argument. Maybe use a global singleton `object()` for the default value, and `is` checks? Certainly, it's a potential wart if specifying the keyword arg equal to `None` has the same effect as not specifying it at all, but the expected behaviour is expressed in terms of whether it's specified. – Steve Jessop Oct 01 '13 at 18:19
  • @SteveJessop: Yes, you're right. However, that is kind of beyond the scope of this question. – Dietrich Epp Oct 01 '13 at 18:19
  • @DietrichEpp Ok. Now I see what you mean with adding another argument. You're right it would be complex to add another keyword argument. – Diego Herranz Oct 01 '13 at 18:21
  • Hmm, sounds a bit like future changes that you imagine are very important, whereas future changes that I imagine are outside the scope of the question ;-). Not that it's desperately important, the questioner hopefully can assess this stuff for himself. Worst case, if another keyword argument is added and the code in place doesn't easily allow for that, you rewrite a line or two of code... – Steve Jessop Oct 01 '13 at 18:24
  • @SteveJessop: I had made some assumptions about coding style, mainly that `None` is an okay sentinel about 99% of the time, whereas I add new arguments to existing functions all the time. I did edit your comment into the answer. – Dietrich Epp Oct 01 '13 at 18:26
  • 2
    Of course, the "correct" answer to this question is entirely subjective, but I prefer this method. If you know all of the possible arguments (and there are only two of them), why would you not want to list them out in the function argument list? Doing anything else makes it harder for users reading the function to determine how it actually works. I really don't want to read the entire source of a function to determine what arguments it accepts. – Mark Hildreth Oct 01 '13 at 18:34
  • 1
    After reading the answers, I get the feeling that as @MarkHildreth says, it might have been better not to use keyword arguments. But since I made the question that way, I'll accept Martijn Pieters' answer. – Diego Herranz Oct 01 '13 at 18:40
0

You can use dict.pop:

def set_value(country, **kwargs):
    euros = kwargs.pop('euro', None)
    dollars = kwargs.pop('dollar', None)
    if (euros is None is dollars) or (euros is not None is not dollars):
        raise ValueError('One keyword argument is required: dollar=x or euro=x')
    elif euros:
        #do something
    else:
        #do something
Bakuriu
  • 98,325
  • 22
  • 197
  • 231