Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/manage/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@ def get_install_to_run(self, tag=None, script=None, *, windowed=False):
if script and not tag:
from .scriptutils import find_install_from_script
try:
return find_install_from_script(self, script)
return find_install_from_script(self, script, windowed=windowed)
except LookupError:
pass
from .installs import get_install_to_run
Expand Down
82 changes: 63 additions & 19 deletions src/manage/scriptutils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
"""This module has functions for looking into scripts to decide how to launch.

Currently, this is primarily shebang lines. This support is intended to allow
scripts to be somewhat portable between POSIX (where they are natively handled)
and Windows, when launching in Python. They are not intended to provide generic
shebang support, although for historical/compatibility reasons it is possible.

Shebang commands shaped like '/usr/bin/<command>' or '/usr/local/bin/<command>'
will have the command matched to an alias or executable name for detected
runtimes, with the first match being selected.
A command of 'py', 'pyw', 'python' or 'pythonw' will match the default runtime.
If the install manager has been launched in windowed mode, and the selected
alias is not marked as windowed, then the first windowed 'run-for' target will
be substituted (if present - otherwise, it will just not run windowed). Aliases
that map to windowed targets are launched windowed.
If no matching command is found, the default install will be used.

Shebang commands shaped like '/usr/bin/env <command>' will do the same lookup as
above. If no matching command is found, the current PATH environment variable
will be searched for a matching command. It will be launched with a warning,
configuration permitting.

Other shebangs will be treated directly as the command, doing the same lookup
and the same PATH search.

It is not yet implemented, but this is also where a search for PEP 723 inline
script metadata would go. Find the comment mentioning PEP 723 below.
"""

import re

from .logging import LOGGER
Expand All @@ -12,7 +41,7 @@ class NoShebang(Exception):
pass


def _find_shebang_command(cmd, full_cmd):
def _find_shebang_command(cmd, full_cmd, *, windowed=None):
sh_cmd = PurePath(full_cmd)
# HACK: Assuming alias/executable suffix is '.exe' here
# (But correctly assuming we can't use with_suffix() or .stem)
Expand All @@ -22,17 +51,32 @@ def _find_shebang_command(cmd, full_cmd):
is_wdefault = sh_cmd.match("pythonw.exe") or sh_cmd.match("pyw.exe")
is_default = is_wdefault or sh_cmd.match("python.exe") or sh_cmd.match("py.exe")

# Internal logic error, but non-fatal, if it has no value
assert windowed is not None

# Ensure we use the default install for a default name. Otherwise, a
# "higher" runtime may claim it via an alias, which is not the intent.
if is_default:
for i in cmd.get_installs():
if i.get("default"):
exe = i["executable"]
if is_wdefault or windowed:
target = [t for t in i.get("run-for", []) if t.get("windowed")]
if target:
exe = target[0]["target"]
return {**i, "executable": i["prefix"] / exe}

for i in cmd.get_installs():
if is_default and i.get("default"):
if is_wdefault:
target = [t for t in i.get("run-for", []) if t.get("windowed")]
if target:
return {**i, "executable": i["prefix"] / target[0]["target"]}
return {**i, "executable": i["prefix"] / i["executable"]}
for a in i.get("alias", ()):
if sh_cmd.match(a["name"]):
exe = a["target"]
LOGGER.debug("Matched alias %s in %s", a["name"], i["id"])
return {**i, "executable": i["prefix"] / a["target"]}
if windowed and not a.get("windowed"):
target = [t for t in i.get("run-for", []) if t.get("windowed")]
if target:
exe = target[0]["target"]
LOGGER.debug("Substituting target %s for windowed=1", exe)
return {**i, "executable": i["prefix"] / exe}
if sh_cmd.full_match(PurePath(i["executable"]).name):
LOGGER.debug("Matched executable name %s in %s", i["executable"], i["id"])
return i
Expand Down Expand Up @@ -69,15 +113,15 @@ def _find_on_path(cmd, full_cmd):
}


def _parse_shebang(cmd, line):
def _parse_shebang(cmd, line, *, windowed=None):
# For /usr[/local]/bin, we look for a matching alias name.
shebang = re.match(r"#!\s*/usr/(?:local/)?bin/(?!env\b)([^\\/\s]+).*", line)
if shebang:
# Handle the /usr[/local]/bin/python cases
full_cmd = shebang.group(1)
LOGGER.debug("Matching shebang: %s", full_cmd)
try:
return _find_shebang_command(cmd, full_cmd)
return _find_shebang_command(cmd, full_cmd, windowed=windowed)
except LookupError:
LOGGER.warn("A shebang '%s' was found, but could not be matched "
"to an installed runtime.", full_cmd)
Expand All @@ -93,7 +137,7 @@ def _parse_shebang(cmd, line):
# First do regular install lookup for /usr/bin/env shebangs
full_cmd = shebang.group(1)
try:
return _find_shebang_command(cmd, full_cmd)
return _find_shebang_command(cmd, full_cmd, windowed=windowed)
except LookupError:
pass
# If not, warn and do regular PATH search
Expand All @@ -107,7 +151,7 @@ def _parse_shebang(cmd, line):
"Python runtimes, set 'shebang_can_run_anything' to "
"'false' in your configuration file.")
return i

else:
LOGGER.warn("A shebang '%s' was found, but could not be matched "
"to an installed runtime.", full_cmd)
Expand All @@ -125,7 +169,7 @@ def _parse_shebang(cmd, line):
# A regular lookup will handle the case where the entire shebang is
# a valid alias.
try:
return _find_shebang_command(cmd, full_cmd)
return _find_shebang_command(cmd, full_cmd, windowed=windowed)
except LookupError:
pass
if cmd.shebang_can_run_anything or cmd.shebang_can_run_anything_silently:
Expand All @@ -149,7 +193,7 @@ def _parse_shebang(cmd, line):
raise NoShebang


def _read_script(cmd, script, encoding):
def _read_script(cmd, script, encoding, *, windowed=None):
try:
f = open(script, "r", encoding=encoding, errors="replace")
except OSError as ex:
Expand All @@ -158,7 +202,7 @@ def _read_script(cmd, script, encoding):
first_line = next(f, "").rstrip()
if first_line.startswith("#!"):
try:
return _parse_shebang(cmd, first_line)
return _parse_shebang(cmd, first_line, windowed=windowed)
except LookupError:
raise LookupError(script) from None
except NoShebang:
Expand All @@ -168,20 +212,20 @@ def _read_script(cmd, script, encoding):
if coding and coding.group(1) != encoding:
raise NewEncoding(coding.group(1))

# TODO: Parse inline script metadata
# TODO: Parse inline script metadata (PEP 723)
# This involves finding '# /// script' followed by
# a line with '# requires-python = <spec>'.
# That spec needs to be processed as a version constraint, which
# cmd.get_install_to_run() can handle.
raise LookupError(script)


def find_install_from_script(cmd, script):
def find_install_from_script(cmd, script, *, windowed=False):
try:
return _read_script(cmd, script, "utf-8-sig")
return _read_script(cmd, script, "utf-8-sig", windowed=windowed)
except NewEncoding as ex:
encoding = ex.args[0]
return _read_script(cmd, script, encoding)
return _read_script(cmd, script, encoding, windowed=windowed)


def _maybe_quote(a):
Expand Down
89 changes: 79 additions & 10 deletions tests/test_scriptutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
)

def _fake_install(v, **kwargs):
try:
kwargs["run-for"] = kwargs.pop("run_for")
except LookupError:
pass
return {
"company": kwargs.get("company", "Test"),
"id": f"test-{v}",
Expand All @@ -28,8 +32,19 @@ def _fake_install(v, **kwargs):
}

