gh-84687: Add filename to the error raised by os.exec* (GH-19915)

serhiy-storchaka <[email protected]> Tue, 11 Aug 2026 09:08:30 -0400 (EDT)
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
https://github.com/python/cpython/commit/fcfa919d9c1b3a65c78e99e8ed6615dd4d583404
commit: fcfa919d9c1b3a65c78e99e8ed6615dd4d583404
branch: main
author: Russell Davis <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-11T16:08:15+03:00
summary:

gh-84687: Add filename to the error raised by os.exec* (GH-19915)

Co-authored-by: Serhiy Storchaka <[email protected]>

files:
A Misc/NEWS.d/next/Library/2020-05-05-06-05-24.gh-issue-84687.ggjoGl.rst
M Lib/os.py
M Lib/test/test_os/test_os.py
M Modules/posixmodule.c

diff --git a/Lib/os.py b/Lib/os.py
index a5e1d805556998..87547e369db817 100644
--- a/Lib/os.py
+++ b/Lib/os.py
@@ -643,11 +643,13 @@ def _execvpe(file, args, env=None):
         argrest = (args,)
         env = environ
 
+    file = fspath(file)
     if path.dirname(file):
         exec_func(file, *argrest)
         return
     saved_exc = None
     path_list = get_exec_path(env)
+    orig_file = file
     if name != 'nt':
         file = fsencode(file)
         path_list = map(fsencode, path_list)
@@ -663,6 +665,11 @@ def _execvpe(file, args, env=None):
                 saved_exc = e
     if saved_exc is not None:
         raise saved_exc
+    # At this point, last_exc.filename contains the full path of whatever
+    # directory happened to be last in path_list. Set it to the filename that
+    # was passed in, which is what the caller will expect. This is what
+    # subprocess does too (see err_filename in Popen._execute_child()).
+    last_exc.filename = orig_file
     raise last_exc
 
 
diff --git a/Lib/test/test_os/test_os.py b/Lib/test/test_os/test_os.py
index bcf83a314f1a6e..4c1ab96065587e 100644
--- a/Lib/test/test_os/test_os.py
+++ b/Lib/test/test_os/test_os.py
@@ -2636,12 +2636,50 @@ def mock_execve(name, *args):
 
 @unittest.skipUnless(hasattr(os, 'execv'),
                      "need os.execv()")
[email protected](support.is_emscripten,
+                 "Emscripten always fails with ENOEXEC")
[email protected](support.is_android,
+                 "PATH contains an inaccessible directory on Android")
 class ExecTests(unittest.TestCase):
-    @unittest.skipIf(USING_LINUXTHREADS,
-                     "avoid triggering a linuxthreads bug: see issue #4970")
+    def _test_bad_program(self, do_exec, exc_type=OSError):
+        bad_filenames = ['nosuchapp', FakePath('nosuchapp')]
+        if os.name != 'nt':
+            # Bytes program names are not supported on Windows.
+            bad_filenames += [b'nosuchapp', FakePath(b'nosuchapp')]
+        for bad_filename in bad_filenames:
+            with self.subTest(bad_filename):
+                with self.assertRaises(exc_type) as ctx:
+                    do_exec(bad_filename)
+                self.assertEqual(ctx.exception.filename,
+                                 os.fspath(bad_filename))
+                self.assertIn('nosuchapp', str(ctx.exception))
+
+    @unittest.skipIf(USING_LINUXTHREADS, "linuxthreads bug: see issue #4970")
+    def test_execv_with_bad_program(self):
+        self._test_bad_program(lambda name: os.execv(name, ['nosuchapp']))
+
+    @unittest.skipIf(USING_LINUXTHREADS, "linuxthreads bug: see issue #4970")
+    def test_execvp_with_bad_program(self):
+        self._test_bad_program(lambda name: os.execvp(name, ['nosuchapp']))
+
+    @unittest.skipIf(USING_LINUXTHREADS, "linuxthreads bug: see issue #4970")
+    def test_execve_with_bad_program(self):
+        self._test_bad_program(lambda name: os.execve(name, ['nosuchapp'], {}))
+
+    @unittest.skipIf(USING_LINUXTHREADS, "linuxthreads bug: see issue #4970")
     def test_execvpe_with_bad_program(self):
-        self.assertRaises(OSError, os.execvpe, 'no such app-',
-                          ['no such app-'], None)
+        self._test_bad_program(lambda name: os.execvpe(name, ['nosuchapp'], {}))
+
+    @unittest.skipUnless(os.name == 'posix', 'POSIX specific test')
+    @unittest.skipIf(USING_LINUXTHREADS, "linuxthreads bug: see issue #4970")
+    def test_execvp_with_bad_path_entry(self):
+        # A regular file in PATH makes the exec fail with ENOTDIR.
+        create_file(os_helper.TESTFN)
+        self.addCleanup(os_helper.unlink, os_helper.TESTFN)
+        with os_helper.EnvironmentVarGuard() as env:
+            env['PATH'] = os.path.abspath(os_helper.TESTFN)
+            self._test_bad_program(lambda name: os.execvp(name, ['nosuchapp']),
+                                   NotADirectoryError)
 
     def test_execv_with_bad_arglist(self):
         self.assertRaises(ValueError, os.execv, 'notepad', ())
diff --git a/Misc/NEWS.d/next/Library/2020-05-05-06-05-24.gh-issue-84687.ggjoGl.rst b/Misc/NEWS.d/next/Library/2020-05-05-06-05-24.gh-issue-84687.ggjoGl.rst
new file mode 100644
index 00000000000000..b6dd108136f6dc
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2020-05-05-06-05-24.gh-issue-84687.ggjoGl.rst
@@ -0,0 +1,3 @@
+The :func:`os.exec\* <os.execl>` functions now set the
+:attr:`~OSError.filename` attribute of the raised :exc:`FileNotFoundError`
+or :exc:`NotADirectoryError` to the program name passed by the caller.
diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c
index db65d586244065..a9dd8647545bc9 100644
--- a/Modules/posixmodule.c
+++ b/Modules/posixmodule.c
@@ -7520,7 +7520,7 @@ os_execv_impl(PyObject *module, path_t *path, PyObject *argv)
 
     /* If we get here it's definitely an error */
 
-    posix_error();
+    posix_path_error(path);
     free_string_array(argvlist, argc);
     return NULL;
 }

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]