Skip to content

Commit a1d9e0a

Browse files
bpo-32604: [_xxsubinterpreters] Propagate exceptions. (GH-19768)
(Note: PEP 554 is not accepted and the implementation in the code base is a private one for use in the test suite.) If code running in a subinterpreter raises an uncaught exception then the "run" call in the calling interpreter fails. A RunFailedError is raised there that summarizes the original exception as a string. The actual exception type, __cause__, __context__, state, etc. are all discarded. This turned out to be functionally insufficient in practice. There is a more helpful solution (and PEP 554 has been updated appropriately). This change adds the exception propagation behavior described in PEP 554 to the _xxsubinterpreters module. With this change a copy of the original exception is set to __cause__ on the RunFailedError. For now we are using "pickle", which preserves the exception's state. We also preserve the original __cause__, __context__, and __traceback__ (since "pickle" does not preserve those). https://bugs.python.org/issue32604
1 parent 8963a7f commit a1d9e0a

File tree

2 files changed

+1317
-123
lines changed

2 files changed

+1317
-123
lines changed

Lib/test/test__xxsubinterpreters.py

Lines changed: 299 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import builtins
12
from collections import namedtuple
23
import contextlib
34
import itertools
@@ -866,10 +867,11 @@ def assert_run_failed(self, exctype, msg=None):
866867
yield
867868
if msg is None:
868869
self.assertEqual(str(caught.exception).split(':')[0],
869-
str(exctype))
870+
exctype.__name__)
870871
else:
871872
self.assertEqual(str(caught.exception),
872-
"{}: {}".format(exctype, msg))
873+
"{}: {}".format(exctype.__name__, msg))
874+
self.assertIsInstance(caught.exception.__cause__, exctype)
873875

874876
def test_invalid_syntax(self):
875877
with self.assert_run_failed(SyntaxError):
@@ -1060,6 +1062,301 @@ def f():
10601062
self.assertEqual(retcode, 0)
10611063

10621064

