I’ve been watching this discussion with some bemusement.
Let me say first up that I’m a big fan of the trailing “raise exception
if a case is not handled” approach. I’ll try to explain why below, with
a real world example from my own code.
But first, Elis’ statement “From a technical standpoint, ValueError
will never be raised” is, I think, incorrect. Here’s his example code
and commentry:
def do_action(value: float, option: Options): if option is Options.IDENTITY: new_value = value elif option is Options.DOUBLE: new_value = value * 2 elif options is Options.SQUARE: new_value = value**2 else: raise ValueError(f'Unhandled option {option}') return new_value
From a technical standpoint,
ValueError
will never be raised.
Why? What if Options
grew a new enumeration value? What if
do_action
is called by code with an outright incorrect value i.e. not
in Options
’ allowed values?
I think his scenario is a fully typechecked codebase that never reached
deployment without passing the type checks. But I might release fully
checked code into the wild and an untypechecked codebase might call it
incorrectly.
And if it does get raised, it’s the incorrect exception to occur.
TypeError
would be the suitable exception to raise.
Maybe. But let me offer a counter example. Suppose Options
grows a new
enumeration value, eg CUBE
? The code above is valid, a valid caller
might call it with Options.CUBE
, a type checker will be happy, and the
function would behave incorrectly.
My common case with this scenario is command line option handling. I’ve
got a command line base class which can require an apply_opt
method.
Here’s a real example:
def apply_opt(self, opt, val):
''' Handle an individual global command line option.
'''
options = self.options
if opt == '-d':
if not isdirpath(val):
raise GetoptError("not a directory: %r" % (val,))
options.spdpath = val
elif opt == '-n':
options.doit = False
else:
raise RuntimeError("unhandled pre-option")
This code catches the gap between the allowed options (constrained by a
getopt
specification earlier) and my implementation of those options.
(Let’s not digress into argparse
vs getopt
; I hate argparse
and
getopt
does what I want.)
I’m not raising ValueError
here - the opt
should be valid here
because the parse phase winnows out unspecified options; instead I’m
raising RuntimeError
because this is a bug in my code - a newly
specified option with no implementation of it.
But one can also invent scenarios where ValueError
is a valid
exception to raise, for example (contrived):
def do_action(value: float, option: Options):
if option is Options.IDENTITY:
new_value = value
elif option is Options.DOUBLE:
new_value = value * 2
elif options is Options.SQUARE:
new_value = value**2
elif options is Options.SQUARE_ROOT and value >= 0:
new_value = sqrt(value)
else:
raise ValueError(f'Unhandled option {option}')
return new_value
Ths isn’t how I’d code this myself (I’d whinge explicitly about the
negative value
) but this will cause a ValueError
in a suitable
situation.
Personally, I think all code of the form “pick amongst an explicitly
enumerated set of choices” wants a catchall at the bottom for a choice
not handled. What that catchall does depends on the function, but not
having it lends the code to bugs. Fail early! If your code it truly
robust the catchall will never fire. But all it takes is a small change
for it to become reachable.
Arguments against remind me of a code review I once had where the
reviewer wanted me to drop just such a catchall (which just logged a
warning) on the basis that it wouldn’t happen in the real world. But
they also argued that the warning would bloat the logs. These seemed
inconsistent to me.
Provide a catchall, and raise an exception on unexpected circumstances.
It is defensive and Pythonic.
Cheers,
Cameron Simpson cs@cskk.id.au