Skip to content

Commit 74ec645

Browse files
committed
Specifically handle ExceptionGroup in the new unpickle_exception_with_attrs. Fixes #84.
1 parent 580d579 commit 74ec645

File tree

3 files changed

+153
-3
lines changed

3 files changed

+153
-3
lines changed

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ exclude: '^(\.tox|ci/templates|\.bumpversion\.cfg|tests/badsyntax.py)(/|$)'
66
# Note the order is intentional to avoid multiple passes of the hooks
77
repos:
88
- repo: https://github.com/astral-sh/ruff-pre-commit
9-
rev: v0.14.1
9+
rev: v0.14.3
1010
hooks:
1111
- id: ruff
1212
args: [--fix, --exit-non-zero-on-fix, --show-fixes, --unsafe-fixes]

src/tblib/pickling_support.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import copyreg
2+
import sys
23
from functools import partial
34
from types import TracebackType
45

56
from . import Frame
67
from . import Traceback
78

9+
if sys.version_info < (3, 11):
10+
ExceptionGroup = None
11+
812

913
def unpickle_traceback(tb_frame, tb_lineno, tb_next):
1014
ret = object.__new__(Traceback)
@@ -22,8 +26,8 @@ def pickle_traceback(tb, *, get_locals=None):
2226
)
2327

2428

