"""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 from .pathutils import Path, PurePath class NewEncoding(Exception): pass class NoShebang(Exception): pass 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) if not sh_cmd.match("*.exe"): sh_cmd = sh_cmd.with_name(sh_cmd.name + ".exe") is_wdefault = ( sh_cmd.match("pythonw.exe") or sh_cmd.match("pyw.exe") or sh_cmd.match("pythonw3.exe") ) is_default = is_wdefault or ( sh_cmd.match("python.exe") or sh_cmd.match("py.exe") or sh_cmd.match("python3.exe") ) # 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(): 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"): 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 if sh_cmd.match(i["executable"]): LOGGER.debug("Matched executable %s in %s", i["executable"], i["id"]) return i # Fallback search for 'python[w].exe' shebangs if sh_cmd.match("pythonw*.exe"): tag = sh_cmd.name[7:-4] return cmd.get_install_to_run(f"PythonCore/{tag}", windowed=True) if sh_cmd.match("python*.exe"): tag = sh_cmd.name[6:-4] return cmd.get_install_to_run(f"PythonCore/{tag}", windowed=windowed) raise LookupError def _find_on_path(cmd, full_cmd): import os import shutil # Recursion prevention if os.getenv("__PYTHON_MANAGER_SUPPRESS_ARBITRARY_SHEBANG"): raise LookupError os.environ["__PYTHON_MANAGER_SUPPRESS_ARBITRARY_SHEBANG"] = "1" exe = shutil.which(full_cmd) if not exe: raise LookupError return { "display-name": "Shebang command", "sort-version": "0.0", "executable": Path(exe), } def _replace_templates(cmd, line, windowed): # Override can be the entire line or just the first argument # Override can be the entire line (including args) or just the first argument m = re.match(r"^#!\s*([^\s]+)(.*)$", line) if not m: return None, None template_key = None 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: # Also map case-insensitive keys back to their original casing keys_cf = {k.casefold(): k for k in cmd.shebang_templates} template_key = keys_cf.get(full_key.casefold()) suffix = "" if not template_key: template_key = keys_cf.get(m.group(1).casefold()) suffix = m.group(2) if not template_key: return None, None new_cmd = cmd.shebang_templates[template_key] LOGGER.verbose("Using '%s' from configuration file in place of shebang '%s'", new_cmd, template_key) 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}{suffix}" 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: # Handle the /usr[/local]/bin/python cases full_cmd = shebang.group(1) LOGGER.debug("Matching shebang: %s", full_cmd) try: 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) LOGGER.warn('If the script does not behave properly, try ' 'installing the correct runtime with "py install".') raise # For /usr/bin/env, we look for a matching alias, followed by PATH search. # We warn about the PATH search, because we don't know we'll be launching # Python at all in this case. shebang = re.match(r"#!\s*/usr/bin/env\s+(?:-S\s+)?([^\\/\s]+).*", line) if shebang: # First do regular install lookup for /usr/bin/env shebangs full_cmd = shebang.group(1) try: return _find_shebang_command(cmd, full_cmd, windowed=windowed) except LookupError: pass # 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 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) LOGGER.warn("To prevent execution of programs that are not " "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) LOGGER.warn("Arbitrary command execution is disabled. Configure " "'shebang_can_run_anything' to 'true' in your " "configuration file to enable it. " "Launching with default runtime.") raise LookupError # All other shebangs get treated as arbitrary commands. We warn about # this case, because we don't know we'll be launching Python at all. shebang = re.match(r"#!\s*(.+)\S*$", line) if shebang: full_cmd = shebang.group(1) # A regular lookup will handle the case where the entire shebang is # a valid alias. try: 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: 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 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.") try: return _find_on_path(cmd, full_cmd) except LookupError: 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) LOGGER.warn("Arbitrary command execution is disabled. Change " "'shebang_can_run_anything' to 'true' in your " "configuration file to enable it. " "Launching with default runtime.") raise LookupError raise NoShebang def _read_script(cmd, script, encoding, *, windowed=None): try: f = open(script, "r", encoding=encoding, errors="replace") except OSError as ex: raise LookupError(script) from ex with f: first_line = next(f, "").rstrip() if first_line.startswith("#!"): try: return _parse_shebang(cmd, first_line, windowed=windowed) except LookupError: raise LookupError(script) from None except NoShebang: pass coding = re.match(r"\s*#.*coding[=:]\s*([-\w.]+)", first_line) if coding and coding.group(1) != encoding: raise NewEncoding(coding.group(1)) # 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 # cmd.get_install_to_run() can handle. raise LookupError(script) def find_install_from_script(cmd, script, *, windowed=False): try: return _read_script(cmd, script, "utf-8-sig", windowed=windowed) except NewEncoding as ex: encoding = ex.args[0] return _read_script(cmd, script, encoding, windowed=windowed) def _maybe_quote(a): if a[:1] == a[-1:] == '"': a = a[1:-1] if " " not in a and '"' not in a: return a if a.endswith("\\"): c = len(a) - len(a.rstrip("\\")) a += "\\" * c if '"' in a: bits = [] for b in a.split('"'): if bits: bits.append('\\"') bits.append(b) if b[-1:] == "\\": bits.append("\\" * (len(b) - len(b.rstrip("\\")))) print(a.split('"'), bits) a = ''.join(bits) return f'"{a}"' if ' ' in a else a def quote_args(args): """Quotes the provided sequence of arguments preserving all characters. All backslashes and quotes in the existing arguments will be preserved and will round-trip through CreateProcess to another Python instance (or another app using the same parsing rules). When an argument already starts and ends with a double quote ('"'), they will be removed and only replaced if necessary. """ return " ".join(_maybe_quote(a) for a in args) def split_args(arg_string, argv0=False): """Splits a single argument string into separate unquoted items. If argv0 is True, the first argument is parsed as if it is the executable name. """ args = [] if argv0 and arg_string[:1] == '"': a, _, arg_string = arg_string[1:].partition('"') args.append(a) arg_buffer = None quoted_arg = [] bits = arg_string.strip().split(' ') while bits: a = bits.pop(0) pre, quot, post = a.partition('"') if arg_buffer: pre = arg_buffer + pre arg_buffer = None if not quot: if quoted_arg: quoted_arg.append(pre) quoted_arg.append(' ') else: args.append(pre.replace('\\\\', '\\')) continue if pre[-1:] == '\\' and (len(pre) - len(pre.rstrip('\\'))) % 2 == 1: arg_buffer = pre[:-1] + quot if post: bits.insert(0, post) continue elif quoted_arg: quoted_arg.append(pre) args.append(''.join(quoted_arg).replace('\\\\', '\\')) quoted_arg.clear() continue quoted_arg.append(pre) if pre: quoted_arg.append(quot) if post: bits.insert(0, post) if quoted_arg: args.append(''.join(quoted_arg)) return args