Currently, it is non-trivial to attach a large number of notes to an exception from an iterable. Calling add_note for each item looks ugly and many method calls incur overhead. Fortunately, the notes are stored in the __notes__ attribute of the exception, which is a list of strings. But that attribute doesn’t exist when there were no notes; it is only added on the first add_note call! Therefore, I propose to add a with_notes method to BaseException that takes an iterable and returns the original exception after the addition of the notes, just like with_traceback sets the traceback and returns the same exception object. Something like:
def with_notes(self, notes, _=object()):
if (first := next(notes := iter(notes), _)) is _: # check against a sentinel
return # no notes to add
self.add_note(first) # create the __notes__ attribute if it doesn't exist
self.__notes__.extend(notes) # works because __notes__ is always a list
Isn’t this a bit over engineered? How many notes are you adding for this to become a performance issue? Are your exceptions not exceptional enough? Is this something you’ve profiled?
for note in notes:
exc.add_note(note)
Alternatively, maybe combine all notes into a single large note. Can probably be cached with functools.cache but it’s hard to imagine a cache being much faster than a join:
It may just so happen that one would like to report a discrepancy between the keys of a dictionary and a container, where the container should be a subset/superset of the keys. In that case, I personally use map to format each missing/extra key in a certain way and add them as notes to the exception. Something like:
raise ValueError('unknown keys:').with_note(
"\n".join(string for string in map(
'- key %d: %r'.__mod__,
enumerate(extra_keys)
))
)
In my opinion only a with_note method should be needed, but I agree it would be a useful utility either way. The pattern of adding a note, then raising the exception should be common enough, so I don’t think a lot more reasoning should be required.
This approach makes sense, but then it would be harder to parse the notes. I’m open to either though, because I have conceded that my use case is very specific.
Are you trying to use notes to pass information around via exceptions?
If this is used for flow control and it happens often enough for there to be performance considerations then have you considered creating a custom exception type? Then there’s no need to “parse” the notes because the unknown keys will be stored in a format you have full control over.
class UnknownKeys(Exception):
def __init__(self, extra_keys):
self.extra_keys = extra_keys
super().__init__(*extra_keys) # Unpack into exc.args
Note that if you don’t need the frame that adds a note to be included in the traceback you can simply re-raise:
try:
...
except Exception as err:
err.add_note(f"This error occurred while processing job: {job_id}")
raise
This is unlikely to be accepted because it goes against the design philosophy of Python’s built-ins and stdlib that any method that modifies an instance in-place should not return the instance.
By the way, instead of adding a note you may also consider raising a custom exception with the original exception chained:
try:
...
except Exception as err:
raise JobProcessingError(f"Error processing job #{job_id}") from err
Maybe there is a chance. I think the primary cause for that design decision was a deep dislike of fluent interfaces, i.e. chaining of several such methods. But chaining is unlikely to happen here. And if need be, .with_note could accept variadic *notes.
I like the notes and I’m glad they were added, but their support feels like unfinished. This [re-raising with a note added] is one of the points. The other is that str and repr do not print them, only the traceback functions do.
Ah true that. Having a precedent certainly helps the case, though in the case of notes adding a with_note method when there’s already an add_note method seems a little comparatively redundant.
The reason is that it makes a clear distinction between mutating and non-mutating methods and helps to catch errors that result from mixing them up. It does preclude fluent interfaces, but I don’t think that’s a problem. I see fluent interfaces as a workaround for languages not having certain features, such as keyword arguments. Python does have those features, so it doesn’t need fluent interfaces.
That would be good, but it is not the case. On my python 3.13, with_traceback does mutate the object
>>> x = ValueError()
>>> x.__traceback__ # default constructor sets __traceback__ to None
>>> y = x.with_traceback(tb)
>>> y.__traceback__
<traceback object at 0x7f0411211880>
>>> x.__traceback__
<traceback object at 0x7f0411211880> # x was mutated
>>> x is y
True # actually, with_traceback does not return a copy