Skip to content

Commit 080ed44

Browse files
authored
Adds shebang_templates configuration for overriding shebangs. (#359)
Fixes #307 Fixes #348
1 parent 1ace2f7 commit 080ed44

6 files changed

Lines changed: 155 additions & 14 deletions

File tree

src/manage/commands.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@ def execute(self):
242242
"include_unmanaged": (config_bool, None, "env"),
243243
"shebang_can_run_anything": (config_bool, None, "env"),
244244
"shebang_can_run_anything_silently": (config_bool, None, "env"),
245+
# Mapping from shebang template to '-V:Company/Tag' argument or an
246+
# executable path. The latter requires 'shebang_can_run_anything'.
247+
"shebang_templates": (dict, config_dict_merge),
245248
# Typically configured to '%VIRTUAL_ENV%' to pick up the active environment
246249
"virtual_env": (str, None, "env", "path"),
247250

@@ -347,6 +350,7 @@ class BaseCommand:
347350
virtual_env = None
348351
shebang_can_run_anything = True
349352
shebang_can_run_anything_silently = False
353+
shebang_templates = {}
350354
welcome_on_update = False
351355

352356
log_file = None
@@ -366,7 +370,7 @@ class BaseCommand:
366370
launcher_exe = None
367371
launcherw_exe = None
368372

369-
source_settings = None
373+
source_settings = {}
370374

371375
show_help = False
372376

src/manage/scriptutils.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,61 @@ def _find_on_path(cmd, full_cmd):
121121
}
122122

123123

124+
def _replace_templates(cmd, line, windowed):
125+
# Override can be the entire line or just the first argument
126+
# Override can be the entire line (including args) or just the first argument
127+
m = re.match(r"^#!\s*([^\s]+)(.*)$", line)
128+
129+
if not m:
130+
return None, None
131+
132+
full_key = (m.group(1) + m.group(2)).strip()
133+
if full_key in cmd.shebang_templates:
134+
template_key = full_key
135+
suffix = ""
136+
elif m.group(1) in cmd.shebang_templates:
137+
template_key = m.group(1)
138+
suffix = m.group(2)
139+
else:
140+
return None, None
141+
142+
new_cmd = cmd.shebang_templates[template_key]
143+
LOGGER.verbose("Using '%s' from configuration file in place of shebang '%s'",
144+
new_cmd, template_key)
145+
install = None
146+
if new_cmd.startswith("py -V:"):
147+
install = cmd.get_install_to_run(new_cmd[6:], windowed=windowed)
148+
elif new_cmd.startswith("pyw -V:"):
149+
install = cmd.get_install_to_run(new_cmd[7:], windowed=True)
150+
elif new_cmd.startswith("py -3"):
151+
install = cmd.get_install_to_run(f"PythonCore/{new_cmd[4:]}", windowed=windowed)
152+
elif new_cmd.startswith("pyw -3"):
153+
install = cmd.get_install_to_run(f"PythonCore/{new_cmd[5:]}", windowed=True)
154+
elif new_cmd == "py":
155+
install = cmd.get_install_to_run(windowed=windowed)
156+
elif new_cmd == "pyw":
157+
install = cmd.get_install_to_run(windowed=True)
158+
else:
159+
# Recreate the shebang with the alternate command and continue.
160+
line = f"#!{new_cmd}{suffix}"
161+
return install, line
162+
163+
124164
def _parse_shebang(cmd, line, *, windowed=None):
165+
# To silence our warning when we get the path from config file
166+
run_anything_silently = False
167+
168+
# First check the user-provided overrides
169+
if cmd.shebang_templates:
170+
install, new_line = _replace_templates(cmd, line, windowed)
171+
if install:
172+
return install
173+
if new_line:
174+
# We don't warn about custom executables if they've come from
175+
# the config file, unless they don't exist or are disabled.
176+
run_anything_silently = True
177+
line = new_line
178+
125179
# For /usr[/local]/bin, we look for a matching alias name.
126180
shebang = re.match(r"#!\s*/usr/(?:local/)?bin/(?!env\b)([^\\/\s]+).*", line)
127181
if shebang:
@@ -151,7 +205,7 @@ def _parse_shebang(cmd, line, *, windowed=None):
151205
# If not, warn and do regular PATH search
152206
if cmd.shebang_can_run_anything or cmd.shebang_can_run_anything_silently:
153207
i = _find_on_path(cmd, full_cmd)
154-
if not cmd.shebang_can_run_anything_silently:
208+
if not cmd.shebang_can_run_anything_silently and not run_anything_silently:
155209
LOGGER.warn("A shebang '%s' was found but could not be matched "
156210
"to an installed runtime, so it will be treated as "
157211
"an arbitrary command.", full_cmd)
@@ -181,14 +235,20 @@ def _parse_shebang(cmd, line, *, windowed=None):
181235
except LookupError:
182236
pass
183237
if cmd.shebang_can_run_anything or cmd.shebang_can_run_anything_silently:
184-
if not cmd.shebang_can_run_anything_silently:
238+
if not cmd.shebang_can_run_anything_silently and not run_anything_silently:
185239
LOGGER.warn("A shebang '%s' was found but does not match any "
186-
"supported template (e.g. '/usr/bin/python'), so it "
187-
"will be treated as an arbitrary command.", full_cmd)
240+
"supported or configured template (e.g. "
241+
"'/usr/bin/python'), so it will be treated as an "
242+
"arbitrary command.", full_cmd)
188243
LOGGER.warn("To prevent execution of programs that are not "
189244
"Python runtimes, set 'shebang_can_run_anything' to "
190245
"'false' in your configuration file.")
191-
return _find_on_path(cmd, full_cmd)
246+
try:
247+
return _find_on_path(cmd, full_cmd)
248+
except LookupError as ex:
249+
LOGGER.error("Could not launch '%s'. Using default interpreter "
250+
"instead.", full_cmd)
251+
raise
192252
else:
193253
LOGGER.warn("A shebang '%s' was found, but could not be matched "
194254
"to an installed runtime.", full_cmd)

