Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GH-75586: Fix case where PATHEXT isn't applied to items in PATH (Windows) #103179

Merged
merged 31 commits into from
Apr 4, 2023
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
01c152e
GH-75586 - Fix case where PATHEXT isn't applied to items in PATH (Win…
csm10495 Apr 2, 2023
0d4cd7b
PR updates
csm10495 Apr 2, 2023
5fac84a
Add tests
csm10495 Apr 2, 2023
fa145da
PR updates
csm10495 Apr 2, 2023
84a7976
line len fix
csm10495 Apr 2, 2023
63a06c4
📜🤖 Added by blurb_it.
blurb-it[bot] Apr 2, 2023
7686a23
Add changelog entry
csm10495 Apr 2, 2023
381e4fe
Double backticks
csm10495 Apr 2, 2023
e7c0b58
Update Lib/shutil.py
csm10495 Apr 2, 2023
26e3b15
PR updates
csm10495 Apr 2, 2023
6272b62
pep8 fix
csm10495 Apr 2, 2023
b6d29c8
Update Lib/test/test_shutil.py
csm10495 Apr 2, 2023
1096cb7
Update Lib/test/test_shutil.py
csm10495 Apr 2, 2023
92955d0
Update Lib/shutil.py
csm10495 Apr 3, 2023
616df6c
docs updates
csm10495 Apr 3, 2023
255e4ff
Add another test
csm10495 Apr 3, 2023
f52868d
Update Doc/library/shutil.rst
csm10495 Apr 3, 2023
6e9269f
rewording
csm10495 Apr 3, 2023
a6b7eab
Rewording
csm10495 Apr 3, 2023
7480daa
Reword whats new
csm10495 Apr 3, 2023
a48260c
Clarify
csm10495 Apr 3, 2023
6bb6f6c
Clarify how to not search cwd for exes
csm10495 Apr 3, 2023
b5f3eba
Doc updates
csm10495 Apr 3, 2023
3bf4b8d
Update Doc/library/shutil.rst
csm10495 Apr 3, 2023
be73608
Update 2023-04-02-22-04-26.gh-issue-75586.526iJm.rst
csm10495 Apr 3, 2023
0169ba9
Update Doc/library/shutil.rst
csm10495 Apr 4, 2023
499d2de
Update Lib/shutil.py
csm10495 Apr 4, 2023
3ead780
Add test for behavior
csm10495 Apr 4, 2023
f9267da
kick ci
csm10495 Apr 4, 2023
d1e68ff
Mention cwd first behavior
csm10495 Apr 4, 2023
9badf8c
Wording
csm10495 Apr 4, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
33 changes: 26 additions & 7 deletions Doc/library/shutil.rst
Original file line number Diff line number Diff line change
Expand Up @@ -433,23 +433,42 @@ Directory and files operations
When no *path* is specified, the results of :func:`os.environ` are used,
returning either the "PATH" value or a fallback of :attr:`os.defpath`.

On Windows, the current directory is always prepended to the *path* whether
or not you use the default or provide your own, which is the behavior the
command shell uses when finding executables. Additionally, when finding the
*cmd* in the *path*, the ``PATHEXT`` environment variable is checked. For
example, if you call ``shutil.which("python")``, :func:`which` will search
``PATHEXT`` to know that it should look for ``python.exe`` within the *path*
directories. For example, on Windows::
On Windows, the current directory is prepended to the *path* if *mode* does
not include ``os.X_OK``. When the *mode* does include ``os.X_OK``, the
Windows API ``NeedCurrentDirectoryForExePathW`` will be consulted to
determine if the current directory should be prepended to *path*. To avoid
consulting the current working directory for executables: set the environment
variable ``NoDefaultCurrentDirectoryInExePath``.

Also on Windows, the ``PATHEXT`` variable is used to resolve commands
that may not already include an extension. For example, if you call
csm10495 marked this conversation as resolved.
Show resolved Hide resolved
``shutil.which("python")``, :func:`which` will search ``PATHEXT``
to know that it should look for ``python.exe`` within the *path*
directories. For example, on Windows::

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

This is also applied when *cmd* is a path that contains a directory
component::

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

.. versionadded:: 3.3

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

.. versionchanged:: 3.12
On Windows, the current directory is no longer prepended to the search
path if *mode* includes ``os.X_OK`` and WinAPI
``NeedCurrentDirectoryForExePathW(cmd)`` is false; ``PATHEXT`` is used
now even when *cmd* includes a directory component or ends with an
extension that is in ``PATHEXT``; and filenames that have no extension
can be found now.
csm10495 marked this conversation as resolved.
Show resolved Hide resolved

.. exception:: Error

This exception collects exceptions that are raised during a multi-file
Expand Down
14 changes: 14 additions & 0 deletions Doc/whatsnew/3.12.rst
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,20 @@ shutil
will be removed in Python 3.14.
(Contributed by Irit Katriel in :gh:`102828`.)

* :func:`shutil.which` now consults the *PATHEXT* environment variable to
find matches within *PATH* on Windows even when the given *cmd* includes
a directory component.
(Contributed by Charles Machalow in :gh:`103179`.)

