Skip to content

gh-76785: Return an "excinfo" Object From Interpreter.run() #111573

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 18 additions & 15 deletions Include/internal/pycore_crossinterp.h
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,14 @@ extern void _PyXI_Fini(PyInterpreterState *interp);
// of the exception in the calling interpreter.

typedef struct _excinfo {
const char *type;
struct _excinfo_type {
PyTypeObject *builtin;
const char *name;
const char *qualname;
const char *module;
} type;
const char *msg;
} _Py_excinfo;
} _PyXI_excinfo;


typedef enum error_code {
Expand All @@ -193,13 +198,13 @@ typedef struct _sharedexception {
// The kind of error to propagate.
_PyXI_errcode code;
// The exception information to propagate, if applicable.
// This is populated only for _PyXI_ERR_UNCAUGHT_EXCEPTION.
_Py_excinfo uncaught;
} _PyXI_exception_info;
// This is populated only for some error codes,
// but always for _PyXI_ERR_UNCAUGHT_EXCEPTION.
_PyXI_excinfo uncaught;
} _PyXI_error;

PyAPI_FUNC(PyObject *) _PyXI_ApplyError(_PyXI_error *err);

PyAPI_FUNC(void) _PyXI_ApplyExceptionInfo(
_PyXI_exception_info *info,
PyObject *exctype);

typedef struct xi_session _PyXI_session;
typedef struct _sharedns _PyXI_namespace;
Expand Down Expand Up @@ -251,13 +256,13 @@ struct xi_session {

// This is set if the interpreter is entered and raised an exception
// that needs to be handled in some special way during exit.
_PyXI_errcode *exc_override;
_PyXI_errcode *error_override;
// This is set if exit captured an exception to propagate.
_PyXI_exception_info *exc;
_PyXI_error *error;

// -- pre-allocated memory --
_PyXI_exception_info _exc;
_PyXI_errcode _exc_override;
_PyXI_error _error;
_PyXI_errcode _error_override;
};

PyAPI_FUNC(int) _PyXI_Enter(
Expand All @@ -266,9 +271,7 @@ PyAPI_FUNC(int) _PyXI_Enter(
PyObject *nsupdates);
PyAPI_FUNC(void) _PyXI_Exit(_PyXI_session *session);

PyAPI_FUNC(void) _PyXI_ApplyCapturedException(
_PyXI_session *session,
PyObject *excwrapper);
PyAPI_FUNC(PyObject *) _PyXI_ApplyCapturedException(_PyXI_session *session);
PyAPI_FUNC(int) _PyXI_HasCapturedException(_PyXI_session *session);


Expand Down
18 changes: 17 additions & 1 deletion Lib/test/support/interpreters.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,27 @@

__all__ = [
'Interpreter', 'get_current', 'get_main', 'create', 'list_all',
'RunFailedError',
'SendChannel', 'RecvChannel',
'create_channel', 'list_all_channels', 'is_shareable',
'ChannelError', 'ChannelNotFoundError',
'ChannelEmptyError',
]


class RunFailedError(RuntimeError):

def __init__(self, excinfo):
msg = excinfo.formatted
if not msg:
if excinfo.type and snapshot.msg:
msg = f'{snapshot.type.__name__}: {snapshot.msg}'
else:
msg = snapshot.type.__name__ or snapshot.msg
super().__init__(msg)
self.snapshot = excinfo


def create(*, isolated=True):
"""Return a new (idle) Python interpreter."""
id = _interpreters.create(isolated=isolated)
Expand Down Expand Up @@ -110,7 +124,9 @@ def run(self, src_str, /, channels=None):
that time, the previous interpreter is allowed to run
in other threads.
"""
_interpreters.exec(self._id, src_str, channels)
excinfo = _interpreters.exec(self._id, src_str, channels)
if excinfo is not None:
raise RunFailedError(excinfo)


def create_channel():
Expand Down
12 changes: 6 additions & 6 deletions Lib/test/test__xxinterpchannels.py
Original file line number Diff line number Diff line change
Expand Up @@ -1017,16 +1017,16 @@ def test_close_multiple_users(self):
_channels.recv({cid})
"""))
channels.close(cid)
with self.assertRaises(interpreters.RunFailedError) as cm:
interpreters.run_string(id1, dedent(f"""

excsnap = interpreters.run_string(id1, dedent(f"""
_channels.send({cid}, b'spam')
"""))
self.assertIn('ChannelClosedError', str(cm.exception))
with self.assertRaises(interpreters.RunFailedError) as cm:
interpreters.run_string(id2, dedent(f"""
self.assertEqual(excsnap.type.__name__, 'ChannelClosedError')

excsnap = interpreters.run_string(id2, dedent(f"""
_channels.send({cid}, b'spam')
"""))
self.assertIn('ChannelClosedError', str(cm.exception))
self.assertEqual(excsnap.type.__name__, 'ChannelClosedError')

def test_close_multiple_times(self):
cid = channels.create()
Expand Down
19 changes: 10 additions & 9 deletions Lib/test/test__xxsubinterpreters.py
Original file line number Diff line number Diff line change
Expand Up @@ -940,7 +940,6 @@ def add_module(self, modname, text):
return script_helper.make_script(tempdir, modname, text)

def run_script(self, text, *, fails=False):
excwrapper = interpreters.RunFailedError
r, w = os.pipe()
try:
script = dedent(f"""
Expand All @@ -956,11 +955,12 @@ class NeverError(Exception): pass
raise NeverError # never raised
""").format(dedent(text))
if fails:
with self.assertRaises(excwrapper) as caught:
interpreters.run_string(self.id, script)
return caught.exception
err = interpreters.run_string(self.id, script)
self.assertIsNot(err, None)
return err
else:
interpreters.run_string(self.id, script)
err = interpreters.run_string(self.id, script)
self.assertIs(err, None)
return None
except:
raise # re-raise
Expand All @@ -979,17 +979,18 @@ def _assert_run_failed(self, exctype, msg, script):
exctype_name = exctype.__name__

# Run the script.
exc = self.run_script(script, fails=True)
excinfo = self.run_script(script, fails=True)

# Check the wrapper exception.
self.assertEqual(excinfo.type.__name__, exctype_name)
if msg is None:
self.assertEqual(str(exc).split(':')[0],
self.assertEqual(excinfo.formatted.split(':')[0],
exctype_name)
else:
self.assertEqual(str(exc),
self.assertEqual(excinfo.formatted,
'{}: {}'.format(exctype_name, msg))

return exc
return excinfo

def assert_run_failed(self, exctype, script):
self._assert_run_failed(exctype, None, script)
Expand Down
10 changes: 6 additions & 4 deletions Lib/test/test_import/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1968,10 +1968,12 @@ def test_disallowed_reimport(self):
print(_testsinglephase)
''')
interpid = _interpreters.create()
with self.assertRaises(_interpreters.RunFailedError):
_interpreters.run_string(interpid, script)
with self.assertRaises(_interpreters.RunFailedError):
_interpreters.run_string(interpid, script)

excsnap = _interpreters.run_string(interpid, script)
self.assertIsNot(excsnap, None)

excsnap = _interpreters.run_string(interpid, script)
self.assertIsNot(excsnap, None)


class TestSinglePhaseSnapshot(ModuleSnapshot):
Expand Down
22 changes: 8 additions & 14 deletions Lib/test/test_importlib/test_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -655,25 +655,19 @@ def test_magic_number(self):
@unittest.skipIf(_interpreters is None, 'subinterpreters required')
class IncompatibleExtensionModuleRestrictionsTests(unittest.TestCase):

ERROR = re.compile("^ImportError: module (.*) does not support loading in subinterpreters")

def run_with_own_gil(self, script):
interpid = _interpreters.create(isolated=True)
try:
_interpreters.run_string(interpid, script)
except _interpreters.RunFailedError as exc:
if m := self.ERROR.match(str(exc)):
modname, = m.groups()
raise ImportError(modname)
excsnap = _interpreters.run_string(interpid, script)
if excsnap is not None:
if excsnap.type.__name__ == 'ImportError':
raise ImportError(excsnap.msg)

def run_with_shared_gil(self, script):
interpid = _interpreters.create(isolated=False)
try:
_interpreters.run_string(interpid, script)
except _interpreters.RunFailedError as exc:
if m := self.ERROR.match(str(exc)):
modname, = m.groups()
raise ImportError(modname)
excsnap = _interpreters.run_string(interpid, script)
if excsnap is not None:
if excsnap.type.__name__ == 'ImportError':
raise ImportError(excsnap.msg)

@unittest.skipIf(_testsinglephase is None, "test requires _testsinglephase module")
def test_single_phase_init_module(self):
Expand Down
5 changes: 5 additions & 0 deletions Lib/test/test_interpreters.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,11 @@ def test_success(self):

self.assertEqual(out, 'it worked!')

def test_failure(self):
interp = interpreters.create()
with self.assertRaises(interpreters.RunFailedError):
interp.run('raise Exception')

def test_in_thread(self):
interp = interpreters.create()
script, file = _captured_script('print("it worked!", end="")')
Expand Down
Loading