Skip to content

Commit 935aa45

Browse files
authored
GH-75586: Make shutil.which() on Windows more consistent with the OS (GH-103179)
1 parent f184abb commit 935aa45

File tree

7 files changed

+233
-54
lines changed

7 files changed

+233
-54
lines changed

Doc/library/shutil.rst

+27-7
Original file line numberDiff line numberDiff line change
@@ -433,23 +433,43 @@ Directory and files operations
433433
When no *path* is specified, the results of :func:`os.environ` are used,
434434
returning either the "PATH" value or a fallback of :attr:`os.defpath`.
435435

436-
On Windows, the current directory is always prepended to the *path* whether
437-
or not you use the default or provide your own, which is the behavior the
438-
command shell uses when finding executables. Additionally, when finding the
439-
*cmd* in the *path*, the ``PATHEXT`` environment variable is checked. For
440-
example, if you call ``shutil.which("python")``, :func:`which` will search
441-
``PATHEXT`` to know that it should look for ``python.exe`` within the *path*
442-
directories. For example, on Windows::
436+
On Windows, the current directory is prepended to the *path* if *mode* does
437+
not include ``os.X_OK``. When the *mode* does include ``os.X_OK``, the
438+
Windows API ``NeedCurrentDirectoryForExePathW`` will be consulted to
439+
determine if the current directory should be prepended to *path*. To avoid
440+
consulting the current working directory for executables: set the environment
441+
variable ``NoDefaultCurrentDirectoryInExePath``.
442+
443+
Also on Windows, the ``PATHEXT`` variable is used to resolve commands
444+
that may not already include an extension. For example, if you call
445+
``shutil.which("python")``, :func:`which` will search ``PATHEXT``
446+
to know that it should look for ``python.exe`` within the *path*
447+
directories. For example, on Windows::
443448

444449
>>> shutil.which("python")
445450
'C:\\Python33\\python.EXE'
446451

452+
This is also applied when *cmd* is a path that contains a directory
453+
component::
454+
455+
>> shutil.which("C:\\Python33\\python")
456+
'C:\\Python33\\python.EXE'
457+
447458
.. versionadded:: 3.3
448459

449460
.. versionchanged:: 3.8
450461
The :class:`bytes` type is now accepted. If *cmd* type is
451462
:class:`bytes`, the result type is also :class:`bytes`.
452463

464+
.. versionchanged:: 3.12
465+
On Windows, the current directory is no longer prepended to the search
466+
path if *mode* includes ``os.X_OK`` and WinAPI
467+
``NeedCurrentDirectoryForExePathW(cmd)`` is false, else the current
468+
directory is prepended even if it is already in the search path;
469+
``PATHEXT`` is used now even when *cmd* includes a directory component
470+
or ends with an extension that is in ``PATHEXT``; and filenames that
471+
have no extension can now be found.
472+
453473
.. exception:: Error
454474

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

Doc/whatsnew/3.12.rst

+14
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,20 @@ shutil
343343
will be removed in Python 3.14.
344344
(Contributed by Irit Katriel in :gh:`102828`.)
345345

346+
* :func:`shutil.which` now consults the *PATHEXT* environment variable to
347+
find matches within *PATH* on Windows even when the given *cmd* includes
348+
a directory component.
349+
(Contributed by Charles Machalow in :gh:`103179`.)
350+
351+
:func:`shutil.which` will call ``NeedCurrentDirectoryForExePathW`` when
352+
querying for executables on Windows to determine if the current working
353+
directory should be prepended to the search path.
354+
(Contributed by Charles Machalow in :gh:`103179`.)
355+
356+
:func:`shutil.which` will return a path matching the *cmd* with a component
357+
from ``PATHEXT`` prior to a direct match elsewhere in the search path on
358+
Windows.
359+
(Contributed by Charles Machalow in :gh:`103179`.)
346360

347361
sqlite3
348362
-------

Lib/shutil.py

+48-41
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@
4040
elif _WINDOWS:
4141
import nt
4242