:func:`shutil.which` will call ``NeedCurrentDirectoryForExePathW`` when
querying for executables on Windows to determine if the current working
directory should be prepended to the search path.
(Contributed by Charles Machalow in :gh:`103179`.)

:func:`shutil.which` will return a path matching the *cmd* with a component
from ``PATHEXT`` prior to a direct match elsewhere in the search path on
Windows.
(Contributed by Charles Machalow in :gh:`103179`.)

sqlite3
-------
Expand Down
89 changes: 48 additions & 41 deletions Lib/shutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@
elif _WINDOWS:
import nt

if sys.platform == 'win32':
import _winapi

COPY_BUFSIZE = 1024 * 1024 if _WINDOWS else 64 * 1024
# This should never be removed, see rationale in:
# https://bugs.python.org/issue43743#msg393429
Expand Down Expand Up @@ -1449,6 +1452,16 @@ def _access_check(fn, mode):
and not os.path.isdir(fn))


def _win_path_needs_curdir(cmd, mode):
"""
On Windows, we can use NeedCurrentDirectoryForExePath to figure out
if we should add the cwd to PATH when searching for executables if
the mode is executable.
"""
return (not (mode & os.X_OK)) or _winapi.NeedCurrentDirectoryForExePath(
os.fsdecode(cmd))


def which(cmd, mode=os.F_OK | os.X_OK, path=None):
"""Given a command, mode, and a PATH string, return the path which
conforms to the given mode on the PATH, or None if there is no such
Expand All @@ -1459,60 +1472,54 @@ def which(cmd, mode=os.F_OK | os.X_OK, path=None):
path.

"""
# If we're given a path with a directory part, look it up directly rather
# than referring to PATH directories. This includes checking relative to the
# current directory, e.g. ./script
if os.path.dirname(cmd):
if _access_check(cmd, mode):
return cmd
return None

use_bytes = isinstance(cmd, bytes)

if path is None:
path = os.environ.get("PATH", None)
if path is None:
try:
path = os.confstr("CS_PATH")
except (AttributeError, ValueError):
# os.confstr() or CS_PATH is not available
path = os.defpath
# bpo-35755: Don't use os.defpath if the PATH environment variable is
# set to an empty string

# PATH='' doesn't match, whereas PATH=':' looks in the current directory
if not path:
return None

if use_bytes:
path = os.fsencode(path)
path = path.split(os.fsencode(os.pathsep))
# If we're given a path with a directory part, look it up directly rather
# than referring to PATH directories. This includes checking relative to
# the current directory, e.g. ./script
dirname, cmd = os.path.split(cmd)
if dirname:
path = [dirname]
else:
path = os.fsdecode(path)
path = path.split(os.pathsep)
if path is None:
path = os.environ.get("PATH", None)
if path is None:
try:
path = os.confstr("CS_PATH")
except (AttributeError, ValueError):
# os.confstr() or CS_PATH is not available
path = os.defpath
# bpo-35755: Don't use os.defpath if the PATH environment variable
# is set to an empty string

# PATH='' doesn't match, whereas PATH=':' looks in the current
# directory
if not path:
return None

if sys.platform == "win32":
# The current directory takes precedence on Windows.
curdir = os.curdir
if use_bytes:
curdir = os.fsencode(curdir)
if curdir not in path:
path = os.fsencode(path)
path = path.split(os.fsencode(os.pathsep))
else:
path = os.fsdecode(path)
path = path.split(os.pathsep)

if sys.platform == "win32" and _win_path_needs_curdir(cmd, mode):
curdir = os.curdir
if use_bytes:
curdir = os.fsencode(curdir)
path.insert(0, curdir)

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

if use_bytes:
pathext = [os.fsencode(ext) for ext in pathext]
# See if the given file matches any of the expected path extensions.
# This will allow us to short circuit when given "python.exe".
# If it does match, only test that one, otherwise we have to try
# others.
if any(cmd.lower().endswith(ext.lower()) for ext in pathext):
files = [cmd]
else:
files = [cmd + 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]
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.
Expand Down
86 changes: 81 additions & 5 deletions Lib/test/test_shutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -2034,18 +2034,68 @@ def test_relative_cmd(self):
rv = shutil.which(relpath, path=base_dir)
self.assertIsNone(rv)

def test_cwd(self):
@unittest.skipUnless(sys.platform != "win32",
"test is for non win32")
def test_cwd_non_win32(self):
# Issue #16957
base_dir = os.path.dirname(self.dir)
with os_helper.change_cwd(path=self.dir):
rv = shutil.which(self.file, path=base_dir)
if sys.platform == "win32":
# Windows: current directory implicitly on PATH
# non-win32: shouldn't match in the current directory.
self.assertIsNone(rv)

