Skip to content

Commit 8a772d9

Browse files
committed
More reliable command selection algorithm
1 parent eeb71aa commit 8a772d9

File tree

2 files changed

+81
-21
lines changed

2 files changed

+81
-21
lines changed

src/manage/scriptutils.py

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,32 @@
1+
"""This module has functions for looking into scripts to decide how to launch.
2+
3+
Currently, this is primarily shebang lines. This support is intended to allow
4+
scripts to be somewhat portable between POSIX (where they are natively handled)
5+
and Windows, when launching in Python. They are not intended to provide generic
6+
shebang support, although for historical/compatibility reasons it is possible.
7+
8+
Shebang commands shaped like '/usr/bin/<command>' or '/usr/local/bin/<command>'
9+
will have the command matched to an alias or executable name for detected
10+
runtimes, with the first match being selected.
11+
A command of 'py', 'pyw', 'python' or 'pythonw' will match the default runtime.
12+
If the install manager has been launched in windowed mode, and the selected
13+
alias is not marked as windowed, then the first windowed 'run-for' target will
14+
be substituted (if present - otherwise, it will just not run windowed). Aliases
15+
that map to windowed targets are launched windowed.
16+
If no matching command is found, the default install will be used.
17+
18+
Shebang commands shaped like '/usr/bin/env <command>' will do the same lookup as
19+
above. If no matching command is found, the current PATH environment variable
20+
will be searched for a matching command. It will be launched with a warning,
21+
configuration permitting.
22+
23+
Other shebangs will be treated directly as the command, doing the same lookup
24+
and the same PATH search.
25+
26+
It is not yet implemented, but this is also where a search for PEP 723 inline
27+
script metadata would go. Find the comment mentioning PEP 723 below.
28+
"""
29+
130
import re
231

332
from .logging import LOGGER
@@ -25,22 +54,29 @@ def _find_shebang_command(cmd, full_cmd, *, windowed=None):
2554
# Internal logic error, but non-fatal, if it has no value
2655
assert windowed is not None
2756

57+
# Ensure we use the default install for a default name. Otherwise, a
58+
# "higher" runtime may claim it via an alias, which is not the intent.
59+
if is_default:
60+
for i in cmd.get_installs():
61+
if i.get("default"):
62+
exe = i["executable"]
63+
if is_wdefault or windowed:
64+
target = [t for t in i.get("run-for", []) if t.get("windowed")]
65+
if target:
66+
exe = target[0]["target"]
67+
return {**i, "executable": i["prefix"] / exe}
68+
2869
for i in cmd.get_installs():
29-
if is_default and i.get("default"):
30-
if is_wdefault or windowed:
31-
target = [t for t in i.get("run-for", []) if t.get("windowed")]
32-
if target:
33-
return {**i, "executable": i["prefix"] / target[0]["target"]}
34-
return {**i, "executable": i["prefix"] / i["executable"]}
3570
for a in i.get("alias", ()):
3671
if sh_cmd.match(a["name"]):
72+
exe = a["target"]
3773
LOGGER.debug("Matched alias %s in %s", a["name"], i["id"])
3874
if windowed and not a.get("windowed"):
39-
for a2 in i.get("alias", ()):
40-
if a2.get("windowed"):
41-
LOGGER.debug("Substituting alias %s for windowed=1", a2["name"])
42-
return {**i, "executable": i["prefix"] / a2["target"]}
43-
return {**i, "executable": i["prefix"] / a["target"]}
75+
target = [t for t in i.get("run-for", []) if t.get("windowed")]
76+
if target:
77+
exe = target[0]["target"]
78+
LOGGER.debug("Substituting target %s for windowed=1", exe)
79+
return {**i, "executable": i["prefix"] / exe}
4480
if sh_cmd.full_match(PurePath(i["executable"]).name):
4581
LOGGER.debug("Matched executable name %s in %s", i["executable"], i["id"])
4682
return i
@@ -115,7 +151,7 @@ def _parse_shebang(cmd, line, *, windowed=None):
115151
"Python runtimes, set 'shebang_can_run_anything' to "
116152
"'false' in your configuration file.")
117153
return i
118-
154+
119155
else:
120156
LOGGER.warn("A shebang '%s' was found, but could not be matched "
121157
"to an installed runtime.", full_cmd)
@@ -176,7 +212,7 @@ def _read_script(cmd, script, encoding, *, windowed=None):
176212
if coding and coding.group(1) != encoding:
177213
raise NewEncoding(coding.group(1))
178214

179-
# TODO: Parse inline script metadata
215+
# TODO: Parse inline script metadata (PEP 723)
180216
# This involves finding '# /// script' followed by
181217
# a line with '# requires-python = <spec>'.
182218
# That spec needs to be processed as a version constraint, which

tests/test_scriptutils.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
)
1818

1919
def _fake_install(v, **kwargs):
20+
try:
21+
kwargs["run-for"] = kwargs.pop("run_for")
22+
except LookupError:
23+
pass
2024
return {
2125
"company": kwargs.get("company", "Test"),
2226
"id": f"test-{v}",
@@ -28,10 +32,19 @@ def _fake_install(v, **kwargs):
2832
}
2933

