mirror of
https://github.com/python/cpython.git
synced 2025-09-26 18:29:57 +00:00
[3.12] gh-109590: Update shutil.which on Windows to prefer a PATHEXT extension on executable files (GH-109995) (#110202)
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.
(cherry picked from commit 29b875bb93
)
Co-authored-by: Charles Machalow <csm10495@gmail.com>
This commit is contained in:
parent
10af2242f4
commit
b61a4da459
4 changed files with 91 additions and 12 deletions
|
@ -476,6 +476,12 @@ Directory and files operations
|
||||||
or ends with an extension that is in ``PATHEXT``; and filenames that
|
or ends with an extension that is in ``PATHEXT``; and filenames that
|
||||||
have no extension can now be found.
|
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
|
.. exception:: Error
|
||||||
|
|
||||||
This exception collects exceptions that are raised during a multi-file
|
This exception collects exceptions that are raised during a multi-file
|
||||||
|
|
|
@ -1554,8 +1554,16 @@ def which(cmd, mode=os.F_OK | os.X_OK, path=None):
|
||||||
if use_bytes:
|
if use_bytes:
|
||||||
pathext = [os.fsencode(ext) for ext in pathext]
|
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:
|
else:
|
||||||
# On other platforms you don't have things like PATHEXT to tell you
|
# 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.
|
# what file suffixes are executable, so just pass on cmd as-is.
|
||||||
|
|
|
@ -2068,6 +2068,14 @@ class TestWhich(BaseTest, unittest.TestCase):
|
||||||
self.curdir = os.curdir
|
self.curdir = os.curdir
|
||||||
self.ext = ".EXE"
|
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):
|
def test_basic(self):
|
||||||
# Given an EXE in a directory, it should be returned.
|
# Given an EXE in a directory, it should be returned.
|
||||||
rv = shutil.which(self.file, path=self.dir)
|
rv = shutil.which(self.file, path=self.dir)
|
||||||
|
@ -2255,9 +2263,9 @@ class TestWhich(BaseTest, unittest.TestCase):
|
||||||
|
|
||||||
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
|
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
|
||||||
def test_pathext(self):
|
def test_pathext(self):
|
||||||
ext = ".xyz"
|
ext = self.to_text_type(".xyz")
|
||||||
temp_filexyz = tempfile.NamedTemporaryFile(dir=self.temp_dir,
|
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)
|
os.chmod(temp_filexyz.name, stat.S_IXUSR)
|
||||||
self.addCleanup(temp_filexyz.close)
|
self.addCleanup(temp_filexyz.close)
|
||||||
|
|
||||||
|
@ -2266,16 +2274,16 @@ class TestWhich(BaseTest, unittest.TestCase):
|
||||||
program = os.path.splitext(program)[0]
|
program = os.path.splitext(program)[0]
|
||||||
|
|
||||||
with os_helper.EnvironmentVarGuard() as env:
|
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)
|
rv = shutil.which(program, path=self.temp_dir)
|
||||||
self.assertEqual(rv, temp_filexyz.name)
|
self.assertEqual(rv, temp_filexyz.name)
|
||||||
|
|
||||||
# Issue 40592: See https://bugs.python.org/issue40592
|
# Issue 40592: See https://bugs.python.org/issue40592
|
||||||
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
|
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
|
||||||
def test_pathext_with_empty_str(self):
|
def test_pathext_with_empty_str(self):
|
||||||
ext = ".xyz"
|
ext = self.to_text_type(".xyz")
|
||||||
temp_filexyz = tempfile.NamedTemporaryFile(dir=self.temp_dir,
|
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)
|
self.addCleanup(temp_filexyz.close)
|
||||||
|
|
||||||
# strip path and extension
|
# strip path and extension
|
||||||
|
@ -2283,7 +2291,7 @@ class TestWhich(BaseTest, unittest.TestCase):
|
||||||
program = os.path.splitext(program)[0]
|
program = os.path.splitext(program)[0]
|
||||||
|
|
||||||
with os_helper.EnvironmentVarGuard() as env:
|
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)
|
rv = shutil.which(program, path=self.temp_dir)
|
||||||
self.assertEqual(rv, temp_filexyz.name)
|
self.assertEqual(rv, temp_filexyz.name)
|
||||||
|
|
||||||
|
@ -2291,13 +2299,14 @@ class TestWhich(BaseTest, unittest.TestCase):
|
||||||
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
|
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
|
||||||
def test_pathext_applied_on_files_in_path(self):
|
def test_pathext_applied_on_files_in_path(self):
|
||||||
with os_helper.EnvironmentVarGuard() as env:
|
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"
|
env["PATHEXT"] = ".test"
|
||||||
|
|
||||||
test_path = pathlib.Path(self.temp_dir) / "test_program.test"
|
test_path = os.path.join(self.temp_dir, self.to_text_type("test_program.test"))
|
||||||
test_path.touch(mode=0o755)
|
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
|
# See GH-75586
|
||||||
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
|
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
|
||||||
|
@ -2313,6 +2322,50 @@ class TestWhich(BaseTest, unittest.TestCase):
|
||||||
self.assertFalse(shutil._win_path_needs_curdir('dontcare', os.X_OK))
|
self.assertFalse(shutil._win_path_needs_curdir('dontcare', os.X_OK))
|
||||||
need_curdir_mock.assert_called_once_with('dontcare')
|
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):
|
class TestWhichBytes(TestWhich):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
@ -2320,9 +2373,18 @@ class TestWhichBytes(TestWhich):
|
||||||
self.dir = os.fsencode(self.dir)
|
self.dir = os.fsencode(self.dir)
|
||||||
self.file = os.fsencode(self.file)
|
self.file = os.fsencode(self.file)
|
||||||
self.temp_file.name = os.fsencode(self.temp_file.name)
|
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.curdir = os.fsencode(self.curdir)
|
||||||
self.ext = os.fsencode(self.ext)
|
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):
|
class TestMove(BaseTest, unittest.TestCase):
|
||||||
|
|
||||||
|
|
|
@ -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.
|
Loading…
Add table
Add a link
Reference in a new issue