Skip to content

Commit d2d1b2e

Browse files
committed
Adds shebang_templates configuration for overriding shebangs.
Fixes #307 Fixes #348
1 parent 1ace2f7 commit d2d1b2e

5 files changed

Lines changed: 106 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: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,50 @@ 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+
shebang = re.match(r"#!\s*(.+)(.*)", line) or re.match(r"#!\s*([^\s]+)(.*)", line)
127+
128+
if not shebang or shebang.group(1) not in cmd.shebang_templates:
129+
return None, None
130+
131+
new_cmd = cmd.shebang_templates[shebang.group(1)]
132+
LOGGER.verbose("Using '%s' from configuration file in place of shebang '%s'",
133+
new_cmd, shebang.group(1))
134+
install = None
135+
if new_cmd.startswith("py -V:"):
136+
install = cmd.get_install_to_run(new_cmd[6:], windowed=windowed)
137+
elif new_cmd.startswith("pyw -V:"):
138+
install = cmd.get_install_to_run(new_cmd[7:], windowed=True)
139+
elif new_cmd.startswith("py -3"):
140+
install = cmd.get_install_to_run(f"PythonCore/{new_cmd[4:]}", windowed=windowed)
141+
elif new_cmd.startswith("pyw -3"):
142+
install = cmd.get_install_to_run(f"PythonCore/{new_cmd[5:]}", windowed=True)
143+
elif new_cmd == "py":
144+
install = cmd.get_install_to_run(windowed=windowed)
145+
elif new_cmd == "pyw":
146+
install = cmd.get_install_to_run(windowed=True)
147+
else:
148+
# Recreate the shebang with the alternate command and continue.
149+
line = f"#!{new_cmd}{shebang.group(2)}"
150+
return install, line
151+
152+
124153
def _parse_shebang(cmd, line, *, windowed=None):
154+
# To silence our warning when we get the path from config file
155+
run_anything_silently = False
156+
157+
# First check the user-provided overrides
158+
if cmd.shebang_templates:
159+
install, new_line = _replace_templates(cmd, line, windowed)
160+
if install:
161+
return install
162+
if new_line:
163+
# We don't warn about custom executables if they've come from
164+
# the config file, unless they don't exist or are disabled.
165+
run_anything_silently = True
166+
line = new_line
167+
125168
# For /usr[/local]/bin, we look for a matching alias name.
126169
shebang = re.match(r"#!\s*/usr/(?:local/)?bin/(?!env\b)([^\\/\s]+).*", line)
127170
if shebang:
@@ -151,7 +194,7 @@ def _parse_shebang(cmd, line, *, windowed=None):
151194
# If not, warn and do regular PATH search
152195
if cmd.shebang_can_run_anything or cmd.shebang_can_run_anything_silently:
153196
i = _find_on_path(cmd, full_cmd)
154-
if not cmd.shebang_can_run_anything_silently:
197+
if not cmd.shebang_can_run_anything_silently and not run_anything_silently:
155198
LOGGER.warn("A shebang '%s' was found but could not be matched "
156199
"to an installed runtime, so it will be treated as "
157200
"an arbitrary command.", full_cmd)
@@ -181,14 +224,19 @@ def _parse_shebang(cmd, line, *, windowed=None):
181224
except LookupError:
182225
pass
183226
if cmd.shebang_can_run_anything or cmd.shebang_can_run_anything_silently:
184-
if not cmd.shebang_can_run_anything_silently:
227+
if not cmd.shebang_can_run_anything_silently and not run_anything_silently:
185228
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)
229+
"supported or configured template (e.g. "
230+
"'/usr/bin/python'), so it will be treated as an "
231+
"arbitrary command.", full_cmd)
188232
LOGGER.warn("To prevent execution of programs that are not "
189233
"Python runtimes, set 'shebang_can_run_anything' to "
190234
"'false' in your configuration file.")
191-
return _find_on_path(cmd, full_cmd)
235+
try:
236+
return _find_on_path(cmd, full_cmd)
237+
except LookupError as ex:
238+
LOGGER.error("Could not launch '%s'. Using default interpreter "
239+
"instead.", full_cmd)
192240
else:
193241
LOGGER.warn("A shebang '%s' was found, but could not be matched "
194242
"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": "py",
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_scriptutils.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
find_install_from_script,
1111
_find_shebang_command,
1212
_read_script,
13+
_replace_templates,
1314
NewEncoding,
1415
_maybe_quote,
1516
quote_args,
@@ -274,3 +275,27 @@ def test_quote_args(args, expect):
274275
assert expect == quote_args(args)
275276
# Test that our split function produces the same result
276277
assert args == split_args(expect), expect
278+
279+
280+
@pytest.mark.parametrize("line, expect_id, expect_line", [pytest.param(*a, id=a[0]) for a in [
281+
("#!/usr/bin/python", "Test1", None),
282+
("#!/usr/bin/python2", "Test2", None),
283+
# TODO: More test cases
284+
]])
285+
def test_shebang_templates(fake_config, line, expect_id, expect_line):
286+
fake_config.installs = [
287+
dict(id="Test1", company="Test", tag="1", default=True),
288+
dict(id="Test2", company="Test", tag="2"),
289+
]
290+
fake_config.shebang_templates = {
291+
"/usr/bin/python": "py",
292+
"/usr/bin/python2": "py -V:Test/2",
293+
}
294+
actual, actual_line = _replace_templates(fake_config, line, False)
295+
if expect_id:
296+
assert actual
297+
assert expect_id == actual["id"]
298+
elif expect_line:
299+
assert expect_line == actual_line
300+
else:
301+
pytest.fail("Invalid test")

0 commit comments

Comments
 (0)