src/pymanager.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@
3434
"launcherw_exe": "./templates/launcherw.exe",
3535
"welcome_on_update": true,
3636

37+
"shebang_templates": {
38+
"/usr/bin/python": "py",
39+
"/usr/bin/pythonw": "pyw",
40+
"/usr/bin/python3": "py",
41+
"/usr/bin/pythonw3": "pyw",
42+
"/usr/local/bin/python": "py",
43+
"/usr/local/bin/pythonw": "pyw",
44+
"/usr/local/bin/python3": "py",
45+
"/usr/local/bin/pythonw3": "pyw"
46+
},
47+
3748
"source_settings": {
3849
"https://www.python.org/ftp/python/index-windows.json": {
3950
"requires_signature": true,

tests/conftest.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -162,28 +162,32 @@ def __init__(self, root, installs=[]):
162162
self.installs = list(installs)
163163
self.shebang_can_run_anything = True
164164
self.shebang_can_run_anything_silently = False
165+
self.shebang_templates = {}
165166
self.scratch = {}
166167

167168
def get_installs(self, *, include_unmanaged=False, set_default=True):
168169
if include_unmanaged:
169170
return self.installs
170171
return [i for i in self.installs if not i.get("unmanaged", 0)]
171172

172-
def get_install_to_run(self, tag, *, windowed=False):
173+
def get_install_to_run(self, tag=None, script=None, *, windowed=False):
173174
if windowed:
174175
i = self.get_install_to_run(tag)
175176
target = [t for t in i.get("run-for", []) if t.get("windowed")]
176177
if target:
177178
return {**i, "executable": i["prefix"] / target[0]["target"]}
178179
return i
179180

180-
company, _, tag = tag.replace("/", "\\").rpartition("\\")
181-
try:
182-
found = [i for i in self.installs
183-
if (not tag or i["tag"] == tag) and (not company or i["company"] == company)]
184-
except LookupError as ex:
185-
# LookupError is expected from this function, so make sure we don't raise it here
186-
raise RuntimeError from ex
181+
if not tag:
182+
found = [i for i in self.installs if i.get("default")]
183+
else:
184+
company, _, tag = tag.replace("/", "\\").rpartition("\\")
185+
try:
186+
found = [i for i in self.installs
187+
if (not tag or i["tag"] == tag) and (not company or i["company"] == company)]
188+
except LookupError as ex:
189+
# LookupError is expected from this function, so make sure we don't raise it here
190+
raise RuntimeError from ex
187191
if found:
188192
return found[0]
189193
raise LookupError(tag)

tests/test_install_command.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ def __init__(self, tmp_path, *args, **kwargs):
244244
self.repair = kwargs.pop("repair", False)
245245
self.shebang_can_run_anything = kwargs.pop("shebang_can_run_anything", False)
246246
self.shebang_can_run_anything_silently = kwargs.pop("shebang_can_run_anything_silently", False)
247+
self.shebang_templates = {}
247248
self.source = kwargs.pop("source", "http://example.com/index.json")
248249
self.target = kwargs.pop("target", None)
249250
if self.target:

tests/test_scriptutils.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66

77
from pathlib import PurePath
88

9+
import manage.scriptutils as SU
910
from manage.scriptutils import (
1011
find_install_from_script,
1112
_find_shebang_command,
13+
_parse_shebang,
1214
_read_script,
15+
_replace_templates,
1316
NewEncoding,
1417
_maybe_quote,
1518
quote_args,
@@ -274,3 +277,61 @@ def test_quote_args(args, expect):
274277
assert expect == quote_args(args)
275278
# Test that our split function produces the same result
276279
assert args == split_args(expect), expect
280+
281+
282+
@pytest.mark.parametrize("line, expect_id, expect_line", [pytest.param(*a, id=a[0]) for a in [
283+
("#!/usr/bin/python", "Test1", None),
284+
("#!/usr/bin/python -Es", "Test1", None),
285+
("#! /usr/bin/pythonw", "Test1", None),
286+
("#! /usr/bin/python2", "Test2", None),
287+
("#! /usr/bin/pythonw2", "Test2", None),
288+
("#! /usr/bin/python3", "PythonCore3", None),
289+
("#! /usr/bin/pythonw3", "PythonCore3", None),
290+
("#! custom", None, "#!CUSTOM"),
291+
("#! full line custom", None, "#!CUSTOM2"),
292+
("#!full line custom with extra", None, None),
293+
("custom", None, None),
294+
("full line custom", None, None),
295+
]])
296+
def test_shebang_templates(fake_config, line, expect_id, expect_line):
297+
fake_config.installs = [
298+
dict(id="Test1", company="Test", tag="1", default=True),
299+
dict(id="Test2", company="Test", tag="2"),
300+
dict(id="Test3", company="Test", tag="3.2"),
301+
dict(id="PythonCore3", company="PythonCore", tag="3.2"),
302+
]
303+
fake_config.shebang_templates = {
304+
"/usr/bin/python": "py",
305+
"/usr/bin/pythonw": "pyw",
306+
"/usr/bin/python2": "py -V:Test/2",
307+
"/usr/bin/pythonw2": "pyw -V:Test/2",
308+
"/usr/bin/python3": "py -3.2",
309+
"/usr/bin/pythonw3": "pyw -3.2",
310+
"custom": "CUSTOM",
311+
"full line custom": "CUSTOM2",
312+
}
313+
actual, actual_line = _replace_templates(fake_config, line, False)
314+
if expect_id:
315+
assert actual
316+
assert expect_id == actual["id"]
317+
elif expect_line:
318+
assert expect_line == actual_line
319+
else:
320+
assert not actual
321+
assert not actual_line
322+
323+
324+
def test_parse_shebang_templates(monkeypatch):
325+
class Cmd:
326+
shebang_templates = True
327+
328+
expect = {"an": "install"}
329+
monkeypatch.setattr(SU, "_replace_templates", lambda *a: (expect, None))
330+
actual = _parse_shebang(Cmd, "Anything at all")
331+
assert expect == actual
332+
333+
expect = {"id": "COMMAND"}
334+
monkeypatch.setattr(SU, "_replace_templates", lambda *a: (None, "#!COMMAND"))
335+
monkeypatch.setattr(SU, "_find_shebang_command", lambda cmd, full_cmd, **kw: {"id": full_cmd})
336+
actual = _parse_shebang(Cmd, "Anything at all", windowed=False)
337+
assert expect == actual

0 commit comments

Comments
 (0)