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: