Stevoisiak
Stevoisiak

Reputation: 26895

How do I make a Python function with mutually exclusive arguments?

I have a Python class which needs to accept one of two mutually exclusive arguments. If the arguments are not exclusive, (ie: if both or neither are given), an error should be raised.

class OrgLocation:
    def __init__(self, location_num=None, location_path=None):
        """location_num & location_path are mutually exclusive"""

In most scenarios, the best option would be to make two separate classes. However, I am working with an external API which requires these two attributes to be mutually exclusive.

Request:

<OrgLocation LocationPathName="ROOT/BU/DIV/SL/DEPT/JOB" LocationNum="1234"/>

Response:

<Error Message="Use either LocationNum or LocationPathName but not both." ErrorCode="1186">

Similar questions seem to indicate that argparse can be used for mutually exclusive arguments in command-line interfaces, but I'm unsure how to apply this to a class constructor

How can I create a Python function with mutually exclusive arguments?

Upvotes: 13

Views: 17208

Answers (7)

codingatty
codingatty

Reputation: 2084

I haven't seen anyone use the simple approach I take. This example is in a plain old method, but it works just as well in the __init__ method of a class:

def circle_area(radius=None, diameter=None, circumference=None):

    # check for mutually-exclusive parameters
    number_of_options_specified = len([opt for opt in [circumference, diameter, radius] if opt is not None])
    if number_of_options_specified != 1:
        raise ValueError(f"Exactly one of radius ({radius}) / diameter ({diameter}) / circumference ({circumference}) must be specified")

    # calculate
    pi = 3.14
    if radius is not None:
        area = pi * radius**2
    if diameter is not None:
        area = pi * (diameter/2.0)**2
    if circumference is not None:
        area = (circumference**2)/(4.0*pi)
    return area

Upvotes: 0

Efron Licht
Efron Licht

Reputation: 498

What you want to do is easy:

class Location:
    def __init__(self, location_num=None, location_path=None):
        """location_num & location_path are mutually exclusive"""

        if location_num is not None and location_path is not None:
            raise ValueError("should have location_num or location_path, but not both")
        elif location_num:
            #create location from int
        elif location_str:
            #create location from str

but it's not considered correct python. You should create alternate constructors as classmethods, instead:

class Location:
    def __init__(self, parsed_location):
        #create location
    @classmethod
    def from_int(cls, location_int):
        return cls(parse_int(location_int))
    @classmethod
    def from_str(cls, location_str):
        return cls(parse_str(location_str))

See What is a clean "pythonic" way to implement multiple constructors? for a more in-depth example.

Upvotes: 3

kabanus
kabanus

Reputation: 26005

Beyond the answer by @Ivonet, a common way in Python is to accept a single parameter, and duck it:

class Location:
    def __init__(self, location):
        """location_num & location_path are mutually exclusive"""
        try:
            x = self.locationArray[location] #location is a num?
        except TypeError:
            x = self.locationDict[location] #location is a string?

possibly with another exception. If you want to use argparse, which may be overkill for only two parameters, but would scale nicely:

import argparse

class Bla:
    parser = argparse.ArgumentParser(prog='Class Bla init')
    path_group = parser.add_mutually_exclusive_group(required=True)
    path_group.add_argument('--num',nargs=1,type=int)
    path_group.add_argument('--path',nargs=1,type=str)

    def __init__(self,**kwargs):
        args=self.parser.parse_args(sum(
            zip(map(
            lambda x: '--'+x,kwargs.keys()),
            map(str,kwargs.values())),()))

#Bla(x='abc')
#Bla(num='abc')
Bla(path='abc')
Bla(path='abc',num=3)

Results from top top bottom:

usage: Class Bla init [-h] (--num NUM | --path PATH)
bla.py: error: one of the arguments --num --path is required

usage: Class Bla init [-h] (--num NUM | --path PATH)
bla.py: error: argument --num: invalid int value: 'abc'

<__main__.Bla object at 0x7fd070652160>

usage: Class Bla init [-h] (--num NUM | --path PATH)
bla.py: error: argument --num: not allowed with argument --path

This is also cool since Bla(help='anything') will actually print the usage (and exit). This is to answer the specific question regarding argparse, but to be clear, @Ivonet has the answer I would actually use for your exact example.

Upvotes: 5

Ivonet
Ivonet

Reputation: 2741

You might want to create a test in the __init__ method but a better question might be... Why?

if location_num is not None and location_path is not None:
    raise TheseParametersAreMutuallyExclusiveError()

Why would you make a class that has multiple purposes? Why not create separate classes?

Upvotes: 9

Timothy C. Quinn
Timothy C. Quinn

Reputation: 4515

