Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Doc/using/windows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
executable :envvar:`PATH` for a Python executable. This corresponds to the
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
--------------------------
Expand Down
53 changes: 45 additions & 8 deletions Lib/test/test_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,16 +187,21 @@ def find_py(cls):
)
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):
if not self.py_exe:
self.py_exe = self.find_py()

ignore = {"VIRTUAL_ENV", "PY_PYTHON", "PY_PYTHON2", "PY_PYTHON3"}
env = {
**{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_DRYRUN": "1",
**{k.upper(): v for k, v in (env or {}).items()},
}
if not argv:
argv = [self.py_exe, *args]
Expand Down Expand Up @@ -496,61 +501,93 @@ def test_virtualenv_with_env(self):

def test_py_shebang(self):
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"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100", data["SearchInfo.tag"])
self.assertEqual(f"X.Y.exe -prearg {script} -postarg", data["stdout"].strip())

def test_py2_shebang(self):
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"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100-32", data["SearchInfo.tag"])
self.assertEqual(f"X.Y-32.exe -prearg {script} -postarg", data["stdout"].strip())

def test_py3_shebang(self):
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"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100-arm64", data["SearchInfo.tag"])
self.assertEqual(f"X.Y-arm64.exe -X fake_arg_for_test -prearg {script} -postarg", data["stdout"].strip())

def test_py_shebang_nl(self):
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"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100", data["SearchInfo.tag"])
self.assertEqual(f"X.Y.exe -prearg {script} -postarg", data["stdout"].strip())

def test_py2_shebang_nl(self):
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"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100-32", data["SearchInfo.tag"])
self.assertEqual(f"X.Y-32.exe -prearg {script} -postarg", data["stdout"].strip())

def test_py3_shebang_nl(self):
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"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100-arm64", data["SearchInfo.tag"])
self.assertEqual(f"X.Y-arm64.exe -X fake_arg_for_test -prearg {script} -postarg", data["stdout"].strip())

def test_py_shebang_short_argv0(self):
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
data = self.run_py([script, "-postarg"], argv=f'"py.exe" "{script}" -postarg')
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100", data["SearchInfo.tag"])
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):
data = self.run_py(["-V:3.10"], env={"PYLAUNCHER_ALWAYS_INSTALL": "1"}, expect_returncode=111)
cmd = data["stdout"].strip()
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
106 changes: 106 additions & 0 deletions PC/launcher2.c
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
#define RC_DUPLICATE_ITEM 110
#define RC_INSTALLING 111
#define RC_NO_PYTHON_AT_ALL 112
#define RC_NO_SHEBANG 113

static FILE * log_fp = NULL;

Expand Down Expand Up @@ -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
_readIni(const wchar_t *section, const wchar_t *settingName, wchar_t *buffer, int bufferLength)
{
Expand Down Expand Up @@ -885,6 +968,12 @@ checkShebang(SearchInfo *search)
}
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
const wchar_t *command;
int commandLength;
Expand All @@ -895,6 +984,7 @@ checkShebang(SearchInfo *search)
L"",
NULL
};

for (const wchar_t **tmpl = shebangTemplates; *tmpl; ++tmpl) {
if (_shebangStartsWith(shebang, shebangLength, *tmpl, &command)) {
commandLength = 0;
Expand All @@ -910,6 +1000,22 @@ checkShebang(SearchInfo *search)
} else if (_shebangStartsWith(command, commandLength, L"python", NULL)) {
search->tag = &command[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->executableArgs = &command[commandLength];
search->executableArgsLength = shebangLength - commandLength;
Expand Down