@unittest.skipUnless(sys.platform == "win32",
"test is for win32")
def test_cwd_win32(self):
base_dir = os.path.dirname(self.dir)
with os_helper.change_cwd(path=self.dir):
with unittest.mock.patch('shutil._win_path_needs_curdir', return_value=True):
rv = shutil.which(self.file, path=base_dir)
# Current directory implicitly on PATH
self.assertEqual(rv, os.path.join(self.curdir, self.file))
else:
# Other platforms: shouldn't match in the current directory.
with unittest.mock.patch('shutil._win_path_needs_curdir', return_value=False):
rv = shutil.which(self.file, path=base_dir)
# Current directory not on PATH
self.assertIsNone(rv)

@unittest.skipUnless(sys.platform == "win32",
"test is for win32")
def test_cwd_win32_added_before_all_other_path(self):
base_dir = pathlib.Path(os.fsdecode(self.dir))

elsewhere_in_path_dir = base_dir / 'dir1'
elsewhere_in_path_dir.mkdir()
match_elsewhere_in_path = elsewhere_in_path_dir / 'hello.exe'
match_elsewhere_in_path.touch()

exe_in_cwd = base_dir / 'hello.exe'
exe_in_cwd.touch()

with os_helper.change_cwd(path=base_dir):
with unittest.mock.patch('shutil._win_path_needs_curdir', return_value=True):
rv = shutil.which('hello.exe', path=elsewhere_in_path_dir)

self.assertEqual(os.path.abspath(rv), os.path.abspath(exe_in_cwd))

@unittest.skipUnless(sys.platform == "win32",
"test is for win32")
def test_pathext_match_before_path_full_match(self):
base_dir = pathlib.Path(os.fsdecode(self.dir))
dir1 = base_dir / 'dir1'
dir2 = base_dir / 'dir2'
dir1.mkdir()
dir2.mkdir()

pathext_match = dir1 / 'hello.com.exe'
path_match = dir2 / 'hello.com'
pathext_match.touch()
path_match.touch()

test_path = os.pathsep.join([str(dir1), str(dir2)])
assert os.path.basename(shutil.which(
'hello.com', path=test_path, mode = os.F_OK
)).lower() == 'hello.com.exe'

@os_helper.skip_if_dac_override
def test_non_matching_mode(self):
# Set the file read-only and ask for writeable files.
Expand Down Expand Up @@ -2179,6 +2229,32 @@ def test_pathext_with_empty_str(self):
rv = shutil.which(program, path=self.temp_dir)
self.assertEqual(rv, temp_filexyz.name)

# See GH-75586
@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["PATHEXT"] = ".test"

test_path = pathlib.Path(self.temp_dir) / "test_program.test"
test_path.touch(mode=0o755)

self.assertEqual(shutil.which("test_program"), str(test_path))

# See GH-75586
@unittest.skipUnless(sys.platform == "win32", 'test specific to Windows')
def test_win_path_needs_curdir(self):
with unittest.mock.patch('_winapi.NeedCurrentDirectoryForExePath', return_value=True) as need_curdir_mock:
self.assertTrue(shutil._win_path_needs_curdir('dontcare', os.X_OK))
need_curdir_mock.assert_called_once_with('dontcare')
need_curdir_mock.reset_mock()
self.assertTrue(shutil._win_path_needs_curdir('dontcare', 0))
need_curdir_mock.assert_not_called()

with unittest.mock.patch('_winapi.NeedCurrentDirectoryForExePath', return_value=False) as need_curdir_mock:
self.assertFalse(shutil._win_path_needs_curdir('dontcare', os.X_OK))
need_curdir_mock.assert_called_once_with('dontcare')


class TestWhichBytes(TestWhich):
def setUp(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix various Windows-specific issues with ``shutil.which``.
21 changes: 21 additions & 0 deletions Modules/_winapi.c
Original file line number Diff line number Diff line change
Expand Up @@ -2054,6 +2054,26 @@ _winapi__mimetypes_read_windows_registry_impl(PyObject *module,
#undef CB_TYPE
}

/*[clinic input]
_winapi.NeedCurrentDirectoryForExePath -> bool

exe_name: LPCWSTR
/
[clinic start generated code]*/

static int
_winapi_NeedCurrentDirectoryForExePath_impl(PyObject *module,
LPCWSTR exe_name)
/*[clinic end generated code: output=a65ec879502b58fc input=972aac88a1ec2f00]*/
{
BOOL result;

Py_BEGIN_ALLOW_THREADS
result = NeedCurrentDirectoryForExePathW(exe_name);
Py_END_ALLOW_THREADS

return result;
}

static PyMethodDef winapi_functions[] = {
_WINAPI_CLOSEHANDLE_METHODDEF
Expand Down Expand Up @@ -2089,6 +2109,7 @@ static PyMethodDef winapi_functions[] = {
_WINAPI_GETACP_METHODDEF
_WINAPI_GETFILETYPE_METHODDEF
_WINAPI__MIMETYPES_READ_WINDOWS_REGISTRY_METHODDEF
_WINAPI_NEEDCURRENTDIRECTORYFOREXEPATH_METHODDEF
{NULL, NULL}
};

Expand Down
42 changes: 41 additions & 1 deletion Modules/clinic/_winapi.c.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.