Here is a mutually exclusive guard I built based on https://stackoverflow.com/a/55156168/286807 :

# Mutually Exclusive function predicate
# Returns True if no. of args that are True or not None is > 1
def ismuex(*a):
    return not bool(sum(map(lambda v: bool(v if isinstance(v, bool) else not v is None), a)) > 1)

Usage:

def my_func(arg_1, arg_2, arg3):
    assert ismuex(arg_1, arg_2, arg3), \
       "arguments arg_1, arg_2 and arg_3 are mutually exclusive"
    #....

Upvotes: 1

Daniel
Daniel

Reputation: 1418

Although a bit hacky, you could use the XOR operator as follows:

class OrgLocation:
    def __init__(self, location_num=None, location_path=None):
        """location_num & location_path are mutually exclusive"""
        assert (location_num is None) ^ bool(location_path is None), "location_num and location_path are mutually exclussive"
        print("OK")

Upvotes: 1

anon
anon

Reputation: 1

I think a decorator is a nice and expressive way to do this. I'm sure my implementation could be improved upon, but it works, and I think it makes the usage very readable:

class MutuallyExclusiveArgsError(Exception):
    def __init__(self, groups):
        err = f"These groups or arguments are mutually exclusive: {','.join(str(tuple(g)) for g in groups)}"
        super().__init__(err)

def exclusive_args(*args):
    import attr
    import functools
    from typing import Callable,Set,Union,Iterable

    @attr.s
    class _inner:
        _arg_groups_conv = lambda val: {arg: group for group in {frozenset([s]) if isinstance(s, str) else s for s in val} for arg in group}

        func : Callable = attr.ib()
        arg_groups : Set[Union[str,Iterable]] = attr.ib(converter=_arg_groups_conv, kw_only=True)

        def __attrs_post_init_(self):
           functools.update_wrapper(self, self.func)

        def __call__(self, *args, **kwargs):
            groups = {self.arg_groups[kw] for kw in kwargs}
            if len(groups) > 1:
                raise MutuallyExclusiveArgsError(groups)
            self.func(*args, **kwargs)
    return functools.partial(_inner, arg_groups=args)

The usage then looks like this:

@exclusive_args("one", "two")
def ex(*, one=None, two=None):
    print(one or two)

ex(one=1, two=2)
---------------------------------------------------------------------------
MutuallyExclusiveArgsError                Traceback (most recent call last)
<ipython-input-38-0f1d142483d2> in <module>
----> 1 ex(one=1, two=2)
<ipython-input-36-c2ff5f47260f> in __call__(self, *args, **kwargs)
     21             groups = {self.arg_groups[kw] for kw in kwargs}
     22             if len(groups) > 1:
---> 23                 raise MutuallyExclusiveArgsError(groups)
     24             self.func(*args, **kwargs)
     25     return functools.partial(_inner, arg_groups=args)
MutuallyExclusiveArgsError: These groups or arguments are mutually exclusive: ('two',),('one',)
ex(one=1)
1
ex(two=2)
2

or like this:

@exclusive_args("one", ("two","three"))
def ex(*, one=None, two=None, three=None):
    print(one, two, three)

ex(one=1)
1 None None

ex(two=1)
None 1 None

ex(three=1)
None None 1

ex(two=1, three=2)
None 1 2

ex(one=1, two=2)
---------------------------------------------------------------------------
MutuallyExclusiveArgsError                Traceback (most recent call last)
<ipython-input-46-0f1d142483d2> in <module>
----> 1 ex(one=1, two=2)
<ipython-input-36-c2ff5f47260f> in __call__(self, *args, **kwargs)
     21             groups = {self.arg_groups[kw] for kw in kwargs}
     22             if len(groups) > 1:
---> 23                 raise MutuallyExclusiveArgsError(groups)
     24             self.func(*args, **kwargs)
     25     return functools.partial(_inner, arg_groups=args)
MutuallyExclusiveArgsError: These groups or arguments are mutually exclusive: ('one',),('two', 'three')

ex(one=1,three=3)
---------------------------------------------------------------------------
MutuallyExclusiveArgsError                Traceback (most recent call last)
<ipython-input-47-0dcb487cba71> in <module>
----> 1 ex(one=1,three=3)
<ipython-input-36-c2ff5f47260f> in __call__(self, *args, **kwargs)
     21             groups = {self.arg_groups[kw] for kw in kwargs}
     22             if len(groups) > 1:
---> 23                 raise MutuallyExclusiveArgsError(groups)
     24             self.func(*args, **kwargs)
     25     return functools.partial(_inner, arg_groups=args)
MutuallyExclusiveArgsError: These groups or arguments are mutually exclusive: ('one',),('two', 'three')

Upvotes: 0

Related Questions