diff --git a/distutils/spawn.py b/distutils/spawn.py index 046b5bbb..d0c72882 100644 --- a/distutils/spawn.py +++ b/distutils/spawn.py @@ -7,6 +7,8 @@ """ import os +import pathlib +import platform import subprocess import sys @@ -68,19 +70,19 @@ def spawn(cmd, search_path=1, verbose=0, dry_run=0, env=None): # noqa: C901 raise DistutilsExecError(f"command {cmd!r} failed with exit code {exitcode}") -def find_executable(executable, path=None): - """Tries to find 'executable' in the directories listed in 'path'. - - A string listing directories separated by 'os.pathsep'; defaults to - os.environ['PATH']. Returns the complete filename or None if not found. +def _executable_candidates(executable: pathlib.Path): + """ + Given an executable, yields common executable variants. """ - _, ext = os.path.splitext(executable) - if (sys.platform == 'win32') and (ext != '.exe'): - executable = executable + '.exe' + yield executable + if platform.system() != 'Windows': + return + exts = os.environ.get('PATHEXT').split(os.pathsep) + unique = (ext for ext in exts if executable.suffix.casefold() != ext.casefold()) + yield from map(executable.with_suffix, unique) - if os.path.isfile(executable): - return executable +def _search_paths(path): if path is None: path = os.environ.get('PATH', None) if path is None: @@ -94,12 +96,23 @@ def find_executable(executable, path=None): # PATH='' doesn't match, whereas PATH=':' looks in the current directory if not path: - return None - - paths = path.split(os.pathsep) - for p in paths: - f = os.path.join(p, executable) - if os.path.isfile(f): - # the file exists, we have a shot at spawn working - return f - return None + return () + + return map(pathlib.Path, path.split(os.pathsep)) + + +def find_executable(executable, path=None): + """Tries to find 'executable' in the directories listed in 'path'. + + A string listing directories separated by 'os.pathsep'; defaults to + os.environ['PATH']. Returns the complete filename or None if not found. + """ + if os.path.isfile(executable): + return executable + + found = ( + os.fspath(exe) + for p in _search_paths(path) + for exe in filter(pathlib.Path.is_file, _executable_candidates(p / executable)) + ) + return next(found, None) diff --git a/distutils/tests/test_spawn.py b/distutils/tests/test_spawn.py index 7ec58626..2d150e47 100644 --- a/distutils/tests/test_spawn.py +++ b/distutils/tests/test_spawn.py @@ -1,6 +1,8 @@ """Tests for distutils.spawn.""" import os +import platform +import random import stat import sys import unittest.mock as mock @@ -45,14 +47,9 @@ def test_spawn(self): spawn([exe]) # should work without any error def test_find_executable(self, tmp_path): - program_noeext = 'program' - # Give the temporary program an ".exe" suffix for all. - # It's needed on Windows and not harmful on other platforms. - program = program_noeext + ".exe" - - program_path = tmp_path / program - program_path.write_text("", encoding='utf-8') - program_path.chmod(stat.S_IXUSR) + program_path = self._make_executable(tmp_path, '.exe') + program = program_path.name + program_noeext = program_path.with_suffix('').name filename = str(program_path) tmp_dir = path.Path(tmp_path) @@ -60,10 +57,10 @@ def test_find_executable(self, tmp_path): rv = find_executable(program, path=tmp_dir) assert rv == filename - if sys.platform == 'win32': + if sys.platform == 'win32': # pragma: no cover # test without ".exe" extension rv = find_executable(program_noeext, path=tmp_dir) - assert rv == filename + assert os.path.samefile(rv, filename) # test find in the current directory with tmp_dir: @@ -121,7 +118,49 @@ def test_find_executable(self, tmp_path): rv = find_executable(program) assert rv == filename + def test_find_executable_alt_ext(self, tmp_path, windows_pathext): + """ + On Windows with PATHEXT set, also executables matching those. + """ + ext = random.choice(os.environ['PATHEXT'].split(os.pathsep)) + program = self._make_executable(tmp_path, ext) + rv = find_executable(program.with_suffix('').name, path=str(tmp_path)) + assert rv == str(program) + + @staticmethod + def _make_executable(tmp_path, ext): + # Give the temporary program a suffix regardless of platform. + # It's needed on Windows and not harmful on others. + program = tmp_path.joinpath('program').with_suffix(ext) + program.write_text("", encoding='utf-8') + program.chmod(stat.S_IXUSR) + return program + def test_spawn_missing_exe(self): with pytest.raises(DistutilsExecError) as ctx: spawn(['does-not-exist']) assert "command 'does-not-exist' failed" in str(ctx.value) + + +@pytest.fixture +def windows_pathext(monkeypatch): + """ + Set PATHEXT as if on Windows. + """ + monkeypatch.setattr(platform, 'system', lambda: 'Windows') + typical_exts = [ + '.COM', + '.EXE', + '.BAT', + '.CMD', + '.VBS', + '.VBE', + '.JS', + '.JSE', + '.WSF', + '.WSH', + '.MSC', + '.PY', + '.PYW', + ] + monkeypatch.setenv('PATHEXT', os.pathsep.join(typical_exts))