25-
def unpickle_exception_with_attrs(func, attrs, cause, tb, context, suppress_context, notes):
26-
inst = func.__new__(func)
29+
def unpickle_exception_with_attrs(func, attrs, cause, tb, context, suppress_context, notes, args=()):
30+
inst = func.__new__(func, *args)
2731
for key, value in attrs.items():
2832
setattr(inst, key, value)
2933
inst.__cause__ = cause
@@ -64,6 +68,7 @@ def pickle_exception(
6468
'__dict__': obj.__dict__,
6569
'args': obj.args,
6670
}
71+
args = ()
6772
if isinstance(obj, OSError):
6873
attrs.update(errno=obj.errno, strerror=obj.strerror)
6974
if (winerror := getattr(obj, 'winerror', None)) is not None:
@@ -72,6 +77,8 @@ def pickle_exception(
7277
attrs['filename'] = obj.filename
7378
if obj.filename2 is not None:
7479
attrs['filename2'] = obj.filename2
80+
if ExceptionGroup is not None and isinstance(obj, ExceptionGroup):
81+
args = (obj.message, obj.exceptions)
7582

7683
return (
7784
unpickle_exception_with_attrs,
@@ -84,6 +91,7 @@ def pickle_exception(
8491
obj.__suppress_context__,
8592
# __notes__ doesn't exist prior to Python 3.11; and even on Python 3.11 it may be absent
8693
getattr(obj, '__notes__', None),
94+
args,
8795
),
8896
*optionals,
8997
)

tests/test_pickle_exception.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,3 +384,145 @@ def test_real_oserror():
384384
assert isinstance(exc, OSError)
385385
assert exc.errno == 2
386386
assert str_output == str(exc)
387+
388+
389+
@pytest.mark.skipif(not has_python311, reason='ExceptionGroup needs Python 3.11')
390+
def test_exception_group():
391+
errors = []
392+
try:
393+
e = CustomWithAttributesException('bar', 1, 2, 3)
394+
e.add_note('test_custom_with_attributes')
395+
raise e
396+
except Exception as e:
397+
errors.append(e)
398+
399+
try:
400+
e = CustomOSError('bar', 2, 'err', 3, None, 5)
401+
e.add_note('test_custom_oserror')
402+
raise e
403+
except Exception as e:
404+
errors.append(e)
405+
406+
try:
407+
e = OSError(2, 'err', 3, None, 5)
408+
e.add_note('test_oserror')
409+
raise e
410+
except Exception as e:
411+
errors.append(e)
412+
413+
try:
414+
bad_open()
415+
except Exception as e:
416+
e.add_note('test_permissionerror')
417+
errors.append(e)
418+
419+
try:
420+
raise BadError
421+
except Exception as e:
422+
e.add_note('test_baderror')
423+
errors.append(e)
424+
425+
try:
426+
e = BadError2('123')
427+
e.add_note('test_baderror2')
428+
raise e
429+
except Exception as e:
430+
errors.append(e)
431+
432+
try:
433+
e = CustomReduceException('foo', 1, 2, 3)
434+
e.add_note('test_custom_reduce')
435+
raise e
436+
except Exception as e:
437+
errors.append(e)
438+
439+
try:
440+
e = OSError(13, 'Permission denied')
441+
e.add_note('test_oserror_simple')
442+
raise e
443+
except Exception as e:
444+
errors.append(e)
445+
446+
try:
447+
os.open('non-existing-file', os.O_RDONLY)
448+
except Exception as e:
449+
e.add_note('test_real_oserror')
450+
real_oserror_str = str(e)
451+
errors.append(e)
452+
else:
453+
pytest.fail('os.open should have raised an OSError')
454+
455+
try:
456+
raise ExceptionGroup('group error', errors) # noqa: F821
457+
except Exception as e:
458+
exc = e
459+
460+
assert len(exc.exceptions) == 9 # before pickling
461+
462+
tblib.pickling_support.install()
463+
exc = pickle.loads(pickle.dumps(exc))
464+
465+
assert len(exc.exceptions) == 9 # after unpickling
466+
467+
assert exc.exceptions[0].__notes__ == ['test_custom_with_attributes']
468+
assert isinstance(exc.exceptions[0], CustomWithAttributesException)
469+
assert exc.exceptions[0].args == ('bar',)
470+
assert exc.exceptions[0].values12 == (1, 2)
471+
assert exc.exceptions[0].value3 == 3
472+
assert exc.exceptions[0].__traceback__ is not None
473+
474+
assert exc.exceptions[1].__notes__ == ['test_custom_oserror']
475+
assert isinstance(exc.exceptions[1], CustomOSError)
476+
assert exc.exceptions[1].message == 'bar'
477+
assert exc.exceptions[1].errno == 2
478+
assert exc.exceptions[1].strerror == 'err'
479+
assert exc.exceptions[1].filename == 3
480+
assert exc.exceptions[1].filename2 == 5
481+
assert exc.exceptions[1].__traceback__ is not None
482+
483+
assert exc.exceptions[2].__notes__ == ['test_oserror']
484+
assert isinstance(exc.exceptions[2], OSError)
485+
assert exc.exceptions[2].errno == 2
486+
assert exc.exceptions[2].strerror == 'err'
487+
assert exc.exceptions[2].filename == 3
488+
assert exc.exceptions[2].filename2 == 5
489+
assert exc.exceptions[2].__traceback__ is not None
490+
491+
assert exc.exceptions[3].__notes__ == ['test_permissionerror']
492+
assert isinstance(exc.exceptions[3], OpenError)
493+
assert exc.exceptions[3].__traceback__ is not None
494+
assert repr(exc.exceptions[3]) == "OpenError(PermissionError(13, 'Booboo'))"
495+
assert str(exc.exceptions[3]) == "[Errno 13] Booboo: 'filename'"
496+
assert exc.exceptions[3].args[0].errno == 13
497+
assert exc.exceptions[3].args[0].strerror == 'Booboo'
498+
assert exc.exceptions[3].args[0].filename == 'filename'
499+
500+
assert exc.exceptions[4].__notes__ == ['test_baderror']
501+
assert isinstance(exc.exceptions[4], BadError)
502+
assert exc.exceptions[4].args == ('Bad Bad Bad!',)
503+
assert exc.exceptions[4].__traceback__ is not None
504+
505+
assert exc.exceptions[5].__notes__ == ['test_baderror2']
506+
assert isinstance(exc.exceptions[5], BadError2)
507+
assert exc.exceptions[5].args == ()
508+
assert exc.exceptions[5].stuff == '123'
509+
assert exc.exceptions[5].__traceback__ is not None
510+
511+
assert exc.exceptions[6].__notes__ == ['test_custom_reduce']
512+
assert isinstance(exc.exceptions[6], CustomReduceException)
513+
assert exc.exceptions[6].args == ('foo',)
514+
assert exc.exceptions[6].values12 == (1, 2)
515+
assert exc.exceptions[6].value3 == 3
516+
assert exc.exceptions[6].__traceback__ is not None
517+
518+
assert exc.exceptions[7].__notes__ == ['test_oserror_simple']
519+
assert isinstance(exc.exceptions[7], OSError)
520+
assert exc.exceptions[7].args == (13, 'Permission denied')
521+
assert exc.exceptions[7].errno == 13
522+
assert exc.exceptions[7].strerror == 'Permission denied'
523+
assert exc.exceptions[7].__traceback__ is not None
524+
525+
assert exc.exceptions[8].__notes__ == ['test_real_oserror']
526+
assert isinstance(exc.exceptions[8], OSError)
527+
assert exc.exceptions[8].errno == 2
528+
assert str(exc.exceptions[8]) == real_oserror_str

0 commit comments

Comments
 (0)