Skip to content

Commit 802b8e0

Browse files
authored
Uses windowed setting in shebang processing to avoid using the console. (#254)
Fixes #216
1 parent 3ce3db9 commit 802b8e0

File tree

3 files changed

+143
-30
lines changed

3 files changed

+143
-30
lines changed

src/manage/commands.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,7 @@ def get_install_to_run(self, tag=None, script=None, *, windowed=False):
659659
if script and not tag:
660660
from .scriptutils import find_install_from_script
661661
try:
662-
return find_install_from_script(self, script)
662+
return find_install_from_script(self, script, windowed=windowed)
663663
except LookupError:
664664
pass
665665
from .installs import get_install_to_run

src/manage/scriptutils.py

Lines changed: 63 additions & 19 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
@@ -12,7 +41,7 @@ class NoShebang(Exception):
1241
pass
1342

1443

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

54+
# Internal logic error, but non-fatal, if it has no value
55+
assert windowed is not None
56+
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+
2569
for i in cmd.get_installs():
26-
if is_default and i.get("default"):
27-
if is_wdefault:
28-
target = [t for t in i.get("run-for", []) if t.get("windowed")]
29-
if target:
30-
return {**i, "executable": i["prefix"] / target[0]["target"]}
31-
return {**i, "executable": i["prefix"] / i["executable"]}
3270
for a in i.get("alias", ()):
3371
if sh_cmd.match(a["name"]):
72+
exe = a["target"]
3473
LOGGER.debug("Matched alias %s in %s", a["name"], i["id"])
35-
return {**i, "executable": i["prefix"] / a["target"]}
74+
if windowed and not a.get("windowed"):
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}
3680
if sh_cmd.full_match(PurePath(i["executable"]).name):
3781
LOGGER.debug("Matched executable name %s in %s", i["executable"], i["id"])
3882
return i
@@ -69,15 +113,15 @@ def _find_on_path(cmd, full_cmd):
69113
}
70114

71115

72-
def _parse_shebang(cmd, line):
116+
def _parse_shebang(cmd, line, *, windowed=None):
73117
# For /usr[/local]/bin, we look for a matching alias name.
74118
shebang = re.match(r"#!\s*/usr/(?:local/)?bin/(?!env\b)([^\\/\s]+).*", line)
75119
if shebang:
76120
# Handle the /usr[/local]/bin/python cases
77121
full_cmd = shebang.group(1)
78122
LOGGER.debug("Matching shebang: %s", full_cmd)
79123
try:
80-
return _find_shebang_command(cmd, full_cmd)
124+
return _find_shebang_command(cmd, full_cmd, windowed=windowed)
81125
except LookupError:
82126
LOGGER.warn("A shebang '%s' was found, but could not be matched "
83127
"to an installed runtime.", full_cmd)
@@ -93,7 +137,7 @@ def _parse_shebang(cmd, line):
93137
# First do regular install lookup for /usr/bin/env shebangs
94138
full_cmd = shebang.group(1)
95139
try:
96-
return _find_shebang_command(cmd, full_cmd)
140+
return _find_shebang_command(cmd, full_cmd, windowed=windowed)
97141
except LookupError:
98142
pass
99143
# If not, warn and do regular PATH search
@@ -107,7 +151,7 @@ def _parse_shebang(cmd, line):
107151
"Python runtimes, set 'shebang_can_run_anything' to "
108152
"'false' in your configuration file.")
109153
return i
110-
154+
111155
else:
112156
LOGGER.warn("A shebang '%s' was found, but could not be matched "
113157
"to an installed runtime.", full_cmd)
@@ -125,7 +169,7 @@ def _parse_shebang(cmd, line):
125169
# A regular lookup will handle the case where the entire shebang is
126170
# a valid alias.
127171
try:
128-
return _find_shebang_command(cmd, full_cmd)
172+
return _find_shebang_command(cmd, full_cmd, windowed=windowed)
129173
except LookupError:
130174
pass
131175
if cmd.shebang_can_run_anything or cmd.shebang_can_run_anything_silently:
@@ -149,7 +193,7 @@ def _parse_shebang(cmd, line):
149193
raise NoShebang
150194

151195

152-
def _read_script(cmd, script, encoding):
196+
def _read_script(cmd, script, encoding, *, windowed=None):
153197
try:
154198
f = open(script, "r", encoding=encoding, errors="replace")
155199
except OSError as ex:
@@ -158,7 +202,7 @@ def _read_script(cmd, script, encoding):
158202
first_line = next(f, "").rstrip()
159203
if first_line.startswith("#!"):
160204
try:
161-
return _parse_shebang(cmd, first_line)
205+
return _parse_shebang(cmd, first_line, windowed=windowed)
162206
except LookupError:
163207
raise LookupError(script) from None
164208
except NoShebang:
@@ -168,20 +212,20 @@ def _read_script(cmd, script, encoding):
168212
if coding and coding.group(1) != encoding:
169213
raise NewEncoding(coding.group(1))
170214

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

178222

179-
def find_install_from_script(cmd, script):
223+
def find_install_from_script(cmd, script, *, windowed=False):
180224
try:
181-
return _read_script(cmd, script, "utf-8-sig")
225+
return _read_script(cmd, script, "utf-8-sig", windowed=windowed)
182226
except NewEncoding as ex:
183227
encoding = ex.args[0]
184-
return _read_script(cmd, script, encoding)
228+
return _read_script(cmd, script, encoding, windowed=windowed)
185229

186230

187231
def _maybe_quote(a):

tests/test_scriptutils.py

