Skip to content

Commit 8a4c1f3

Browse files
gh-76785: Show the Traceback for Uncaught Subinterpreter Exceptions (gh-113034)
When an exception is uncaught in Interpreter.exec_sync(), it helps to show that exception's error display if uncaught in the calling interpreter. We do so here by generating a TracebackException in the subinterpreter and passing it between interpreters using pickle.
1 parent 7316dfb commit 8a4c1f3

File tree

5 files changed

+351
-16
lines changed

5 files changed

+351
-16
lines changed

Include/internal/pycore_crossinterp.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,8 @@ typedef struct _excinfo {
188188
const char *module;
189189
} type;
190190
const char *msg;
191+
const char *pickled;
192+
Py_ssize_t pickled_len;
191193
} _PyXI_excinfo;
192194

193195

Lib/test/support/interpreters/__init__.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,36 @@ def __getattr__(name):
3434
raise AttributeError(name)
3535

3636

37+
_EXEC_FAILURE_STR = """
38+
{superstr}
39+
40+
Uncaught in the interpreter:
41+
42+
{formatted}
43+
""".strip()
44+
3745
class ExecFailure(RuntimeError):
3846

3947
def __init__(self, excinfo):
4048
msg = excinfo.formatted
4149
if not msg:
42-
if excinfo.type and snapshot.msg:
43-
msg = f'{snapshot.type.__name__}: {snapshot.msg}'
50+
if excinfo.type and excinfo.msg:
51+
msg = f'{excinfo.type.__name__}: {excinfo.msg}'
4452
else:
45-
msg = snapshot.type.__name__ or snapshot.msg
53+
msg = excinfo.type.__name__ or excinfo.msg
4654
super().__init__(msg)
47-
self.snapshot = excinfo
55+
self.excinfo = excinfo
56+
57+
def __str__(self):
58+
try:
59+
formatted = ''.join(self.excinfo.tbexc.format()).rstrip()
60+
except Exception:
61+
return super().__str__()
62+
else:
63+
return _EXEC_FAILURE_STR.format(
64+
superstr=super().__str__(),
65+
formatted=formatted,
66+
)
4867

4968

5069
def create():

Lib/test/test_interpreters/test_api.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,54 @@ def test_failure(self):
525525
with self.assertRaises(interpreters.ExecFailure):
526526
interp.exec_sync('raise Exception')
527527

528+
def test_display_preserved_exception(self):
529+
tempdir = self.temp_dir()
530+
modfile = self.make_module('spam', tempdir, text="""
531+
def ham():
532+
raise RuntimeError('uh-oh!')
533+
534+
def eggs():
535+
ham()
536+
""")
537+
scriptfile = self.make_script('script.py', tempdir, text="""
538+
from test.support import interpreters
539+
540+
def script():
541+
import spam
542+
spam.eggs()
543+
544+
interp = interpreters.create()
545+
interp.exec_sync(script)
546+
""")
547+
548+
stdout, stderr = self.assert_python_failure(scriptfile)
549+
self.maxDiff = None
550+
interpmod_line, = (l for l in stderr.splitlines() if ' exec_sync' in l)
551+
# File "{interpreters.__file__}", line 179, in exec_sync
552+
self.assertEqual(stderr, dedent(f"""\
553+
Traceback (most recent call last):
554+
File "{scriptfile}", line 9, in <module>
555+
interp.exec_sync(script)
556+
~~~~~~~~~~~~~~~~^^^^^^^^
557+
{interpmod_line.strip()}
558+
raise ExecFailure(excinfo)
559+
test.support.interpreters.ExecFailure: RuntimeError: uh-oh!
560+
561+
Uncaught in the interpreter:
562+
563+
Traceback (most recent call last):
564+
File "{scriptfile}", line 6, in script
565+
spam.eggs()
566+
~~~~~~~~~^^
567+
File "{modfile}", line 6, in eggs
568+
ham()
569+
~~~^^
570+
File "{modfile}", line 3, in ham
571+
raise RuntimeError('uh-oh!')
572+
RuntimeError: uh-oh!
573+
"""))
574+
self.assertEqual(stdout, '')
575+
528576
def test_in_thread(self):
529577
interp = interpreters.create()
530578
script, file = _captured_script('print("it worked!", end="")')

Lib/test/test_interpreters/utils.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import contextlib
22
import os
3+
import os.path
4+
import subprocess
5+
import sys
6+
import tempfile
37
import threading
48
from textwrap import dedent
59
import unittest
610

11+
from test import support
12+
from test.support import os_helper
13+
714
from test.support import interpreters
815

916

@@ -71,5 +78,70 @@ def ensure_closed(fd):
7178
self.addCleanup(lambda: ensure_closed(w))
7279
return r, w
7380

81+
def temp_dir(self):
82+
tempdir = tempfile.mkdtemp()
83+
tempdir = os.path.realpath(tempdir)
84+
self.addCleanup(lambda: os_helper.rmtree(tempdir))
85+
return tempdir
86+
87+
def make_script(self, filename, dirname=None, text=None):
88+
if text:
89+
text = dedent(text)
90+
if dirname is None:
91+
dirname = self.temp_dir()
92+
filename = os.path.join(dirname, filename)
93+
94+
os.makedirs(os.path.dirname(filename), exist_ok=True)
95+
with open(filename, 'w', encoding='utf-8') as outfile:
96+
outfile.write(text or '')
97+
return filename
98+
99+
def make_module(self, name, pathentry=None, text=None):
100+
if text:
101+
text = dedent(text)
102+
if pathentry is None:
103+
pathentry = self.temp_dir()
104+
else:
105+
os.makedirs(pathentry, exist_ok=True)
106+
*subnames, basename = name.split('.')
107+
108+
dirname = pathentry
109+
for subname in subnames:
110+
dirname = os.path.join(dirname, subname)
111+
if os.path.isdir(dirname):
112+
pass
113+
elif os.path.exists(dirname):
114+
raise Exception(dirname)
115+
else:
116+
os.mkdir(dirname)
117+
initfile = os.path.join(dirname, '__init__.py')
118+
if not os.path.exists(initfile):
119+
with open(initfile, 'w'):
120+
pass
121+
filename = os.path.join(dirname, basename + '.py')
122+
123+
with open(filename, 'w', encoding='utf-8') as outfile:
124+
outfile.write(text or '')
125+
return filename
126+
127+
@support.requires_subprocess()
128+
def run_python(self, *argv):
129+
proc = subprocess.run(
130+
[sys.executable, *argv],
131+
capture_output=True,
132+
text=True,
133+
)
134+
return proc.returncode, proc.stdout, proc.stderr
135+
136+
def assert_python_ok(self, *argv):
137+
exitcode, stdout, stderr = self.run_python(*argv)
138+
self.assertNotEqual(exitcode, 1)
139+
return stdout, stderr
140+
141+
def assert_python_failure(self, *argv):
142+
exitcode, stdout, stderr = self.run_python(*argv)
143+
self.assertNotEqual(exitcode, 0)
144+
return stdout, stderr
145+
74146
def tearDown(self):
75147
clean_up_interpreters()

0 commit comments

Comments
 (0)