Skip to content

Commit

Permalink
pythongh-100247: Fix py.exe launcher not using entire shebang command…
Browse files Browse the repository at this point in the history
… for finding custom commands (pythonGH-100944)
  • Loading branch information
zooba authored Jan 13, 2023
1 parent b5d4347 commit 468c3bf
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 94 deletions.
39 changes: 27 additions & 12 deletions Doc/using/windows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -831,7 +831,7 @@ To allow shebang lines in Python scripts to be portable between Unix and
Windows, this launcher supports a number of 'virtual' commands to specify
which interpreter to use. The supported virtual commands are:

* ``/usr/bin/env python``
* ``/usr/bin/env``
* ``/usr/bin/python``
* ``/usr/local/bin/python``
* ``python``
Expand Down Expand Up @@ -868,14 +868,28 @@ minor version. I.e. ``/usr/bin/python3.7-32`` will request usage of the

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.
executable :envvar:`PATH` for a Python executable matching the name provided
as the first argument. 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.
be found, but the argument starts with ``python``, it will be handled as
described for the other virtual commands.
The environment variable :envvar:`PYLAUNCHER_NO_SEARCH_PATH` may be set
(to any value) to skip this search of :envvar:`PATH`.

Shebang lines that do not match any of these patterns are looked up in the
``[commands]`` section of the launcher's :ref:`.INI file <launcher-ini>`.
This may be used to handle certain commands in a way that makes sense for your
system. The name of the command must be a single argument (no spaces),
and the value substituted is the full path to the executable (no arguments
may be added).

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

.. _launcher-ini:

Customization via INI files
^^^^^^^^^^^^^^^^^^^^^^^^^^^

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

Customization specified in the "application directory" will have precedence over
the one next to the executable, so a user, who may not have write access to the
Expand Down
57 changes: 40 additions & 17 deletions Lib/test/test_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,17 @@
)


TEST_PY_COMMANDS = "\n".join([
TEST_PY_DEFAULTS = "\n".join([
"[defaults]",
*[f"{k[3:].lower()}={v}" for k, v in TEST_PY_ENV.items()]
*[f"{k[3:].lower()}={v}" for k, v in TEST_PY_ENV.items()],
])


TEST_PY_COMMANDS = "\n".join([
"[commands]",
"test-command=TEST_EXE.exe",
])

def create_registry_data(root, data):
def _create_registry_data(root, key, value):
if isinstance(value, dict):
Expand Down Expand Up @@ -429,21 +434,21 @@ def test_search_major_2(self):
self.assertTrue(data["env.tag"].startswith("2."), data["env.tag"])

def test_py_default(self):
with self.py_ini(TEST_PY_COMMANDS):
with self.py_ini(TEST_PY_DEFAULTS):
data = self.run_py(["-arg"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100", data["SearchInfo.tag"])
self.assertEqual("X.Y.exe -arg", data["stdout"].strip())

def test_py2_default(self):
with self.py_ini(TEST_PY_COMMANDS):
with self.py_ini(TEST_PY_DEFAULTS):
data = self.run_py(["-2", "-arg"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100-32", data["SearchInfo.tag"])
self.assertEqual("X.Y-32.exe -arg", data["stdout"].strip())

def test_py3_default(self):
with self.py_ini(TEST_PY_COMMANDS):
with self.py_ini(TEST_PY_DEFAULTS):
data = self.run_py(["-3", "-arg"])
self.assertEqual("PythonTestSuite", data["SearchInfo.company"])
self.assertEqual("3.100-arm64", data["SearchInfo.tag"])
Expand All @@ -468,7 +473,7 @@ def test_py3_default_env(self):
self.assertEqual("X.Y-arm64.exe -X fake_arg_for_test -arg", data["stdout"].strip())

def test_py_default_short_argv0(self):
with self.py_ini(TEST_PY_COMMANDS):
with self.py_ini(TEST_PY_DEFAULTS):
for argv0 in ['"py.exe"', 'py.exe', '"py"', 'py']:
with self.subTest(argv0):
data = self.run_py(["--version"], argv=f'{argv0} --version')
Expand Down Expand Up @@ -518,63 +523,63 @@ def test_virtualenv_with_env(self):
self.assertNotEqual(data2["SearchInfo.lowPriorityTag"], "True")

def test_py_shebang(self):
with self.py_ini(TEST_PY_COMMANDS):
with self.py_ini(TEST_PY_DEFAULTS):
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_python_shebang(self):
with self.py_ini(TEST_PY_COMMANDS):
with self.py_ini(TEST_PY_DEFAULTS):
with self.script("#! 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.py_ini(TEST_PY_DEFAULTS):
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.py_ini(TEST_PY_DEFAULTS):
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.py_ini(TEST_PY_DEFAULTS):
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.py_ini(TEST_PY_DEFAULTS):
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.py_ini(TEST_PY_DEFAULTS):
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.py_ini(TEST_PY_DEFAULTS):
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')
Expand All @@ -591,7 +596,7 @@ def test_py_handle_64_in_ini(self):

def test_search_path(self):
stem = Path(sys.executable).stem
with self.py_ini(TEST_PY_COMMANDS):
with self.py_ini(TEST_PY_DEFAULTS):
with self.script(f"#! /usr/bin/env {stem} -prearg") as script:
data = self.run_py(
[script, "-postarg"],
Expand All @@ -602,7 +607,7 @@ def test_search_path(self):
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.py_ini(TEST_PY_DEFAULTS):
with self.script(f"#! /usr/bin/env {name} -prearg") as script:
data = self.run_py(
[script, "-postarg"],
Expand All @@ -612,7 +617,7 @@ def test_search_path_exe(self):

def test_recursive_search_path(self):
stem = self.get_py_exe().stem
with self.py_ini(TEST_PY_COMMANDS):
with self.py_ini(TEST_PY_DEFAULTS):
with self.script(f"#! /usr/bin/env {stem}") as script:
data = self.run_py(
[script],
Expand Down Expand Up @@ -673,3 +678,21 @@ def test_literal_shebang_quoted_escape(self):
f'"{script.parent}\\some\\ random app" -witharg {script}',
data["stdout"].strip(),
)

def test_literal_shebang_command(self):
with self.py_ini(TEST_PY_COMMANDS):
with self.script('#! test-command arg1') as script:
data = self.run_py([script])
self.assertEqual(
f"TEST_EXE.exe arg1 {script}",
data["stdout"].strip(),
)

def test_literal_shebang_invalid_template(self):
with self.script('#! /usr/bin/not-python arg1') as script:
data = self.run_py([script])
expect = script.parent / "/usr/bin/not-python"
self.assertEqual(
f"{expect} arg1 {script}",
data["stdout"].strip(),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Restores support for the :file:`py.exe` launcher finding shebang commands in
its configuration file using the full command name.
Loading

0 comments on commit 468c3bf

Please sign in to comment.