Skip to content

Commit 88297e2

Browse files
authored
gh-98692: Enable treating shebang lines as executables in py.exe launcher (GH-98732)
1 parent 4702552 commit 88297e2

File tree

4 files changed

+124
-4
lines changed

4 files changed

+124
-4
lines changed

Doc/using/windows.rst

+7-1
Original file line numberDiff line numberDiff line change
@@ -866,7 +866,6 @@ minor version. I.e. ``/usr/bin/python3.7-32`` will request usage of the
866866
not provably i386/32-bit". To request a specific environment, use the new
867867
``-V:<TAG>`` argument with the complete tag.
868868

869-
870869
The ``/usr/bin/env`` form of shebang line has one further special property.
871870
Before looking for installed Python interpreters, this form will search the
872871
executable :envvar:`PATH` for a Python executable. This corresponds to the
@@ -876,6 +875,13 @@ be found, it will be handled as described below. Additionally, the environment
876875
variable :envvar:`PYLAUNCHER_NO_SEARCH_PATH` may be set (to any value) to skip
877876
this additional search.
878877

878+
Shebang lines that do not match any of these patterns are treated as **Windows**
879+
paths that are absolute or relative to the directory containing the script file.
880+
This is a convenience for Windows-only scripts, such as those generated by an
881+
installer, since the behavior is not compatible with Unix-style shells.
882+
These paths may be quoted, and may include multiple arguments, after which the
883+
path to the script and any additional arguments will be appended.
884+
879885

880886
Arguments in shebang lines
881887
--------------------------

Lib/test/test_launcher.py

+47
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,14 @@ def test_py_shebang(self):
516516
self.assertEqual("3.100", data["SearchInfo.tag"])
517517
self.assertEqual(f"X.Y.exe -prearg {script} -postarg", data["stdout"].strip())
518518

519+
def test_python_shebang(self):
520+
with self.py_ini(TEST_PY_COMMANDS):
521+
with self.script("#! python -prearg") as script:
522+
data = self.run_py([script, "-postarg"])
523+
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
524+
self.assertEqual("3.100", data["SearchInfo.tag"])
525+
self.assertEqual(f"X.Y.exe -prearg {script} -postarg", data["stdout"].strip())
526+
519527
def test_py2_shebang(self):
520528
with self.py_ini(TEST_PY_COMMANDS):
521529
with self.script("#! /usr/bin/python2 -prearg") as script:
@@ -617,3 +625,42 @@ def test_install(self):
617625
self.assertIn("winget.exe", cmd)
618626
# Both command lines include the store ID
619627
self.assertIn("9PJPW5LDXLZ5", cmd)
628+
629+
def test_literal_shebang_absolute(self):
630+
with self.script(f"#! C:/some_random_app -witharg") as script:
631+
data = self.run_py([script])
632+
self.assertEqual(
633+
f"C:\\some_random_app -witharg {script}",
634+
data["stdout"].strip(),
635+
)
636+
637+
def test_literal_shebang_relative(self):
638+
with self.script(f"#! ..\\some_random_app -witharg") as script:
639+
data = self.run_py([script])
640+
self.assertEqual(
641+
f"{script.parent.parent}\\some_random_app -witharg {script}",
642+
data["stdout"].strip(),
643+
)
644+
645+
def test_literal_shebang_quoted(self):
646+
with self.script(f'#! "some random app" -witharg') as script:
647+
data = self.run_py([script])
648+
self.assertEqual(
649+
f'"{script.parent}\\some random app" -witharg {script}',
650+
data["stdout"].strip(),
651+
)
652+
653+
with self.script(f'#! some" random "app -witharg') as script:
654+
data = self.run_py([script])
655+
self.assertEqual(
656+
f'"{script.parent}\\some random app" -witharg {script}',
657+
data["stdout"].strip(),
658+
)
659+
660+
def test_literal_shebang_quoted_escape(self):
661+
with self.script(f'#! some\\" random "app -witharg') as script:
662+
data = self.run_py([script])
663+
self.assertEqual(
664+
f'"{script.parent}\\some\\ random app" -witharg {script}',
665+
data["stdout"].strip(),
666+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix the :ref:`launcher` ignoring unrecognized shebang lines instead of
2+
treating them as local paths

PC/launcher2.c

+68-3
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,62 @@ _findCommand(SearchInfo *search, const wchar_t *command, int commandLength)
871871
}
872872

