diff options
author | Charles Machalow <csm10495@gmail.com> | 2023-10-02 08:27:30 (GMT) |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-10-02 08:27:30 (GMT) |
commit | 29b875bb93099171aeb7a60cd18d4e1f4ea3c1db (patch) | |
tree | 4125892f44bd5a1a0a6dd733909ffcfcb9ec284a | |
parent | 29c3a445d99f7e29086f6fdc4612e200cbbdc0ff (diff) | |
download | cpython-29b875bb93099171aeb7a60cd18d4e1f4ea3c1db.zip cpython-29b875bb93099171aeb7a60cd18d4e1f4ea3c1db.tar.gz cpython-29b875bb93099171aeb7a60cd18d4e1f4ea3c1db.tar.bz2 |
gh-109590: Update shutil.which on Windows to prefer a PATHEXT extension on executable files (GH-109995)
The default arguments for shutil.which() request an executable file, but extensionless files are not executable on Windows and should be ignored.
-rw-r--r-- | Doc/library/shutil.rst | 6 | ||||
-rw-r--r-- | Lib/shutil.py | 12 | ||||
-rw-r--r-- | Lib/test/test_shutil.py | 82 | ||||
-rw-r--r-- | Misc/NEWS.d/next/Library/2023-09-24-06-04-14.gh-issue-109590.9EMofC.rst | 3 |
4 files changed, 91 insertions, 12 deletions
diff --git a/Doc/library/shutil.rst b/Doc/library/shutil.rst index 4390a8e..d1949d6 100644 --- a/Doc/library/shutil.rst +++ b/Doc/library/shutil.rst @@ -476,6 +476,12 @@ Directory and files operations or ends with an extension that is in ``PATHEXT``; and filenames that have no extension can now be found. + .. versionchanged:: 3.12.1 + On Windows, if *mode* includes ``os.X_OK``, executables with an + extension in ``PATHEXT`` will be preferred over executables without a + matching extension. + This brings behavior closer to that of Python 3.11. + .. exception:: Error This exception collects exceptions that are raised during a multi-file diff --git a/Lib/shutil.py b/Lib/shutil.py index b903f13..a278b74 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -1554,8 +1554,16 @@ def which(cmd, mode=os.F_OK | os.X_OK, path=None): if use_bytes: pathext = [os.fsencode(ext) for ext in pathext] - # Always try checking the originally given cmd, if it doesn't match, try pathext - files = [cmd] + [cmd + ext for ext in pathext] + files = ([cmd] + [cmd + ext for ext in pathext]) + + # gh-109590. If we are looking for an executable, we need to look + # for a PATHEXT match. The first cmd is the direct match + # (e.g. python.exe instead of python) + # Check that direct match first if and only if the extension is in PATHEXT + # Otherwise check it last + suffix = os.path.splitext(files[0])[1].upper() + if mode & os.X_OK and not any(suffix == ext.upper() for ext in pathext): + files.append(files.pop(0)) else: # On other platforms you don't have things like PATHEXT to tell you # what file suffixes are executable, so just pass on cmd as-is. diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index a2ca4df..d231e66 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -2067,6 +2067,14 @@ class TestWhich(BaseTest, unittest.TestCase): self.curdir = os.curdir self.ext = ".EXE" + def to_text_type(self, s): + ''' + In this class we're testing with str, so convert s to a str + ''' + if isinstance(s, bytes): + return s.decode() + return s + def test_basic(self): # Given an EXE in a directory, it should be returned. rv = shutil.which(self.file, path=self.dir) @@ -2254,9 +2262,9 @@ class TestWhich(BaseTest, unittest.TestCase): @unittest.skipUnless(sys.platform == "win32", 'test specific to Windows') def test_pathext(self): - ext = ".xyz" + ext = self.to_text_type(".xyz") temp_filexyz = tempfile.NamedTemporaryFile(dir=self.temp_dir, - prefix="Tmp2", suffix=ext) + prefix=self.to_text_type("Tmp2"), suffix=ext) os.chmod(temp_filexyz.name, stat.S_IXUSR) self.addCleanup(temp_filexyz.close) @@ -2265,16 +2273,16 @@ class TestWhich(BaseTest, unittest.TestCase): program = os.path.splitext(program)[0] with os_helper.EnvironmentVarGuard() as env: - env['PATHEXT'] = ext + env['PATHEXT'] = ext if isinstance(ext, str) else ext.decode() rv = shutil.which(program, path=self.temp_dir) self.assertEqual(rv, temp_filexyz.name) # Issue 40592: See https://bugs.python.org/issue40592 @unittest.skipUnless(sys.platform == "win32", 'test specific to Windows') def test_pathext_with_empty_str(self): - ext = ".xyz" + ext = self.to_text_type(".xyz") temp_filexyz = tempfile.NamedTemporaryFile(dir=self.temp_dir, - prefix="Tmp2", suffix=ext) + prefix=self.to_text_type("Tmp2"), suffix=ext) self.addCleanup(temp_filexyz.close) # strip path and extension @@ -2282,7 +2290,7 @@ class TestWhich(BaseTest, unittest.TestCase): program = os.path.splitext(program)[0] with os_helper.EnvironmentVarGuard() as env: - env['PATHEXT'] = f"{ext};" # note the ; + env['PATHEXT'] = f"{ext if isinstance(ext, str) else ext.decode()};" # note the ; rv = shutil.which(program, path=self.temp_dir) self.assertEqual(rv, temp_filexyz.name) @@ -2290,13 +2298,14 @@ class TestWhich(BaseTest, unittest.TestCase): @unittest.skipUnless(sys.platform == "win32", 'test specific to Windows') def test_pathext_applied_on_files_in_path(self): with os_helper.EnvironmentVarGuard() as env: - env["PATH"] = self.temp_dir + env["PATH"] = self.temp_dir if isinstance(self.temp_dir, str) else self.temp_dir.decode() env["PATHEXT"] = ".test" - test_path = pathlib.Path(self.temp_dir) / "test_program.test" - test_path.touch(mode=0o755) + test_path = os.path.join(self.temp_dir, self.to_text_type("test_program.test")) + open(test_path, 'w').close() + os.chmod(test_path, 0o755) - self.assertEqual(shutil.which("test_program"), str(test_path)) + self.assertEqual(shutil.which(self.to_text_type("test_program")), test_path) # See GH-75586 @unittest.skipUnless(sys.platform == "win32", 'test specific to Windows') @@ -2312,6 +2321,50 @@ class TestWhich(BaseTest, unittest.TestCase): self.assertFalse(shutil._win_path_needs_curdir('dontcare', os.X_OK)) need_curdir_mock.assert_called_once_with('dontcare') + # See GH-109590 + @unittest.skipUnless(sys.platform == "win32", 'test specific to Windows') + def test_pathext_preferred_for_execute(self): + with os_helper.EnvironmentVarGuard() as env: + env["PATH"] = self.temp_dir if isinstance(self.temp_dir, str) else self.temp_dir.decode() + env["PATHEXT"] = ".test" + + exe = os.path.join(self.temp_dir, self.to_text_type("test.exe")) + open(exe, 'w').close() + os.chmod(exe, 0o755) + + # default behavior allows a direct match if nothing in PATHEXT matches + self.assertEqual(shutil.which(self.to_text_type("test.exe")), exe) + + dot_test = os.path.join(self.temp_dir, self.to_text_type("test.exe.test")) + open(dot_test, 'w').close() + os.chmod(dot_test, 0o755) + + # now we have a PATHEXT match, so it take precedence + self.assertEqual(shutil.which(self.to_text_type("test.exe")), dot_test) + + # but if we don't use os.X_OK we don't change the order based off PATHEXT + # and therefore get the direct match. + self.assertEqual(shutil.which(self.to_text_type("test.exe"), mode=os.F_OK), exe) + + # See GH-109590 + @unittest.skipUnless(sys.platform == "win32", 'test specific to Windows') + def test_pathext_given_extension_preferred(self): + with os_helper.EnvironmentVarGuard() as env: + env["PATH"] = self.temp_dir if isinstance(self.temp_dir, str) else self.temp_dir.decode() + env["PATHEXT"] = ".exe2;.exe" + + exe = os.path.join(self.temp_dir, self.to_text_type("test.exe")) + open(exe, 'w').close() + os.chmod(exe, 0o755) + + exe2 = os.path.join(self.temp_dir, self.to_text_type("test.exe2")) + open(exe2, 'w').close() + os.chmod(exe2, 0o755) + + # even though .exe2 is preferred in PATHEXT, we matched directly to test.exe + self.assertEqual(shutil.which(self.to_text_type("test.exe")), exe) + self.assertEqual(shutil.which(self.to_text_type("test")), exe2) + class TestWhichBytes(TestWhich): def setUp(self): @@ -2319,9 +2372,18 @@ class TestWhichBytes(TestWhich): self.dir = os.fsencode(self.dir) self.file = os.fsencode(self.file) self.temp_file.name = os.fsencode(self.temp_file.name) + self.temp_dir = os.fsencode(self.temp_dir) self.curdir = os.fsencode(self.curdir) self.ext = os.fsencode(self.ext) + def to_text_type(self, s): + ''' + In this class we're testing with bytes, so convert s to a bytes + ''' + if isinstance(s, str): + return s.encode() + return s + class TestMove(BaseTest, unittest.TestCase): diff --git a/Misc/NEWS.d/next/Library/2023-09-24-06-04-14.gh-issue-109590.9EMofC.rst b/Misc/NEWS.d/next/Library/2023-09-24-06-04-14.gh-issue-109590.9EMofC.rst new file mode 100644 index 0000000..647e84e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-09-24-06-04-14.gh-issue-109590.9EMofC.rst @@ -0,0 +1,3 @@ +:func:`shutil.which` will prefer files with an extension in ``PATHEXT`` if the given mode includes ``os.X_OK`` on win32. +If no ``PATHEXT`` match is found, a file without an extension in ``PATHEXT`` can be returned. +This change will have :func:`shutil.which` act more similarly to previous behavior in Python 3.11. |