1

I have a function which is designed to be called by passing in one of to keyword arguments. I'm using a sentinel object as default value, so that I can make sure no one just calls func() without any arguments, which is a clear logical error. It is ok to call the function by passing None as a value for one of the arguments, in those cases it just doesn't do any processing.

NO_VALUE = object()


def func(*, arg1 = NO_VALUE, arg2 = NO_VALUE):
    if arg1 is NO_VALUE and arg2 is NO_VALUE:
        raise ValueError("Pass in one of `arg1` or arg2`.")

    if arg1 is not NO_VALUE and arg1:
        # Do something with a truthy `arg1` value.
    if arg2 is not NO_VALUE and arg2:
        # Do something with a truthy `arg2` value.

Could I somehow easily make NO_VALUE be falsy, so that I could simplify the if arg1 is not NO_VALUE and arg1 and if arg2 is not NO_VALUE and arg2 to just if arg1 and if arg2 respectively?

I tried making NO_VALUE an empty tuple () but it seems that the id() of an empty tuple is always(?) same as the id() of any other empty tuple. I also don't want to make NO_VALUE e.g. an empty list object, since then I'd get linter warnings about using a mutable default value.

ruohola
  • 21,987
  • 6
  • 62
  • 97
  • 3
    You could make it falsy, but valid objects like `None` would still be falsy and fail the test – Carson Jul 29 '22 at 19:10
  • 2
    You can, but then your if statement would apply to `None`, and you said you wanted that handled differently. – Tim Roberts Jul 29 '22 at 19:10
  • @Carson No that'd be ok, I'm just using the sentinel so I can be sure that either `arg1` or `arg2` is passed in. So no one just calls `func()`. – ruohola Jul 29 '22 at 19:11
  • @TimRoberts I edited the first sentence to be bit clearer. – ruohola Jul 29 '22 at 19:12
  • 3
    In that case, why wouldn't `None` work? – Tim Roberts Jul 29 '22 at 19:14
  • @TimRoberts This is called as `func(arg1=might_return_none())`, in which case I don't want to raise an error, and also don't want to do any processing (at least here in the start of the function). – ruohola Jul 29 '22 at 19:15
  • 1
    I'm sure there's an easier way but `NO_VALUE = type('NO_VALUE', (), {'__bool__': lambda *_: False})()` should work. – Axe319 Jul 29 '22 at 19:18
  • An empty list is falsy, and cannot have the same id as any other list in existence (unlike immutable objects such as tuples, which can potentially be shared). – jasonharper Jul 29 '22 at 19:28
  • Yes @jasonharper, but I addressed that in the end of my question. – ruohola Jul 29 '22 at 19:29
  • https://stackoverflow.com/questions/14749328/how-to-check-whether-optional-function-parameter-is-set May answer your question – Carson Jul 29 '22 at 19:43
  • @Carson Not the same question. I know very well about the patterns there. – ruohola Jul 29 '22 at 19:53
  • @ruohola can you not just use the methods shown there to test if an argument was passed in? – Carson Jul 29 '22 at 19:56
  • @ruohola I think @jasonharper meant `NO_VALUE = []`, does that give those linter warnings? – Kelly Bundy Jul 29 '22 at 19:56
  • @KellyBundy It does, in PyCharm: `"Default argument value is mutable "`. – ruohola Jul 29 '22 at 19:57
  • @ruohola How about `NO_VALUE = list()`? I wonder how smart PyCharm is, how much we need to hide the mutability. I mean, jsbueno's object is also mutable, so there must be a point where PyCharm can't tell anymore... – Kelly Bundy Jul 29 '22 at 20:00
  • @KellyBundy :D Actually PyCharm catches that as well. Also, I don't consider jsbueno's object to be mutable in the sense that a list is. And one could always just stick a `__slots__ = []` there. – ruohola Jul 29 '22 at 20:04
  • Ugh. Not sure I'm more impressed or more annoyed by that :-). In any case, I'd probably just disable that warning. Whenever I use such mutable defaults, I do it on purpose and it's fine. Such training wheels warnings are for beginners. – Kelly Bundy Jul 29 '22 at 20:09
  • @KellyBundy I kinda prefer the explicitness of the sentinel class compared to an empty list. – ruohola Jul 29 '22 at 20:15
  • @ruohola Yes, I like that as well, except for the abominable name. Should be `FalseSentinel`. I don't understand why people go against Python terminology. – Kelly Bundy Jul 29 '22 at 20:31
  • @KellyBundy :D What, False and *falsy* are too separate terms in Python. `FalsySentinel` ain't `False` here. – ruohola Jul 29 '22 at 21:03
  • No. `False` and false are two separate terms, and falsy isn't Python terminology at all. Look at any place in the documentation that involves truth values, like truth value testing, `if` statement, `while` statement, and Boolean operations. Nowhere does it say truthy/falsy. It's always true/false. – Kelly Bundy Jul 29 '22 at 21:07

2 Answers2

2

In any case: for a Python object to be "Falsy" it can be an instance of a class which implements __bool__ and returns False when that is called:

class FalsySentinel:
    def  __bool__(self):
        return False


NO_VALUE = FalsySentinel()


def func(*, arg1 = NO_VALUE, arg2 = NO_VALUE):
    if arg1 is NO_VALUE and arg2 is NO_VALUE:
        raise ValueError("Pass in one of `arg1` or arg2`.")

    if arg1:
        # Do something with a truthy `arg1` value.
    if arg2:
        # Do something with a truthy `arg2` value.

There are other ways to produce falsy objects, like objects that implement __len__ and return 0 on it - but this is the most explicit and straightforward way.

ruohola
  • 21,987
  • 6
  • 62
  • 97
jsbueno
  • 99,910
  • 10
  • 151
  • 209
  • 1
    `If you want to simplify the checks, than maybe you could just use None as your sentinel.` <- this does not work for me as this is sometimes just called as `func(arg1=might_return_none())`, in which case I don't want to raise an error, and also don't want to do any processing (at least here in the start of the function). But thank you, the actual solution in the answer seems nice. – ruohola Jul 29 '22 at 19:26
1

To get the behavior you describe, I think you should use kwargs instead of keyword args. Consider the following function:


def func(**kwargs):
    if kwargs.keys() == set(['arg1']):
        print(kwargs['arg1'])
    elif kwargs.keys() == set(['arg2']):
        print(kwargs['arg2'])
    else:
        raise ValueError("Pass in one of `arg1` or `arg2`")

Because you're ultimately trying to achieve more complicated logic on your function arguments, it makes sense to handle this with code, as opposed to letting the compiler try to sort it out with weird sentinels.

Carson
  • 2,700
  • 11
  • 24
  • 1
    The problem with this is that the signature doesn't document the function at all. I'm also in reality using type hints, and with `**kwargs` I couldn't use different types for the parameters. – ruohola Jul 29 '22 at 19:24
  • 2
    @Carson the `dict.keys()` returns a view and should be compared with a set, not with a list. – VPfB Jul 29 '22 at 19:25
  • I'd like to ask some type hints expert: could `@typing.overload` help to overcome the signature problem? – VPfB Jul 29 '22 at 19:31
  • I'm not a typing expert but [PEP 692](https://peps.python.org/pep-0692/) will be much more suitable, when/if it is released. – Axe319 Jul 29 '22 at 19:37