873873

874+
int
875+
_useShebangAsExecutable(SearchInfo *search, const wchar_t *shebang, int shebangLength)
876+
{
877+
wchar_t buffer[MAXLEN];
878+
wchar_t script[MAXLEN];
879+
wchar_t command[MAXLEN];
880+
881+
int commandLength = 0;
882+
int inQuote = 0;
883+
884+
if (!shebang || !shebangLength) {
885+
return 0;
886+
}
887+
888+
wchar_t *pC = command;
889+
for (int i = 0; i < shebangLength; ++i) {
890+
wchar_t c = shebang[i];
891+
if (isspace(c) && !inQuote) {
892+
commandLength = i;
893+
break;
894+
} else if (c == L'"') {
895+
inQuote = !inQuote;
896+
} else if (c == L'/' || c == L'\\') {
897+
*pC++ = L'\\';
898+
} else {
899+
*pC++ = c;
900+
}
901+
}
902+
*pC = L'\0';
903+
904+
if (!GetCurrentDirectoryW(MAXLEN, buffer) ||
905+
wcsncpy_s(script, MAXLEN, search->scriptFile, search->scriptFileLength) ||
906+
FAILED(PathCchCombineEx(buffer, MAXLEN, buffer, script,
907+
PATHCCH_ALLOW_LONG_PATHS)) ||
908+
FAILED(PathCchRemoveFileSpec(buffer, MAXLEN)) ||
909+
FAILED(PathCchCombineEx(buffer, MAXLEN, buffer, command,
910+
PATHCCH_ALLOW_LONG_PATHS))
911+
) {
912+
return RC_NO_MEMORY;
913+
}
914+
915+
int n = (int)wcsnlen(buffer, MAXLEN);
916+
wchar_t *path = allocSearchInfoBuffer(search, n + 1);
917+
if (!path) {
918+
return RC_NO_MEMORY;
919+
}
920+
wcscpy_s(path, n + 1, buffer);
921+
search->executablePath = path;
922+
if (commandLength) {
923+
search->executableArgs = &shebang[commandLength];
924+
search->executableArgsLength = shebangLength - commandLength;
925+
}
926+
return 0;
927+
}
928+
929+
874930
int
875931
checkShebang(SearchInfo *search)
876932
{
@@ -963,13 +1019,19 @@ checkShebang(SearchInfo *search)
9631019
L"/usr/bin/env ",
9641020
L"/usr/bin/",
9651021
L"/usr/local/bin/",
966-
L"",
1022+
L"python",
9671023
NULL
9681024
};
9691025

9701026
for (const wchar_t **tmpl = shebangTemplates; *tmpl; ++tmpl) {
9711027
if (_shebangStartsWith(shebang, shebangLength, *tmpl, &command)) {
9721028
commandLength = 0;
1029+
// Normally "python" is the start of the command, but we also need it
1030+
// as a shebang prefix for back-compat. We move the command marker back
1031+
// if we match on that one.
1032+
if (0 == wcscmp(*tmpl, L"python")) {
1033+
command -= 6;
1034+
}
9731035
while (command[commandLength] && !isspace(command[commandLength])) {
9741036
commandLength += 1;
9751037
}
@@ -1012,11 +1074,14 @@ checkShebang(SearchInfo *search)
10121074
debug(L"# Found shebang command but could not execute it: %.*s\n",
10131075
commandLength, command);
10141076
}
1015-
break;
1077+
// search is done by this point
1078+
return 0;
10161079
}
10171080
}
10181081

1019-
return 0;
1082+
// Unrecognised commands are joined to the script's directory and treated
1083+
// as the executable path
1084+
return _useShebangAsExecutable(search, shebang, shebangLength);
10201085
}
10211086

10221087

0 commit comments

Comments
 (0)