diff --git a/Lib/shutil.py b/Lib/shutil.py index 0a65ae228e094c..60cc66aec060a6 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -1551,17 +1551,19 @@ def which(cmd, mode=os.F_OK | os.X_OK, path=None): pathext_source = os.getenv("PATHEXT") or _WIN_DEFAULT_PATHEXT pathext = [ext for ext in pathext_source.split(os.pathsep) if ext] - dot = '.' if use_bytes: pathext = [os.fsencode(ext) for ext in pathext] - dot = b'.' - - # Attempt to match CMD behavior: - # Only try the given cmd if it has an extension (therefore has a dot) - # or a dot is a pathext in PATHEXT. - # Otherwise use PATHEXT to formulate paths to check. - files = (([cmd] if (dot in cmd or dot in pathext) else []) + - [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 + if mode & os.X_OK and not any( + [os.path.splitext(files[0])[1].upper() == ext.upper() for ext in pathext] + ): + files = files[1:] 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 9a9c79239f5724..20ea2e2d62de5a 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -2323,34 +2323,40 @@ def test_win_path_needs_curdir(self): # See GH-109590 @unittest.skipUnless(sys.platform == "win32", 'test specific to Windows') - def test_extensionless_file_resolution_no_dot_in_pathext(self): + def test_pathext_enforced_for_execute(self): with os_helper.EnvironmentVarGuard() as env: - env['PATHEXT'] = ".test;" - env['PATH'] = self.temp_dir if isinstance(self.temp_dir, str) else self.temp_dir.decode() - - extensionless_file_in_path = os.path.join(self.temp_dir, self.to_text_type("file")) - open(extensionless_file_in_path, 'w').close() + env["PATH"] = self.temp_dir if isinstance(self.temp_dir, str) else self.temp_dir.decode() + env["PATHEXT"] = ".test" - extensioned_file_in_path = os.path.join(self.temp_dir, self.to_text_type("file.test")) - open(extensioned_file_in_path, 'w').close() + exe = os.path.join(self.temp_dir, self.to_text_type("test.exe")) + open(exe, 'w').close() + os.chmod(exe, 0o755) + # default does not match since .exe is not in PATHEXT + self.assertIsNone(shutil.which(self.to_text_type("test.exe"))) - self.assertEqual(shutil.which(self.to_text_type('file'), os.F_OK), extensioned_file_in_path) + # but if we don't use os.X_OK we're ok not matching PATHEXT + 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_extensionless_file_resolution_dot_in_pathext(self): + def test_pathext_given_extension_preferred(self): with os_helper.EnvironmentVarGuard() as env: - env['PATHEXT'] = ".test;.;" - env['PATH'] = self.temp_dir if isinstance(self.temp_dir, str) else self.temp_dir.decode() + 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) - extensionless_file_in_path = os.path.join(self.temp_dir, self.to_text_type("file")) - open(extensionless_file_in_path, 'w').close() + exe2 = os.path.join(self.temp_dir, self.to_text_type("test.exe2")) + open(exe2, 'w').close() + os.chmod(exe2, 0o755) - extensioned_file_in_path = os.path.join(self.temp_dir, self.to_text_type("file.test")) - open(extensioned_file_in_path, 'w').close() + # 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('file')), extensionless_file_in_path) + self.assertEqual(shutil.which(self.to_text_type("test")), exe2) class TestWhichBytes(TestWhich):