INSTALLS = [
_fake_install("1.0", alias=[{"name": "test1.0.exe", "target": "./test-binary-1.0.exe"}]),
_fake_install("1.1", alias=[{"name": "test1.1.exe", "target": "./test-binary-1.1.exe"}]),
_fake_install("1.0",
run_for=[dict(tag="1.0", target="./test-binary-1.0.exe"),
dict(tag="1.0", target="./test-binary-1.0-win.exe", windowed=1)],
alias=[dict(name="test1.0.exe", target="./test-binary-1.0.exe"),
dict(name="testw1.0.exe", target="./test-binary-w-1.0.exe", windowed=1)],
),
_fake_install("1.1",
default=1,
run_for=[dict(tag="1.1", target="./test-binary-1.1.exe"),
dict(tag="1.1", target="./test-binary-1.1-win.exe", windowed=1)],
alias=[dict(name="test1.1.exe", target="./test-binary-1.1.exe"),
dict(name="testw1.1.exe", target="./test-binary-w-1.1.exe", windowed=1)],
),
_fake_install("1.3.1", company="PythonCore"),
_fake_install("1.3.2", company="PythonOther"),
_fake_install("2.0", alias=[{"name": "test2.0.exe", "target": "./test-binary-2.0.exe"}]),
Expand Down Expand Up @@ -64,12 +79,63 @@ def test_read_shebang(fake_config, tmp_path, script, expect):
script = script.encode()
script_py.write_bytes(script)
try:
actual = find_install_from_script(fake_config, script_py)
actual = find_install_from_script(fake_config, script_py, windowed=False)
assert expect == actual
except LookupError:
assert not expect


@pytest.mark.parametrize("script, expect, windowed", [
# Non-windowed alias from non-windowed launcher uses default 'executable'
("#! /usr/bin/test1.0\n", "test-binary-1.0.exe", False),
# Non-windowed alias from windowed launcher uses first windowed 'run-for'
("#! /usr/bin/test1.0\n", "test-binary-1.0-win.exe", True),
# Windowed alias from either launcher uses the discovered alias
("#! /usr/bin/testw1.0\n", "test-binary-w-1.0.exe", False),
("#! /usr/bin/testw1.0\n", "test-binary-w-1.0.exe", True),

# No windowed option for 2.0, so picks the regular executable
("#! /usr/bin/test2.0\n", "test-binary-2.0.exe", False),
("#! /usr/bin/test2.0\n", "test-binary-2.0.exe", True),
("#! /usr/bin/testw2.0\n", None, False),
("#! /usr/bin/testw2.0\n", None, True),
("#!test1.0.exe\n", "test-binary-1.0.exe", False),
("#!test1.0.exe\n", "test-binary-1.0-win.exe", True),
("#!testw1.0.exe\n", "test-binary-w-1.0.exe", False),
("#!testw1.0.exe\n", "test-binary-w-1.0.exe", True),
("#!test1.1.exe\n", "test-binary-1.1.exe", False),
("#!test1.1.exe\n", "test-binary-1.1-win.exe", True),
("#!testw1.1.exe\n", "test-binary-w-1.1.exe", False),
("#!testw1.1.exe\n", "test-binary-w-1.1.exe", True),

# Matching executable name won't be overridden by windowed setting
("#!test-binary-1.1.exe\n", "test-binary-1.1.exe", False),
("#!test-binary-1.1.exe\n", "test-binary-1.1.exe", True),
("#! /usr/bin/env test1.0\n", "test-binary-1.0.exe", False),
("#! /usr/bin/env test1.0\n", "test-binary-1.0-win.exe", True),
("#! /usr/bin/env testw1.0\n", "test-binary-w-1.0.exe", False),
("#! /usr/bin/env testw1.0\n", "test-binary-w-1.0.exe", True),

# Default name will use default 'executable' or first windowed 'run-for'
("#! /usr/bin/python\n", "test-binary-1.1.exe", False),
("#! /usr/bin/python\n", "test-binary-1.1-win.exe", True),
("#! /usr/bin/pythonw\n", "test-binary-1.1-win.exe", False),
("#! /usr/bin/pythonw\n", "test-binary-1.1-win.exe", True),
])
def test_read_shebang_windowed(fake_config, tmp_path, script, expect, windowed):
fake_config.installs.extend(INSTALLS)

script_py = tmp_path / "test-script.py"
if isinstance(script, str):
script = script.encode()
script_py.write_bytes(script)
try:
actual = find_install_from_script(fake_config, script_py, windowed=windowed)
assert actual["executable"].match(expect)
except LookupError:
assert not expect


def test_default_py_shebang(fake_config, tmp_path):
inst = _fake_install("1.0", company="PythonCore", prefix=PurePath("C:\\TestRoot"), default=True)
inst["run-for"] = [
Expand All @@ -78,14 +144,17 @@ def test_default_py_shebang(fake_config, tmp_path):
]
fake_config.installs[:] = [inst]

def t(n):
return _find_shebang_command(fake_config, n, windowed=False)

# Finds the install's default executable
assert _find_shebang_command(fake_config, "python")["executable"].match("test-binary-1.0.exe")
assert _find_shebang_command(fake_config, "py")["executable"].match("test-binary-1.0.exe")
assert _find_shebang_command(fake_config, "python1.0")["executable"].match("test-binary-1.0.exe")
assert t("python")["executable"].match("test-binary-1.0.exe")
assert t("py")["executable"].match("test-binary-1.0.exe")
assert t("python1.0")["executable"].match("test-binary-1.0.exe")
# Finds the install's run-for executable with windowed=1
assert _find_shebang_command(fake_config, "pythonw")["executable"].match("pythonw.exe")
assert _find_shebang_command(fake_config, "pyw")["executable"].match("pythonw.exe")
assert _find_shebang_command(fake_config, "pythonw1.0")["executable"].match("pythonw.exe")
assert t("pythonw")["executable"].match("pythonw.exe")
assert t("pyw")["executable"].match("pythonw.exe")
assert t("pythonw1.0")["executable"].match("pythonw.exe")



Expand All @@ -104,7 +173,7 @@ def test_read_coding_comment(fake_config, tmp_path, script, expect):
script = script.encode()
script_py.write_bytes(script)
try:
_read_script(fake_config, script_py, "utf-8-sig")
_read_script(fake_config, script_py, "utf-8-sig", windowed=False)
except NewEncoding as enc:
assert enc.args[0] == expect
except LookupError:
Expand Down