15

Sometimes in my code I have a function which can take an argument in one of two ways. Something like:

def func(objname=None, objtype=None):
    if objname is not None and objtype is not None:
        raise ValueError("only 1 of the ways at a time")
    if objname is not None:
        obj = getObjByName(objname)
    elif objtype is not None:
        obj = getObjByType(objtype)
    else:
        raise ValueError("not given any of the ways")

    doStuffWithObj(obj)

Is there any more elegant way to do this? What if the arg could come in one of three ways? If the types are distinct I could do:

def func(objnameOrType):
    if type(objnameOrType) is str:
        getObjByName(objnameOrType)
    elif type(objnameOrType) is type:
        getObjByType(objnameOrType)
    else:
        raise ValueError("unk arg type: %s" % type(objnameOrType))

But what if they are not? This alternative seems silly:

def func(objnameOrType, isName=True):
    if isName:
        getObjByName(objnameOrType)
    else:
        getObjByType(objnameOrType)

cause then you have to call it like func(mytype, isName=False) which is weird.

alain.janinm
  • 19,951
  • 10
  • 65
  • 112
Claudiu
  • 224,032
  • 165
  • 485
  • 680
  • 8
    Why wouldn't you just have two separate functions, one accepting a name and the other accepting a type? This approach seems like it would only confuse consumers of the API. – Mark Rushakoff Mar 03 '11 at 17:51
  • 3
    Or, if you really want to have a single function that does it all, why not just accept a single argument and then figure out what it is? If the argument is a type, look up the object by type, otherwise look it up by name. Or even just try one and then the other and see which works (I can't imagine a case where both would retrieve something). – kindall Mar 03 '11 at 19:51
  • 1
    @Mark: if the func is a constructor that won't work – Claudiu Mar 03 '11 at 20:52

8 Answers8

9

How about using something like a command dispatch pattern:

def funct(objnameOrType):
   dispatcher = {str: getObjByName,
                 type1: getObjByType1,
                 type2: getObjByType2}
   t = type(objnameOrType)
   obj = dispatcher[t](objnameOrType)
   doStuffWithObj(obj)

where type1,type2, etc are actual python types (e.g. int, float, etc).

JoshAdel
  • 66,734
  • 27
  • 141
  • 140
3

One way to make it slightly shorter is

def func(objname=None, objtype=None):
    if [objname, objtype].count(None) != 1:
        raise TypeError("Exactly 1 of the ways must be used.")
    if objname is not None:
        obj = getObjByName(objname)
    else: 
        obj = getObjByType(objtype)

I have not yet decided if I would call this "elegant".

Note that you should raise a TypeError if the wrong number of arguments was given, not a ValueError.

Sven Marnach
  • 574,206
  • 118
  • 941
  • 841
  • Why TypeError? Because one of the arguments' types must be NoneType? – Ryan C. Thompson Mar 03 '11 at 17:53
  • 1
    @Ryan: To be consistent with the general use of `ValueError` and `TypeError` in Python. If you pass the wrong number of arguments to a function, Python will raise a `TypeError`, so you should do the same. – Sven Marnach Mar 03 '11 at 17:57
  • @Sven, How about [`if bool(objname) == bool(objtype):`](http://stackoverflow.com/questions/432842/how-do-you-get-the-logical-xor-of-two-variables-in-python/433161#433161) – senderle Mar 03 '11 at 18:29
  • 1
    @senderle: That does something different if you pass in falsy values. – Sven Marnach Mar 03 '11 at 18:43
  • @Sven, true, but we want to reject falsy values like `''` and `0` anyway, right? Still I suppose I see what you mean -- the above squashes distinctions between passed-in and default vals... – senderle Mar 03 '11 at 18:48
  • @Sven, not sure why I've become obsessed with this, but here's another try: `if (objename == None) == (objtype == None):`. No need to adopt it, of course, but do you think it's acceptable? – senderle Mar 03 '11 at 22:19
3

Sounds like it should go to https://codereview.stackexchange.com/

Anyway, keeping the same interface, I may try

arg_parsers = {
  'objname': getObjByName,
  'objtype': getObjByType,
  ...
}
def func(**kwargs):
  assert len(kwargs) == 1 # replace this with your favorite exception
  (argtypename, argval) = next(kwargs.items())
  obj = arg_parsers[argtypename](argval) 
  doStuffWithObj(obj)

or simply create 2 functions?

def funcByName(name): ...
def funcByType(type_): ...
Community
  • 1
  • 1
kennytm
  • 510,854
  • 105
  • 1,084
  • 1,005
  • +1 for codereview, didnt even know about it! what is the `next` function though? – Claudiu Mar 03 '11 at 20:54
  • @Claudiu: `next` returns an item from the iterator and advance the iterator. In this usage, it is roughly equivalent to `x[0]`. – kennytm Mar 03 '11 at 21:05
  • ah ok i typed it into python, but said it's not define, apparently it's new in 2.6 – Claudiu Mar 04 '11 at 16:30
  • 1
    you're supposed to use assert to catch your own coding mistakes, not callers' mistaken paramters – endolith Jul 09 '12 at 16:43
3

For whatever it's worth, similar kinds of things happen in the Standard Libraries; see, for example, the beginning of GzipFile in gzip.py (shown here with docstrings removed):

class GzipFile:
    myfileobj = None
    max_read_chunk = 10 * 1024 * 1024   # 10Mb
    def __init__(self, filename=None, mode=None,
                 compresslevel=9, fileobj=None):
        if mode and 'b' not in mode:
            mode += 'b'
        if fileobj is None:
            fileobj = self.myfileobj = __builtin__.open(filename, mode or 'rb')
        if filename is None:
            if hasattr(fileobj, 'name'): filename = fileobj.name
            else: filename = ''
        if mode is None:
            if hasattr(fileobj, 'mode'): mode = fileobj.mode
            else: mode = 'rb'

Of course this accepts both filename and fileobj keywords and defines a particular behavior in the case that it receives both; but the general approach seems pretty much identical.

senderle
  • 145,869
  • 36
  • 209
  • 233
2

I use a decorator:

from functools import wraps

def one_of(kwarg_names):
    # assert that one and only one of the given kwarg names are passed to the decorated function
    def inner(f):
        @wraps(f)
        def wrapped(*args, **kwargs):
            count = 0
            for kw in kwargs:
                if kw in kwarg_names and kwargs[kw] is not None:
                    count += 1

            assert count == 1, f'exactly one of {kwarg_names} required, got {kwargs}'

            return f(*args, **kwargs)
        return wrapped
    return inner

Used as:

@one_of(['kwarg1', 'kwarg2'])
def my_func(kwarg1='default', kwarg2='default'):
    pass

Note that this only accounts for non- None values that are passed as keyword arguments. E.g. multiple of the kwarg_names may still be passed if all but one of them have a value of None.

To allow for passing none of the kwargs simply assert that the count is <= 1.

oren.tysor
  • 21
  • 3
0

It sounds like you're looking for function overloading, which isn't implemented in Python 2. In Python 2, your solution is nearly as good as you can expect to get.

You could probably bypass the extra argument problem by allowing your function to process multiple objects and return a generator:

import types

all_types = set([getattr(types, t) for t in dir(types) if t.endswith('Type')])

def func(*args):
    for arg in args:
        if arg in all_types:
            yield getObjByType(arg)
        else:
            yield getObjByName(arg)

Test:

>>> getObjByName = lambda a: {'Name': a}
>>> getObjByType = lambda a: {'Type': a}
>>> list(func('IntType'))
[{'Name': 'IntType'}]
>>> list(func(types.IntType))
[{'Type': <type 'int'>}]
kojiro
  • 74,557
  • 19
  • 143
  • 201
0

The built-in sum() can be used to on a list of boolean expressions. In Python, bool is a subclass of int, and in arithmetic operations, True behaves as 1, and False behaves as 0.

This means that this rather short code will test mutual exclusivity for any number of arguments:

def do_something(a=None, b=None, c=None):
    if sum([a is not None, b is not None, c is not None]) != 1:
        raise TypeError("specify exactly one of 'a', 'b', or 'c'")

Variations are also possible:

def do_something(a=None, b=None, c=None):
    if sum([a is not None, b is not None, c is not None]) > 1:
        raise TypeError("specify at most one of 'a', 'b', or 'c'")
wouter bolsterlee
  • 3,879
  • 22
  • 30
  • Or perhaps `if sum([bool(a), bool(b), bool(c)]) > 1` ? – mhucka Jan 28 '21 at 18:38
  • imo that makes it look even more ‘clever’ (i don't like clever), but importantly, that will not behave in the same way, since the `bool` conversion is a ‘falsy’/‘truthy’ check, while the goal here is to check for *missing* values. as an example, empty list/string/set values will behave differently. (usually a missing value is indicated by `None`, but in case `None` has special meaning, another special sentinal value can be used instead, e.g. `MISSING = object()`. the `is not None` check would become `is not MISSING` in that case.) – wouter bolsterlee Feb 02 '21 at 20:13
0

I occasionally run into this problem as well, and it is hard to find an easily generalisable solution. Say I have more complex combinations of arguments that are delineated by a set of mutually exclusive arguments and want to support additional arguments for each (some of which may be required and some optional), as in the following signatures:

def func(mutex1: str, arg1: bool): ...
def func(mutex2: str): ...
def func(mutex3: int, arg1: Optional[bool] = None): ...

I would use object orientation to wrap the arguments in a set of descriptors (with names depending on the business meaning of the arguments), which can then be validated by something like pydantic:

from typing import Optional
from pydantic import BaseModel, Extra

# Extra.forbid ensures validation error if superfluous arguments are provided
class BaseDescription(BaseModel, extra=Extra.forbid):
    pass  # Arguments common to all descriptions go here

class Description1(BaseDescription):
    mutex1: str
    arg1: bool

class Description2(BaseDescription):
    mutex2: str

class Description3(BaseDescription):
    mutex3: int
    arg1: Optional[bool]

You could instantiate these descriptions with a factory:

class DescriptionFactory:
    _class_map = {
        'mutex1': Description1,
        'mutex2': Description2,
        'mutex3': Description3
    }
    
    @classmethod
    def from_kwargs(cls, **kwargs) -> BaseDescription:
        kwargs = {k: v for k, v in kwargs.items() if v is not None}
        set_fields = kwargs.keys() & cls._class_map.keys()
        
        try:
            [set_field] = set_fields
        except ValueError:
            raise ValueError(f"exactly one of {list(cls._class_map.keys())} must be provided")
        
        return cls._class_map[set_field](**kwargs)
    
    @classmethod
    def validate_kwargs(cls, func):
        def wrapped(**kwargs):
            return func(cls.from_kwargs(**kwargs))
        return wrapped

Then you can wrap your actual function implementation like this and use type checking to see which arguments were provided:

@DescriptionFactory.validate_kwargs
def func(desc: BaseDescription):
    if isinstance(desc, Description1):
        ...  # use desc.mutex1 and desc.arg1
    elif isinstance(desc, Description2):
        ...  # use desc.mutex2
    ...  # etc.

and call as func(mutex1='', arg1=True), func(mutex2=''), func(mutex3=123) and so on.

This is not overall shorter code, but it performs argument validation in a very descriptive way according to your specification, raises useful pydantic errors when validation fails, and results in accurate static types in each branch of the function implementation.

Note that if you're using Python 3.10+, structural pattern matching could simplify some parts of this.

Seb
  • 4,422
  • 14
  • 23