43+
if sys.platform == 'win32':
44+
import _winapi
45+
4346
COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024
4447
# This should never be removed, see rationale in:
4548
# https://bugs.python.org/issue43743#msg393429
@@ -1449,6 +1452,16 @@ def _access_check(fn, mode):
14491452
and not os.path.isdir(fn))
14501453

14511454

1455+
def _win_path_needs_curdir(cmd, mode):
1456+
"""
1457+
On Windows, we can use NeedCurrentDirectoryForExePath to figure out
1458+
if we should add the cwd to PATH when searching for executables if
1459+
the mode is executable.
1460+
"""
1461+
return (not (mode & os.X_OK)) or _winapi.NeedCurrentDirectoryForExePath(
1462+
os.fsdecode(cmd))
1463+
1464+
14521465
def which(cmd, mode=os.F_OK | os.X_OK, path=None):
14531466
"""Given a command, mode, and a PATH string, return the path which
14541467
conforms to the given mode on the PATH, or None if there is no such
@@ -1459,60 +1472,54 @@ def which(cmd, mode=os.F_OK | os.X_OK, path=None):
14591472
path.
14601473
14611474
"""
1462-
# If we're given a path with a directory part, look it up directly rather
1463-
# than referring to PATH directories. This includes checking relative to the
1464-
# current directory, e.g. ./script
1465-
if os.path.dirname(cmd):
1466-
if _access_check(cmd, mode):
1467-
return cmd
1468-
return None
1469-
14701475
use_bytes = isinstance(cmd, bytes)
14711476

1472-
if path is None:
1473-
path = os.environ.get("PATH", None)
1474-
if path is None:
1475-
try:
1476-
path = os.confstr("CS_PATH")
1477-
except (AttributeError, ValueError):
1478-
# os.confstr() or CS_PATH is not available
1479-
path = os.defpath
1480-
# bpo-35755: Don't use os.defpath if the PATH environment variable is
1481-
# set to an empty string
1482-
1483-
# PATH='' doesn't match, whereas PATH=':' looks in the current directory
1484-
if not path:
1485-
return None
1486-
1487-
if use_bytes:
1488-
path = os.fsencode(path)
1489-
path = path.split(os.fsencode(os.pathsep))
1477+
# If we're given a path with a directory part, look it up directly rather
1478+
# than referring to PATH directories. This includes checking relative to
1479+
# the current directory, e.g. ./script
1480+
dirname, cmd = os.path.split(cmd)
1481+
if dirname:
1482+
path = [dirname]
14901483
else:
1491-
path = os.fsdecode(path)
1492-
path = path.split(os.pathsep)
1484+
if path is None:
1485+
path = os.environ.get("PATH", None)
1486+
if path is None:
1487+
try:
1488+
path = os.confstr("CS_PATH")
1489+
except (AttributeError, ValueError):
1490+
# os.confstr() or CS_PATH is not available
1491+
path = os.defpath
1492+
# bpo-35755: Don't use os.defpath if the PATH environment variable
1493+
# is set to an empty string
1494+
1495+
# PATH='' doesn't match, whereas PATH=':' looks in the current
1496+
# directory
1497+
if not path:
1498+
return None
14931499

1494-
if sys.platform == "win32":
1495-
# The current directory takes precedence on Windows.
1496-
curdir = os.curdir
14971500
if use_bytes:
1498-
curdir = os.fsencode(curdir)
1499-
if curdir not in path:
1501+
path = os.fsencode(path)
1502+
path = path.split(os.fsencode(os.pathsep))
1503+
else:
1504+
path = os.fsdecode(path)
1505+
path = path.split(os.pathsep)
1506+
1507+
if sys.platform == "win32" and _win_path_needs_curdir(cmd, mode):
1508+
curdir = os.curdir
1509+
if use_bytes:
1510+
curdir = os.fsencode(curdir)
15001511
path.insert(0, curdir)
15011512

1513+
if sys.platform == "win32":
15021514
# PATHEXT is necessary to check on Windows.
15031515
pathext_source = os.getenv("PATHEXT") or _WIN_DEFAULT_PATHEXT
15041516
pathext = [ext for ext in pathext_source.split(os.pathsep) if ext]
15051517

