PEP 678: Enriching Exceptions with Notes

To give user more control on the exception rendering. How about allowing something like:

try:
  fn()
except Exception as e:
  e.add_note(f'Error in {fn.__name__}: {{}}')
  raise

Then printing the exception would recursively apply .format(exc_msg) on all the Exception.__notes__.

We are not relying on BaseException.__str__ to print the notes - we cannot assume that is was not overridden.

Sure, I just meant printing the exception (in the stacktrace). Updated my comment.

I don’t understand your suggestion. There is no recursion in the PEP-678 solution, just a sequence of strings.

Sorry for the explaination.
For example, with my above example, the exception would be:

e = KeyError('Wrong key.')
e.__notes__ = [
    '{}.\nDid you mean `correct_key`',
    'Error in fn0:\n{}',
    'Error in fn1:\n{}',
    '{}\nCould not load dataset',
]

Then if would get displayed by applying exc_msg = note.format(exc_msg) on each note:

exc_msg = str(e)
for note in e.__notes__:
  exc_msg = note.format(exc_msg)

Which would display:

Error in fn1:
Error in fn0:
Wrong key.
Did you mean `correct_key`
Could not load dataset

If you want a note inserted before existing notes, you can write this helper function yourself:

def insert_note(exc, note):
    notes = getattr(exc, "__notes__", ())
    exc.__notes__ = (note,) + notes

But ideally I would like to display a note before the original exception. Not just before the previous notes.

The current proposal is already a good step forward. I just wanted to share my experience that in the vast majority of epy.reraise usage, the notes are added before the original exception.

Also epy.reraise give me more fine grain control on how to format the final exception (when new lines \n are added)

That sounds like a job for a custom exception/traceback renderer (which you can easily build out of pieces in traceback.py).

1 Like

Thanks all for chiming in - I’m going to have to expand the acknowledgements section again :smiling_face_with_three_hearts:


@Conchylicultor, I really appreciate your notes here, and wish I was taking them on board in more than a “expand the rejected ideas section” way. To make this concrete, I’d express your example as:

raise KeyError(f'Invalid key `{k}`')
...
err.add_note(f'Did you mean `{key}` ?')
...
raise RuntimeError('Could not load database. This could indicate an error in the specs.') from err

which would render as something along the lines of

Traceback (most recent call last):
  File ..., line 10, in top_function
  File ..., line 20, in nested_function
KeyError: Invalid key: `wrong_key`
Did you mean `correct_key` ?

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
RuntimeError: Could not load database. This could indicate an error in the specs.

I’ve also found that if you anticipate a chain of notes in innermost-first note order, phrasing them as “While doing A, observed B \n While doing C, observed D…” etc. This is already the pattern for chained tracebacks, so users will have to recognise it eventually.

Unfortunately I don’t think we can support an option to put notes before the exception message (including with the formatting-based approach) without introducing unacceptable complexity for simpler use-cases, including in maintainence and documentation. At least your reraise() helper will still work as well as it does now, and as Guido notes you can probably go further by modifying __notes__ and/or patching traceback.py :upside_down_face:

Thank you for the discussion. This got me interested into what other projects are currently doing.

It looks like mutating exceptions to add messages is a very common pattern:

Some have defined utils like me:

But it looks like the most common pattern by far is to mutate e.args:

Searching for exc.args = , err.args =, e.args = , e.args +=,… allow to find thoushands of examples of people augmenting extension with error messages.

Here again, we can see many users add the note before the exception:

e.args = ("Problem installing fixtures: %s" % e,)
e.args = (f'Error encountered while resolving {xbns_dir}: {e.args[0]}'
e.args = (self._add_line_info(e.args[0], pos),)
e.args = ("In '%s': %s" % (instance_class, e),)
e.args = ("The following error happened while"
          " compiling the node", node, "\n") + e.args
e.args = (f"While constructing an argument of type {arg_annotation}",) + e.args
e.args = (f"Error from {key} dataset", *e.args)
...

And there’s many, many more examples…


Here’s another simpler proposal: Have a mutable e.__message__: str.

The first time is accessed it would default to str(e), but could be mutated:

try:
  fn()
except Exception as e:
  e.__message__ = 'Error in fn: ' + e.__message__
	raise
  • This is simpler for the end user than the current PEP.
  • This allow arbitrary nesting of try/catch
  • This is more flexible
  • This is the API stackoverflow users expected

This is somewhat similar to what was originally proposed in this PEP, but without the drawbacks:

What would the drawback ?

  • str(e) would be computed during the first e.__message__ call. But is it really an issue in practice ? All the current reraise implementations have this “limitation” currently !
  • It isn’t possible to track individual notes which have been added. Is it really an issue in practice ? (If really required it would be possible to track the history of messages in some e.__messages__ = [msg0, msg1, ...])

For me, the benefits largely outweighs the drawback.

I would find it a little sad if this new PEP is not able to replace the the implementation of the thoushands of users who actually needed this.

After more thoughts, I implemented a proof of concept to fix the 2 drawbacks from above:

API is:

  • There is a single mutable str field e.__message__
  • (For advanced users only) individual notes are still available through e.__notes__: list[str]

In the most common use-case (99% of users), users would just assign the e.__message__ field, which I feel is quite intuitive:

try:
  try:
    try:
      try:
        raise ValueError('Message')
      except Exception as e:
        e.__message__ = f'Exception in fn: {e.__message__}'
        raise
    except Exception as e:
      e.__message__ = f'{e.__message__}. Did you mean x ?'
      raise
  except Exception as e:
    e.__message__ = e.__message__ + '\nPlease retry later.'
    raise
except Exception as e:
  e.__message__ = 'Loading failed with:\n' + e.__message__
  raise

The exception is rendered as:

ValueError: Loading failed with:
Exception in fn: Message. Did you mean x ?
Please retry later.

(For advanced users) The individual notes are still available through e.__notes__:

>>> e.__notes__
['Exception in fn: {}',
 '{}. Did you mean x ?',
 '{}\nPlease retry later.',
 'Loading failed with:\n{}']

An error is raised in the following cases ('__message__' has to contain the previous '__message__' once):

e.__message__ = 'message'
e.__message__ = f'{e.__message__} duplicated: {e.__message__}'

The implementation is a little hacky but this would be hidden from the end user for which everything would magically work.

See implementation in colab.

The drawback now is that e.__message__ is only intended to build the intermediate notes, but the final message should be only accessed with str(e), not print(e.__message__). So maybe e.__message__ should be renamed in something more explicit.

I’ll be curious to hear what people think ?

  • Almost all code that modifies .args is broken when considered with e.g. OSError(errno, strerror, filename)
  • Mutating e.__message__ is indeed equivalent to the string .__note__ proposal, which we rejected in favor of interoperability and translation support.
  • Your format-based approach seems much more complex; difficult for humans to parse nested messages, and in places seems redundant with tracebacks.

I’d note that the PEP won’t make .args-based approaches any more difficult than they were already; the point is to give people a safe and easier alternative.

Exactly, mutating .args is broken, yet used by many users, including major repositories [1], which is why I feel Python should provide a good replacement alternative.

My point is the current PEP is not a viable good alternative, because it assumes:

A) The user don’t want to add the note before the original message
B) The user always want to separate notes with \n

In real world example, many users break those assumptions [1].

So users are left with 2 choices:

  1. Keeping ugly e.arg = hack in their code
  2. Switching to the “official” e.__notes__ system, which leads to less readable error messages (Error in fn: <original_msg> is better than <original_msg>\nError was in in fn.)

Are you expecting that developers won’t care about loosing control on how their errors messages is rendered to justify switching to __notes__ ?

Before discussing the API, I would like to understand:

It seems that a lot [1] of of users, including major repositories are adding the note before the exception. What is the reason why Python don’t want to support this use-case natively ?

It only make sense to talk about API & proposal if Python core developper think A and B are legitimate use-cases.

[1] Examples includes major repositories, like setuptools, sklearn, numba, tensorflow, pytorch, pandas, sphinx, django, cython,…

Since we already have plenty of use cases for notes after the exception message, we would have to support an option for before-or-after, and that adds a fair bit of complexity in implementation and for downstream libraries and users. In particular, starting each note on a new line makes translation and custom formatting much easier.

I’d also note two things. First, I don’t speak for Python - and the Steering Council haven’t decided on this PEP yet. Second, it’s not that I don’t want to support this - it’s that I don’t think supporting this is worth the cost! I wish I saw a way to have both simplicity and power, but in this case I don’t, and I choose simplicity.

Yes, in almost all cases. A convenient interface and good ecosystem support beats small formatting changes.

1 Like

And, as Guido already mentioned above, those who do care about how tracebacks are rendered use the components of the traceback module to create custom formatting to fit their precise needs. Most people don’t need to, but for the few who do the option exists and will continue to exist.

I don’t think mutating the traceback is a realistic option in production code as this would conflict with pytest, ipython,… (which already customize the traceback) and potentially user code too.
And it would be confusing for users that importing a library has side-effects on the traceback.

So the option left is to stick with the e.args += or epy.reraise hacks (which as you say are both broken).

A small change to this PEP would be to initialise e.__notes__ = (str(e),) instead of e.__notes__ = (). So users can insert message before the original message.

We are not suggesting to mutate the traceback, but to use the building blocks in traceback to control how an exception is rendered (at the time of rendering, not at library import time). This is currently a standard technique.

str(e) is currently called at the time of the exception’s rendering. If we start calling it earlier that is a change in semantics. It is also wasteful to create the str before we know that we need it. Many exceptions never get rendered.

1 Like

I apologize if I haven’t understood what you suggested. Could you give an example ?

How can I update the exception in-place to modify the way it will be rendered when:

  • I don’t control the original exception (can be any custom user exception)
  • I don’t control the user code in which the exception is propagated

I think Irit is talking about using the stdlib traceback module to globally customize exception display. This is the approach taken by the exceptiongroup backport; alternatively I believe trio installs a custom sys.excepthook.

The Steering Council reviewed your updates and is Accepting the latest iteration of PEP 678: Enriching Exceptions with Notes. :grin:

A short and sweet reply, we don’t have more to add.

@Zac-HD @iritkatriel

-gps on behalf of the Python Steering Council

9 Likes