3034
INSTALLS = [
31-
_fake_install("1.0", alias=[{"name": "test1.0.exe", "target": "./test-binary-1.0.exe"},
32-
{"name": "testw1.0.exe", "target": "./test-binary-w-1.0.exe", "windowed": 1}]),
33-
_fake_install("1.1", alias=[{"name": "test1.1.exe", "target": "./test-binary-1.1.exe"},
34-
{"name": "testw1.1.exe", "target": "./test-binary-w-1.1.exe", "windowed": 1}]),
35+
_fake_install("1.0",
36+
run_for=[dict(tag="1.0", target="./test-binary-1.0.exe"),
37+
dict(tag="1.0", target="./test-binary-1.0-win.exe", windowed=1)],
38+
alias=[dict(name="test1.0.exe", target="./test-binary-1.0.exe"),
39+
dict(name="testw1.0.exe", target="./test-binary-w-1.0.exe", windowed=1)],
40+
),
41+
_fake_install("1.1",
42+
default=1,
43+
run_for=[dict(tag="1.1", target="./test-binary-1.1.exe"),
44+
dict(tag="1.1", target="./test-binary-1.1-win.exe", windowed=1)],
45+
alias=[dict(name="test1.1.exe", target="./test-binary-1.1.exe"),
46+
dict(name="testw1.1.exe", target="./test-binary-w-1.1.exe", windowed=1)],
47+
),
3548
_fake_install("1.3.1", company="PythonCore"),
3649
_fake_install("1.3.2", company="PythonOther"),
3750
_fake_install("2.0", alias=[{"name": "test2.0.exe", "target": "./test-binary-2.0.exe"}]),
@@ -73,30 +86,41 @@ def test_read_shebang(fake_config, tmp_path, script, expect):
7386

7487

7588
@pytest.mark.parametrize("script, expect, windowed", [
89+
# Non-windowed alias from non-windowed launcher uses default 'executable'
7690
("#! /usr/bin/test1.0\n", "test-binary-1.0.exe", False),
77-
("#! /usr/bin/test1.0\n", "test-binary-w-1.0.exe", True),
91+
# Non-windowed alias from windowed launcher uses first windowed 'run-for'
92+
("#! /usr/bin/test1.0\n", "test-binary-1.0-win.exe", True),
93+
# Windowed alias from either launcher uses the discovered alias
7894
("#! /usr/bin/testw1.0\n", "test-binary-w-1.0.exe", False),
7995
("#! /usr/bin/testw1.0\n", "test-binary-w-1.0.exe", True),
96+
8097
# No windowed option for 2.0, so picks the regular executable
8198
("#! /usr/bin/test2.0\n", "test-binary-2.0.exe", False),
8299
("#! /usr/bin/test2.0\n", "test-binary-2.0.exe", True),
83100
("#! /usr/bin/testw2.0\n", None, False),
84101
("#! /usr/bin/testw2.0\n", None, True),
85102
("#!test1.0.exe\n", "test-binary-1.0.exe", False),
86-
("#!test1.0.exe\n", "test-binary-w-1.0.exe", True),
103+
("#!test1.0.exe\n", "test-binary-1.0-win.exe", True),
87104
("#!testw1.0.exe\n", "test-binary-w-1.0.exe", False),
88105
("#!testw1.0.exe\n", "test-binary-w-1.0.exe", True),
89106
("#!test1.1.exe\n", "test-binary-1.1.exe", False),
90-
("#!test1.1.exe\n", "test-binary-w-1.1.exe", True),
107+
("#!test1.1.exe\n", "test-binary-1.1-win.exe", True),
91108
("#!testw1.1.exe\n", "test-binary-w-1.1.exe", False),
92109
("#!testw1.1.exe\n", "test-binary-w-1.1.exe", True),
110+
93111
# Matching executable name won't be overridden by windowed setting
94112
("#!test-binary-1.1.exe\n", "test-binary-1.1.exe", False),
95113
("#!test-binary-1.1.exe\n", "test-binary-1.1.exe", True),
96114
("#! /usr/bin/env test1.0\n", "test-binary-1.0.exe", False),
97-
("#! /usr/bin/env test1.0\n", "test-binary-w-1.0.exe", True),
115+
("#! /usr/bin/env test1.0\n", "test-binary-1.0-win.exe", True),
98116
("#! /usr/bin/env testw1.0\n", "test-binary-w-1.0.exe", False),
99117
("#! /usr/bin/env testw1.0\n", "test-binary-w-1.0.exe", True),
118+
119+
# Default name will use default 'executable' or first windowed 'run-for'
120+
("#! /usr/bin/python\n", "test-binary-1.1.exe", False),
121+
("#! /usr/bin/python\n", "test-binary-1.1-win.exe", True),
122+
("#! /usr/bin/pythonw\n", "test-binary-1.1-win.exe", False),
123+
("#! /usr/bin/pythonw\n", "test-binary-1.1-win.exe", True),
100124
])
101125
def test_read_shebang_windowed(fake_config, tmp_path, script, expect, windowed):
102126
fake_config.installs.extend(INSTALLS)

0 commit comments

Comments
 (0)