3

Consider:

def foobar(*, foo, bar):
    if foo:
        print('foo', end="")
    if bar:
        print('bar', end="")
    if foo and bar:
        print('No bueno', end='')  # I want this to be impossible
    if not foo and not bar:
        print('No bueno', end='')  # I want this to be impossible
    print('')


foobar(foo='bar')  # I want to pass inspection
foobar(bar='foo')  # I want to pass inspection
foobar(foo='bar', bar='foo')  # I want to fail inspection
foobar()  # I want to fail inspection

Is there a way to set up a function so that way calling it only passes inspection when just one of foo or bar is being passed, without manually checking inside the function?

  • `foo` and `bar` are keyword-only parameters in that `foobar` definition, not optional parameters. (I've seen a lot of people make the opposite mistake, but this is the first time I've seen anyone mix things up in this direction.) – user2357112 Feb 01 '19 at 20:40
  • Yes I'm aware that they are not optional right now. I was wondering if there is a way to make it so that way to pass inspection when calling `foobar`, you could only pass one of foo or bar. – Samuel Anderson Feb 01 '19 at 20:43
  • 2
    Make them both named with a setting to none and check that? – eagle33322 Feb 01 '19 at 20:47
  • While that would work on making it fail, it still passes inspection, and so someone wouldn't realise something is wrong until running the code. This is where I'm coming from. – Samuel Anderson Feb 01 '19 at 20:53
  • 2
    What do you mean by "it still passes inspection"? What sort of inspection do you want to catch this? Some specific automated tool? Code review by a programmer of a certain Python experience level? – user2357112 Feb 01 '19 at 20:55
  • Basically I want pycharm to give me a warning before I run the code, and I want the python to raise an error when trying to enter the function. – Samuel Anderson Feb 01 '19 at 20:59

4 Answers4

7

Syntactically no. However it's relatively easy to do this using a decorator:

from functools import wraps

def mutually_exclusive(keyword, *keywords):
    keywords = (keyword,)+keywords
    def wrapper(func):
        @wraps(func)
        def inner(*args, **kwargs):
            if sum(k in keywords for k in kwargs) != 1:
                raise TypeError('You must specify exactly one of {}'.format(', '.join(keywords)))
            return func(*args, **kwargs)
        return inner
    return wrapper

Used as:

>>> @mutually_exclusive('foo', 'bar')
... def foobar(*, foo=None, bar=None):
...     print(foo, bar)
... 
>>> foobar(foo=1)
1 None
>>> foobar(bar=1)
None 1
>>> foobar(bar=1, foo=2)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 7, in inner
TypeError: You must specify exactly one of foo, bar
>>> foobar()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 7, in inner
TypeError: You must specify exactly one of foo, bar

The decorator ignores positionals and keyword arguments not included in the list given:

>>> @mutually_exclusive('foo', 'bar')
... def foobar(a,b,c, *, foo=None, bar=None, taz=None):
...     print(a,b,c,foo,bar,taz)
... 
>>> foobar(1,2,3, foo=4, taz=5)
1 2 3 4 None 5
>>> foobar(1,2,3, foo=4, bar=5,taz=6)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 7, in inner
TypeError: You must specify exactly one of foo, bar

If the arguments might be "optional" (i.e. you may specify at most one of those keyword arguments, but you may also omit all of them) just change != 1 to <= 1 or in (0,1) as you prefer.

If you replace 1 with a number k you generalize the decorator to accept exactly (or at most) k of the specified arguments from the set you provided.

This however will not help PyCharm in anyway. As far as I know currently it's simply impossible to tell an IDE what you want.


The above decorator has a little "bug": it considers foo=None as if you passed a value for foo since it appears in the kwargs list. Usually you'd expect that passing the default value should behave identically as if you did not specify the argument at all.

Fixing this properly would require to inspect func inside wrapper to lookup the defaults and change k in keywords with something like k in keywords and kwargs[k] != defaults[k].

Bakuriu
  • 98,325
  • 22
  • 197
  • 231
  • This is a very pythonic way of achieving the desired results. Though I would suggest `ValueError` or `RuntimeError`in place of `TypeError`. Personal preference, I suppose. – PMende Feb 01 '19 at 21:15
  • 2
    @PMende I chose `TypeError` because typically the errors raised when you provide the wrong arguments raise `TypeError` (e.g. `def f(a): pass` then `f(b=1)` raises `TypeError: f() got an unexpected keyword argument 'b'`. So I believe `TypeError` is more consistent in this case since we are simulating a special *signature* for the function... But then this is an opinion. – Bakuriu Feb 01 '19 at 21:18
  • Would this also work for a constructor of a class? Think of the canonical example of a circle class for which objects would be generated using a midpoint and either (exclusively) a radius or a diameter.... – GertVdE Jun 06 '19 at 10:14
  • And for a python dataclass ? – GertVdE Jun 06 '19 at 10:22
  • @GertVdE You can manually define the `__init__` and put the decorator on it (given that you work with mutually excusive arguments chances are you might want some other custom logic anyway). If you don't want to write your own custom `__init__` chances are you have to use metaclasses. You should lookup some tutorial on python3 metaclasses. – Bakuriu Jun 07 '19 at 16:39
1

The standard library uses a simple runtime check for this:

def foobar(*, foo=None, bar=None):
    if (foo is None) == (bar is None):
        raise ValueError('Exactly one of `foo` and `bar` must be provided')
Draconis
  • 3,209
  • 1
  • 19
  • 31
0

In short: no you cannot do that.

The closest you can get to that might be the use of an assertion:

def foobar(foo=None, bar=None):
    assert bool(foo) != bool(bar)

foobar(foo='bar')             # Passes
foobar(bar='foo')             # Passes
foobar(foo='bar', bar='foo')  # Raises an AssertionError
foobar()                      # Raises an AssertionError

The combination of the bool conversions and the != will make a logical XOR.

Be careful with assertions though; they can be disabled. It's fine if your check is required during development only.

R H
  • 1,898
  • 1
  • 17
  • 14
  • 1
    Be careful checking for boolean equality instead of `is None`. If only one falsey parameter was passed this would still raise an exception. – 101 Feb 01 '19 at 21:17
0

You could refactor slightly and take two non-optional parameters that together provide one value:

def foobar(name, value):
    if name == 'foo':
        foo = value
    elif name == 'bar':
        bar = value
    else:
        raise ValueError()

That way it's impossible to pass two foo or bar values. PyCharm would also warn you if you added extra parameters.

101
  • 8,514
  • 6
  • 43
  • 69