Skip to content

Commit 6492492

Browse files
gh-100247: Fix py.exe launcher not using entire shebang command for finding custom commands (GH-100944)
(cherry picked from commit 468c3bf) Co-authored-by: Steve Dower <[email protected]>
1 parent 2834fdc commit 6492492

File tree

4 files changed

+154
-94
lines changed

4 files changed

+154
-94
lines changed

Doc/using/windows.rst

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -818,7 +818,7 @@ To allow shebang lines in Python scripts to be portable between Unix and
818818
Windows, this launcher supports a number of 'virtual' commands to specify
819819
which interpreter to use. The supported virtual commands are:
820820

821-
* ``/usr/bin/env python``
821+
* ``/usr/bin/env``
822822
* ``/usr/bin/python``
823823
* ``/usr/local/bin/python``
824824
* ``python``
@@ -855,14 +855,28 @@ minor version. I.e. ``/usr/bin/python3.7-32`` will request usage of the
855855

856856
The ``/usr/bin/env`` form of shebang line has one further special property.
857857
Before looking for installed Python interpreters, this form will search the
858-
executable :envvar:`PATH` for a Python executable. This corresponds to the
859-
behaviour of the Unix ``env`` program, which performs a :envvar:`PATH` search.
858+
executable :envvar:`PATH` for a Python executable matching the name provided
859+
as the first argument. This corresponds to the behaviour of the Unix ``env``
860+
program, which performs a :envvar:`PATH` search.
860861
If an executable matching the first argument after the ``env`` command cannot
861-
be found, it will be handled as described below. Additionally, the environment
862-
variable :envvar:`PYLAUNCHER_NO_SEARCH_PATH` may be set (to any value) to skip
863-
this additional search.
862+
be found, but the argument starts with ``python``, it will be handled as
863+
described for the other virtual commands.
864+
The environment variable :envvar:`PYLAUNCHER_NO_SEARCH_PATH` may be set
865+
(to any value) to skip this search of :envvar:`PATH`.
866+
867+
Shebang lines that do not match any of these patterns are looked up in the
868+
``[commands]`` section of the launcher's :ref:`.INI file <launcher-ini>`.
869+
This may be used to handle certain commands in a way that makes sense for your
870+
system. The name of the command must be a single argument (no spaces),
871+
and the value substituted is the full path to the executable (no arguments
872+
may be added).
864873

865-
Shebang lines that do not match any of these patterns are treated as **Windows**
874+
.. code-block:: ini
875+
876+
[commands]
877+
/bin/sh=C:\Program Files\Bash\bash.exe
878+
879+
Any commands not found in the .INI file are treated as **Windows** executable
866880
paths that are absolute or relative to the directory containing the script file.
867881
This is a convenience for Windows-only scripts, such as those generated by an
868882
installer, since the behavior is not compatible with Unix-style shells.
@@ -885,15 +899,16 @@ Then Python will be started with the ``-v`` option
885899
Customization
886900
-------------
887901

902+
.. _launcher-ini:
903+
888904
Customization via INI files
889905
^^^^^^^^^^^^^^^^^^^^^^^^^^^
890906

891907
Two .ini files will be searched by the launcher - ``py.ini`` in the current
892-
user's "application data" directory (i.e. the directory returned by calling the
893-
Windows function ``SHGetFolderPath`` with ``CSIDL_LOCAL_APPDATA``) and ``py.ini`` in the
894-
same directory as the launcher. The same .ini files are used for both the
895-
'console' version of the launcher (i.e. py.exe) and for the 'windows' version
896-
(i.e. pyw.exe).
908+
user's application data directory (``%LOCALAPPDATA%`` or ``$env:LocalAppData``)
909+
and ``py.ini`` in the same directory as the launcher. The same .ini files are
910+
used for both the 'console' version of the launcher (i.e. py.exe) and for the
911+
'windows' version (i.e. pyw.exe).
897912

898913
Customization specified in the "application directory" will have precedence over
899914
the one next to the executable, so a user, who may not have write access to the

Lib/test/test_launcher.py

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,17 @@
6868
)
6969

7070

