From eeb71aa6741d227685b57f3e7c39b1fa388f98bf Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Mon, 19 Jan 2026 21:31:42 +0000 Subject: [PATCH 1/2] Uses windowed setting in shebang processing to avoid using the console. Fixes #216 --- src/manage/commands.py | 2 +- src/manage/scriptutils.py | 30 +++++++++++------- tests/test_scriptutils.py | 65 +++++++++++++++++++++++++++++++++------ 3 files changed, 75 insertions(+), 22 deletions(-) diff --git a/src/manage/commands.py b/src/manage/commands.py index e58c1b1..1179fa7 100644 --- a/src/manage/commands.py +++ b/src/manage/commands.py @@ -659,7 +659,7 @@ def get_install_to_run(self, tag=None, script=None, *, windowed=False): if script and not tag: from .scriptutils import find_install_from_script try: - return find_install_from_script(self, script) + return find_install_from_script(self, script, windowed=windowed) except LookupError: pass from .installs import get_install_to_run diff --git a/src/manage/scriptutils.py b/src/manage/scriptutils.py index 07e4a11..d11c8a7 100644 --- a/src/manage/scriptutils.py +++ b/src/manage/scriptutils.py @@ -12,7 +12,7 @@ class NoShebang(Exception): pass -def _find_shebang_command(cmd, full_cmd): +def _find_shebang_command(cmd, full_cmd, *, windowed=None): sh_cmd = PurePath(full_cmd) # HACK: Assuming alias/executable suffix is '.exe' here # (But correctly assuming we can't use with_suffix() or .stem) @@ -22,9 +22,12 @@ def _find_shebang_command(cmd, full_cmd): is_wdefault = sh_cmd.match("pythonw.exe") or sh_cmd.match("pyw.exe") is_default = is_wdefault or sh_cmd.match("python.exe") or sh_cmd.match("py.exe") + # Internal logic error, but non-fatal, if it has no value + assert windowed is not None + for i in cmd.get_installs(): if is_default and i.get("default"): - if is_wdefault: + if is_wdefault or windowed: target = [t for t in i.get("run-for", []) if t.get("windowed")] if target: return {**i, "executable": i["prefix"] / target[0]["target"]} @@ -32,6 +35,11 @@ def _find_shebang_command(cmd, full_cmd): for a in i.get("alias", ()): if sh_cmd.match(a["name"]): LOGGER.debug("Matched alias %s in %s", a["name"], i["id"]) + if windowed and not a.get("windowed"): + for a2 in i.get("alias", ()): + if a2.get("windowed"): + LOGGER.debug("Substituting alias %s for windowed=1", a2["name"]) + return {**i, "executable": i["prefix"] / a2["target"]} return {**i, "executable": i["prefix"] / a["target"]} if sh_cmd.full_match(PurePath(i["executable"]).name): LOGGER.debug("Matched executable name %s in %s", i["executable"], i["id"]) @@ -69,7 +77,7 @@ def _find_on_path(cmd, full_cmd): } -def _parse_shebang(cmd, line): +def _parse_shebang(cmd, line, *, windowed=None): # 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: @@ -77,7 +85,7 @@ def _parse_shebang(cmd, line): full_cmd = shebang.group(1) LOGGER.debug("Matching shebang: %s", full_cmd) try: - return _find_shebang_command(cmd, full_cmd) + return _find_shebang_command(cmd, full_cmd, windowed=windowed) except LookupError: LOGGER.warn("A shebang '%s' was found, but could not be matched " "to an installed runtime.", full_cmd) @@ -93,7 +101,7 @@ def _parse_shebang(cmd, line): # First do regular install lookup for /usr/bin/env shebangs full_cmd = shebang.group(1) try: - return _find_shebang_command(cmd, full_cmd) + return _find_shebang_command(cmd, full_cmd, windowed=windowed) except LookupError: pass # If not, warn and do regular PATH search @@ -125,7 +133,7 @@ def _parse_shebang(cmd, line): # A regular lookup will handle the case where the entire shebang is # a valid alias. try: - return _find_shebang_command(cmd, full_cmd) + return _find_shebang_command(cmd, full_cmd, windowed=windowed) except LookupError: pass if cmd.shebang_can_run_anything or cmd.shebang_can_run_anything_silently: @@ -149,7 +157,7 @@ def _parse_shebang(cmd, line): raise NoShebang -def _read_script(cmd, script, encoding): +def _read_script(cmd, script, encoding, *, windowed=None): try: f = open(script, "r", encoding=encoding, errors="replace") except OSError as ex: @@ -158,7 +166,7 @@ def _read_script(cmd, script, encoding): first_line = next(f, "").rstrip() if first_line.startswith("#!"): try: - return _parse_shebang(cmd, first_line) + return _parse_shebang(cmd, first_line, windowed=windowed) except LookupError: raise LookupError(script) from None except NoShebang: @@ -176,12 +184,12 @@ def _read_script(cmd, script, encoding): raise LookupError(script) -def find_install_from_script(cmd, script): +def find_install_from_script(cmd, script, *, windowed=False): try: - return _read_script(cmd, script, "utf-8-sig") + return _read_script(cmd, script, "utf-8-sig", windowed=windowed) except NewEncoding as ex: encoding = ex.args[0] - return _read_script(cmd, script, encoding) + return _read_script(cmd, script, encoding, windowed=windowed) def _maybe_quote(a): diff --git a/tests/test_scriptutils.py b/tests/test_scriptutils.py index 0ee8804..4dcea86 100644 --- a/tests/test_scriptutils.py +++ b/tests/test_scriptutils.py @@ -28,8 +28,10 @@ def _fake_install(v, **kwargs): } INSTALLS = [ - _fake_install("1.0", alias=[{"name": "test1.0.exe", "target": "./test-binary-1.0.exe"}]), - _fake_install("1.1", alias=[{"name": "test1.1.exe", "target": "./test-binary-1.1.exe"}]), + _fake_install("1.0", alias=[{"name": "test1.0.exe", "target": "./test-binary-1.0.exe"}, + {"name": "testw1.0.exe", "target": "./test-binary-w-1.0.exe", "windowed": 1}]), + _fake_install("1.1", alias=[{"name": "test1.1.exe", "target": "./test-binary-1.1.exe"}, + {"name": "testw1.1.exe", "target": "./test-binary-w-1.1.exe", "windowed": 1}]), _fake_install("1.3.1", company="PythonCore"), _fake_install("1.3.2", company="PythonOther"), _fake_install("2.0", alias=[{"name": "test2.0.exe", "target": "./test-binary-2.0.exe"}]), @@ -64,12 +66,52 @@ def test_read_shebang(fake_config, tmp_path, script, expect): script = script.encode() script_py.write_bytes(script) try: - actual = find_install_from_script(fake_config, script_py) + actual = find_install_from_script(fake_config, script_py, windowed=False) assert expect == actual except LookupError: assert not expect +@pytest.mark.parametrize("script, expect, windowed", [ + ("#! /usr/bin/test1.0\n", "test-binary-1.0.exe", False), + ("#! /usr/bin/test1.0\n", "test-binary-w-1.0.exe", True), + ("#! /usr/bin/testw1.0\n", "test-binary-w-1.0.exe", False), + ("#! /usr/bin/testw1.0\n", "test-binary-w-1.0.exe", True), + # No windowed option for 2.0, so picks the regular executable + ("#! /usr/bin/test2.0\n", "test-binary-2.0.exe", False), + ("#! /usr/bin/test2.0\n", "test-binary-2.0.exe", True), + ("#! /usr/bin/testw2.0\n", None, False), + ("#! /usr/bin/testw2.0\n", None, True), + ("#!test1.0.exe\n", "test-binary-1.0.exe", False), + ("#!test1.0.exe\n", "test-binary-w-1.0.exe", True), + ("#!testw1.0.exe\n", "test-binary-w-1.0.exe", False), + ("#!testw1.0.exe\n", "test-binary-w-1.0.exe", True), + ("#!test1.1.exe\n", "test-binary-1.1.exe", False), + ("#!test1.1.exe\n", "test-binary-w-1.1.exe", True), + ("#!testw1.1.exe\n", "test-binary-w-1.1.exe", False), + ("#!testw1.1.exe\n", "test-binary-w-1.1.exe", True), + # Matching executable name won't be overridden by windowed setting + ("#!test-binary-1.1.exe\n", "test-binary-1.1.exe", False), + ("#!test-binary-1.1.exe\n", "test-binary-1.1.exe", True), + ("#! /usr/bin/env test1.0\n", "test-binary-1.0.exe", False), + ("#! /usr/bin/env test1.0\n", "test-binary-w-1.0.exe", True), + ("#! /usr/bin/env testw1.0\n", "test-binary-w-1.0.exe", False), + ("#! /usr/bin/env testw1.0\n", "test-binary-w-1.0.exe", True), +]) +def test_read_shebang_windowed(fake_config, tmp_path, script, expect, windowed): + fake_config.installs.extend(INSTALLS) + + script_py = tmp_path / "test-script.py" + if isinstance(script, str): + script = script.encode() + script_py.write_bytes(script) + try: + actual = find_install_from_script(fake_config, script_py, windowed=windowed) + assert actual["executable"].match(expect) + except LookupError: + assert not expect + + def test_default_py_shebang(fake_config, tmp_path): inst = _fake_install("1.0", company="PythonCore", prefix=PurePath("C:\\TestRoot"), default=True) inst["run-for"] = [ @@ -78,14 +120,17 @@ def test_default_py_shebang(fake_config, tmp_path): ] fake_config.installs[:] = [inst] + def t(n): + return _find_shebang_command(fake_config, n, windowed=False) + # Finds the install's default executable - assert _find_shebang_command(fake_config, "python")["executable"].match("test-binary-1.0.exe") - assert _find_shebang_command(fake_config, "py")["executable"].match("test-binary-1.0.exe") - assert _find_shebang_command(fake_config, "python1.0")["executable"].match("test-binary-1.0.exe") + assert t("python")["executable"].match("test-binary-1.0.exe") + assert t("py")["executable"].match("test-binary-1.0.exe") + assert t("python1.0")["executable"].match("test-binary-1.0.exe") # Finds the install's run-for executable with windowed=1 - assert _find_shebang_command(fake_config, "pythonw")["executable"].match("pythonw.exe") - assert _find_shebang_command(fake_config, "pyw")["executable"].match("pythonw.exe") - assert _find_shebang_command(fake_config, "pythonw1.0")["executable"].match("pythonw.exe") + assert t("pythonw")["executable"].match("pythonw.exe") + assert t("pyw")["executable"].match("pythonw.exe") + assert t("pythonw1.0")["executable"].match("pythonw.exe") @@ -104,7 +149,7 @@ def test_read_coding_comment(fake_config, tmp_path, script, expect): script = script.encode() script_py.write_bytes(script) try: - _read_script(fake_config, script_py, "utf-8-sig") + _read_script(fake_config, script_py, "utf-8-sig", windowed=False) except NewEncoding as enc: assert enc.args[0] == expect except LookupError: From 8a772d9fbba4f5e46418ef07a943335713da7872 Mon Sep 17 00:00:00 2001 From: Steve Dower Date: Tue, 20 Jan 2026 15:14:37 +0000 Subject: [PATCH 2/2] More reliable command selection algorithm --- src/manage/scriptutils.py | 62 +++++++++++++++++++++++++++++++-------- tests/test_scriptutils.py | 40 ++++++++++++++++++++----- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/src/manage/scriptutils.py b/src/manage/scriptutils.py index d11c8a7..4dda417 100644 --- a/src/manage/scriptutils.py +++ b/src/manage/scriptutils.py @@ -1,3 +1,32 @@ +"""This module has functions for looking into scripts to decide how to launch. + +Currently, this is primarily shebang lines. This support is intended to allow +scripts to be somewhat portable between POSIX (where they are natively handled) +and Windows, when launching in Python. They are not intended to provide generic +shebang support, although for historical/compatibility reasons it is possible. + +Shebang commands shaped like '/usr/bin/' or '/usr/local/bin/' +will have the command matched to an alias or executable name for detected +runtimes, with the first match being selected. +A command of 'py', 'pyw', 'python' or 'pythonw' will match the default runtime. +If the install manager has been launched in windowed mode, and the selected +alias is not marked as windowed, then the first windowed 'run-for' target will +be substituted (if present - otherwise, it will just not run windowed). Aliases +that map to windowed targets are launched windowed. +If no matching command is found, the default install will be used. + +Shebang commands shaped like '/usr/bin/env ' will do the same lookup as +above. If no matching command is found, the current PATH environment variable +will be searched for a matching command. It will be launched with a warning, +configuration permitting. + +Other shebangs will be treated directly as the command, doing the same lookup +and the same PATH search. + +It is not yet implemented, but this is also where a search for PEP 723 inline +script metadata would go. Find the comment mentioning PEP 723 below. +""" + import re from .logging import LOGGER @@ -25,22 +54,29 @@ def _find_shebang_command(cmd, full_cmd, *, windowed=None): # Internal logic error, but non-fatal, if it has no value assert windowed is not None + # Ensure we use the default install for a default name. Otherwise, a + # "higher" runtime may claim it via an alias, which is not the intent. + if is_default: + for i in cmd.get_installs(): + if i.get("default"): + exe = i["executable"] + if is_wdefault or windowed: + target = [t for t in i.get("run-for", []) if t.get("windowed")] + if target: + exe = target[0]["target"] + return {**i, "executable": i["prefix"] / exe} + for i in cmd.get_installs(): - if is_default and i.get("default"): - if is_wdefault or windowed: - target = [t for t in i.get("run-for", []) if t.get("windowed")] - if target: - return {**i, "executable": i["prefix"] / target[0]["target"]} - return {**i, "executable": i["prefix"] / i["executable"]} for a in i.get("alias", ()): if sh_cmd.match(a["name"]): + exe = a["target"] LOGGER.debug("Matched alias %s in %s", a["name"], i["id"]) if windowed and not a.get("windowed"): - for a2 in i.get("alias", ()): - if a2.get("windowed"): - LOGGER.debug("Substituting alias %s for windowed=1", a2["name"]) - return {**i, "executable": i["prefix"] / a2["target"]} - return {**i, "executable": i["prefix"] / a["target"]} + target = [t for t in i.get("run-for", []) if t.get("windowed")] + if target: + exe = target[0]["target"] + LOGGER.debug("Substituting target %s for windowed=1", exe) + return {**i, "executable": i["prefix"] / exe} if sh_cmd.full_match(PurePath(i["executable"]).name): LOGGER.debug("Matched executable name %s in %s", i["executable"], i["id"]) return i @@ -115,7 +151,7 @@ def _parse_shebang(cmd, line, *, windowed=None): "Python runtimes, set 'shebang_can_run_anything' to " "'false' in your configuration file.") return i - + else: LOGGER.warn("A shebang '%s' was found, but could not be matched " "to an installed runtime.", full_cmd) @@ -176,7 +212,7 @@ def _read_script(cmd, script, encoding, *, windowed=None): if coding and coding.group(1) != encoding: raise NewEncoding(coding.group(1)) - # TODO: Parse inline script metadata + # TODO: Parse inline script metadata (PEP 723) # This involves finding '# /// script' followed by # a line with '# requires-python = '. # That spec needs to be processed as a version constraint, which diff --git a/tests/test_scriptutils.py b/tests/test_scriptutils.py index 4dcea86..6165053 100644 --- a/tests/test_scriptutils.py +++ b/tests/test_scriptutils.py @@ -17,6 +17,10 @@ ) def _fake_install(v, **kwargs): + try: + kwargs["run-for"] = kwargs.pop("run_for") + except LookupError: + pass return { "company": kwargs.get("company", "Test"), "id": f"test-{v}", @@ -28,10 +32,19 @@ def _fake_install(v, **kwargs): } INSTALLS = [ - _fake_install("1.0", alias=[{"name": "test1.0.exe", "target": "./test-binary-1.0.exe"}, - {"name": "testw1.0.exe", "target": "./test-binary-w-1.0.exe", "windowed": 1}]), - _fake_install("1.1", alias=[{"name": "test1.1.exe", "target": "./test-binary-1.1.exe"}, - {"name": "testw1.1.exe", "target": "./test-binary-w-1.1.exe", "windowed": 1}]), + _fake_install("1.0", + run_for=[dict(tag="1.0", target="./test-binary-1.0.exe"), + dict(tag="1.0", target="./test-binary-1.0-win.exe", windowed=1)], + alias=[dict(name="test1.0.exe", target="./test-binary-1.0.exe"), + dict(name="testw1.0.exe", target="./test-binary-w-1.0.exe", windowed=1)], + ), + _fake_install("1.1", + default=1, + run_for=[dict(tag="1.1", target="./test-binary-1.1.exe"), + dict(tag="1.1", target="./test-binary-1.1-win.exe", windowed=1)], + alias=[dict(name="test1.1.exe", target="./test-binary-1.1.exe"), + dict(name="testw1.1.exe", target="./test-binary-w-1.1.exe", windowed=1)], + ), _fake_install("1.3.1", company="PythonCore"), _fake_install("1.3.2", company="PythonOther"), _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): @pytest.mark.parametrize("script, expect, windowed", [ + # Non-windowed alias from non-windowed launcher uses default 'executable' ("#! /usr/bin/test1.0\n", "test-binary-1.0.exe", False), - ("#! /usr/bin/test1.0\n", "test-binary-w-1.0.exe", True), + # Non-windowed alias from windowed launcher uses first windowed 'run-for' + ("#! /usr/bin/test1.0\n", "test-binary-1.0-win.exe", True), + # Windowed alias from either launcher uses the discovered alias ("#! /usr/bin/testw1.0\n", "test-binary-w-1.0.exe", False), ("#! /usr/bin/testw1.0\n", "test-binary-w-1.0.exe", True), + # No windowed option for 2.0, so picks the regular executable ("#! /usr/bin/test2.0\n", "test-binary-2.0.exe", False), ("#! /usr/bin/test2.0\n", "test-binary-2.0.exe", True), ("#! /usr/bin/testw2.0\n", None, False), ("#! /usr/bin/testw2.0\n", None, True), ("#!test1.0.exe\n", "test-binary-1.0.exe", False), - ("#!test1.0.exe\n", "test-binary-w-1.0.exe", True), + ("#!test1.0.exe\n", "test-binary-1.0-win.exe", True), ("#!testw1.0.exe\n", "test-binary-w-1.0.exe", False), ("#!testw1.0.exe\n", "test-binary-w-1.0.exe", True), ("#!test1.1.exe\n", "test-binary-1.1.exe", False), - ("#!test1.1.exe\n", "test-binary-w-1.1.exe", True), + ("#!test1.1.exe\n", "test-binary-1.1-win.exe", True), ("#!testw1.1.exe\n", "test-binary-w-1.1.exe", False), ("#!testw1.1.exe\n", "test-binary-w-1.1.exe", True), + # Matching executable name won't be overridden by windowed setting ("#!test-binary-1.1.exe\n", "test-binary-1.1.exe", False), ("#!test-binary-1.1.exe\n", "test-binary-1.1.exe", True), ("#! /usr/bin/env test1.0\n", "test-binary-1.0.exe", False), - ("#! /usr/bin/env test1.0\n", "test-binary-w-1.0.exe", True), + ("#! /usr/bin/env test1.0\n", "test-binary-1.0-win.exe", True), ("#! /usr/bin/env testw1.0\n", "test-binary-w-1.0.exe", False), ("#! /usr/bin/env testw1.0\n", "test-binary-w-1.0.exe", True), + + # Default name will use default 'executable' or first windowed 'run-for' + ("#! /usr/bin/python\n", "test-binary-1.1.exe", False), + ("#! /usr/bin/python\n", "test-binary-1.1-win.exe", True), + ("#! /usr/bin/pythonw\n", "test-binary-1.1-win.exe", False), + ("#! /usr/bin/pythonw\n", "test-binary-1.1-win.exe", True), ]) def test_read_shebang_windowed(fake_config, tmp_path, script, expect, windowed): fake_config.installs.extend(INSTALLS)