15061518
if use_bytes:
15071519
pathext = [os.fsencode(ext) for ext in pathext]
1508-
# See if the given file matches any of the expected path extensions.
1509-
# This will allow us to short circuit when given "python.exe".
1510-
# If it does match, only test that one, otherwise we have to try
1511-
# others.
1512-
if any(cmd.lower().endswith(ext.lower()) for ext in pathext):
1513-
files = [cmd]
1514-
else:
1515-
files = [cmd + ext for ext in pathext]
1520+
1521+
# Always try checking the originally given cmd, if it doesn't match, try pathext
1522+
files = [cmd] + [cmd + ext for ext in pathext]
15161523
else:
15171524
# On other platforms you don't have things like PATHEXT to tell you
15181525
# what file suffixes are executable, so just pass on cmd as-is.

Lib/test/test_shutil.py

+81-5
Original file line numberDiff line numberDiff line change
@@ -2034,18 +2034,68 @@ def test_relative_cmd(self):
20342034
rv = shutil.which(relpath, path=base_dir)
20352035
self.assertIsNone(rv)
20362036

2037-
def test_cwd(self):
2037+
@unittest.skipUnless(sys.platform != "win32",
2038+
"test is for non win32")
2039+
def test_cwd_non_win32(self):
20382040
# Issue #16957
20392041
base_dir = os.path.dirname(self.dir)
20402042
with os_helper.change_cwd(path=self.dir):
20412043
rv = shutil.which(self.file, path=base_dir)
2042-
if sys.platform == "win32":
2043-
# Windows: current directory implicitly on PATH
2044+
# non-win32: shouldn't match in the current directory.
2045+
self.assertIsNone(rv)
2046+
2047+
@unittest.skipUnless(sys.platform == "win32",
2048+
"test is for win32")
2049+
def test_cwd_win32(self):
2050+
base_dir = os.path.dirname(self.dir)
2051+
with os_helper.change_cwd(path=self.dir):
2052+
with unittest.mock.patch('shutil._win_path_needs_curdir', return_value=True):
2053+
rv = shutil.which(self.file, path=base_dir)
2054+
# Current directory implicitly on PATH
20442055
self.assertEqual(rv, os.path.join(self.curdir, self.file))
2045-
else:
2046-
# Other platforms: shouldn't match in the current directory.
2056+
with unittest.mock.patch('shutil._win_path_needs_curdir', return_value=False):
2057+
rv = shutil.which(self.file, path=base_dir)
2058+
# Current directory not on PATH
20472059
self.assertIsNone(rv)
20482060

2061+
@unittest.skipUnless(sys.platform == "win32",
2062+
"test is for win32")
2063+
def test_cwd_win32_added_before_all_other_path(self):
2064+
base_dir = pathlib.Path(os.fsdecode(self.dir))
2065+
2066+
elsewhere_in_path_dir = base_dir / 'dir1'
2067+
elsewhere_in_path_dir.mkdir()
2068+
match_elsewhere_in_path = elsewhere_in_path_dir / 'hello.exe'
2069+
match_elsewhere_in_path.touch()
2070+
2071+
exe_in_cwd = base_dir / 'hello.exe'
2072+
exe_in_cwd.touch()
2073+
2074+
with os_helper.change_cwd(path=base_dir):
2075+
with unittest.mock.patch('shutil._win_path_needs_curdir', return_value=True):
2076+
rv = shutil.which('hello.exe', path=elsewhere_in_path_dir)
2077+
2078+
self.assertEqual(os.path.abspath(rv), os.path.abspath(exe_in_cwd))
2079+
2080+
@unittest.skipUnless(sys.platform == "win32",
2081+
"test is for win32")
2082+
def test_pathext_match_before_path_full_match(self):
2083+
base_dir = pathlib.Path(os.fsdecode(self.dir))
2084+
dir1 = base_dir / 'dir1'
2085+
dir2 = base_dir / 'dir2'
2086+
dir1.mkdir()
2087+
dir2.mkdir()
2088+
2089+
pathext_match = dir1 / 'hello.com.exe'
2090+
path_match = dir2 / 'hello.com'
2091+
pathext_match.touch()
2092+
path_match.touch()
2093+
2094+
test_path = os.pathsep.join([str(dir1), str(dir2)])
2095+
assert os.path.basename(shutil.which(
2096+
'hello.com', path=test_path, mode = os.F_OK
2097+
)).lower() == 'hello.com.exe'
2098+
20492099
@os_helper.skip_if_dac_override
20502100
def test_non_matching_mode(self):
20512101
# Set the file read-only and ask for writeable files.
@@ -2179,6 +2229,32 @@ def test_pathext_with_empty_str(self):
21792229
rv = shutil.which(program, path=self.temp_dir)
21802230
self.assertEqual(rv, temp_filexyz.name)
21812231

2232+
# See GH-75586
2233+
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
2234+
def test_pathext_applied_on_files_in_path(self):
2235+
with os_helper.EnvironmentVarGuard() as env:
2236+
env["PATH"] = self.temp_dir
2237+
env["PATHEXT"] = ".test"
2238+
2239+
test_path = pathlib.Path(self.temp_dir) / "test_program.test"
2240+
test_path.touch(mode=0o755)
2241+
2242+
self.assertEqual(shutil.which("test_program"), str(test_path))
2243+
2244+
# See GH-75586
2245+
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
2246+
def test_win_path_needs_curdir(self):
2247+
with unittest.mock.patch('_winapi.NeedCurrentDirectoryForExePath', return_value=True) as need_curdir_mock:
2248+
self.assertTrue(shutil._win_path_needs_curdir('dontcare', os.X_OK))
2249+
need_curdir_mock.assert_called_once_with('dontcare')
2250+
need_curdir_mock.reset_mock()
2251+
self.assertTrue(shutil._win_path_needs_curdir('dontcare', 0))
2252+
need_curdir_mock.assert_not_called()
2253+
2254+
with unittest.mock.patch('_winapi.NeedCurrentDirectoryForExePath', return_value=False) as need_curdir_mock:
2255+
self.assertFalse(shutil._win_path_needs_curdir('dontcare', os.X_OK))
2256+
need_curdir_mock.assert_called_once_with('dontcare')
2257+
21822258

21832259
class TestWhichBytes(TestWhich):
21842260
def setUp(self):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix various Windows-specific issues with ``shutil.which``.

Modules/_winapi.c

+21
Original file line numberDiff line numberDiff line change
@@ -2054,6 +2054,26 @@ _winapi__mimetypes_read_windows_registry_impl(PyObject *module,
20542054
#undef CB_TYPE
20552055
}
20562056

2057+
/*[clinic input]
2058+
_winapi.NeedCurrentDirectoryForExePath -> bool
2059+
2060+
exe_name: LPCWSTR
2061+
/
2062+
[clinic start generated code]*/
2063+
2064+
static int
2065+
_winapi_NeedCurrentDirectoryForExePath_impl(PyObject *module,
2066+
LPCWSTR exe_name)
2067+
/*[clinic end generated code: output=a65ec879502b58fc input=972aac88a1ec2f00]*/
2068+
{
2069+
BOOL result;
2070+
2071+
Py_BEGIN_ALLOW_THREADS
2072+
result = NeedCurrentDirectoryForExePathW(exe_name);
2073+
Py_END_ALLOW_THREADS
2074+
2075+
return result;
2076+
}
20572077

20582078
static PyMethodDef winapi_functions[] = {
20592079
_WINAPI_CLOSEHANDLE_METHODDEF
@@ -2089,6 +2109,7 @@ static PyMethodDef winapi_functions[] = {
20892109
_WINAPI_GETACP_METHODDEF
20902110
_WINAPI_GETFILETYPE_METHODDEF
20912111
_WINAPI__MIMETYPES_READ_WINDOWS_REGISTRY_METHODDEF
2112+
_WINAPI_NEEDCURRENTDIRECTORYFOREXEPATH_METHODDEF
20922113
{NULL, NULL}
20932114
};
20942115

Modules/clinic/_winapi.c.h

+41-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)