71-
TEST_PY_COMMANDS = "\n".join([
71+
TEST_PY_DEFAULTS = "\n".join([
7272
"[defaults]",
73-
*[f"{k[3:].lower()}={v}" for k, v in TEST_PY_ENV.items()]
73+
*[f"{k[3:].lower()}={v}" for k, v in TEST_PY_ENV.items()],
7474
])
7575

7676

77+
TEST_PY_COMMANDS = "\n".join([
78+
"[commands]",
79+
"test-command=TEST_EXE.exe",
80+
])
81+
7782
def create_registry_data(root, data):
7883
def _create_registry_data(root, key, value):
7984
if isinstance(value, dict):
@@ -430,21 +435,21 @@ def test_search_major_2(self):
430435
self.assertTrue(data["env.tag"].startswith("2."), data["env.tag"])
431436

432437
def test_py_default(self):
433-
with self.py_ini(TEST_PY_COMMANDS):
438+
with self.py_ini(TEST_PY_DEFAULTS):
434439
data = self.run_py(["-arg"])
435440
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
436441
self.assertEqual("3.100", data["SearchInfo.tag"])
437442
self.assertEqual("X.Y.exe -arg", data["stdout"].strip())
438443

439444
def test_py2_default(self):
440-
with self.py_ini(TEST_PY_COMMANDS):
445+
with self.py_ini(TEST_PY_DEFAULTS):
441446
data = self.run_py(["-2", "-arg"])
442447
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
443448
self.assertEqual("3.100-32", data["SearchInfo.tag"])
444449
self.assertEqual("X.Y-32.exe -arg", data["stdout"].strip())
445450

446451
def test_py3_default(self):
447-
with self.py_ini(TEST_PY_COMMANDS):
452+
with self.py_ini(TEST_PY_DEFAULTS):
448453
data = self.run_py(["-3", "-arg"])
449454
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
450455
self.assertEqual("3.100-arm64", data["SearchInfo.tag"])
@@ -469,7 +474,7 @@ def test_py3_default_env(self):
469474
self.assertEqual("X.Y-arm64.exe -X fake_arg_for_test -arg", data["stdout"].strip())
470475

471476
def test_py_default_short_argv0(self):
472-
with self.py_ini(TEST_PY_COMMANDS):
477+
with self.py_ini(TEST_PY_DEFAULTS):
473478
for argv0 in ['"py.exe"', 'py.exe', '"py"', 'py']:
474479
with self.subTest(argv0):
475480
data = self.run_py(["--version"], argv=f'{argv0} --version')
@@ -519,63 +524,63 @@ def test_virtualenv_with_env(self):
519524
self.assertNotEqual(data2["SearchInfo.lowPriorityTag"], "True")
520525

521526
def test_py_shebang(self):
522-
with self.py_ini(TEST_PY_COMMANDS):
527+
with self.py_ini(TEST_PY_DEFAULTS):
523528
with self.script("#! /usr/bin/python -prearg") as script:
524529
data = self.run_py([script, "-postarg"])
525530
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
526531
self.assertEqual("3.100", data["SearchInfo.tag"])
527532
self.assertEqual(f"X.Y.exe -prearg {script} -postarg", data["stdout"].strip())
528533

529534
def test_python_shebang(self):
530-
with self.py_ini(TEST_PY_COMMANDS):
535+
with self.py_ini(TEST_PY_DEFAULTS):
531536
with self.script("#! python -prearg") as script:
532537
data = self.run_py([script, "-postarg"])
533538
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
534539
self.assertEqual("3.100", data["SearchInfo.tag"])
535540
self.assertEqual(f"X.Y.exe -prearg {script} -postarg", data["stdout"].strip())
536541

537542
def test_py2_shebang(self):
538-
with self.py_ini(TEST_PY_COMMANDS):
543+
with self.py_ini(TEST_PY_DEFAULTS):
539544
with self.script("#! /usr/bin/python2 -prearg") as script:
540545
data = self.run_py([script, "-postarg"])
541546
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
542547
self.assertEqual("3.100-32", data["SearchInfo.tag"])
543548
self.assertEqual(f"X.Y-32.exe -prearg {script} -postarg", data["stdout"].strip())
544549

545550
def test_py3_shebang(self):
546-
with self.py_ini(TEST_PY_COMMANDS):
551+
with self.py_ini(TEST_PY_DEFAULTS):
547552
with self.script("#! /usr/bin/python3 -prearg") as script:
548553
data = self.run_py([script, "-postarg"])
549554
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
550555
self.assertEqual("3.100-arm64", data["SearchInfo.tag"])
551556
self.assertEqual(f"X.Y-arm64.exe -X fake_arg_for_test -prearg {script} -postarg", data["stdout"].strip())
552557

553558
def test_py_shebang_nl(self):
554-
with self.py_ini(TEST_PY_COMMANDS):
559+
with self.py_ini(TEST_PY_DEFAULTS):
555560
with self.script("#! /usr/bin/python -prearg\n") as script:
556561
data = self.run_py([script, "-postarg"])
557562
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
558563
self.assertEqual("3.100", data["SearchInfo.tag"])
559564
self.assertEqual(f"X.Y.exe -prearg {script} -postarg", data["stdout"].strip())
560565

561566
def test_py2_shebang_nl(self):
562-
with self.py_ini(TEST_PY_COMMANDS):
567+
with self.py_ini(TEST_PY_DEFAULTS):
563568
with self.script("#! /usr/bin/python2 -prearg\n") as script:
564569
data = self.run_py([script, "-postarg"])
565570
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
566571
self.assertEqual("3.100-32", data["SearchInfo.tag"])
567572
self.assertEqual(f"X.Y-32.exe -prearg {script} -postarg", data["stdout"].strip())
568573

569574
def test_py3_shebang_nl(self):
570-
with self.py_ini(TEST_PY_COMMANDS):
575+
with self.py_ini(TEST_PY_DEFAULTS):
571576
with self.script("#! /usr/bin/python3 -prearg\n") as script:
572577
data = self.run_py([script, "-postarg"])
573578
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
574579
self.assertEqual("3.100-arm64", data["SearchInfo.tag"])
575580
self.assertEqual(f"X.Y-arm64.exe -X fake_arg_for_test -prearg {script} -postarg", data["stdout"].strip())
576581

577582
def test_py_shebang_short_argv0(self):
578-
with self.py_ini(TEST_PY_COMMANDS):
583+
with self.py_ini(TEST_PY_DEFAULTS):
579584
with self.script("#! /usr/bin/python -prearg") as script:
580585
# Override argv to only pass "py.exe" as the command
581586
data = self.run_py([script, "-postarg"], argv=f'"py.exe" "{script}" -postarg')
@@ -592,7 +597,7 @@ def test_py_handle_64_in_ini(self):
592597

593598
def test_search_path(self):
594599
stem = Path(sys.executable).stem
595-
with self.py_ini(TEST_PY_COMMANDS):
600+
with self.py_ini(TEST_PY_DEFAULTS):
596601
with self.script(f"#! /usr/bin/env {stem} -prearg") as script:
597602
data = self.run_py(
598603
[script, "-postarg"],
@@ -603,7 +608,7 @@ def test_search_path(self):
603608
def test_search_path_exe(self):
604609
# Leave the .exe on the name to ensure we don't add it a second time
605610
name = Path(sys.executable).name
606-
with self.py_ini(TEST_PY_COMMANDS):
611+
with self.py_ini(TEST_PY_DEFAULTS):
607612
with self.script(f"#! /usr/bin/env {name} -prearg") as script:
608613
data = self.run_py(
609614
[script, "-postarg"],
@@ -613,7 +618,7 @@ def test_search_path_exe(self):
613618

614619
def test_recursive_search_path(self):
615620
stem = self.get_py_exe().stem
616-
with self.py_ini(TEST_PY_COMMANDS):
621+
with self.py_ini(TEST_PY_DEFAULTS):
617622
with self.script(f"#! /usr/bin/env {stem}") as script:
618623
data = self.run_py(
619624
[script],
@@ -674,3 +679,21 @@ def test_literal_shebang_quoted_escape(self):
674679
f'"{script.parent}\\some\\ random app" -witharg {script}',
675680
data["stdout"].strip(),
676681
)
682+
683+
def test_literal_shebang_command(self):
684+
with self.py_ini(TEST_PY_COMMANDS):
685+
with self.script('#! test-command arg1') as script:
686+
data = self.run_py([script])
687+
self.assertEqual(
688+
f"TEST_EXE.exe arg1 {script}",
689+
data["stdout"].strip(),
690+
)
691+
692+
def test_literal_shebang_invalid_template(self):
693+
with self.script('#! /usr/bin/not-python arg1') as script:
694+
data = self.run_py([script])
695+
expect = script.parent / "/usr/bin/not-python"
696+
self.assertEqual(
697+
f"{expect} arg1 {script}",
698+
data["stdout"].strip(),
699+
)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Restores support for the :file:`py.exe` launcher finding shebang commands in
2+
its configuration file using the full command name.

0 commit comments

Comments
 (0)