Lines changed: 79 additions & 10 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,8 +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-
_fake_install("1.1", alias=[{"name": "test1.1.exe", "target": "./test-binary-1.1.exe"}]),
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+
),
3348
_fake_install("1.3.1", company="PythonCore"),
3449
_fake_install("1.3.2", company="PythonOther"),
3550
_fake_install("2.0", alias=[{"name": "test2.0.exe", "target": "./test-binary-2.0.exe"}]),
@@ -64,12 +79,63 @@ def test_read_shebang(fake_config, tmp_path, script, expect):
6479
script = script.encode()
6580
script_py.write_bytes(script)
6681
try:
67-
actual = find_install_from_script(fake_config, script_py)
82+
actual = find_install_from_script(fake_config, script_py, windowed=False)
6883
assert expect == actual
6984
except LookupError:
7085
assert not expect
7186

7287

88+
@pytest.mark.parametrize("script, expect, windowed", [
89+
# Non-windowed alias from non-windowed launcher uses default 'executable'
90+
("#! /usr/bin/test1.0\n", "test-binary-1.0.exe", False),
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
94+
("#! /usr/bin/testw1.0\n", "test-binary-w-1.0.exe", False),
95+
("#! /usr/bin/testw1.0\n", "test-binary-w-1.0.exe", True),
96+
97+
# No windowed option for 2.0, so picks the regular executable
98+
("#! /usr/bin/test2.0\n", "test-binary-2.0.exe", False),
99+
("#! /usr/bin/test2.0\n", "test-binary-2.0.exe", True),
100+
("#! /usr/bin/testw2.0\n", None, False),
101+
("#! /usr/bin/testw2.0\n", None, True),
102+
("#!test1.0.exe\n", "test-binary-1.0.exe", False),
103+
("#!test1.0.exe\n", "test-binary-1.0-win.exe", True),
104+
("#!testw1.0.exe\n", "test-binary-w-1.0.exe", False),
105+
("#!testw1.0.exe\n", "test-binary-w-1.0.exe", True),
106+
("#!test1.1.exe\n", "test-binary-1.1.exe", False),
107+
("#!test1.1.exe\n", "test-binary-1.1-win.exe", True),
108+
("#!testw1.1.exe\n", "test-binary-w-1.1.exe", False),
109+
("#!testw1.1.exe\n", "test-binary-w-1.1.exe", True),
110+
111+
# Matching executable name won't be overridden by windowed setting
112+
("#!test-binary-1.1.exe\n", "test-binary-1.1.exe", False),
113+
("#!test-binary-1.1.exe\n", "test-binary-1.1.exe", True),
114+
("#! /usr/bin/env test1.0\n", "test-binary-1.0.exe", False),
115+
("#! /usr/bin/env test1.0\n", "test-binary-1.0-win.exe", True),
116+
("#! /usr/bin/env testw1.0\n", "test-binary-w-1.0.exe", False),
117+
("#! /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),
124+
])
125+
def test_read_shebang_windowed(fake_config, tmp_path, script, expect, windowed):
126+
fake_config.installs.extend(INSTALLS)
127+
128+
script_py = tmp_path / "test-script.py"
129+
if isinstance(script, str):
130+
script = script.encode()
131+
script_py.write_bytes(script)
132+
try:
133+
actual = find_install_from_script(fake_config, script_py, windowed=windowed)
134+
assert actual["executable"].match(expect)
135+
except LookupError:
136+
assert not expect
137+
138+
73139
def test_default_py_shebang(fake_config, tmp_path):
74140
inst = _fake_install("1.0", company="PythonCore", prefix=PurePath("C:\\TestRoot"), default=True)
75141
inst["run-for"] = [
@@ -78,14 +144,17 @@ def test_default_py_shebang(fake_config, tmp_path):
78144
]
79145
fake_config.installs[:] = [inst]
80146

147+
def t(n):
148+
return _find_shebang_command(fake_config, n, windowed=False)
149+
81150
# Finds the install's default executable
82-
assert _find_shebang_command(fake_config, "python")["executable"].match("test-binary-1.0.exe")
83-
assert _find_shebang_command(fake_config, "py")["executable"].match("test-binary-1.0.exe")
84-
assert _find_shebang_command(fake_config, "python1.0")["executable"].match("test-binary-1.0.exe")
151+
assert t("python")["executable"].match("test-binary-1.0.exe")
152+
assert t("py")["executable"].match("test-binary-1.0.exe")
153+
assert t("python1.0")["executable"].match("test-binary-1.0.exe")
85154
# Finds the install's run-for executable with windowed=1
86-
assert _find_shebang_command(fake_config, "pythonw")["executable"].match("pythonw.exe")
87-
assert _find_shebang_command(fake_config, "pyw")["executable"].match("pythonw.exe")
88-
assert _find_shebang_command(fake_config, "pythonw1.0")["executable"].match("pythonw.exe")
155+
assert t("pythonw")["executable"].match("pythonw.exe")
156+
assert t("pyw")["executable"].match("pythonw.exe")
157+
assert t("pythonw1.0")["executable"].match("pythonw.exe")
89158

90159

91160

@@ -104,7 +173,7 @@ def test_read_coding_comment(fake_config, tmp_path, script, expect):
104173
script = script.encode()
105174
script_py.write_bytes(script)
106175
try:
107-
_read_script(fake_config, script_py, "utf-8-sig")
176+
_read_script(fake_config, script_py, "utf-8-sig", windowed=False)
108177
except NewEncoding as enc:
109178
assert enc.args[0] == expect
110179
except LookupError:

0 commit comments

Comments
 (0)