Skip to content

Commit b61a4da

Browse files
[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 29b875b) Co-authored-by: Charles Machalow <[email protected]>
1 parent 10af224 commit b61a4da

File tree

4 files changed

+91
-12
lines changed

4 files changed

+91
-12
lines changed

Doc/library/shutil.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,12 @@ Directory and files operations
476476
or ends with an extension that is in ``PATHEXT``; and filenames that
477477
have no extension can now be found.
478478

479+
.. versionchanged:: 3.12.1
480+
On Windows, if *mode* includes ``os.X_OK``, executables with an
481+
extension in ``PATHEXT`` will be preferred over executables without a
482+
matching extension.
483+
This brings behavior closer to that of Python 3.11.
484+
479485
.. exception:: Error
480486

481487
This exception collects exceptions that are raised during a multi-file

Lib/shutil.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1554,8 +1554,16 @@ def which(cmd, mode=os.F_OK | os.X_OK, path=None):
15541554
if use_bytes:
15551555
pathext = [os.fsencode(ext) for ext in pathext]
15561556

1557-
# Always try checking the originally given cmd, if it doesn't match, try pathext
1558-
files = [cmd] + [cmd + ext for ext in pathext]
1557+
files = ([cmd] + [cmd + ext for ext in pathext])
1558+
1559+
# gh-109590. If we are looking for an executable, we need to look
1560+
# for a PATHEXT match. The first cmd is the direct match
1561+
# (e.g. python.exe instead of python)
1562+
# Check that direct match first if and only if the extension is in PATHEXT
1563+
# Otherwise check it last
1564+
suffix = os.path.splitext(files[0])[1].upper()
1565+
if mode & os.X_OK and not any(suffix == ext.upper() for ext in pathext):
1566+
files.append(files.pop(0))
15591567
else:
15601568
# On other platforms you don't have things like PATHEXT to tell you
15611569
# what file suffixes are executable, so just pass on cmd as-is.

Lib/test/test_shutil.py

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2068,6 +2068,14 @@ def setUp(self):
20682068
self.curdir = os.curdir
20692069
self.ext = ".EXE"
20702070

2071+
def to_text_type(self, s):
2072+
'''
2073+
In this class we're testing with str, so convert s to a str
2074+
'''
2075+
if isinstance(s, bytes):
2076+
return s.decode()
2077+
return s
2078+
20712079
def test_basic(self):
20722080
# Given an EXE in a directory, it should be returned.
20732081
rv = shutil.which(self.file, path=self.dir)
@@ -2255,9 +2263,9 @@ def test_empty_path_no_PATH(self):
22552263

22562264
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
22572265
def test_pathext(self):
2258-
ext = ".xyz"
2266+
ext = self.to_text_type(".xyz")
22592267
temp_filexyz = tempfile.NamedTemporaryFile(dir=self.temp_dir,
2260-
prefix="Tmp2", suffix=ext)
2268+
prefix=self.to_text_type("Tmp2"), suffix=ext)
22612269
os.chmod(temp_filexyz.name, stat.S_IXUSR)
22622270
self.addCleanup(temp_filexyz.close)
22632271

@@ -2266,38 +2274,39 @@ def test_pathext(self):
22662274
program = os.path.splitext(program)[0]
22672275

22682276
with os_helper.EnvironmentVarGuard() as env:
2269-
env['PATHEXT'] = ext
2277+
env['PATHEXT'] = ext if isinstance(ext, str) else ext.decode()
22702278
rv = shutil.which(program, path=self.temp_dir)
22712279
self.assertEqual(rv, temp_filexyz.name)
22722280

22732281
# Issue 40592: See https://bugs.python.org/issue40592
22742282
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
22752283
def test_pathext_with_empty_str(self):
2276-
ext = ".xyz"
2284+
ext = self.to_text_type(".xyz")
22772285
temp_filexyz = tempfile.NamedTemporaryFile(dir=self.temp_dir,
2278-
prefix="Tmp2", suffix=ext)
2286+
prefix=self.to_text_type("Tmp2"), suffix=ext)
22792287
self.addCleanup(temp_filexyz.close)
22802288

22812289
# strip path and extension
22822290
program = os.path.basename(temp_filexyz.name)
22832291
program = os.path.splitext(program)[0]
22842292

22852293
with os_helper.EnvironmentVarGuard() as env:
2286-
env['PATHEXT'] = f"{ext};" # note the ;
2294+
env['PATHEXT'] = f"{ext if isinstance(ext, str) else ext.decode()};" # note the ;
22872295
rv = shutil.which(program, path=self.temp_dir)
22882296
self.assertEqual(rv, temp_filexyz.name)
22892297

22902298
# See GH-75586
22912299
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
22922300
def test_pathext_applied_on_files_in_path(self):
22932301
with os_helper.EnvironmentVarGuard() as env:
2294-
env["PATH"] = self.temp_dir
2302+
env["PATH"] = self.temp_dir if isinstance(self.temp_dir, str) else self.temp_dir.decode()
22952303
env["PATHEXT"] = ".test"
22962304

2297-
test_path = pathlib.Path(self.temp_dir) / "test_program.test"
2298-
test_path.touch(mode=0o755)
2305+
test_path = os.path.join(self.temp_dir, self.to_text_type("test_program.test"))
2306+
open(test_path, 'w').close()
2307+
os.chmod(test_path, 0o755)
22992308

2300-
self.assertEqual(shutil.which("test_program"), str(test_path))
2309+
self.assertEqual(shutil.which(self.to_text_type("test_program")), test_path)
23012310

23022311
# See GH-75586
23032312
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
@@ -2313,16 +2322,69 @@ def test_win_path_needs_curdir(self):
23132322
self.assertFalse(shutil._win_path_needs_curdir('dontcare', os.X_OK))
23142323
need_curdir_mock.assert_called_once_with('dontcare')
23152324

2325+
# See GH-109590
2326+
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
2327+
def test_pathext_preferred_for_execute(self):
2328+
with os_helper.EnvironmentVarGuard() as env:
2329+
env["PATH"] = self.temp_dir if isinstance(self.temp_dir, str) else self.temp_dir.decode()
2330+
env["PATHEXT"] = ".test"
2331+
2332+
exe = os.path.join(self.temp_dir, self.to_text_type("test.exe"))
2333+
open(exe, 'w').close()
2334+
os.chmod(exe, 0o755)
2335+
2336+
# default behavior allows a direct match if nothing in PATHEXT matches
2337+
self.assertEqual(shutil.which(self.to_text_type("test.exe")), exe)
2338+
2339+
dot_test = os.path.join(self.temp_dir, self.to_text_type("test.exe.test"))
2340+
open(dot_test, 'w').close()
2341+
os.chmod(dot_test, 0o755)
2342+
2343+
# now we have a PATHEXT match, so it take precedence
2344+
self.assertEqual(shutil.which(self.to_text_type("test.exe")), dot_test)
2345+
2346+
# but if we don't use os.X_OK we don't change the order based off PATHEXT
2347+
# and therefore get the direct match.
2348+
self.assertEqual(shutil.which(self.to_text_type("test.exe"), mode=os.F_OK), exe)
2349+
2350+
# See GH-109590
2351+
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
2352+
def test_pathext_given_extension_preferred(self):
2353+
with os_helper.EnvironmentVarGuard() as env:
2354+
env["PATH"] = self.temp_dir if isinstance(self.temp_dir, str) else self.temp_dir.decode()
2355+
env["PATHEXT"] = ".exe2;.exe"
2356+
2357+
exe = os.path.join(self.temp_dir, self.to_text_type("test.exe"))
2358+
open(exe, 'w').close()
2359+
os.chmod(exe, 0o755)
2360+
2361+
exe2 = os.path.join(self.temp_dir, self.to_text_type("test.exe2"))
2362+
open(exe2, 'w').close()
2363+
os.chmod(exe2, 0o755)
2364+
2365+
# even though .exe2 is preferred in PATHEXT, we matched directly to test.exe
2366+
self.assertEqual(shutil.which(self.to_text_type("test.exe")), exe)
2367+
self.assertEqual(shutil.which(self.to_text_type("test")), exe2)
2368+
23162369

23172370
class TestWhichBytes(TestWhich):
23182371
def setUp(self):
23192372
TestWhich.setUp(self)
23202373
self.dir = os.fsencode(self.dir)
23212374
self.file = os.fsencode(self.file)
23222375
self.temp_file.name = os.fsencode(self.temp_file.name)
2376+
self.temp_dir = os.fsencode(self.temp_dir)
23232377
self.curdir = os.fsencode(self.curdir)
23242378
self.ext = os.fsencode(self.ext)
23252379

2380+
def to_text_type(self, s):
2381+
'''
2382+
In this class we're testing with bytes, so convert s to a bytes
2383+
'''
2384+
if isinstance(s, str):
2385+
return s.encode()
2386+
return s
2387+
23262388

23272389
class TestMove(BaseTest, unittest.TestCase):
23282390

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
:func:`shutil.which` will prefer files with an extension in ``PATHEXT`` if the given mode includes ``os.X_OK`` on win32.
2+
If no ``PATHEXT`` match is found, a file without an extension in ``PATHEXT`` can be returned.
3+
This change will have :func:`shutil.which` act more similarly to previous behavior in Python 3.11.

0 commit comments

Comments
 (0)