1065+
def build_exception(exctype, /, *args, **kwargs):
1066+
# XXX Use __qualname__?
1067+
name = exctype.__name__
1068+
argreprs = [repr(a) for a in args]
1069+
if kwargs:
1070+
kwargreprs = [f'{k}={v!r}' for k, v in kwargs.items()]
1071+
script = f'{name}({", ".join(argreprs)}, {", ".join(kwargreprs)})'
1072+
else:
1073+
script = f'{name}({", ".join(argreprs)})'
1074+
expected = exctype(*args, **kwargs)
1075+
return script, expected
1076+
1077+
1078+
def build_exceptions(self, *exctypes, default=None, custom=None, bases=True):
1079+
if not exctypes:
1080+
raise NotImplementedError
1081+
if not default:
1082+
default = ((), {})
1083+
elif isinstance(default, str):
1084+
default = ((default,), {})
1085+
elif type(default) is not tuple:
1086+
raise NotImplementedError
1087+
elif len(default) != 2:
1088+
default = (default, {})
1089+
elif type(default[0]) is not tuple:
1090+
default = (default, {})
1091+
elif type(default[1]) is not dict:
1092+
default = (default, {})
1093+
# else leave it alone
1094+
1095+
for exctype in exctypes:
1096+
customtype = None
1097+
values = default
1098+
if custom:
1099+
if exctype in custom:
1100+
customtype = exctype
1101+
elif bases:
1102+
for customtype in custom:
1103+
if issubclass(exctype, customtype):
1104+
break
1105+
else:
1106+
customtype = None
1107+
if customtype is not None:
1108+
values = custom[customtype]
1109+
if values is None:
1110+
continue
1111+
args, kwargs = values
1112+
script, expected = build_exception(exctype, *args, **kwargs)
1113+
yield exctype, customtype, script, expected
1114+
1115+
1116+
try:
1117+
raise Exception
1118+
except Exception as exc:
1119+
assert exc.__traceback__ is not None
1120+
Traceback = type(exc.__traceback__)
1121+
1122+
1123+
class RunFailedTests(TestBase):
1124+
1125+
BUILTINS = [v
1126+
for v in vars(builtins).values()
1127+
if (type(v) is type
1128+
and issubclass(v, Exception)
1129+
#and issubclass(v, BaseException)
1130+
)
1131+
]
1132+
BUILTINS_SPECIAL = [
1133+
# These all have extra attributes (i.e. args/kwargs)
1134+
SyntaxError,
1135+
ImportError,
1136+
UnicodeError,
1137+
OSError,
1138+
SystemExit,
1139+
StopIteration,
1140+
]
1141+
1142+
@classmethod
1143+
def build_exceptions(cls, exctypes=None, default=(), custom=None):
1144+
if exctypes is None:
1145+
exctypes = cls.BUILTINS
1146+
if custom is None:
1147+
# Skip the "special" ones.
1148+
custom = {et: None for et in cls.BUILTINS_SPECIAL}
1149+
yield from build_exceptions(*exctypes, default=default, custom=custom)
1150+
1151+
def assertExceptionsEqual(self, exc, expected, *, chained=True):
1152+
if type(expected) is type:
1153+
self.assertIs(type(exc), expected)
1154+
return
1155+
elif not isinstance(exc, Exception):
1156+
self.assertEqual(exc, expected)
1157+
elif not isinstance(expected, Exception):
1158+
self.assertEqual(exc, expected)
1159+
else:
1160+
# Plain equality doesn't work, so we have to compare manually.
1161+
self.assertIs(type(exc), type(expected))
1162+
self.assertEqual(exc.args, expected.args)
1163+
self.assertEqual(exc.__reduce__(), expected.__reduce__())
1164+
if chained:
1165+
self.assertExceptionsEqual(exc.__context__,
1166+
expected.__context__)
1167+
self.assertExceptionsEqual(exc.__cause__,
1168+
expected.__cause__)
1169+
self.assertEqual(exc.__suppress_context__,
1170+
expected.__suppress_context__)
1171+
1172+
def assertTracebacksEqual(self, tb, expected):
1173+
if not isinstance(tb, Traceback):
1174+
self.assertEqual(tb, expected)
1175+
elif not isinstance(expected, Traceback):
1176+
self.assertEqual(tb, expected)
1177+
else:
1178+
self.assertEqual(tb.tb_frame.f_code.co_name,
1179+
expected.tb_frame.f_code.co_name)
1180+
self.assertEqual(tb.tb_frame.f_code.co_filename,
1181+
expected.tb_frame.f_code.co_filename)
1182+
self.assertEqual(tb.tb_lineno, expected.tb_lineno)
1183+
self.assertTracebacksEqual(tb.tb_next, expected.tb_next)
1184+
1185+
# XXX Move this to TestBase?
1186+
@contextlib.contextmanager
1187+
def expected_run_failure(self, expected):
1188+
exctype = expected if type(expected) is type else type(expected)
1189+
1190+
with self.assertRaises(interpreters.RunFailedError) as caught:
1191+
yield caught
1192+
exc = caught.exception
1193+
1194+
modname = exctype.__module__
1195+
if modname == 'builtins' or modname == '__main__':
1196+
exctypename = exctype.__name__
1197+
else:
1198+
exctypename = f'{modname}.{exctype.__name__}'
1199+
if exctype is expected:
1200+
self.assertEqual(str(exc).split(':')[0], exctypename)
1201+
else:
1202+
self.assertEqual(str(exc), f'{exctypename}: {expected}')
1203+
self.assertExceptionsEqual(exc.__cause__, expected)
1204+
if exc.__cause__ is not None:
1205+
self.assertIsNotNone(exc.__cause__.__traceback__)
1206+
1207+
def test_builtin_exceptions(self):
1208+
interpid = interpreters.create()
1209+
msg = '<a message>'
1210+
for i, info in enumerate(self.build_exceptions(
1211+
default=msg,
1212+
custom={
1213+
SyntaxError: ((msg, '<stdin>', 1, 3, 'a +?'), {}),
1214+
ImportError: ((msg,), {'name': 'spam', 'path': '/x/spam.py'}),
1215+
UnicodeError: None,
1216+
#UnicodeError: ((), {}),
1217+
#OSError: ((), {}),
1218+
SystemExit: ((1,), {}),
1219+
StopIteration: (('<a value>',), {}),
1220+
},
1221+
)):
1222+
exctype, _, script, expected = info
1223+
testname = f'{i+1} - {script}'
1224+
script = f'raise {script}'
1225+
1226+
with self.subTest(testname):
1227+
with self.expected_run_failure(expected):
1228+
interpreters.run_string(interpid, script)
1229+
1230+
def test_custom_exception_from___main__(self):
1231+
script = dedent("""
1232+
class SpamError(Exception):
1233+
def __init__(self, q):
1234+
super().__init__(f'got {q}')
1235+
self.q = q
1236+
raise SpamError('eggs')
1237+
""")
1238+
expected = Exception(f'SpamError: got {"eggs"}')
1239+
1240+
interpid = interpreters.create()
1241+
with self.assertRaises(interpreters.RunFailedError) as caught:
1242+
interpreters.run_string(interpid, script)
1243+
cause = caught.exception.__cause__
1244+
1245+
self.assertExceptionsEqual(cause, expected)
1246+
1247+
class SpamError(Exception):
1248+
# The normal Exception.__reduce__() produces a funny result
1249+
# here. So we have to use a custom __new__().
1250+
def __new__(cls, q):
1251+
if type(q) is SpamError:
1252+
return q
1253+
return super().__new__(cls, q)
1254+
def __init__(self, q):
1255+
super().__init__(f'got {q}')
1256+
self.q = q
1257+
1258+
def test_custom_exception(self):
1259+
script = dedent("""
1260+
import test.test__xxsubinterpreters
1261+
SpamError = test.test__xxsubinterpreters.RunFailedTests.SpamError
1262+
raise SpamError('eggs')
1263+
""")
1264+
try:
1265+
ns = {}
1266+
exec(script, ns, ns)
1267+
except Exception as exc:
1268+
expected = exc
1269+
1270+
interpid = interpreters.create()
1271+
with self.expected_run_failure(expected):
1272+
interpreters.run_string(interpid, script)
1273+
1274+
class SpamReducedError(Exception):
1275+
def __init__(self, q):
1276+
super().__init__(f'got {q}')
1277+
self.q = q
1278+
def __reduce__(self):
1279+
return (type(self), (self.q,), {})
1280+
1281+
def test_custom___reduce__(self):
1282+
script = dedent("""
1283+
import test.test__xxsubinterpreters
1284+
SpamError = test.test__xxsubinterpreters.RunFailedTests.SpamReducedError
1285+
raise SpamError('eggs')
1286+
""")
1287+
try:
1288+
exec(script, (ns := {'__name__': '__main__'}), ns)
1289+
except Exception as exc:
1290+
expected = exc
1291+
1292+
interpid = interpreters.create()
1293+
with self.expected_run_failure(expected):
1294+
interpreters.run_string(interpid, script)
1295+
1296+
def test_traceback_propagated(self):
1297+
script = dedent("""
1298+
def do_spam():
1299+
raise Exception('uh-oh')
1300+
def do_eggs():
1301+
return do_spam()
1302+
class Spam:
1303+
def do(self):
1304+
return do_eggs()
1305+
def get_handler():
1306+
def handler():
1307+
return Spam().do()
1308+
return handler
1309+
go = (lambda: get_handler()())
1310+
def iter_all():
1311+
yield from (go() for _ in [True])
1312+
yield None
1313+
def main():
1314+
for v in iter_all():
1315+
pass
1316+
main()
1317+
""")
1318+
try:
1319+
ns = {}
1320+
exec(script, ns, ns)
1321+
except Exception as exc:
1322+
expected = exc
1323+
expectedtb = exc.__traceback__.tb_next
1324+
1325+
interpid = interpreters.create()
1326+
with self.expected_run_failure(expected) as caught:
1327+
interpreters.run_string(interpid, script)
1328+
exc = caught.exception
1329+
1330+
self.assertTracebacksEqual(exc.__cause__.__traceback__,
1331+
expectedtb)
1332+
1333+
def test_chained_exceptions(self):
1334+
script = dedent("""
1335+
try:
1336+
raise ValueError('msg 1')
1337+
except Exception as exc1:
1338+
try:
1339+
raise TypeError('msg 2')
1340+
except Exception as exc2:
1341+
try:
1342+
raise IndexError('msg 3') from exc2
1343+
except Exception:
1344+
raise AttributeError('msg 4')
1345+
""")
1346+
try:
1347+
exec(script, {}, {})
1348+
except Exception as exc:
1349+
expected = exc
1350+
1351+
interpid = interpreters.create()
1352+
with self.expected_run_failure(expected) as caught:
1353+
interpreters.run_string(interpid, script)
1354+
exc = caught.exception
1355+
1356+
# ...just to be sure.
1357+
self.assertIs(type(exc.__cause__), AttributeError)
1358+
1359+
10631360
##################################
10641361
# channel tests
10651362

0 commit comments

Comments
 (0)