From 3b5997c8144302bc7a27eb288b108388648597df Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 19 Apr 2024 14:43:47 +1000 Subject: [PATCH 1/8] Extend Windows system executable extensions --- distutils/spawn.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/distutils/spawn.py b/distutils/spawn.py index 046b5bbb..be64587f 100644 --- a/distutils/spawn.py +++ b/distutils/spawn.py @@ -75,8 +75,11 @@ def find_executable(executable, path=None): os.environ['PATH']. Returns the complete filename or None if not found. """ _, ext = os.path.splitext(executable) - if (sys.platform == 'win32') and (ext != '.exe'): - executable = executable + '.exe' + executable_candidates = [executable] + if (sys.platform == 'win32'): + exts = os.environ.get('PATHEXT').lower().split(os.pathsep) + if (ext not in exts): + executable_candidates = [executable + ext for ext in exts] if os.path.isfile(executable): return executable @@ -98,8 +101,9 @@ def find_executable(executable, path=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 + for executable_candidate in executable_candidates: + f = os.path.join(p, executable_candidate) + if os.path.isfile(f): + # the file exists, we have a shot at spawn working + return f return None From 4791d6f108cd9a8c4b86d22e3c7d4b6620d54b65 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 20 Apr 2024 15:04:40 -0400 Subject: [PATCH 2/8] Extract function for generating executable candidates. --- distutils/spawn.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/distutils/spawn.py b/distutils/spawn.py index be64587f..4156e610 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,24 @@ 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 _executable_candidates(executable: pathlib.Path): + """ + Given an executable, yields common executable variants. + """ + yield executable + if platform.system() != 'Windows': + return + exts = os.environ.get('PATHEXT').lower().split(os.pathsep) + for ext in filter(executable.suffix.__ne__, exts): + yield executable.with_suffix(ext) + + 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. """ - _, ext = os.path.splitext(executable) - executable_candidates = [executable] - if (sys.platform == 'win32'): - exts = os.environ.get('PATHEXT').lower().split(os.pathsep) - if (ext not in exts): - executable_candidates = [executable + ext for ext in exts] - if os.path.isfile(executable): return executable @@ -99,11 +106,7 @@ def find_executable(executable, path=None): if not path: return None - paths = path.split(os.pathsep) - for p in paths: - for executable_candidate in executable_candidates: - f = os.path.join(p, executable_candidate) - if os.path.isfile(f): - # the file exists, we have a shot at spawn working - return f + for p in map(pathlib.Path, path.split(os.pathsep)): + for exe in filter(pathlib.Path.is_file, _executable_candidates(p / executable)): + return os.fspath(exe) return None From 8ec17e14ecf7121fd9d22cf59b64643e97c24a40 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 20 Apr 2024 15:14:52 -0400 Subject: [PATCH 3/8] Extract function for searching the paths. --- distutils/spawn.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/distutils/spawn.py b/distutils/spawn.py index 4156e610..7a56e557 100644 --- a/distutils/spawn.py +++ b/distutils/spawn.py @@ -82,15 +82,7 @@ def _executable_candidates(executable: pathlib.Path): yield executable.with_suffix(ext) -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 - +def _search_paths(path): if path is None: path = os.environ.get('PATH', None) if path is None: @@ -104,9 +96,21 @@ def find_executable(executable, path=None): # PATH='' doesn't match, whereas PATH=':' looks in the current directory if not path: - 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 - for p in map(pathlib.Path, path.split(os.pathsep)): + for p in _search_paths(path): for exe in filter(pathlib.Path.is_file, _executable_candidates(p / executable)): return os.fspath(exe) return None From 7455b532f5e3579d98fafd35d90be809b714af7b Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 20 Apr 2024 15:20:39 -0400 Subject: [PATCH 4/8] Replace inner loop return with a generator and default. --- distutils/spawn.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/distutils/spawn.py b/distutils/spawn.py index 7a56e557..cdd2ec7c 100644 --- a/distutils/spawn.py +++ b/distutils/spawn.py @@ -110,7 +110,9 @@ def find_executable(executable, path=None): if os.path.isfile(executable): return executable - for p in _search_paths(path): - for exe in filter(pathlib.Path.is_file, _executable_candidates(p / executable)): - return os.fspath(exe) - return None + 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) From 7d2c1970f73ae90e69f80f09f982385a795d6b4b Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 20 Apr 2024 15:44:11 -0400 Subject: [PATCH 5/8] Extract _make_executable for TestSpawn. --- distutils/tests/test_spawn.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/distutils/tests/test_spawn.py b/distutils/tests/test_spawn.py index 7ec58626..1f623837 100644 --- a/distutils/tests/test_spawn.py +++ b/distutils/tests/test_spawn.py @@ -45,14 +45,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) @@ -121,6 +116,15 @@ def test_find_executable(self, tmp_path): rv = find_executable(program) assert rv == filename + @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']) From 75c63c8619ac9fa86795914153cda76a8067f497 Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 20 Apr 2024 15:58:33 -0400 Subject: [PATCH 6/8] Add a test for find_executable when alternate PATHEXTs are found on Windows. --- distutils/tests/test_spawn.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/distutils/tests/test_spawn.py b/distutils/tests/test_spawn.py index 1f623837..a9ce01ac 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 @@ -116,6 +118,15 @@ 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. @@ -129,3 +140,27 @@ 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)) From 8ebebb6a2b45bffdd33c2661dacad2e5b74d527b Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 20 Apr 2024 16:21:55 -0400 Subject: [PATCH 7/8] When generating executable candidates, retain the case of extensions in PATHEXT. Although Windows and Mac are (typically) case insensitive, Linux is case sensitive, so instead honor the case insensitivity, but retain the case so that on a case-sensitive platform, the logic holds. --- distutils/spawn.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/distutils/spawn.py b/distutils/spawn.py index cdd2ec7c..d0c72882 100644 --- a/distutils/spawn.py +++ b/distutils/spawn.py @@ -77,9 +77,9 @@ def _executable_candidates(executable: pathlib.Path): yield executable if platform.system() != 'Windows': return - exts = os.environ.get('PATHEXT').lower().split(os.pathsep) - for ext in filter(executable.suffix.__ne__, exts): - yield executable.with_suffix(ext) + 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) def _search_paths(path): From d12c13e9a820354fdfaaad71ca1f9720d8e5795f Mon Sep 17 00:00:00 2001 From: "Jason R. Coombs" Date: Sat, 20 Apr 2024 16:34:33 -0400 Subject: [PATCH 8/8] Adjust the test to pass if both values reference the same file. --- distutils/tests/test_spawn.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/distutils/tests/test_spawn.py b/distutils/tests/test_spawn.py index a9ce01ac..2d150e47 100644 --- a/distutils/tests/test_spawn.py +++ b/distutils/tests/test_spawn.py @@ -57,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: