gh-94399: Restore PATH search behaviour of py.exe launcher for '/usr/bin/env' shebang lines (GH-95582)

This commit is contained in:
Steve Dower 2022-08-03 22:18:51 +01:00 committed by GitHub
parent b53aed76d2
commit 67840edb28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 159 additions and 8 deletions

View File

@ -868,6 +868,11 @@ The ``/usr/bin/env`` form of shebang line has one further special property.
Before looking for installed Python interpreters, this form will search the Before looking for installed Python interpreters, this form will search the
executable :envvar:`PATH` for a Python executable. This corresponds to the executable :envvar:`PATH` for a Python executable. This corresponds to the
behaviour of the Unix ``env`` program, which performs a :envvar:`PATH` search. behaviour of the Unix ``env`` program, which performs a :envvar:`PATH` search.
If an executable matching the first argument after the ``env`` command cannot
be found, it will be handled as described below. Additionally, the environment
variable :envvar:`PYLAUNCHER_NO_SEARCH_PATH` may be set (to any value) to skip
this additional search.
Arguments in shebang lines Arguments in shebang lines
-------------------------- --------------------------

View File

@ -187,6 +187,11 @@ class RunPyMixin:
) )
return py_exe return py_exe
def get_py_exe(self):
if not self.py_exe:
self.py_exe = self.find_py()
return self.py_exe
def run_py(self, args, env=None, allow_fail=False, expect_returncode=0, argv=None): def run_py(self, args, env=None, allow_fail=False, expect_returncode=0, argv=None):
if not self.py_exe: if not self.py_exe:
self.py_exe = self.find_py() self.py_exe = self.find_py()
@ -194,9 +199,9 @@ class RunPyMixin:
ignore = {"VIRTUAL_ENV", "PY_PYTHON", "PY_PYTHON2", "PY_PYTHON3"} ignore = {"VIRTUAL_ENV", "PY_PYTHON", "PY_PYTHON2", "PY_PYTHON3"}
env = { env = {
**{k.upper(): v for k, v in os.environ.items() if k.upper() not in ignore}, **{k.upper(): v for k, v in os.environ.items() if k.upper() not in ignore},
**{k.upper(): v for k, v in (env or {}).items()},
"PYLAUNCHER_DEBUG": "1", "PYLAUNCHER_DEBUG": "1",
"PYLAUNCHER_DRYRUN": "1", "PYLAUNCHER_DRYRUN": "1",
**{k.upper(): v for k, v in (env or {}).items()},
} }
if not argv: if not argv:
argv = [self.py_exe, *args] argv = [self.py_exe, *args]
@ -496,7 +501,7 @@ class TestLauncher(unittest.TestCase, RunPyMixin):
def test_py_shebang(self): def test_py_shebang(self):
with self.py_ini(TEST_PY_COMMANDS): with self.py_ini(TEST_PY_COMMANDS):
with self.script("#! /usr/bin/env python -prearg") as script: with self.script("#! /usr/bin/python -prearg") as script:
data = self.run_py([script, "-postarg"]) data = self.run_py([script, "-postarg"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100", data["SearchInfo.tag"]) self.assertEqual("3.100", data["SearchInfo.tag"])
@ -504,7 +509,7 @@ class TestLauncher(unittest.TestCase, RunPyMixin):
def test_py2_shebang(self): def test_py2_shebang(self):
with self.py_ini(TEST_PY_COMMANDS): with self.py_ini(TEST_PY_COMMANDS):
with self.script("#! /usr/bin/env python2 -prearg") as script: with self.script("#! /usr/bin/python2 -prearg") as script:
data = self.run_py([script, "-postarg"]) data = self.run_py([script, "-postarg"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100-32", data["SearchInfo.tag"]) self.assertEqual("3.100-32", data["SearchInfo.tag"])
@ -512,7 +517,7 @@ class TestLauncher(unittest.TestCase, RunPyMixin):
def test_py3_shebang(self): def test_py3_shebang(self):
with self.py_ini(TEST_PY_COMMANDS): with self.py_ini(TEST_PY_COMMANDS):
with self.script("#! /usr/bin/env python3 -prearg") as script: with self.script("#! /usr/bin/python3 -prearg") as script:
data = self.run_py([script, "-postarg"]) data = self.run_py([script, "-postarg"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100-arm64", data["SearchInfo.tag"]) self.assertEqual("3.100-arm64", data["SearchInfo.tag"])
@ -520,7 +525,7 @@ class TestLauncher(unittest.TestCase, RunPyMixin):
def test_py_shebang_nl(self): def test_py_shebang_nl(self):
with self.py_ini(TEST_PY_COMMANDS): with self.py_ini(TEST_PY_COMMANDS):
with self.script("#! /usr/bin/env python -prearg\n") as script: with self.script("#! /usr/bin/python -prearg\n") as script:
data = self.run_py([script, "-postarg"]) data = self.run_py([script, "-postarg"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100", data["SearchInfo.tag"]) self.assertEqual("3.100", data["SearchInfo.tag"])
@ -528,7 +533,7 @@ class TestLauncher(unittest.TestCase, RunPyMixin):
def test_py2_shebang_nl(self): def test_py2_shebang_nl(self):
with self.py_ini(TEST_PY_COMMANDS): with self.py_ini(TEST_PY_COMMANDS):
with self.script("#! /usr/bin/env python2 -prearg\n") as script: with self.script("#! /usr/bin/python2 -prearg\n") as script:
data = self.run_py([script, "-postarg"]) data = self.run_py([script, "-postarg"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100-32", data["SearchInfo.tag"]) self.assertEqual("3.100-32", data["SearchInfo.tag"])
@ -536,7 +541,7 @@ class TestLauncher(unittest.TestCase, RunPyMixin):
def test_py3_shebang_nl(self): def test_py3_shebang_nl(self):
with self.py_ini(TEST_PY_COMMANDS): with self.py_ini(TEST_PY_COMMANDS):
with self.script("#! /usr/bin/env python3 -prearg\n") as script: with self.script("#! /usr/bin/python3 -prearg\n") as script:
data = self.run_py([script, "-postarg"]) data = self.run_py([script, "-postarg"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100-arm64", data["SearchInfo.tag"]) self.assertEqual("3.100-arm64", data["SearchInfo.tag"])
@ -544,13 +549,45 @@ class TestLauncher(unittest.TestCase, RunPyMixin):
def test_py_shebang_short_argv0(self): def test_py_shebang_short_argv0(self):
with self.py_ini(TEST_PY_COMMANDS): with self.py_ini(TEST_PY_COMMANDS):
with self.script("#! /usr/bin/env python -prearg") as script: with self.script("#! /usr/bin/python -prearg") as script:
# Override argv to only pass "py.exe" as the command # Override argv to only pass "py.exe" as the command
data = self.run_py([script, "-postarg"], argv=f'"py.exe" "{script}" -postarg') data = self.run_py([script, "-postarg"], argv=f'"py.exe" "{script}" -postarg')
self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100", data["SearchInfo.tag"]) self.assertEqual("3.100", data["SearchInfo.tag"])
self.assertEqual(f'X.Y.exe -prearg "{script}" -postarg', data["stdout"].strip()) self.assertEqual(f'X.Y.exe -prearg "{script}" -postarg', data["stdout"].strip())
def test_search_path(self):
stem = Path(sys.executable).stem
with self.py_ini(TEST_PY_COMMANDS):
with self.script(f"#! /usr/bin/env {stem} -prearg") as script:
data = self.run_py(
[script, "-postarg"],
env={"PATH": f"{Path(sys.executable).parent};{os.getenv('PATH')}"},
)
self.assertEqual(f"{sys.executable} -prearg {script} -postarg", data["stdout"].strip())
def test_search_path_exe(self):
# Leave the .exe on the name to ensure we don't add it a second time
name = Path(sys.executable).name
with self.py_ini(TEST_PY_COMMANDS):
with self.script(f"#! /usr/bin/env {name} -prearg") as script:
data = self.run_py(
[script, "-postarg"],
env={"PATH": f"{Path(sys.executable).parent};{os.getenv('PATH')}"},
)
self.assertEqual(f"{sys.executable} -prearg {script} -postarg", data["stdout"].strip())
def test_recursive_search_path(self):
stem = self.get_py_exe().stem
with self.py_ini(TEST_PY_COMMANDS):
with self.script(f"#! /usr/bin/env {stem}") as script:
data = self.run_py(
[script],
env={"PATH": f"{self.get_py_exe().parent};{os.getenv('PATH')}"},
)
# The recursive search is ignored and we get normal "py" behavior
self.assertEqual(f"X.Y.exe {script}", data["stdout"].strip())
def test_install(self): def test_install(self):
data = self.run_py(["-V:3.10"], env={"PYLAUNCHER_ALWAYS_INSTALL": "1"}, expect_returncode=111) data = self.run_py(["-V:3.10"], env={"PYLAUNCHER_ALWAYS_INSTALL": "1"}, expect_returncode=111)
cmd = data["stdout"].strip() cmd = data["stdout"].strip()

View File

@ -0,0 +1,3 @@
Restores the behaviour of :ref:`launcher` for ``/usr/bin/env`` shebang
lines, which will now search :envvar:`PATH` for an executable matching the
given command. If none is found, the usual search process is used.

View File

@ -36,6 +36,7 @@
#define RC_DUPLICATE_ITEM 110 #define RC_DUPLICATE_ITEM 110
#define RC_INSTALLING 111 #define RC_INSTALLING 111
#define RC_NO_PYTHON_AT_ALL 112 #define RC_NO_PYTHON_AT_ALL 112
#define RC_NO_SHEBANG 113
static FILE * log_fp = NULL; static FILE * log_fp = NULL;
@ -750,6 +751,88 @@ _shebangStartsWith(const wchar_t *buffer, int bufferLength, const wchar_t *prefi
} }
int
searchPath(SearchInfo *search, const wchar_t *shebang, int shebangLength)
{
if (isEnvVarSet(L"PYLAUNCHER_NO_SEARCH_PATH")) {
return RC_NO_SHEBANG;
}
wchar_t *command;
if (!_shebangStartsWith(shebang, shebangLength, L"/usr/bin/env ", &command)) {
return RC_NO_SHEBANG;
}
wchar_t filename[MAXLEN];
int lastDot = 0;
int commandLength = 0;
while (commandLength < MAXLEN && command[commandLength] && !isspace(command[commandLength])) {
if (command[commandLength] == L'.') {
lastDot = commandLength;
}
filename[commandLength] = command[commandLength];
commandLength += 1;
}
if (!commandLength || commandLength == MAXLEN) {
return RC_BAD_VIRTUAL_PATH;
}
filename[commandLength] = L'\0';
const wchar_t *ext = L".exe";
// If the command already has an extension, we do not want to add it again
if (!lastDot || _comparePath(&filename[lastDot], -1, ext, -1)) {
if (wcscat_s(filename, MAXLEN, L".exe")) {
return RC_BAD_VIRTUAL_PATH;
}
}
wchar_t pathVariable[MAXLEN];
int n = GetEnvironmentVariableW(L"PATH", pathVariable, MAXLEN);
if (!n) {
if (GetLastError() == ERROR_ENVVAR_NOT_FOUND) {
return RC_NO_SHEBANG;
}
winerror(0, L"Failed to read PATH\n", filename);
return RC_INTERNAL_ERROR;
}
wchar_t buffer[MAXLEN];
n = SearchPathW(pathVariable, filename, NULL, MAXLEN, buffer, NULL);
if (!n) {
if (GetLastError() == ERROR_FILE_NOT_FOUND) {
debug(L"# Did not find %s on PATH\n", filename);
// If we didn't find it on PATH, let normal handling take over
return RC_NO_SHEBANG;
}
// Other errors should cause us to break
winerror(0, L"Failed to find %s on PATH\n", filename);
return RC_BAD_VIRTUAL_PATH;
}
// Check that we aren't going to call ourselves again
// If we are, pretend there was no shebang and let normal handling take over
if (GetModuleFileNameW(NULL, filename, MAXLEN) &&
0 == _comparePath(filename, -1, buffer, -1)) {
debug(L"# ignoring recursive shebang command\n");
return RC_NO_SHEBANG;
}
wchar_t *buf = allocSearchInfoBuffer(search, n + 1);
if (!buf || wcscpy_s(buf, n + 1, buffer)) {
return RC_NO_MEMORY;
}
search->executablePath = buf;
search->executableArgs = &command[commandLength];
search->executableArgsLength = shebangLength - commandLength;
debug(L"# Found %s on PATH\n", buf);
return 0;
}
int int
_readIni(const wchar_t *section, const wchar_t *settingName, wchar_t *buffer, int bufferLength) _readIni(const wchar_t *section, const wchar_t *settingName, wchar_t *buffer, int bufferLength)
{ {
@ -885,6 +968,12 @@ checkShebang(SearchInfo *search)
} }
debug(L"Shebang: %s\n", shebang); debug(L"Shebang: %s\n", shebang);
// Handle shebangs that we should search PATH for
exitCode = searchPath(search, shebang, shebangLength);
if (exitCode != RC_NO_SHEBANG) {
return exitCode;
}
// Handle some known, case-sensitive shebang templates // Handle some known, case-sensitive shebang templates
const wchar_t *command; const wchar_t *command;
int commandLength; int commandLength;
@ -895,6 +984,7 @@ checkShebang(SearchInfo *search)
L"", L"",
NULL NULL
}; };
for (const wchar_t **tmpl = shebangTemplates; *tmpl; ++tmpl) { for (const wchar_t **tmpl = shebangTemplates; *tmpl; ++tmpl) {
if (_shebangStartsWith(shebang, shebangLength, *tmpl, &command)) { if (_shebangStartsWith(shebang, shebangLength, *tmpl, &command)) {
commandLength = 0; commandLength = 0;
@ -910,6 +1000,22 @@ checkShebang(SearchInfo *search)
} else if (_shebangStartsWith(command, commandLength, L"python", NULL)) { } else if (_shebangStartsWith(command, commandLength, L"python", NULL)) {
search->tag = &command[6]; search->tag = &command[6];
search->tagLength = commandLength - 6; search->tagLength = commandLength - 6;
// If we had 'python3.12.exe' then we want to strip the suffix
// off of the tag
if (search->tagLength > 4) {
const wchar_t *suffix = &search->tag[search->tagLength - 4];
if (0 == _comparePath(suffix, 4, L".exe", -1)) {
search->tagLength -= 4;
}
}
// If we had 'python3_d' then we want to strip the '_d' (any
// '.exe' is already gone)
if (search->tagLength > 2) {
const wchar_t *suffix = &search->tag[search->tagLength - 2];
if (0 == _comparePath(suffix, 2, L"_d", -1)) {
search->tagLength -= 2;
}
}
search->oldStyleTag = true; search->oldStyleTag = true;
search->executableArgs = &command[commandLength]; search->executableArgs = &command[commandLength];
search->executableArgsLength = shebangLength - commandLength; search->executableArgsLength = shebangLength - commandLength;