From d2d1b2e714c8cef1fed1304b7d2d49803014f1a0 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 3 Jun 2026 00:29:37 +0100 Subject: [PATCH 1/5] Adds shebang_templates configuration for overriding shebangs. Fixes #307 Fixes #348 --- src/manage/commands.py | 6 +++- src/manage/scriptutils.py | 58 +++++++++++++++++++++++++++++++++++---- src/pymanager.json | 11 ++++++++ tests/conftest.py | 20 ++++++++------ tests/test_scriptutils.py | 25 +++++++++++++++++ 5 files changed, 106 insertions(+), 14 deletions(-) diff --git a/src/manage/commands.py b/src/manage/commands.py index e88a623..a9f5504 100644 --- a/src/manage/commands.py +++ b/src/manage/commands.py @@ -242,6 +242,9 @@ def execute(self): "include_unmanaged": (config_bool, None, "env"), "shebang_can_run_anything": (config_bool, None, "env"), "shebang_can_run_anything_silently": (config_bool, None, "env"), + # Mapping from shebang template to '-V:Company/Tag' argument or an + # executable path. The latter requires 'shebang_can_run_anything'. + "shebang_templates": (dict, config_dict_merge), # Typically configured to '%VIRTUAL_ENV%' to pick up the active environment "virtual_env": (str, None, "env", "path"), @@ -347,6 +350,7 @@ class BaseCommand: virtual_env = None shebang_can_run_anything = True shebang_can_run_anything_silently = False + shebang_templates = {} welcome_on_update = False log_file = None @@ -366,7 +370,7 @@ class BaseCommand: launcher_exe = None launcherw_exe = None - source_settings = None + source_settings = {} show_help = False diff --git a/src/manage/scriptutils.py b/src/manage/scriptutils.py index d203d8c..acde4dc 100644 --- a/src/manage/scriptutils.py +++ b/src/manage/scriptutils.py @@ -121,7 +121,50 @@ def _find_on_path(cmd, full_cmd): } +def _replace_templates(cmd, line, windowed): + # Override can be the entire line or just the first argument + shebang = re.match(r"#!\s*(.+)(.*)", line) or re.match(r"#!\s*([^\s]+)(.*)", line) + + if not shebang or shebang.group(1) not in cmd.shebang_templates: + return None, None + + new_cmd = cmd.shebang_templates[shebang.group(1)] + LOGGER.verbose("Using '%s' from configuration file in place of shebang '%s'", + new_cmd, shebang.group(1)) + install = None + if new_cmd.startswith("py -V:"): + install = cmd.get_install_to_run(new_cmd[6:], windowed=windowed) + elif new_cmd.startswith("pyw -V:"): + install = cmd.get_install_to_run(new_cmd[7:], windowed=True) + elif new_cmd.startswith("py -3"): + install = cmd.get_install_to_run(f"PythonCore/{new_cmd[4:]}", windowed=windowed) + elif new_cmd.startswith("pyw -3"): + install = cmd.get_install_to_run(f"PythonCore/{new_cmd[5:]}", windowed=True) + elif new_cmd == "py": + install = cmd.get_install_to_run(windowed=windowed) + elif new_cmd == "pyw": + install = cmd.get_install_to_run(windowed=True) + else: + # Recreate the shebang with the alternate command and continue. + line = f"#!{new_cmd}{shebang.group(2)}" + return install, line + + def _parse_shebang(cmd, line, *, windowed=None): + # To silence our warning when we get the path from config file + run_anything_silently = False + + # First check the user-provided overrides + if cmd.shebang_templates: + install, new_line = _replace_templates(cmd, line, windowed) + if install: + return install + if new_line: + # We don't warn about custom executables if they've come from + # the config file, unless they don't exist or are disabled. + run_anything_silently = True + line = new_line + # 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: @@ -151,7 +194,7 @@ def _parse_shebang(cmd, line, *, windowed=None): # If not, warn and do regular PATH search if cmd.shebang_can_run_anything or cmd.shebang_can_run_anything_silently: i = _find_on_path(cmd, full_cmd) - if not cmd.shebang_can_run_anything_silently: + if not cmd.shebang_can_run_anything_silently and not run_anything_silently: LOGGER.warn("A shebang '%s' was found but could not be matched " "to an installed runtime, so it will be treated as " "an arbitrary command.", full_cmd) @@ -181,14 +224,19 @@ def _parse_shebang(cmd, line, *, windowed=None): except LookupError: pass if cmd.shebang_can_run_anything or cmd.shebang_can_run_anything_silently: - if not cmd.shebang_can_run_anything_silently: + if not cmd.shebang_can_run_anything_silently and not run_anything_silently: LOGGER.warn("A shebang '%s' was found but does not match any " - "supported template (e.g. '/usr/bin/python'), so it " - "will be treated as an arbitrary command.", full_cmd) + "supported or configured template (e.g. " + "'/usr/bin/python'), so it will be treated as an " + "arbitrary command.", full_cmd) LOGGER.warn("To prevent execution of programs that are not " "Python runtimes, set 'shebang_can_run_anything' to " "'false' in your configuration file.") - return _find_on_path(cmd, full_cmd) + try: + return _find_on_path(cmd, full_cmd) + except LookupError as ex: + LOGGER.error("Could not launch '%s'. Using default interpreter " + "instead.", full_cmd) else: LOGGER.warn("A shebang '%s' was found, but could not be matched " "to an installed runtime.", full_cmd) diff --git a/src/pymanager.json b/src/pymanager.json index fc8c8a0..2870a30 100644 --- a/src/pymanager.json +++ b/src/pymanager.json @@ -34,6 +34,17 @@ "launcherw_exe": "./templates/launcherw.exe", "welcome_on_update": true, + "shebang_templates": { + "/usr/bin/python": "py", + "/usr/bin/pythonw": "py", + "/usr/bin/python3": "py", + "/usr/bin/pythonw3": "pyw", + "/usr/local/bin/python": "py", + "/usr/local/bin/pythonw": "pyw", + "/usr/local/bin/python3": "py", + "/usr/local/bin/pythonw3": "pyw" + }, + "source_settings": { "https://www.python.org/ftp/python/index-windows.json": { "requires_signature": true, diff --git a/tests/conftest.py b/tests/conftest.py index 32e5629..189129a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -162,6 +162,7 @@ def __init__(self, root, installs=[]): self.installs = list(installs) self.shebang_can_run_anything = True self.shebang_can_run_anything_silently = False + self.shebang_templates = {} self.scratch = {} def get_installs(self, *, include_unmanaged=False, set_default=True): @@ -169,7 +170,7 @@ def get_installs(self, *, include_unmanaged=False, set_default=True): return self.installs return [i for i in self.installs if not i.get("unmanaged", 0)] - def get_install_to_run(self, tag, *, windowed=False): + def get_install_to_run(self, tag=None, script=None, *, windowed=False): if windowed: i = self.get_install_to_run(tag) target = [t for t in i.get("run-for", []) if t.get("windowed")] @@ -177,13 +178,16 @@ def get_install_to_run(self, tag, *, windowed=False): return {**i, "executable": i["prefix"] / target[0]["target"]} return i - company, _, tag = tag.replace("/", "\\").rpartition("\\") - try: - found = [i for i in self.installs - if (not tag or i["tag"] == tag) and (not company or i["company"] == company)] - except LookupError as ex: - # LookupError is expected from this function, so make sure we don't raise it here - raise RuntimeError from ex + if not tag: + found = [i for i in self.installs if i.get("default")] + else: + company, _, tag = tag.replace("/", "\\").rpartition("\\") + try: + found = [i for i in self.installs + if (not tag or i["tag"] == tag) and (not company or i["company"] == company)] + except LookupError as ex: + # LookupError is expected from this function, so make sure we don't raise it here + raise RuntimeError from ex if found: return found[0] raise LookupError(tag) diff --git a/tests/test_scriptutils.py b/tests/test_scriptutils.py index 9ccf506..6946d38 100644 --- a/tests/test_scriptutils.py +++ b/tests/test_scriptutils.py @@ -10,6 +10,7 @@ find_install_from_script, _find_shebang_command, _read_script, + _replace_templates, NewEncoding, _maybe_quote, quote_args, @@ -274,3 +275,27 @@ def test_quote_args(args, expect): assert expect == quote_args(args) # Test that our split function produces the same result assert args == split_args(expect), expect + + +@pytest.mark.parametrize("line, expect_id, expect_line", [pytest.param(*a, id=a[0]) for a in [ + ("#!/usr/bin/python", "Test1", None), + ("#!/usr/bin/python2", "Test2", None), + # TODO: More test cases +]]) +def test_shebang_templates(fake_config, line, expect_id, expect_line): + fake_config.installs = [ + dict(id="Test1", company="Test", tag="1", default=True), + dict(id="Test2", company="Test", tag="2"), + ] + fake_config.shebang_templates = { + "/usr/bin/python": "py", + "/usr/bin/python2": "py -V:Test/2", + } + actual, actual_line = _replace_templates(fake_config, line, False) + if expect_id: + assert actual + assert expect_id == actual["id"] + elif expect_line: + assert expect_line == actual_line + else: + pytest.fail("Invalid test") From e139e5304bb2b17d04f72d8a18ed7c5931377392 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 3 Jun 2026 19:49:54 +0100 Subject: [PATCH 2/5] Additional test --- tests/test_install_command.py | 1 + tests/test_scriptutils.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_install_command.py b/tests/test_install_command.py index 08f9bb8..bbb07b1 100644 --- a/tests/test_install_command.py +++ b/tests/test_install_command.py @@ -244,6 +244,7 @@ def __init__(self, tmp_path, *args, **kwargs): self.repair = kwargs.pop("repair", False) self.shebang_can_run_anything = kwargs.pop("shebang_can_run_anything", False) self.shebang_can_run_anything_silently = kwargs.pop("shebang_can_run_anything_silently", False) + self.shebang_templates = {} self.source = kwargs.pop("source", "http://example.com/index.json") self.target = kwargs.pop("target", None) if self.target: diff --git a/tests/test_scriptutils.py b/tests/test_scriptutils.py index 6946d38..0f1ae1b 100644 --- a/tests/test_scriptutils.py +++ b/tests/test_scriptutils.py @@ -279,7 +279,10 @@ def test_quote_args(args, expect): @pytest.mark.parametrize("line, expect_id, expect_line", [pytest.param(*a, id=a[0]) for a in [ ("#!/usr/bin/python", "Test1", None), - ("#!/usr/bin/python2", "Test2", None), + ("#! /usr/bin/python2", "Test2", None), + ("#! custom", None, "#!CUSTOM"), + ("#! custom full line", None, "#!CUSTOM2"), + ("#!custom full line with extra", None, None), # TODO: More test cases ]]) def test_shebang_templates(fake_config, line, expect_id, expect_line): @@ -290,6 +293,8 @@ def test_shebang_templates(fake_config, line, expect_id, expect_line): fake_config.shebang_templates = { "/usr/bin/python": "py", "/usr/bin/python2": "py -V:Test/2", + "custom": "CUSTOM", + "custom full line": "CUSTOM2", } actual, actual_line = _replace_templates(fake_config, line, False) if expect_id: @@ -298,4 +303,5 @@ def test_shebang_templates(fake_config, line, expect_id, expect_line): elif expect_line: assert expect_line == actual_line else: - pytest.fail("Invalid test") + assert not actual + assert not actual_line From d1e305b886b747e67123db8f4c4d74ec0210e117 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 3 Jun 2026 21:14:01 +0100 Subject: [PATCH 3/5] More cases; more coverage --- tests/test_scriptutils.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/test_scriptutils.py b/tests/test_scriptutils.py index 0f1ae1b..9361fe7 100644 --- a/tests/test_scriptutils.py +++ b/tests/test_scriptutils.py @@ -6,9 +6,11 @@ from pathlib import PurePath +import manage.scriptutils as SU from manage.scriptutils import ( find_install_from_script, _find_shebang_command, + _parse_shebang, _read_script, _replace_templates, NewEncoding, @@ -279,20 +281,31 @@ def test_quote_args(args, expect): @pytest.mark.parametrize("line, expect_id, expect_line", [pytest.param(*a, id=a[0]) for a in [ ("#!/usr/bin/python", "Test1", None), - ("#! /usr/bin/python2", "Test2", None), + ("#! /usr/bin/pythonw", "Test1", None), + ("#! /usr/bin/python2", "Test2", None), + ("#! /usr/bin/pythonw2", "Test2", None), + ("#! /usr/bin/python3", "PythonCore3", None), + ("#! /usr/bin/pythonw3", "PythonCore3", None), ("#! custom", None, "#!CUSTOM"), ("#! custom full line", None, "#!CUSTOM2"), ("#!custom full line with extra", None, None), - # TODO: More test cases + ("custom", None, None), + ("custom full line", None, None), ]]) def test_shebang_templates(fake_config, line, expect_id, expect_line): fake_config.installs = [ dict(id="Test1", company="Test", tag="1", default=True), dict(id="Test2", company="Test", tag="2"), + dict(id="Test3", company="Test", tag="3.2"), + dict(id="PythonCore3", company="PythonCore", tag="3.2"), ] fake_config.shebang_templates = { "/usr/bin/python": "py", + "/usr/bin/pythonw": "pyw", "/usr/bin/python2": "py -V:Test/2", + "/usr/bin/pythonw2": "pyw -V:Test/2", + "/usr/bin/python3": "py -3.2", + "/usr/bin/pythonw3": "pyw -3.2", "custom": "CUSTOM", "custom full line": "CUSTOM2", } @@ -305,3 +318,19 @@ def test_shebang_templates(fake_config, line, expect_id, expect_line): else: assert not actual assert not actual_line + + +def test_parse_shebang_templates(monkeypatch): + class Cmd: + shebang_templates = True + + expect = {"an": "install"} + monkeypatch.setattr(SU, "_replace_templates", lambda *a: (expect, None)) + actual = _parse_shebang(Cmd, "Anything at all") + assert expect == actual + + expect = {"id": "COMMAND"} + monkeypatch.setattr(SU, "_replace_templates", lambda *a: (None, "#!COMMAND")) + monkeypatch.setattr(SU, "_find_shebang_command", lambda cmd, full_cmd, **kw: {"id": full_cmd}) + actual = _parse_shebang(Cmd, "Anything at all", windowed=False) + assert expect == actual From e76c216ee4de2d05cf1a7e37badda5b271be8ca9 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 3 Jun 2026 23:09:54 +0100 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Steve Dower --- src/manage/scriptutils.py | 22 +++++++++++++++++----- src/pymanager.json | 2 +- tests/test_scriptutils.py | 1 + 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/manage/scriptutils.py b/src/manage/scriptutils.py index acde4dc..9afaa63 100644 --- a/src/manage/scriptutils.py +++ b/src/manage/scriptutils.py @@ -123,14 +123,25 @@ def _find_on_path(cmd, full_cmd): def _replace_templates(cmd, line, windowed): # Override can be the entire line or just the first argument - shebang = re.match(r"#!\s*(.+)(.*)", line) or re.match(r"#!\s*([^\s]+)(.*)", line) + # Override can be the entire line (including args) or just the first argument + m = re.match(r"^#!\s*([^\s]+)(.*)$", line) - if not shebang or shebang.group(1) not in cmd.shebang_templates: + if not m: return None, None - new_cmd = cmd.shebang_templates[shebang.group(1)] + full_key = (m.group(1) + m.group(2)).strip() + if full_key in cmd.shebang_templates: + template_key = full_key + suffix = "" + elif m.group(1) in cmd.shebang_templates: + template_key = m.group(1) + suffix = m.group(2) + else: + return None, None + + new_cmd = cmd.shebang_templates[template_key] LOGGER.verbose("Using '%s' from configuration file in place of shebang '%s'", - new_cmd, shebang.group(1)) + new_cmd, template_key) install = None if new_cmd.startswith("py -V:"): install = cmd.get_install_to_run(new_cmd[6:], windowed=windowed) @@ -146,7 +157,7 @@ def _replace_templates(cmd, line, windowed): install = cmd.get_install_to_run(windowed=True) else: # Recreate the shebang with the alternate command and continue. - line = f"#!{new_cmd}{shebang.group(2)}" + line = f"#!{new_cmd}{suffix}" return install, line @@ -237,6 +248,7 @@ def _parse_shebang(cmd, line, *, windowed=None): except LookupError as ex: LOGGER.error("Could not launch '%s'. Using default interpreter " "instead.", full_cmd) + raise else: LOGGER.warn("A shebang '%s' was found, but could not be matched " "to an installed runtime.", full_cmd) diff --git a/src/pymanager.json b/src/pymanager.json index 2870a30..95ca599 100644 --- a/src/pymanager.json +++ b/src/pymanager.json @@ -36,7 +36,7 @@ "shebang_templates": { "/usr/bin/python": "py", - "/usr/bin/pythonw": "py", + "/usr/bin/pythonw": "pyw", "/usr/bin/python3": "py", "/usr/bin/pythonw3": "pyw", "/usr/local/bin/python": "py", diff --git a/tests/test_scriptutils.py b/tests/test_scriptutils.py index 9361fe7..ad0f697 100644 --- a/tests/test_scriptutils.py +++ b/tests/test_scriptutils.py @@ -281,6 +281,7 @@ def test_quote_args(args, expect): @pytest.mark.parametrize("line, expect_id, expect_line", [pytest.param(*a, id=a[0]) for a in [ ("#!/usr/bin/python", "Test1", None), + ("#!/usr/bin/python -Es", "Test1", None), ("#! /usr/bin/pythonw", "Test1", None), ("#! /usr/bin/python2", "Test2", None), ("#! /usr/bin/pythonw2", "Test2", None), From 04ddca28fc4b3ef06de3443c3d33d2b18eec49cf Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Wed, 3 Jun 2026 23:16:00 +0100 Subject: [PATCH 5/5] Fix test --- tests/test_scriptutils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_scriptutils.py b/tests/test_scriptutils.py index ad0f697..f281486 100644 --- a/tests/test_scriptutils.py +++ b/tests/test_scriptutils.py @@ -288,10 +288,10 @@ def test_quote_args(args, expect): ("#! /usr/bin/python3", "PythonCore3", None), ("#! /usr/bin/pythonw3", "PythonCore3", None), ("#! custom", None, "#!CUSTOM"), - ("#! custom full line", None, "#!CUSTOM2"), - ("#!custom full line with extra", None, None), + ("#! full line custom", None, "#!CUSTOM2"), + ("#!full line custom with extra", None, None), ("custom", None, None), - ("custom full line", None, None), + ("full line custom", None, None), ]]) def test_shebang_templates(fake_config, line, expect_id, expect_line): fake_config.installs = [ @@ -308,7 +308,7 @@ def test_shebang_templates(fake_config, line, expect_id, expect_line): "/usr/bin/python3": "py -3.2", "/usr/bin/pythonw3": "pyw -3.2", "custom": "CUSTOM", - "custom full line": "CUSTOM2", + "full line custom": "CUSTOM2", } actual, actual_line = _replace_templates(fake_config, line, False) if expect_id: