From 3b41ecbc86d5ee8c4d9e01e8b5f019fec86ce1dc Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Thu, 5 Nov 2026 13:20:19 +0100 Subject: [PATCH 1/8] gh-161705: Prevent shadowing stdlib in gzip/tarfile/zipfile CLI Remove sys.path[0] in gzip, tarfile and zipfile command line interface to prevent shadowing standard library modules from extracted files. * Add sys._path0 variable. * pymain_sys_path_add_path0() sets sys._path0. * Add runpy._remove_sys_path0(). * gzpi, tarfile and zipfile main functions now call _remove_sys_path0() to prevent shadowing stdlib modules from extracted files. Co-authored-by: Gregory P. Smith <68491+gpshead@users.noreply.github.com> --- Doc/c-api/init_config.rst | 4 +- Doc/library/sys.rst | 18 +++++ Doc/using/cmdline.rst | 24 +++--- Lib/gzip.py | 5 ++ Lib/runpy.py | 17 ++++ Lib/tarfile.py | 4 + Lib/test/test_runpy.py | 78 +++++++++++++++++++ Lib/test/test_sys.py | 9 +++ Lib/test/test_zipfile/test_core.py | 22 ++++-- Lib/zipfile/__init__.py | 4 + ...-11-07-15-56-47.gh-issue-161705.OjDK2d.rst | 6 ++ Modules/main.c | 4 + Python/pathconfig.c | 2 +- Python/sysmodule.c | 2 + 14 files changed, 178 insertions(+), 21 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst diff --git a/Doc/c-api/init_config.rst b/Doc/c-api/init_config.rst index f9190cbafc6a24e..cc96ba67c4e85be 100644 --- a/Doc/c-api/init_config.rst +++ b/Doc/c-api/init_config.rst @@ -1227,8 +1227,8 @@ PyConfig .. c:member:: int safe_path - If equals to zero, ``Py_RunMain()`` prepends a potentially unsafe path to - :data:`sys.path` at startup: + If equals to zero, ``Py_RunMain()`` prepends a potentially unsafe + :data:`sys._path0` to :data:`sys.path` at startup: * If :c:member:`argv[0] ` is equal to ``L"-m"`` (``python -m module``), prepend the current working directory. diff --git a/Doc/library/sys.rst b/Doc/library/sys.rst index 33de85b04f4b0ea..f08a06d3edae9ba 100644 --- a/Doc/library/sys.rst +++ b/Doc/library/sys.rst @@ -1651,6 +1651,24 @@ always available. Unless explicitly noted otherwise, all variables are read-only * Module :mod:`site` This describes how to use .pth files to extend :data:`sys.path`. +.. data:: _path0 + + Path prepended to :data:`sys.path` at Python startup: + + * Current directory if running a module + with ``python -m module`` command line. + * Script's directory if running a script + with ``python script.py``. + * Otherwise an empty string, which means the current working directory, + for ``python -c code`` or ``python`` (REPL) command line. + + If :option:`-P` or :option:`-I` command line options are used or if + :envvar:`PYTHONSAFEPATH` environment variable is used, :data:`sys._path0` is + ``None`` and :data:`sys.path` is left unchanged. + + .. versionadded:: next + + .. data:: path_hooks A list of callables that take a path argument to try to create a diff --git a/Doc/using/cmdline.rst b/Doc/using/cmdline.rst index e0ad7945a8ecef2..ffb2587840be5a1 100644 --- a/Doc/using/cmdline.rst +++ b/Doc/using/cmdline.rst @@ -322,10 +322,12 @@ Miscellaneous options Run Python in isolated mode. This also implies :option:`-E`, :option:`-P` and :option:`-s` options. - In isolated mode :data:`sys.path` contains neither the script's directory nor - the user's site-packages directory. All ``PYTHON*`` environment - variables are ignored, too. Further restrictions may be imposed to prevent - the user from injecting malicious code. + In isolated mode, Python does not prepend the potentially unsafe path + :data:`sys._path0` to :data:`sys.path`, and ignores all ``PYTHON*`` + environment variables, e.g. :envvar:`PYTHONPATH` and :envvar:`PYTHONHOME`, + that might be set. + Further restrictions may be imposed to prevent the user from injecting + malicious code. .. versionadded:: 3.4 @@ -353,14 +355,8 @@ Miscellaneous options .. option:: -P - Don't prepend a potentially unsafe path to :data:`sys.path`: - - * ``python -m module`` command line: Don't prepend the current working - directory. - * ``python script.py`` command line: Don't prepend the script's directory. - If it's a symbolic link, resolve symbolic links. - * ``python -c code`` and ``python`` (REPL) command lines: Don't prepend an - empty string, which means the current working directory. + Don't prepend the potentially unsafe path :data:`sys._path0` to + :data:`sys.path`. See also the :envvar:`PYTHONSAFEPATH` environment variable, and :option:`-E` and :option:`-I` (isolated) options. @@ -820,7 +816,9 @@ conflict. .. envvar:: PYTHONSAFEPATH If this is set to a non-empty string, don't prepend a potentially unsafe - path to :data:`sys.path`: see the :option:`-P` option for details. + path :data:`sys._path0` to :data:`sys.path`. + + See also the :option:`-P` option. .. versionadded:: 3.11 diff --git a/Lib/gzip.py b/Lib/gzip.py index 3ed2055d801a3c8..48c4ae4920f6d35 100644 --- a/Lib/gzip.py +++ b/Lib/gzip.py @@ -746,6 +746,11 @@ def opener(path, flags): def main(): from argparse import ArgumentParser + import runpy + + # Prevent shadowing a stdlib module from the extracted file + runpy._remove_sys_path0() + parser = ArgumentParser(description= "A simple command line interface for the `gzip` module: act like `gzip`, " "but do not delete the input file.", diff --git a/Lib/runpy.py b/Lib/runpy.py index a535b4f651a5ae8..4663dbf990f1277 100644 --- a/Lib/runpy.py +++ b/Lib/runpy.py @@ -315,6 +315,23 @@ def run_path(path_name, init_globals=None, run_name=None): pass +def _remove_sys_path0(): + """ + Remove sys._path0 from sys.argv. + Do nothing if it has already been removed. + Similar to using -P (safe path) command line option. + """ + path0 = sys._path0 + if path0 is None: + return + + # Remove all path0 copies, not only the first one. + # We are intentionally not trying to normalize & match alternate + # representations of path0, this is only intended to remove copies + # of the exact value the runtime itself may have have added. + sys.path = [path for path in sys.path if path != path0] + + if __name__ == "__main__": # Run the module specified as the next command line argument if len(sys.argv) < 2: diff --git a/Lib/tarfile.py b/Lib/tarfile.py index 227a28766b634d6..abf7786812a0634 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -3172,6 +3172,10 @@ def is_tarfile(name): def main(): import argparse + import runpy + + # Prevent shadowing stdlib modules from extracted files + runpy._remove_sys_path0() description = 'A simple command-line interface for tarfile module.' parser = argparse.ArgumentParser(description=description) diff --git a/Lib/test/test_runpy.py b/Lib/test/test_runpy.py index 55b9673ef6c91c0..bcc3f9d0ebca2cc 100644 --- a/Lib/test/test_runpy.py +++ b/Lib/test/test_runpy.py @@ -21,6 +21,7 @@ verbose, ) from test import support +from test.support import os_helper from test.support.import_helper import forget, make_legacy_pyc, unload from test.support.os_helper import create_empty_file, temp_dir, FakePath from test.support.script_helper import make_script, make_zip_script @@ -917,5 +918,82 @@ def test_pymain_run_module(self): self.assertSigInt(["-m", ham.stem], cwd=ham.parent) +class MiscTests(unittest.TestCase): + CODE = textwrap.dedent(""" + import runpy, sys + + # Remove sys.path[0] + orig_path = sys.path.copy() + path0 = sys._path0 + runpy._remove_sys_path0() + + if path0 is not None and path0 in sys.path: + raise Exception(f"path0 {path0!r} found in sys.path {sys.path!r}") + if not sys.flags.safe_path: + if sys.path == orig_path: + raise Exception("sys.path was not changed") + else: + if sys.path != orig_path: + raise Exception("sys.path was changed") + + # Calling it twice must do nothing + clean_path = sys.path.copy() + runpy._remove_sys_path0() + if sys.path != clean_path: + raise Exception("sys.path was changed") + """) + + def run_python(self, *args, PYTHONPATH=None): + cmd = [sys.executable, *args] + env = support.make_clean_env() + if PYTHONPATH: + env['PYTHONPATH'] = PYTHONPATH + proc = subprocess.run(cmd, text=True, stderr=subprocess.PIPE, env=env) + self.assertEqual(proc.returncode, 0, proc.stderr) + + def test_remove_sys_path0(self): + # Check that all path0 copies are removed + path0 = '/path/zero' + stdlib_path = '/to/stdlib' + other_path = '/other/path' + sys_path = [path0, stdlib_path, path0, '', other_path, path0] + expected = [stdlib_path, '', other_path] + with (support.swap_attr(sys, '_path0', path0), + support.swap_attr(sys, 'path', sys_path)): + runpy._remove_sys_path0() + self.assertEqual(sys.path, expected) + + # Calling it twice does nothing + runpy._remove_sys_path0() + self.assertEqual(sys.path, expected) + + # Do nothing is sys._path0 is None + sys_path = ['', stdlib_path, other_path] + with (support.swap_attr(sys, '_path0', None), + support.swap_attr(sys, 'path', sys_path)): + runpy._remove_sys_path0() + self.assertEqual(sys.path, sys_path) + + def test_remove_sys_path0_run_python(self): + # Test "python -c code" + self.run_python('-c', self.CODE) + self.run_python('-P', '-c', self.CODE) + + with tempfile.TemporaryDirectory() as tmpdir: + name = 'test_remove_sys_path0' + filename = os.path.join(tmpdir, f'{name}.py') + with open(filename, 'w') as fp: + fp.write(self.CODE) + + # Test "python script.py" + self.run_python(filename) + self.run_python('-P', filename) + + with os_helper.change_cwd(tmpdir): + # Test "python -m module" + self.run_python('-m', name) + self.run_python('-P', '-m', name, PYTHONPATH=tmpdir) + + if __name__ == "__main__": unittest.main() diff --git a/Lib/test/test_sys.py b/Lib/test/test_sys.py index 201c209d8214150..3b65bf5128cdbf2 100644 --- a/Lib/test/test_sys.py +++ b/Lib/test/test_sys.py @@ -1493,6 +1493,15 @@ class PythonType: self.assertFalse(is_immortal(b'abc'.decode())) self.assertFalse(is_immortal('abc'.encode())) + def test_path(self): + self.assertIsInstance(sys.path, list) + for path in sys.path: + self.assertIsInstance(path, str) + + def test_path0(self): + self.assertTrue(sys._path0 is None or isinstance(sys._path0, str), + sys._path0) + @test.support.cpython_only @test.support.force_not_colorized_test_class diff --git a/Lib/test/test_zipfile/test_core.py b/Lib/test/test_zipfile/test_core.py index 84000f472c4df9e..2f8fef2a006c381 100644 --- a/Lib/test/test_zipfile/test_core.py +++ b/Lib/test/test_zipfile/test_core.py @@ -58,6 +58,15 @@ def get_files(test): test.assertFalse(f.closed) +@contextlib.contextmanager +def save_restore_sys_path(): + old_path = sys.path.copy() + try: + yield + finally: + sys.path[:] = old_path + + class LazyImportTest(unittest.TestCase): @cpython_only def test_lazy_import(self): @@ -5987,17 +5996,18 @@ def test_add_comment(self): def test_cli_with_metadata_encoding(self): errmsg = "Non-conforming encodings not supported with -c." args = ["--metadata-encoding=shift_jis", "-c", "nonesuch", "nonesuch"] - with captured_stdout() as stdout: - with captured_stderr() as stderr: + with (captured_stdout() as stdout, + captured_stderr() as stderr, + save_restore_sys_path()): self.assertRaises(SystemExit, zipfile.main, args) self.assertEqual(stdout.getvalue(), "") self.assertIn(errmsg, stderr.getvalue()) - with captured_stdout() as stdout: + with captured_stdout() as stdout, save_restore_sys_path(): zipfile.main(["--metadata-encoding=shift_jis", "-t", TESTFN]) listing = stdout.getvalue() - with captured_stdout() as stdout: + with captured_stdout() as stdout, save_restore_sys_path(): zipfile.main(["--metadata-encoding=shift_jis", "-l", TESTFN]) listing = stdout.getvalue() for name in self.file_names: @@ -6016,7 +6026,9 @@ def test_cli_with_metadata_encoding_extract(self): except UnicodeEncodeError: self.skipTest(f'cannot encode file name {fn!a}') - zipfile.main(["--metadata-encoding=shift_jis", "-e", TESTFN, TESTFN2]) + with save_restore_sys_path(): + zipfile.main(["--metadata-encoding=shift_jis", "-e", + TESTFN, TESTFN2]) listing = os.listdir(TESTFN2) for name in self.file_names: self.assertIn(name, listing) diff --git a/Lib/zipfile/__init__.py b/Lib/zipfile/__init__.py index 3110759b29f2713..346bc02ce058eef 100644 --- a/Lib/zipfile/__init__.py +++ b/Lib/zipfile/__init__.py @@ -2926,6 +2926,10 @@ def _get_code(self, pathname, basename): def main(args=None): import argparse + import runpy + + # Prevent shadowing stdlib modules from extracted files + runpy._remove_sys_path0() description = 'A simple command-line interface for zipfile module.' parser = argparse.ArgumentParser(description=description) diff --git a/Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst b/Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst new file mode 100644 index 000000000000000..d9a862ed858e012 --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-11-07-15-56-47.gh-issue-161705.OjDK2d.rst @@ -0,0 +1,6 @@ +.. cve: CVE-2026-115791 + +Remove ``sys.path[0]`` in :mod:`gzip`, :mod:`tarfile` and :mod:`zipfile` +command line interface to prevent shadowing standard library modules from +extracted files. Issue reported by Jelle Zijlstra, patch by Gregory P. +Smith and Victor Stinner. diff --git a/Modules/main.c b/Modules/main.c index d2587e9fffdad26..d826ed17e0293c1 100644 --- a/Modules/main.c +++ b/Modules/main.c @@ -214,6 +214,10 @@ pymain_sys_path_add_path0(PyObject *path0) return -1; } + if (PyDict_SetItemString(sysdict, "_path0", path0) < 0) { + return -1; + } + if (PyList_Insert(sys_path, 0, path0)) { return -1; } diff --git a/Python/pathconfig.c b/Python/pathconfig.c index b4ba1c52066879b..89684d9da8e6291 100644 --- a/Python/pathconfig.c +++ b/Python/pathconfig.c @@ -340,7 +340,7 @@ Py_GetProgramName(void) /* Compute module search path from argv[0] or the current working - directory ("-m module" case) which will be prepended to sys.argv: + directory ("-m module" case) which will be prepended to sys.path: sys.path[0]. Return 1 if the path is correctly resolved and written into *path0_p. diff --git a/Python/sysmodule.c b/Python/sysmodule.c index e8dd956c3f48828..f8c72b096eb4529 100644 --- a/Python/sysmodule.c +++ b/Python/sysmodule.c @@ -4161,6 +4161,8 @@ _PySys_InitCore(PyThreadState *tstate, PyObject *sysdict) SET_SYS("path_importer_cache", PyDict_New()); SET_SYS("path_hooks", PyList_New(0)); + SET_SYS("_path0", Py_None); + if (_PyErr_Occurred(tstate)) { goto err_occurred; } From 6882ff167e3b6c61b64e1223b3262eefccbfe8d7 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Mon, 9 Nov 2026 22:04:23 +0100 Subject: [PATCH 2/8] _remove_sys_path0() now modifies sys.path in-place Don't create a new list object anymore. --- Lib/runpy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/runpy.py b/Lib/runpy.py index 4663dbf990f1277..49915d9714c6ab4 100644 --- a/Lib/runpy.py +++ b/Lib/runpy.py @@ -329,7 +329,7 @@ def _remove_sys_path0(): # We are intentionally not trying to normalize & match alternate # representations of path0, this is only intended to remove copies # of the exact value the runtime itself may have have added. - sys.path = [path for path in sys.path if path != path0] + sys.path[:] = [path for path in sys.path if path != path0] if __name__ == "__main__": From ca98c1b07eae5affb563a37cbaf5e5396ddf3878 Mon Sep 17 00:00:00 2001 From: Ethan Furman Date: Mon, 9 Nov 2026 13:29:30 -0800 Subject: [PATCH 3/8] fix typo --- Lib/test/test_runpy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_runpy.py b/Lib/test/test_runpy.py index bcc3f9d0ebca2cc..d39bdaea3178e08 100644 --- a/Lib/test/test_runpy.py +++ b/Lib/test/test_runpy.py @@ -967,7 +967,7 @@ def test_remove_sys_path0(self): runpy._remove_sys_path0() self.assertEqual(sys.path, expected) - # Do nothing is sys._path0 is None + # Do nothing if sys._path0 is None sys_path = ['', stdlib_path, other_path] with (support.swap_attr(sys, '_path0', None), support.swap_attr(sys, 'path', sys_path)): From feaffef732fdee6bdba15700c7760e06f0a0df4f Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Mon, 9 Nov 2026 23:03:24 +0100 Subject: [PATCH 4/8] Update Lib/runpy.py Co-authored-by: Kirill Podoprigora --- Lib/runpy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/runpy.py b/Lib/runpy.py index 49915d9714c6ab4..9dbe050205178bc 100644 --- a/Lib/runpy.py +++ b/Lib/runpy.py @@ -317,7 +317,7 @@ def run_path(path_name, init_globals=None, run_name=None): def _remove_sys_path0(): """ - Remove sys._path0 from sys.argv. + Remove sys._path0 from sys.path. Do nothing if it has already been removed. Similar to using -P (safe path) command line option. """ From eb7d635fc5b74a2ff512aa476049c247cdc7b10e Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Mon, 9 Nov 2026 23:03:40 +0100 Subject: [PATCH 5/8] Update Lib/runpy.py Co-authored-by: Kirill Podoprigora --- Lib/runpy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/runpy.py b/Lib/runpy.py index 9dbe050205178bc..8047fbf06324303 100644 --- a/Lib/runpy.py +++ b/Lib/runpy.py @@ -328,7 +328,7 @@ def _remove_sys_path0(): # Remove all path0 copies, not only the first one. # We are intentionally not trying to normalize & match alternate # representations of path0, this is only intended to remove copies - # of the exact value the runtime itself may have have added. + # of the exact value the runtime itself may have added. sys.path[:] = [path for path in sys.path if path != path0] From 296c65e10804b01b8276327bfa10e667dd3d3f4d Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Mon, 9 Nov 2026 23:15:10 +0100 Subject: [PATCH 6/8] Do nothing is sys._path0 == sys._stdlib_dir Cleanup tests --- Lib/runpy.py | 9 +++-- Lib/test/test_runpy.py | 92 +++++++++++++++++++++++------------------- 2 files changed, 56 insertions(+), 45 deletions(-) diff --git a/Lib/runpy.py b/Lib/runpy.py index 8047fbf06324303..ff639369da08a1c 100644 --- a/Lib/runpy.py +++ b/Lib/runpy.py @@ -317,14 +317,17 @@ def run_path(path_name, init_globals=None, run_name=None): def _remove_sys_path0(): """ - Remove sys._path0 from sys.path. - Do nothing if it has already been removed. - Similar to using -P (safe path) command line option. + Remove sys._path0 from sys.path. Do nothing if sys._path0 is + sys._stdlib_dir or if it has already been removed. Similar to using -P + (safe path) command line option. """ path0 = sys._path0 if path0 is None: return + if path0 == sys._stdlib_dir: + return + # Remove all path0 copies, not only the first one. # We are intentionally not trying to normalize & match alternate # representations of path0, this is only intended to remove copies diff --git a/Lib/test/test_runpy.py b/Lib/test/test_runpy.py index d39bdaea3178e08..16e1a67a5655264 100644 --- a/Lib/test/test_runpy.py +++ b/Lib/test/test_runpy.py @@ -919,46 +919,14 @@ def test_pymain_run_module(self): class MiscTests(unittest.TestCase): - CODE = textwrap.dedent(""" - import runpy, sys - - # Remove sys.path[0] - orig_path = sys.path.copy() - path0 = sys._path0 - runpy._remove_sys_path0() - - if path0 is not None and path0 in sys.path: - raise Exception(f"path0 {path0!r} found in sys.path {sys.path!r}") - if not sys.flags.safe_path: - if sys.path == orig_path: - raise Exception("sys.path was not changed") - else: - if sys.path != orig_path: - raise Exception("sys.path was changed") - - # Calling it twice must do nothing - clean_path = sys.path.copy() - runpy._remove_sys_path0() - if sys.path != clean_path: - raise Exception("sys.path was changed") - """) - - def run_python(self, *args, PYTHONPATH=None): - cmd = [sys.executable, *args] - env = support.make_clean_env() - if PYTHONPATH: - env['PYTHONPATH'] = PYTHONPATH - proc = subprocess.run(cmd, text=True, stderr=subprocess.PIPE, env=env) - self.assertEqual(proc.returncode, 0, proc.stderr) - def test_remove_sys_path0(self): # Check that all path0 copies are removed - path0 = '/path/zero' - stdlib_path = '/to/stdlib' - other_path = '/other/path' - sys_path = [path0, stdlib_path, path0, '', other_path, path0] - expected = [stdlib_path, '', other_path] - with (support.swap_attr(sys, '_path0', path0), + PATH0 = '/path/zero' + STDLIB_PATH = '/to/stdlib' + OTHER_PATH = '/other/path' + sys_path = [PATH0, STDLIB_PATH, PATH0, '', OTHER_PATH, PATH0] + expected = [STDLIB_PATH, '', OTHER_PATH] + with (support.swap_attr(sys, '_path0', PATH0), support.swap_attr(sys, 'path', sys_path)): runpy._remove_sys_path0() self.assertEqual(sys.path, expected) @@ -967,23 +935,63 @@ def test_remove_sys_path0(self): runpy._remove_sys_path0() self.assertEqual(sys.path, expected) + # Do nothing if sys._path0 is sys._stdlib_dir + sys_path = ['', STDLIB_PATH, OTHER_PATH] + with (support.swap_attr(sys, '_stdlib_dir', None), + support.swap_attr(sys, '_path0', STDLIB_PATH), + support.swap_attr(sys, 'path', sys_path)): + runpy._remove_sys_path0() + self.assertEqual(sys.path, sys_path) + # Do nothing if sys._path0 is None - sys_path = ['', stdlib_path, other_path] + sys_path = ['', STDLIB_PATH, OTHER_PATH] with (support.swap_attr(sys, '_path0', None), support.swap_attr(sys, 'path', sys_path)): runpy._remove_sys_path0() self.assertEqual(sys.path, sys_path) + def run_python(self, *args, PYTHONPATH=None): + cmd = [sys.executable, *args] + env = support.make_clean_env() + if PYTHONPATH: + env['PYTHONPATH'] = PYTHONPATH + proc = subprocess.run(cmd, text=True, stderr=subprocess.PIPE, env=env) + self.assertEqual(proc.returncode, 0, proc.stderr) + def test_remove_sys_path0_run_python(self): + CODE = textwrap.dedent(""" + import runpy, sys + + # Remove sys.path[0] + orig_path = sys.path.copy() + path0 = sys._path0 + runpy._remove_sys_path0() + + if path0 is not None and path0 in sys.path: + raise Exception(f"path0 {path0!r} found in sys.path {sys.path!r}") + if not sys.flags.safe_path: + if sys.path == orig_path: + raise Exception("sys.path was not changed") + else: + if sys.path != orig_path: + raise Exception("sys.path was changed") + + # Calling it twice must do nothing + clean_path = sys.path.copy() + runpy._remove_sys_path0() + if sys.path != clean_path: + raise Exception("sys.path was changed") + """) + # Test "python -c code" - self.run_python('-c', self.CODE) - self.run_python('-P', '-c', self.CODE) + self.run_python('-c', CODE) + self.run_python('-P', '-c', CODE) with tempfile.TemporaryDirectory() as tmpdir: name = 'test_remove_sys_path0' filename = os.path.join(tmpdir, f'{name}.py') with open(filename, 'w') as fp: - fp.write(self.CODE) + fp.write(CODE) # Test "python script.py" self.run_python(filename) From 59a8da439639c180aa5fdd39a1017c135878d7f5 Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Mon, 9 Nov 2026 23:26:03 +0100 Subject: [PATCH 7/8] Fix test Keep a copy of sys_path to make sure that it's unchanged. --- Lib/test/test_runpy.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_runpy.py b/Lib/test/test_runpy.py index 16e1a67a5655264..36a6514248f512e 100644 --- a/Lib/test/test_runpy.py +++ b/Lib/test/test_runpy.py @@ -926,7 +926,8 @@ def test_remove_sys_path0(self): OTHER_PATH = '/other/path' sys_path = [PATH0, STDLIB_PATH, PATH0, '', OTHER_PATH, PATH0] expected = [STDLIB_PATH, '', OTHER_PATH] - with (support.swap_attr(sys, '_path0', PATH0), + with (support.swap_attr(sys, '_stdlib_dir', STDLIB_PATH), + support.swap_attr(sys, '_path0', PATH0), support.swap_attr(sys, 'path', sys_path)): runpy._remove_sys_path0() self.assertEqual(sys.path, expected) @@ -937,18 +938,21 @@ def test_remove_sys_path0(self): # Do nothing if sys._path0 is sys._stdlib_dir sys_path = ['', STDLIB_PATH, OTHER_PATH] - with (support.swap_attr(sys, '_stdlib_dir', None), + expected = sys_path.copy() + with (support.swap_attr(sys, '_stdlib_dir', STDLIB_PATH), support.swap_attr(sys, '_path0', STDLIB_PATH), support.swap_attr(sys, 'path', sys_path)): runpy._remove_sys_path0() - self.assertEqual(sys.path, sys_path) + self.assertEqual(sys.path, expected) # Do nothing if sys._path0 is None sys_path = ['', STDLIB_PATH, OTHER_PATH] - with (support.swap_attr(sys, '_path0', None), + expected = sys_path.copy() + with (support.swap_attr(sys, '_stdlib_dir', STDLIB_PATH), + support.swap_attr(sys, '_path0', None), support.swap_attr(sys, 'path', sys_path)): runpy._remove_sys_path0() - self.assertEqual(sys.path, sys_path) + self.assertEqual(sys.path, expected) def run_python(self, *args, PYTHONPATH=None): cmd = [sys.executable, *args] From a25df3f5f1bffd2e38c2971ca70625bd22734baa Mon Sep 17 00:00:00 2001 From: Victor Stinner Date: Tue, 10 Nov 2026 00:08:27 +0100 Subject: [PATCH 8/8] test_remove_sys_path0_run_python() requires subprocess support --- Lib/test/test_runpy.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_runpy.py b/Lib/test/test_runpy.py index 36a6514248f512e..f6aae9bed570d21 100644 --- a/Lib/test/test_runpy.py +++ b/Lib/test/test_runpy.py @@ -962,6 +962,7 @@ def run_python(self, *args, PYTHONPATH=None): proc = subprocess.run(cmd, text=True, stderr=subprocess.PIPE, env=env) self.assertEqual(proc.returncode, 0, proc.stderr) + @support.requires_subprocess() def test_remove_sys_path0_run_python(self): CODE = textwrap.dedent(""" import runpy, sys