Search code examples
pythonexceptionpython-internals

Why does Exception proxy __str__ onto the args?


Why does printing an exception instance print the value of exc.args instead of representing exc directly? The docs call it a convenience but it's actually an inconvenience in practice.

Can't tell the difference between *args and a tuple:

>>> print(Exception(123, 456))
(123, 456)
>>> print(Exception((123, 456)))
(123, 456)

Can't reliably discern type:

>>> print(Exception('123'))
123
>>> print(Exception(123))
123

And the lovely "invisible" exception:

>>> print(Exception())

>>> 

Which you'll inherit unless you specifically ask not to:

>>> class MyError(Exception):
...     """an error in MyLibrary"""
...     
>>> print(MyError())

>>> 

This can be a real problem if you forget to log error instances specifically with repr - a default string representation in a log file has irreversibly lost information.

What's the rationale for such strange implementation of Exception.__str__? Presumably if a user wanted to print exc.args then they should just print exc.args?


Solution

  • BaseException.__str__ could have been fixed in a backwards-incompatible manner with Python 3 to include at least the type of the exception, but perhaps no one noticed that it is a thing that should be fixed.

    The current implementation dates back to PEP 0352 which provides rationale:

    No restriction is placed upon what may be passed in for args for backwards-compatibility reasons. In practice, though, only a single string argument should be used. This keeps the string representation of the exception to be a useful message about the exception that is human-readable; this is why the __str__ method special-cases on length-1 args value. Including programmatic information (e.g., an error code number) should be stored as a separate attribute in a subclass.

    Of course Python itself breaks this principle of useful human-readable messages in many cases - for example stringification of a KeyError is the key that was not found, which leads to debug messages like

    An error occurred: 42
    

    The reason why str(e) is essentially str(e.args) or str(e.args[0]) was originally backwards-compatibility with Python 1.0. In Python 1.0, the syntax for raising an exception, such as ValueError would have been:

    >>> raise ValueError, 'x must be positive'
    Traceback (innermost last):
      File "<stdin>", line 1
    ValueError: x must be positive
    

    Python retained backwards-compatibility with 1.0 up to 2.7, so that you can run most Python 1.0 programs unchanged in Python 2.7 (like you never would):

    >>> raise ValueError, 'x must be positive'
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
    ValueError: x must be positive
    

    Likewise, in Python 1.0 you would catch the ValueError with

    >>> try:
    ...     raise ValueError, 'foo'
    ... except ValueError, e:
    ...     print 'Got ValueError', e
    

    which worked unchanged in Python 2.7.

    But the mechanism of how this worked internally had changed: In Python 1.0.1, ValueError was a string with value... 'ValueError'

    >>> ValueError, type(ValueError)
    ('ValueError', <type 'string'>)
    

    There were no exception classes at all, and you could only raise a single argument, or a tuple, with a string as a discriminator:

    >>> class MyCustomException: 
    ...     pass
    ...   
    >>> raise MyCustomException, 'my custom exception'
    Traceback (innermost last):
      File "<stdin>", line 1
    TypeError: exceptions must be strings
    

    It would also be possible to give a tuple as an argument:

    >>> raise ValueError, ('invalid value for x', 42)
    Traceback (innermost last):
      File "<stdin>", line 1
    ValueError: ('invalid value for x', 42)
    

    And if you catch this "exception" in Python 1.0, what you get in e is:

    >>> try:
    ...     raise ValueError, ('invalid value for x', 42)
    ... except ValueError, e:
    ...     print e, type(e)
    ... 
    ('invalid value for x', 42) 42 <type 'tuple'>
    

    A tuple!

    Let's try the code in Python 2.7:

    >>> try:
    ...     raise ValueError, ('invalid value for x', 42)
    ... except ValueError, e:
    ...     print e, e[1], type(e)
    ... 
    ('invalid value for x', 42) 42 <type 'exceptions.ValueError'>
    

    The output looks identical, except for the type of the value; which was a tuple before and now an exception... Not only does the Exception delegate __str__ to the args member, but it also supports indexing like a tuple does - and unpacking, iteration and so on:

    Python 2.7

    >>> a, b, c = ValueError(1, 2, 3)
    >>> print a, b, c
    1 2 3
    

    All these hacks for the purpose of keeping backwards-compatibility.

    The Python 2.7 behaviour comes from the BaseException class that was introduced in PEP 0352; PEP 0352 was originally implemented in Python 2.5.


    In Python 3, the old syntax was removed - you could not raise exceptions with raise discriminator, (arg, um, ents); and the except could only use the Exception as e syntax.

    PEP 0352 discussed about dropping support for multiple arguments to BaseException:

    It was decided that it would be better to deprecate the message attribute in Python 2.6 (and remove it in Python 2.7 and Python 3.0) and consider a more long-term transition strategy in Python 3.0 to remove multiple-argument support in BaseException in preference of accepting only a single argument. Thus the introduction of message and the original deprecation of args has been retracted.

    Seemingly this deprecation of args was forgotten, as it still does exist in Python 3.7 and is the only way to access the arguments given to many built-in exceptions. Likewise __str__ no longer needs to delegate to the args, and could actually alias the BaseException.__repr__ which gives nicer, unambiguous representation:

    >>> BaseException.__str__(ValueError('foo', 'bar', 'baz'))
    "('foo', 'bar', 'baz')"
    >>> BaseException.__repr__(ValueError('foo', 'bar', 'baz'))
    "ValueError('foo', 'bar', 'baz')"
    

    but no one considered it.


    P.S. The repr of an exception is useful - next time try printing your exception with !r format:

    print(f'Oops, I got a {e!r}')
    

    which results in

    ZeroDivisionError('division by zero',)
    

    being output.